Skip to content

Commit 0ea15d6

Browse files
committed
WIP early alpha of private gated retrievals.
1 parent 7356833 commit 0ea15d6

16 files changed

Lines changed: 1577 additions & 42 deletions

cuhttp/shared_routes.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,32 @@ import (
77
"golang.org/x/xerrors"
88

99
"github.com/filecoin-project/curio/deps"
10+
"github.com/filecoin-project/curio/lib/ethchain"
1011
"github.com/filecoin-project/curio/market/denylist"
1112
ipni_provider "github.com/filecoin-project/curio/market/ipni/ipni-provider"
1213
"github.com/filecoin-project/curio/market/retrieval"
14+
"github.com/filecoin-project/curio/market/retrieval/gate"
1315
)
1416

1517
// MountRetrievalPublicRoutes mounts piece/IPFS retrieval with bad-bits denylist filtering.
1618
// Skiff and full Curio both use this; tests can mount it without standing up IPNI.
1719
func MountRetrievalPublicRoutes(ctx context.Context, r *chi.Mux, d *deps.Deps) *denylist.Filter {
1820
df := denylist.NewFilter(ctx, d.Cfg.HTTP.DenylistServers)
1921
rp := retrieval.NewRetrievalProvider(ctx, d.DB, d.IndexStore, d.CachedPieceReader, df)
20-
retrieval.Router(r, rp, df)
22+
23+
// Opt-in retrieval permissioning. The resolver only dials the eth node on a gated request, so a
24+
// disabled gate (the default) costs nothing.
25+
ethGet := func(context.Context) (ethchain.EthClient, error) {
26+
if d.EthClient == nil {
27+
return nil, xerrors.New("eth client not configured; gated retrieval requires it")
28+
}
29+
return d.EthClient.Val()
30+
}
31+
res := gate.NewResolver(d.DB, d.IndexStore, ethGet)
32+
pieceGate := gate.NewMiddleware(d.Cfg.HTTP.EnableGatedRetrieval, "/piece/", res)
33+
ipfsGate := gate.NewContentMiddleware(d.Cfg.HTTP.EnableGatedRetrieval, "/ipfs/", res)
34+
35+
retrieval.Router(r, rp, df, pieceGate, ipfsGate)
2136
return df
2237
}
2338

deps/config/types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1023,6 +1023,13 @@ type HTTPConfig struct {
10231023
// will receive HTTP 503. (Default: ["https://badbits.dwebops.pub/denylist.json"])
10241024
// Updates will affect running instances.
10251025
DenylistServers *Dynamic[[]string]
1026+
1027+
// EnableGatedRetrieval turns on opt-in permissioning for PDP piece retrieval (/piece/{cid}).
1028+
// When true, a request for a piece whose every containing data set is marked private (via the
1029+
// on-chain "withRetrievalACL" data-set metadata flag) must present a valid, dataset-scoped,
1030+
// payer-signed retrieval credential; pieces in any public data set stay publicly retrievable.
1031+
// When false (default), all retrieval is public — the historical behaviour. (Default: false)
1032+
EnableGatedRetrieval bool
10261033
}
10271034

