|
| 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