diff --git a/cuhttp/shared_routes.go b/cuhttp/shared_routes.go index c737e6797..f6e272df2 100644 --- a/cuhttp/shared_routes.go +++ b/cuhttp/shared_routes.go @@ -7,9 +7,11 @@ import ( "golang.org/x/xerrors" "github.com/filecoin-project/curio/deps" + "github.com/filecoin-project/curio/lib/ethchain" "github.com/filecoin-project/curio/market/denylist" ipni_provider "github.com/filecoin-project/curio/market/ipni/ipni-provider" "github.com/filecoin-project/curio/market/retrieval" + "github.com/filecoin-project/curio/market/retrieval/gate" ) // MountRetrievalPublicRoutes mounts piece/IPFS retrieval with bad-bits denylist filtering. @@ -17,7 +19,20 @@ import ( func MountRetrievalPublicRoutes(ctx context.Context, r *chi.Mux, d *deps.Deps) *denylist.Filter { df := denylist.NewFilter(ctx, d.Cfg.HTTP.DenylistServers) rp := retrieval.NewRetrievalProvider(ctx, d.DB, d.IndexStore, d.CachedPieceReader, df) - retrieval.Router(r, rp, df) + + // Opt-in retrieval permissioning. The resolver only dials the eth node on a gated request, so a + // disabled gate (the default) costs nothing. + ethGet := func(context.Context) (ethchain.EthClient, error) { + if d.EthClient == nil { + return nil, xerrors.New("eth client not configured; gated retrieval requires it") + } + return d.EthClient.Val() + } + res := gate.NewResolver(d.DB, d.IndexStore, ethGet) + pieceGate := gate.NewMiddleware(d.Cfg.HTTP.EnableGatedRetrieval, "/piece/", res) + ipfsGate := gate.NewContentMiddleware(d.Cfg.HTTP.EnableGatedRetrieval, "/ipfs/", res) + + retrieval.Router(r, rp, df, pieceGate, ipfsGate) return df } diff --git a/deps/config/types.go b/deps/config/types.go index 070518214..dabcd84c7 100644 --- a/deps/config/types.go +++ b/deps/config/types.go @@ -1023,6 +1023,13 @@ type HTTPConfig struct { // will receive HTTP 503. (Default: ["https://badbits.dwebops.pub/denylist.json"]) // Updates will affect running instances. DenylistServers *Dynamic[[]string] + + // EnableGatedRetrieval turns on opt-in permissioning for PDP piece retrieval (/piece/{cid}). + // When true, a request for a piece whose every containing data set is marked private (via the + // on-chain "withRetrievalACL" data-set metadata flag) must present a valid, dataset-scoped, + // payer-signed retrieval credential; pieces in any public data set stay publicly retrievable. + // When false (default), all retrieval is public — the historical behaviour. (Default: false) + EnableGatedRetrieval bool } // CompressionConfig holds the compression levels for supported types diff --git a/market/retrieval/gate/RETRIEVAL-AUTH-SPEC.md b/market/retrieval/gate/RETRIEVAL-AUTH-SPEC.md new file mode 100644 index 000000000..52a96fb0a --- /dev/null +++ b/market/retrieval/gate/RETRIEVAL-AUTH-SPEC.md @@ -0,0 +1,271 @@ +# Curio Retrieval Authorization — wire-format spec + +**Status:** draft for review by the PDP-retrieval and PoRep-market teams. A **working reference +implementation** of this exact scheme (the PDP side) exists on Curio branch +`feat/gated-pdp-retrievals` (`market/retrieval/gate`), verified by unit + integration tests and a +live devnet run. +**Goal:** a single credential scheme for authenticated piece retrieval through Curio, usable by any +storage subsystem (FWSS/PDP data sets, PoRep market deals, …). Both subsystems serve pieces through +the same Curio retrieval endpoint (`GET /piece/{cid}`, `/ipfs/{cid}`), so they should share one +gate and one credential format rather than two lookalikes. + +This is normative. **MUST/SHOULD/MAY** per RFC 2119. + +--- + +## 1. Model + +Access is **capability + proof-of-possession (PoP)** — two EIP-712 objects, never one: + +- **`RetrievalVoucher`** — the *capability*. The resource's on-chain **owner (payer)** signs it + **once, offline**, delegating access for a whole *scope* (a data set or a deal) to a **grantee** + address. Reusable, long-lived, freely storable/transferable. +- **`RetrievalProof`** — *proof of possession*. The requester signs it **fresh, per request**, + binding the **exact resource CID** and a short deadline. + +A gated request MUST carry a proof (always) and, for delegated access, the voucher. + +> **Why not a bearer voucher.** A voucher presented alone is a bearer token: anyone who captures it +> can retrieve until its deadline; the `grantee` field is decorative. Requiring a fresh, +> resource-bound proof signed by the grantee's key means **a stolen voucher is useless** — only the +> holder of the grantee key can mint a matching proof. The grantee is typically offline when the +> voucher is issued, so this is a capability-delegation model, not an interactive (OIDC-style) grant. + +**Statelessness.** Replay protection is time-bounded, not stored: the server bounds how far in the +future a proof's `deadline` may be (`MAX_PROOF_TTL`) and binds the proof to the resource. No nonce +database. A captured *full request* (proof+voucher) is therefore replayable only within the proof's +short window and only for that one piece; closing that residual window would require a server-side +seen-cache and is intentionally out of scope for v1. + +--- + +## 2. Notation & primitives + +- Signatures are secp256k1 ECDSA over the EIP-712 digest (EIP-191 `0x19 0x01` prefix), recovered via + `ecrecover`. 65-byte `r‖s‖v`, `v ∈ {27,28}` (implementations MUST also accept `{0,1}`). +- Verification is **off-chain** (in Curio). The EIP-712 `verifyingContract` is used purely for + domain separation; no on-chain call is required to verify a credential. +- Portable across any secp256k1 signer: `viem` / MetaMask `eth_signTypedData_v4` / `@noble/curves` / + go-ethereum / a headless agent. No wallet interaction is required at request time for machine + clients; a human delegates once (voucher) and their software mints proofs. + +--- + +## 3. EIP-712 domain + +``` +EIP712Domain(string name, string version, uint256 chainId, address verifyingContract) +``` + +| Field | Value | +|---|---| +| `name` | `"CurioRetrieval"` | +| `version` | `"1"` | +| `chainId` | the FEVM chain id (e.g. `314159` calibration, `314` mainnet) | +| `verifyingContract` | **the owning service's contract** for the scope (see §5) | + +`verifyingContract` MUST be the service contract that owns the scope — the **FWSS service address** +for a PDP data set, the **PoRep market contract** for a deal. This gives cross-service domain +separation: a voucher minted for a PoRep deal cannot be replayed against a PDP data set of the same +numeric id, because the digest differs. + +--- + +## 4. Structures + +### 4.1 RetrievalVoucher (capability) + +``` +RetrievalVoucher(address grantee, uint256 scope, uint256 issuedAt, uint256 deadline) +``` + +| Field | Meaning | +|---|---| +| `grantee` | the delegate's address; the proof for this voucher MUST recover to it | +| `scope` | the access unit — a **data set id** (PDP) or **deal id** (PoRep), see §5 | +| `issuedAt` | unix seconds, for audit | +| `deadline` | unix seconds; the voucher is valid while `now ≤ deadline` (MAY be long-lived) | + +Signed by the scope's **owner (payer)**. + +### 4.2 RetrievalProof (proof of possession) + +``` +RetrievalProof(uint256 scope, string resource, uint256 deadline) +``` + +| Field | Meaning | +|---|---| +| `scope` | MUST equal the voucher's `scope` (or, for owner-direct access, any scope the owner owns that contains the piece) | +| `resource` | the requested piece CID **exactly as it appears in the request path** (see §6) | +| `deadline` | unix seconds; MUST be near-future (`now ≤ deadline ≤ now + MAX_PROOF_TTL`) | + +Signed **fresh per request** by the requester (the grantee, or the owner for owner-direct access). + +`MAX_PROOF_TTL` is server policy; RECOMMENDED **≤ 5 minutes**. + +--- + +## 5. `scope`, `resource`, and service binding + +- **`scope`** is a `uint256` that a service interprets: PDP → `dataSetId`; PoRep → `dealId`. Each + scope belongs to exactly one service, from which Curio derives the `verifyingContract` and the + `owner`. +- **`resource`** is the CID string from the request path: the piece CID for `GET /piece/{cid}`, or + the payload/root CID for `GET /ipfs/{cid}[/subpath]` (gated on the root CID; sub-blocks that + resolve to other pieces are not individually re-checked). +- A piece MAY belong to multiple scopes (content-addressed dedup). The credential names the scope it + claims through; Curio verifies the piece is actually in that scope. **Public-wins:** if the piece + is in any non-access-controlled scope, it is served without a credential (service policy). + +--- + +## 6. Credential token & presentation + +Wire token = base64url(JSON), no padding: + +```json +{ + "scheme": "eip712", + "proof": { "scope": "1001", "resource": "bafk…", "deadline": "1767225600" }, + "proofSig": "0x…", + "voucher": { "grantee": "0xabc…", "scope": "1001", "issuedAt": "1767139200", "deadline": "1767744000" }, + "voucherSig":"0x…" +} +``` + +- `voucher`/`voucherSig` are **omitted for owner-direct access** (the proof signer is the owner). +- uint256 fields are **decimal strings**; addresses and signatures are `0x`-hex; `resource` is the + CID string. + +Presentation (a client MUST support at least one; a gate MUST accept both): + +- `Authorization: CurioRetrieval ` — SDK / server / browser `fetch` / headless agent. +- `?auth=` query parameter — header-less browser tags (``/`