10281035
// CompressionConfig holds the compression levels for supported types
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
# Curio Retrieval Authorization — wire-format spec
2+
3+
**Status:** draft for review by the PDP-retrieval and PoRep-market teams. A **working reference
4+
implementation** of this exact scheme (the PDP side) exists on Curio branch
5+
`feat/gated-pdp-retrievals` (`market/retrieval/gate`), verified by unit + integration tests and a
6+
live devnet run.
7+
**Goal:** a single credential scheme for authenticated piece retrieval through Curio, usable by any
8+
storage subsystem (FWSS/PDP data sets, PoRep market deals, …). Both subsystems serve pieces through
9+
the same Curio retrieval endpoint (`GET /piece/{cid}`, `/ipfs/{cid}`), so they should share one
10+
gate and one credential format rather than two lookalikes.
11+
12+
This is normative. **MUST/SHOULD/MAY** per RFC 2119.
13+
14+
---
15+
16+
## 1. Model
17+
18+
Access is **capability + proof-of-possession (PoP)** — two EIP-712 objects, never one:
19+
20+
- **`RetrievalVoucher`** — the *capability*. The resource's on-chain **owner (payer)** signs it
21+
**once, offline**, delegating access for a whole *scope* (a data set or a deal) to a **grantee**
22+
address. Reusable, long-lived, freely storable/transferable.
23+
- **`RetrievalProof`***proof of possession*. The requester signs it **fresh, per request**,
24+
binding the **exact resource CID** and a short deadline.
25+
26+
A gated request MUST carry a proof (always) and, for delegated access, the voucher.
27+
28+
> **Why not a bearer voucher.** A voucher presented alone is a bearer token: anyone who captures it
29+
> can retrieve until its deadline; the `grantee` field is decorative. Requiring a fresh,
30+
> resource-bound proof signed by the grantee's key means **a stolen voucher is useless** — only the
31+
> holder of the grantee key can mint a matching proof. The grantee is typically offline when the
32+
> voucher is issued, so this is a capability-delegation model, not an interactive (OIDC-style) grant.
33+
34+
**Statelessness.** Replay protection is time-bounded, not stored: the server bounds how far in the
35+
future a proof's `deadline` may be (`MAX_PROOF_TTL`) and binds the proof to the resource. No nonce
36+
database. A captured *full request* (proof+voucher) is therefore replayable only within the proof's
37+
short window and only for that one piece; closing that residual window would require a server-side
38+
seen-cache and is intentionally out of scope for v1.
39+
40+
---
41+
42+
## 2. Notation & primitives
43+
44+
- Signatures are secp256k1 ECDSA over the EIP-712 digest (EIP-191 `0x19 0x01` prefix), recovered via
45+
`ecrecover`. 65-byte `r‖s‖v`, `v ∈ {27,28}` (implementations MUST also accept `{0,1}`).
46+
- Verification is **off-chain** (in Curio). The EIP-712 `verifyingContract` is used purely for
47+
domain separation; no on-chain call is required to verify a credential.
48+
- Portable across any secp256k1 signer: `viem` / MetaMask `eth_signTypedData_v4` / `@noble/curves` /
49+
go-ethereum / a headless agent. No wallet interaction is required at request time for machine
50+
clients; a human delegates once (voucher) and their software mints proofs.
51+
52+
---
53+
54+
## 3. EIP-712 domain
55+
56+
```
57+
EIP712Domain(string name, string version, uint256 chainId, address verifyingContract)
58+
```
59+
60+
| Field | Value |
61+
|---|---|
62+
| `name` | `"CurioRetrieval"` |
63+
| `version` | `"1"` |
64+
| `chainId` | the FEVM chain id (e.g. `314159` calibration, `314` mainnet) |
65+
| `verifyingContract` | **the owning service's contract** for the scope (see §5) |
66+
67+
`verifyingContract` MUST be the service contract that owns the scope — the **FWSS service address**
68+
for a PDP data set, the **PoRep market contract** for a deal. This gives cross-service domain
69+
separation: a voucher minted for a PoRep deal cannot be replayed against a PDP data set of the same
70+
numeric id, because the digest differs.
71+
72+
---
73+
74+
## 4. Structures
75+
76+
### 4.1 RetrievalVoucher (capability)
77+
78+
```
79+
RetrievalVoucher(address grantee, uint256 scope, uint256 issuedAt, uint256 deadline)
80+
```
81+
82+
| Field | Meaning |
83+
|---|---|
84+
| `grantee` | the delegate's address; the proof for this voucher MUST recover to it |
85+
| `scope` | the access unit — a **data set id** (PDP) or **deal id** (PoRep), see §5 |
86+
| `issuedAt` | unix seconds, for audit |
87+
| `deadline` | unix seconds; the voucher is valid while `now ≤ deadline` (MAY be long-lived) |
88+
89+
Signed by the scope's **owner (payer)**.
90+
91+
### 4.2 RetrievalProof (proof of possession)
92+
93+
```
94+
RetrievalProof(uint256 scope, string resource, uint256 deadline)
95+
```
96+
97+
| Field | Meaning |
98+
|---|---|
99+
| `scope` | MUST equal the voucher's `scope` (or, for owner-direct access, any scope the owner owns that contains the piece) |
100+
| `resource` | the requested piece CID **exactly as it appears in the request path** (see §6) |
101+
| `deadline` | unix seconds; MUST be near-future (`now ≤ deadline ≤ now + MAX_PROOF_TTL`) |
102+
103+
Signed **fresh per request** by the requester (the grantee, or the owner for owner-direct access).
104+
105+
`MAX_PROOF_TTL` is server policy; RECOMMENDED **≤ 5 minutes**.
106+
107+
---
108+
109+
## 5. `scope`, `resource`, and service binding
110+
111+
- **`scope`** is a `uint256` that a service interprets: PDP → `dataSetId`; PoRep → `dealId`. Each
112+
scope belongs to exactly one service, from which Curio derives the `verifyingContract` and the
113+
`owner`.
114+
- **`resource`** is the CID string from the request path: the piece CID for `GET /piece/{cid}`, or
115+
the payload/root CID for `GET /ipfs/{cid}[/subpath]` (gated on the root CID; sub-blocks that
116+
resolve to other pieces are not individually re-checked).
117+
- A piece MAY belong to multiple scopes (content-addressed dedup). The credential names the scope it
118+
claims through; Curio verifies the piece is actually in that scope. **Public-wins:** if the piece
119+
is in any non-access-controlled scope, it is served without a credential (service policy).
120+
121+
---
122+
123+
## 6. Credential token & presentation
124+
125+
Wire token = base64url(JSON), no padding:
126+
127+
```json
128+
{
129+
"scheme": "eip712",
130+
"proof": { "scope": "1001", "resource": "bafk…", "deadline": "1767225600" },
131+
"proofSig": "0x…",
132+
"voucher": { "grantee": "0xabc…", "scope": "1001", "issuedAt": "1767139200", "deadline": "1767744000" },
133+
"voucherSig":"0x…"
134+
}
135+
```
136+
137+
- `voucher`/`voucherSig` are **omitted for owner-direct access** (the proof signer is the owner).
138+
- uint256 fields are **decimal strings**; addresses and signatures are `0x`-hex; `resource` is the
139+
CID string.
140+
141+
Presentation (a client MUST support at least one; a gate MUST accept both):
142+
143+
- `Authorization: CurioRetrieval <token>` — SDK / server / browser `fetch` / headless agent.
144+
- `?auth=<token>` query parameter — header-less browser tags (`<img>`/`<video>`/`<a download>`).
145+
Because the token embeds a fresh proof, a leaked `?auth=` URL is a short-lived, single-resource
146+
capability, not a durable bearer token.
147+
148+
Gated responses MUST be `Cache-Control: private, no-store`.
149+
150+
---
151+
152+
## 7. Verification algorithm (normative)
153+
154+
For a request on resource CID `R`, with parsed credential `C`:
155+
156+
1. `C.scheme` MUST be `"eip712"`, else reject.
157+
2. Resolve the access-controlled scopes containing `R` (per service). If `R` is in any public scope
158+
**serve** (public-wins). Else continue; let `PRIV` be the set of controlling scopes.
159+
3. `C.proof.resource` MUST equal `R` (byte-exact CID string).
160+
4. `now ≤ C.proof.deadline ≤ now + MAX_PROOF_TTL`, else reject.
161+
5. `C.proof.scope ∈ PRIV`, else reject.
162+
6. Resolve `C.proof.scope` → owning service → `verifyingContract` and `owner`.
163+
7. `requester = ecrecover(EIP712(domain(verifyingContract), RetrievalProof, C.proof), C.proofSig)`.
164+
8. If `requester == owner`**authorize** (owner-direct; voucher not required).
165+
9. Else the voucher is REQUIRED:
166+
- `C.voucher` present; `C.voucher.scope == C.proof.scope`; `now ≤ C.voucher.deadline`;
167+
`C.voucher.grantee == requester`;
168+
- `issuer = ecrecover(EIP712(domain(verifyingContract), RetrievalVoucher, C.voucher), C.voucherSig)`;
169+
`issuer == owner`.
170+
- all hold → **authorize**.
171+
10. Else **deny** (403).
172+
173+
Response codes: missing credential → **401**; present but unauthorized/invalid → **403**;
174+
resolver/chain/DB failure → **503** (fail closed — never serve a controlled piece on error).
175+
176+
---
177+
178+
## 8. Service integration (the resolver contract)
179+
180+
A subsystem plugs into the shared gate by implementing a small resolver — the Curio gate's `Backend`
181+
interface, one method group per concept:
182+
183+
```
184+
ScopesForResource(ctx, resourceCID) -> []{ scope, service } // piece → the scopes that contain it
185+
IsScopePrivate(ctx, service, scope) -> bool // access-controlled?
186+
ScopeOwner(ctx, service, scope) -> address // the payer/owner (EIP-712 signer to match)
187+
ServiceContract(service) -> address // the domain verifyingContract
188+
ChainID(ctx) -> uint256
189+
```
190+
191+
- **PDP/FWSS:** scope = `dataSetId`; owner = FWSS-view `GetDataSet().Payer`; service contract =
192+
FWSS service address; "private" = the `withRetrievalACL` data-set-metadata key present.
193+
- **PoRep market:** scope = `dealId`; owner = the deal's payer; service contract = the PoRep market
194+
contract; "private" = the deal's equivalent opt-in flag.
195+
196+
The gate's crypto/verification path is identical for both.
197+
198+
---
199+
200+
## 9. Security considerations
201+
202+
- **Theft resistance.** The voucher alone authorizes nothing; every request needs a fresh
203+
grantee-signed, resource-bound proof. A leaked voucher/token cannot be used without the grantee
204+
key.
205+
- **Residual replay window.** A captured full request replays only within `MAX_PROOF_TTL` and only
206+
for that resource. An OPTIONAL server-side proof-`(signer,resource,deadline)` seen-cache closes it
207+
at the cost of statelessness.
208+
- **Contract/multisig owners (EIP-1271).** v1 assumes an EOA owner (ecrecover). A scope whose owner
209+
is a contract cannot verify by ecrecover; EIP-1271 support is a documented follow-up.
210+
- **Revocation.** There is no revocation before `deadline`; the voucher's short-to-medium lifetime is
211+
the only kill switch in v1. If pre-expiry revocation is later required, add a `nonce` field plus an
212+
owner-published deny-list — deliberately deferred until a concrete need arises.
213+
- **`/ipfs/` scoping.** Gated on the requested root/path CID's piece(s); DAG sub-blocks resolving to
214+
other pieces are not individually re-checked.
215+
- **Domain separation.** `verifyingContract` prevents cross-service and cross-network replay; clients
216+
MUST sign under the owning service's contract.
217+
218+
---
219+
220+
## 10. Worked example
221+
222+
Owner `0x47cc…` delegates PDP data set `1001` to grantee `0xabc…`, then the grantee retrieves piece
223+
`bafk…`:
224+
225+
**Voucher (signed once, offline, by the owner):**
226+
```json
227+
{ "domain": { "name": "CurioRetrieval", "version": "1", "chainId": 314159, "verifyingContract": "0x<FWSS>" },
228+
"types": { "RetrievalVoucher": [
229+
{"name":"grantee","type":"address"},{"name":"scope","type":"uint256"},
230+
{"name":"issuedAt","type":"uint256"},{"name":"deadline","type":"uint256"} ] },
231+
"primaryType": "RetrievalVoucher",
232+
"message": { "grantee":"0xabc…","scope":"1001","issuedAt":"1767139200","deadline":"1767744000" } }
233+
```
234+
235+
**Proof (signed fresh per request, by the grantee):**
236+
```json
237+
{ "domain": { "name": "CurioRetrieval", "version": "1", "chainId": 314159, "verifyingContract": "0x<FWSS>" },
238+
"types": { "RetrievalProof": [
239+
{"name":"scope","type":"uint256"},{"name":"resource","type":"string"},{"name":"deadline","type":"uint256"} ] },
240+
"primaryType": "RetrievalProof",
241+
"message": { "scope":"1001","resource":"bafk…","deadline":"1767225600" } }
242+
```
243+
244+
Both go into the token (§6); the request carries it via header or `?auth=`.
245+
246+
---
247+
248+
## 11. Migration from the two current implementations
249+
250+
**PDP retrieval gate (this repo) — DONE (reference implementation).** Branch
251+
`feat/gated-pdp-retrievals` already implements this exact scheme: `RetrievalVoucher` /
252+
`RetrievalProof`, `scope`, `deadline`, `verifyingContract` (= the FWSS service address), and
253+
string-encoded uint256s — the `market/retrieval/gate` package. Verified by unit + HTTP integration
254+
tests and a live devnet run (owner-direct, delegated, stolen-voucher→403, resource/expiry binding).
255+
Treat it as the working reference for the wire format; the client signer lives in
256+
`synapse-sdk/examples/authz/retrieval.mjs`.
257+
258+
**PoRep market voucher — adopt the missing half + shared shape:**
259+
- add the `RetrievalProof` PoP object and require it on every request (the current single voucher is
260+
bearer — a stolen voucher is usable until `deadline`);
261+
- rename domain `PoRepPieceAccess``CurioRetrieval`; `dealId``scope`;
262+
- add `issuedAt` to the voucher (audit);
263+
- keep `verifyingContract` (the PoRep market contract), `grantee`, `deadline` — already aligned.
264+
265+
---
266+
267+
## 12. Open questions for the two teams
268+
1. `MAX_PROOF_TTL` value (RECOMMENDED ≤ 5 min) and whether a seen-cache is wanted for v1.
269+
2. Whether to unify on `scope` (uint256) or keep service-specific field names (`dataSetId`/`dealId`)
270+
with a shared proof — `scope` maximizes shared verification code.
271+
3. EIP-1271 timeline (contract/multisig owners).

0 commit comments

Comments
 (0)