From af1da3833889b5ab21271adf83287b1d8c5a3ce8 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Tue, 4 Aug 2026 11:45:39 +0100 Subject: [PATCH 1/5] Add an RFC for secrets management in TrUAPI --- docs/rfcs/secrets.md | 360 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/rfcs/secrets.md diff --git a/docs/rfcs/secrets.md b/docs/rfcs/secrets.md new file mode 100644 index 00000000..ee95045b --- /dev/null +++ b/docs/rfcs/secrets.md @@ -0,0 +1,360 @@ +--- +title: "Secrets Management in TrUAPI" +owner: "@BigTava" +--- + +# RFC: Secrets Management in TrUAPI + +| | | +| --------------- | -------------------------------------------------------------------------------------------------- | +| **RFC Number** | (assigned on merge) | +| **Start Date** | 2026-08-03 | +| **Description** | A single TrUAPI method letting products use secrets they never hold, held by backends they don't run | +| **Authors** | Tiago Tavares | + +## Summary + +Add one method to a new `Secrets` trait, `request`, which sends a product's request to a **backend** that holds a credential and returns only the result. A backend is an HTTPS endpoint named in the product's dotNS text records, with a small set of host-provided defaults for the ones Parity operates. The record also states what caller identity the backend requires, which the host produces from the user's own credentials. + +Nothing returns a credential to the product, and nothing transports one to the host, because a host on the user's machine cannot keep a secret from that user. Whether the credential belongs to Parity or to a product's deployer changes only who declared the backend, not the API, the record shape, or the trust model. + +## Definitions + +- **Backend**. An HTTPS endpoint that holds a credential and acts on the product's behalf. Distinct from the identity backend, which is always named in full. +- **Secret name**. What a product asks for. Resolves to a dotNS text record naming the backend, the one operation it will perform, and the caller proof it wants. Several names may sit in front of the same underlying credential, one per operation. +- **Caller proof**. Evidence about who is calling, produced by the host from the user's own credentials. The backend states which level it requires. +- **Product account**. An sr25519 account the host derives per product from the user's root secret (RFC-0022). +- **Ring VRF proof**. The anonymous bandersnatch proof of people-set membership from `create_account_proof` (RFC-0004). Proves membership without revealing which member. +- **Contextual alias**. The identifier `create_account_proof` derives from the member key and a `ProductProofContext`. The same member key under different contexts yields different, unlinkable aliases (RFC-0004). +- **`AutoSigning`**. The RFC-0010 capability handing the host a product's subtree secret key so it can sign locally without a round trip. + +## Motivation + +A funding product wants a meld.io API key for fiat onramp. A game product wants TURN credentials so two players can connect through a relay when their networks refuse a direct path. Neither can hold what they are asking for. [Meld's documentation](https://docs.meld.io/docs/meld-api/getting-started) states it plainly: "Always call Meld from your backend. Direct calls from a browser or mobile app expose your API key." A TURN relay secret mints unlimited credentials for a relay somebody pays bandwidth for. Both are long-lived credentials that must stay unknown to the person running the product. + +The two asks look different and are not. In both cases a credential lives somewhere, something uses it to do a job, and the product needs the result of that job. The only real variable is who runs that thing. + +Requirements for a solution: + +1. **Products never receive secret material.** What crosses the TrUAPI boundary is the result of an operation, or a credential that expires on its own. +2. **Parity holds no deployer credentials.** Using this must not require trusting Parity with a third party's key. +3. **Backends are declared per product.** Each product's records are its own namespace, so `meld` under one product is unrelated to `meld` under another. +4. **A backend can tell its callers apart.** A publicly reachable endpoint must not mean anonymous unlimited use of the credential behind it. +5. **Nothing depends on the host's platform.** The desktop and web hosts have no equivalent of Apple App Attest or Google Play Integrity and must not be second-class. +6. **One mechanism.** A product using Parity's relay, a product using its own relay, and a product calling Meld should differ in configuration, not in code shape. + +## Detailed Design + +### Trust boundary + +For any host implementing this API: + +> A credential that must remain unknown to the user MUST NOT be transmitted to the host or to the product. + +Every other decision in this RFC follows from that constraint. The user controls the host, so delivering a secret into it delivers the secret to them regardless of transport. + +### Declaration and resolution + +A backend is named in the product's dotNS text records: + +```text +Key: secret: +Value: { + "endpoint": "https://onramp.example.com", + "path": "/meld/session", + "method": "POST", + "caller": "signature" +} +``` + +The credential never appears in the record. What is published is one operation the deployer is willing to spend it on, and what identity they want with the request. + +A request names the product whose records to read and the secret within them, so `secret:` is resolved under that product. The record it resolves to names the backend that holds it. The host falls back to its built-in defaults when the named product declares nothing under that name, and that fallback is the whole of the platform case: + +| Secret | Default backend | `caller` | Overridable | +| ------ | -------------------------------------------- | ------------ | ----------- | +| `turn` | The identity backend's `POST /v1/turn/issue` | `personhood` | Yes | + +A product wanting Parity's relay names `turn` and declares nothing. A product running its own relay publishes `secret:turn` and gets theirs. Same method, same response shape, no code change. + +```ts +// Parity's relay, no record needed. +await truapi.secrets.request({ product: self, name: "turn" }); + +// The deployer's own Meld session backend. +await truapi.secrets.request({ product: self, name: "meld-session", body }); +``` + +Naming another product's dotNS name is allowed, following RFC-0004 and RFC-0023, which take a product identifier rather than assuming the caller's. Records are public and endpoints are publicly reachable, so refusing this would buy obscurity rather than isolation. What actually protects a backend is the caller proof and its own rate limiting, not who is permitted to name it. + +Typed convenience such as a `getIceServers()` that parses the TURN response into `RTCIceServer[]` belongs in the product SDK, above this RFC. + +### Method + +Added to a new `Secrets` trait: + +```rust +/// Send a request to a backend, which holds a credential the product never +/// sees, and return its response. +/// +/// The backend is resolved as `secret:` in `product`'s dotNS records, +/// falling back to a host default. That record fixes the endpoint, path, and +/// method, so the caller supplies only a body, query, and headers. +#[wire(request_id = 152)] +async fn request( + &self, + _cx: &CallContext, + _request: HostSecretRequest, +) -> Result> { + Err(CallError::unavailable()) +} +``` + +### Request, response, and error + +```rust +struct HostSecretRequest { + /// dotNS name whose records declare the backend. Usually the caller's + /// own, but naming another product is allowed. + product: DotNsName, + /// Secret name, resolved as `secret:` in that product's records, + /// falling back to a host default. The record it finds names the backend. + name: String, + /// Appended to the record's fixed path as a query string. + query: Vec<(String, String)>, + headers: Vec<(String, String)>, + body: Option, +} + +/// The dotNS record, published by the deployer rather than sent over the wire. +struct BackendRecord { + /// Origin the request is sent to. Shown to the user at consent time. + endpoint: String, + /// Fixed path. The product cannot vary it. + path: String, + /// Fixed method. The product cannot vary it. + method: String, + caller: CallerRequirement, +} + +struct HostSecretResponse { + status: u16, + headers: Vec<(String, String)>, + body: Bytes, +} + +/// Declared per backend in the dotNS record. +enum CallerRequirement { + None, + Signature, + Personhood, +} + +enum HostSecretError { + /// No authenticated session (RFC-0009). The host must not auto-prompt login. + NotConnected, + /// No record and no host default under that name. + UnknownSecret, + /// The record exists but does not parse, or names an unsupported field. + MalformedRecord, + /// The user declined consent or the signing confirmation. + Rejected, + /// The backend requires `personhood` and the user is not a people-set + /// member. Mirrors `NotMember` from RFC-0004. + NotMember, + /// The endpoint could not be reached. + Transport, + Unknown { reason: String }, +} +``` + +The caller proof travels as request headers, so a backend verifies it without parsing an arbitrary payload: + +```text +X-Polkadot-Product dotNS name of the calling product, which may differ + from the product whose record was resolved. Host-asserted. +X-Polkadot-Caller Product account public key. Signature and personhood. +X-Polkadot-Timestamp Unix seconds, to bound replay. +X-Polkadot-Nonce Random per request, to bound replay. +X-Polkadot-Signature Signature over the canonical digest. +X-Polkadot-Ring-Proof Ring VRF proof of people-set membership. Personhood only. +X-Polkadot-Alias Contextual alias for this backend. Personhood only. +``` + +The canonical digest covers the method, the full request URL including query, the timestamp, the nonce, and a hash of the body. Backends reject a request whose timestamp falls outside their accepted window, and should reject a repeated nonce within it. + +### Call semantics + +The host resolves `secret:` from `product`'s dotNS records, falling back to a built-in default, and returns `UnknownSecret` if neither exists. It builds the request from the record's `endpoint`, `path`, and `method`, appending the caller's `query`. It obtains user consent, produces the caller proof the record requires, and attaches it, failing rather than downgrading to a weaker level. + +The host MUST strip any caller-supplied header in the `X-Polkadot-` namespace before attaching its own, so a product cannot forge or displace the proof. The product never selects its own caller level: that comes from the record, which the deployer controls. + +The response is returned unmodified except for hop-by-hop headers. Products must not assume the request originated from the user's address, because the host makes it directly and the backend makes any upstream call from its own infrastructure. + +### Identifying the caller + +A declared endpoint is publicly reachable, so without something more, anyone can spend the credential behind it by posting to it. + +Three properties get conflated here. **Non-impersonation** means one caller cannot claim another's identifier. **Scarcity** means being someone new costs something. **Volume** means how much any one caller may do. Rate limiting only delivers volume, and volume limits are worthless without scarcity, because a limit per identity is no limit when identities are free. + +| `caller` | Host attaches | Backend gets | +| ------------ | --------------------------------------------- | ------------------------------------------------------- | +| `none` | Product name only | Nothing verifiable. Rate limit by IP. | +| `signature` | Product account key and a request signature | Non-impersonation and continuity. No scarcity. | +| `personhood` | The above plus a ring VRF proof and its alias | One verified human, one stable identifier, per backend. | + +`signature` is the default and costs nothing. The host signs the canonical digest with the product account (RFC-0022). The backend gets a key that is the same for that user on every visit and that no other backend can correlate, because per-product derivation already separates them. That is what Meld's `externalCustomerId` wants, and it is a hash of a wallet address today for the same reason. + +`personhood` adds scarcity through `create_account_proof` (RFC-0004), which proves people-set membership without revealing which member. The unlinkable identifier comes free with it: the call takes a `ProductProofContext { product_id, suffix }`, and the same member key under different contexts yields different, unlinkable contextual aliases. Setting `suffix` from the backend gives one stable alias per person per backend and an unrelated one everywhere else, so no separate nullifier construction is needed. + +This is deliberately the ring path and not `sign_vrf` (RFC-0023). That method produces an sr25519 VRF bound to the product account, for participants who are **not yet** people-set members. It is identity-bound rather than anonymous, and a non-member account is free to create, so it delivers neither the anonymity nor the scarcity this tier exists for. The two are complementary and only the ring path fits here. + +Two consequences matter. Verification happens against the People chain, so Parity is not in the path and no token format, key distribution, or availability dependency is involved. And nothing depends on the platform, so a ring proof works identically on a downloadable desktop host. That is why device attestation is not part of this design, and why the identity backend's own attestation is not reused: it covers mobile only, and its HS256 tokens are verifiable by nobody but itself. + +Which level to require is a judgement about payoff. Minting TURN tickets hands an attacker free relay bandwidth, which is directly monetisable, so it warrants `personhood`. Creating a Meld session hands an attacker a link to spend their own money into their own wallet, so `signature` is proportionate and `personhood` would exclude users for little gain. + +### Authorization + +Producing a caller proof follows the rules already governing the primitive it uses: local when `AutoSigning` (RFC-0010) covers the account, otherwise a per-call confirmation presented by the Account Holder. A `personhood` proof additionally follows `create_account_proof`'s rules and returns `NotMember` when the user is not in the ring. + +What consent sits on top of that, for the outbound call itself, is unresolved. See Unresolved Questions. + +### Consuming-backend contract + +A backend that verifies these proofs MUST: + +> Derive the caller identity it rate limits from the verified proof itself, never from a caller-supplied field. For `signature`, that is the key the signature verifies under. For `personhood`, that is the contextual alias carried by a ring proof checked against the current ring on the People chain. + +`X-Polkadot-Product` is host-asserted and unverifiable, so a backend must never make a trust decision on it. It is a routing and diagnostics hint. In particular a backend cannot restrict itself to the product that declared it, because any product may name that record and the caller field asserting otherwise is unverifiable. A backend that ignores this contract gains nothing an attacker cannot forge, and the failure is silent, which is why it is stated normatively rather than left to implementers. + +### Flows + +Parity's relay, reached through the host default. No record is published, and the identity backend verifies a ring proof instead of the JWT it uses today. + +```mermaid +sequenceDiagram + participant P as Product + participant H as Host + participant IB as Identity Backend + participant T as TURN relay + + P->>H: secrets.request({ product: self, name: "turn" }) + H->>H: no secret:turn record, use the host default + H->>H: obtain user consent, build the caller proof + H->>IB: POST /v1/turn/issue + X-Polkadot-Ring-Proof, -Alias + IB->>IB: verify proof against the People chain + IB->>IB: HMAC(TURN_SECRET, ":") + IB-->>H: { servers, username, credential, expires_at } + H-->>P: HostSecretResponse + P->>T: allocate using the ticket + T-->>P: relay candidate + Note over P,T: A product running its own relay publishes secret:turn.
The same call then reaches its backend instead. +``` + +A deployer's own credential, reached through a declared record. The Meld key never leaves their infrastructure. + +```mermaid +sequenceDiagram + participant D as Deployer + participant N as dotNS records + participant P as Product + participant H as Host + participant S as Deployer's backend + participant M as api.meld.io + + D->>N: publish secret:meld-session = { endpoint, path, method, caller } + Note over D,S: The Meld key stays on the deployer's backend. It is never published. + + P->>H: secrets.request({ product: self, name: "meld-session" }) + H->>H: resolve secret:meld-session, obtain user consent + H->>H: sign canonical digest with the product account + H->>S: POST /session + X-Polkadot-Caller, -Signature + S->>S: verify signature, apply per-caller rate limit + S->>M: POST /crypto/session/widget, Authorization: Basic + M-->>S: { serviceProviderWidgetUrl } + S-->>H: { widgetUrl } + H-->>P: HostSecretResponse + P->>P: open the widget URL for this buyer +``` + +### Accounts Protocol companion + +None. Both caller proofs reuse primitives that already have their companions, `sign_raw` and `create_account_proof`, so the Host and Account Holder boundary is unchanged. + +## Implementation notes + +- **Query values are the only caller-controlled part of the URL.** Endpoint, path, and method all come from the record, so encoding the query is the whole of the injection surface. +- **Conformance tests** worth writing against a mock backend: a caller cannot influence the resolved URL path or method, caller-supplied `X-Polkadot-` headers are stripped, a `personhood` backend returns `NotMember` rather than falling back to `signature`, a product record shadows a host default of the same name, and the same user yields the same contextual alias across sessions and different aliases across backends. +- **The TURN default is verifiable end to end** against a real relay: a ticket derived from the wrong secret produces `401` and no relay candidate. + +## Non-goals + +For credentials that belong to a **deployer or to Parity** and must stay unknown to the user, where the product needs the result of an operation rather than the credential itself. + +**Not** for secrets that belong to the user. Those can be encrypted to the user's own key, and the objection driving this design does not apply to them. + +**Not** a general outbound HTTP proxy. The endpoint is fixed by the record, and a product wanting arbitrary network access already has `RemotePermission::Remote`. + +**Not** a way to hide anything from the user. Results cross into the host and are therefore readable by whoever runs it. Only the credential stays out of reach. + +## Drawbacks + +- **Deployers must run something.** There is no path here to shipping a product with a third-party credential and no infrastructure. For small products that may be the difference between shipping and not. +- **A declared endpoint is publicly reachable.** Caller proofs raise the bar without making it private. Backends still need rate limiting, and the abuse cost lands on whoever runs them. +- **`signature` provides no scarcity.** Keypairs are free, so at that tier the operator is relying on the attacker's payoff being low. That is a judgement about a specific backend, not a guarantee. +- **`personhood` excludes non-members.** It rests on people-set membership, so it shuts out anyone still verifying. RFC-0023 exists precisely because that population needs a different path, and this tier has no equivalent for them. +- **Product identity is unverifiable, so a backend cannot restrict who invokes it.** Any product may name another's record, and the caller field carrying product identity is host-asserted. Backends gate on the caller proof and their own rate limits. What impersonation buys is calls against the backend's own endpoint, not possession of a credential. +- **One record per operation.** A deployer needing several calls against the same credential publishes several names. That is the cost of the product not choosing paths. + +## Alternatives + +### A generic `get_host_secret(name)` + +Rejected. It breaches the trust boundary by definition, and it cannot serve the TURN case anyway, because the identity backend holds no credential to return, only a minting endpoint. A flat namespace with no owner also lets two products each claim `meld`. + +### Bake secrets into host distributions + +Rejected. A downloadable host makes any embedded secret public. This is not hypothetical, it is what ships today. + +### Fetch the secret from the deployer's URL into the host + +Rejected, and worth distinguishing from the accepted design because it looks similar. An endpoint that hands the plaintext credential to whoever asks is strictly worse than publishing the credential, because it adds a false sense of control. The host calls a backend to have work done, never to collect a key. + +### Escrow the secret with a Parity-run resolver + +Considered at length and set aside. The deployer would encrypt the secret to a resolver's published key, publish the ciphertext in the record, and the resolver would decrypt and attach it. It spares deployers from running anything, but it makes Parity the custodian of third-party payment credentials with the liability that follows, and concentrates every deployer's secret behind one breach. Confidential computing with remote attestation reduces that trust rather than removing it, at the cost of reproducible builds, enclave-hosted TLS egress, and re-attestation on every deploy. If requiring deployer-run infrastructure proves to block adoption, this is what to revisit. + +### Encrypt secrets to every user's key + +Rejected. Authorising a user to decrypt gives that user the plaintext, which is the outcome the Meld case must avoid. The same objection defeats encrypting to a host key, since the host is the user's to control. It also requires enumerating users before they arrive, grows the record linearly, and cannot revoke what has already been decrypted. The scheme is correct for secrets that belong to the user, which is a non-goal here. + +### Identity-backend attestation tokens as the caller proof + +Rejected. It attests app instances using Apple App Attest, Google Play Integrity, and Android key attestation, none of which exist on the desktop or web hosts. Its tokens are HS256, so the identity backend is the only party able to verify them, and a deployer could not check one without new asymmetric signing, a published JWKS, and an audience claim. Personhood delivers stronger scarcity, works everywhere, and is verifiable against the People chain. + +### Deliver results over the statement store + +Rejected as the general transport. It offers durability across reloads and multi-device delivery, but it is a public broadcast medium, so it publishes durable metadata about which product called what and when, and RFC-0010 names that observer as the threat it defends against. It also adds propagation latency at the moment a user taps buy, needs a slot allowance, and bounds payload size. It remains plausible as an optional delivery mode for small latency-tolerant payloads. + +### Have the provider issue a client-safe credential + +Not rejected, and preferable where available. Meld Checkout accepts a `publicKey` in the URL and requires no backend, which would leave the funding product needing nothing from this RFC, at the cost of Meld's hosted UI in place of a custom provider and quote flow. Worth checking before declaring a backend. + +## Prior Art and References + +- **RFC-0004**, `create_account_proof`. The ring-VRF proof and the `ProductProofContext` whose suffix yields unlinkable contextual aliases, which the `personhood` tier is built from. Its `NotMember` error is mirrored here. +- **RFC-0010**, allowance and `AutoSigning`, which decides whether a caller proof needs a per-call confirmation. +- **RFC-0022**, account key derivations. Source of the product account the `signature` tier signs with. +- **RFC-0023**, `sign_vrf`. The complementary sr25519 path for participants who are not yet people-set members, and why it is not the primitive used here. +- **RFC-0024**, personhood as a product (in review). It adds an explicit `key_handle` to `create_account_proof` and deletes RFC-0004's host-side key selection, so the `personhood` tier here depends on whichever of the two lands. It also requires every proof context to be built with TrUAPI's product-scoped context function, which constrains how this RFC may derive its suffix. +- `POST /v1/turn/issue` in the identity backend. Already implemented, and the default `turn` backend. +- [Meld API getting started](https://docs.meld.io/docs/meld-api/getting-started), for the backend-only constraint and the note that "Meld does not require IP or CORS allowlisting", which rules out origin restriction as a mitigation. + +## Unresolved Questions + +- **What user consent does this call require?** Reusing `RemotePermission::Remote { domains }` for the endpoint origin is the obvious fit, but it was written for a product reaching out directly, and here the host calls on the product's behalf. Open within that: whether consent is per backend or per call, whether the record's declared endpoint is shown at grant time, and whether `personhood` needs its own prompt given it discloses more than `signature`. +- **How the `ProductProofContext` suffix is derived from the backend.** It must bind in a way the backend operator can reproduce and a product cannot vary to farm fresh aliases. The endpoint origin is the obvious binding, which means changing endpoint resets every identifier. RFC-0004 leaves the suffix to the caller, and RFC-0024 requires contexts to use the product-scoped construction, so this needs settling against whichever lands. +- **Where the `key_handle` comes from if RFC-0024 lands.** That RFC deletes host-side key selection, so a `personhood` request would need a handle the product does not have and must not learn. The host supplying it from the registry is the obvious answer and is not specified here. +- **How host defaults are discovered.** A product needs to know whether `turn` exists before calling it, and hosts differ. This may want a companion to the existing `featureSupported` probe. +- **Whether the record needs a schema version.** One field now avoids a migration later, when `caller` grows variants. +- **Should the platform `turn` default really require `personhood`?** Minting relay tickets is directly monetisable, which argues yes, but it would lock every non-member out of WebRTC entirely. `signature` plus a tight per-caller quota may be the better trade, and this is a product decision rather than a protocol one. From b6526b1b5665b58156132e7f741a8bd8e01c6328 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Tue, 4 Aug 2026 11:47:53 +0100 Subject: [PATCH 2/5] Number the secrets RFC as 0025 --- docs/rfcs/{secrets.md => 0025-secrets.md} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docs/rfcs/{secrets.md => 0025-secrets.md} (99%) diff --git a/docs/rfcs/secrets.md b/docs/rfcs/0025-secrets.md similarity index 99% rename from docs/rfcs/secrets.md rename to docs/rfcs/0025-secrets.md index ee95045b..84ac588e 100644 --- a/docs/rfcs/secrets.md +++ b/docs/rfcs/0025-secrets.md @@ -3,11 +3,11 @@ title: "Secrets Management in TrUAPI" owner: "@BigTava" --- -# RFC: Secrets Management in TrUAPI +# RFC-0025: Secrets Management in TrUAPI | | | | --------------- | -------------------------------------------------------------------------------------------------- | -| **RFC Number** | (assigned on merge) | +| **RFC Number** | 25 | | **Start Date** | 2026-08-03 | | **Description** | A single TrUAPI method letting products use secrets they never hold, held by backends they don't run | | **Authors** | Tiago Tavares | From 5c0d20d2520172521b0befbdaabcdffb675d85df Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Tue, 4 Aug 2026 12:00:26 +0100 Subject: [PATCH 3/5] Add the Secrets trait and regenerate the wire tables for RFC 0025 --- docs/rfcs/0025-secrets.md | 2 +- .../truapi-codegen/tests/golden/wire_table.rs | 10 +++ .../truapi-server/src/generated/wire_table.rs | 10 +++ rust/crates/truapi/src/api.rs | 2 + rust/crates/truapi/src/api/secrets.rs | 36 ++++++++ rust/crates/truapi/src/v01.rs | 2 + rust/crates/truapi/src/v01/secrets.rs | 83 +++++++++++++++++++ rust/crates/truapi/src/versioned.rs | 1 + rust/crates/truapi/src/versioned/secrets.rs | 9 ++ 9 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 rust/crates/truapi/src/api/secrets.rs create mode 100644 rust/crates/truapi/src/v01/secrets.rs create mode 100644 rust/crates/truapi/src/versioned/secrets.rs diff --git a/docs/rfcs/0025-secrets.md b/docs/rfcs/0025-secrets.md index 84ac588e..59a7cdf0 100644 --- a/docs/rfcs/0025-secrets.md +++ b/docs/rfcs/0025-secrets.md @@ -100,7 +100,7 @@ Added to a new `Secrets` trait: /// The backend is resolved as `secret:` in `product`'s dotNS records, /// falling back to a host default. That record fixes the endpoint, path, and /// method, so the caller supplies only a body, query, and headers. -#[wire(request_id = 152)] +#[wire(request_id = 166)] async fn request( &self, _cx: &CallContext, diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d042..999f27d3 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -466,6 +466,12 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `secrets_request`. +pub const SECRETS_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +735,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "secrets_request", + kind: WireKind::Request(SECRETS_REQUEST), + }, ]; diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d042..999f27d3 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -466,6 +466,12 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `secrets_request`. +pub const SECRETS_REQUEST: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +735,8 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "secrets_request", + kind: WireKind::Request(SECRETS_REQUEST), + }, ]; diff --git a/rust/crates/truapi/src/api.rs b/rust/crates/truapi/src/api.rs index 957509e4..2f5a407e 100644 --- a/rust/crates/truapi/src/api.rs +++ b/rust/crates/truapi/src/api.rs @@ -11,6 +11,7 @@ pub mod payment; pub mod permissions; pub mod preimage; pub mod resource_allocation; +pub mod secrets; pub mod signing; pub mod statement_store; pub mod system; @@ -27,6 +28,7 @@ pub use payment::Payment; pub use permissions::Permissions; pub use preimage::Preimage; pub use resource_allocation::ResourceAllocation; +pub use secrets::Secrets; pub use signing::Signing; pub use statement_store::StatementStore; pub use system::System; diff --git a/rust/crates/truapi/src/api/secrets.rs b/rust/crates/truapi/src/api/secrets.rs new file mode 100644 index 00000000..a86f96b6 --- /dev/null +++ b/rust/crates/truapi/src/api/secrets.rs @@ -0,0 +1,36 @@ +//! Unified [`Secrets`] trait. + +use crate::versioned::secrets::{HostSecretError, HostSecretRequest, HostSecretResponse}; +use crate::wire; +use crate::{CallContext, CallError}; + +/// Calls made with a credential the product never holds. +#[crate::async_trait] +pub trait Secrets: Send + Sync { + /// Send a request to a backend, which holds a credential the product never + /// sees, and return its response. + /// + /// The backend is resolved as `secret:` in `product`'s dotNS records, + /// falling back to a host default. That record fixes the endpoint, path, + /// and method, so the caller supplies only a query, headers, and a body. + /// + /// ```ts + /// const result = await truapi.secrets.request({ + /// product: "onramp.dot", + /// name: "meld-session", + /// query: [], + /// headers: [{ name: "Content-Type", value: "application/json" }], + /// body: encoded, + /// }); + /// assert(result.isOk(), "secrets.request failed:", result); + /// console.log("backend responded:", result.value.status); + /// ``` + #[wire(request_id = 166)] + async fn request( + &self, + _cx: &CallContext, + _request: HostSecretRequest, + ) -> Result> { + Err(CallError::unavailable()) + } +} diff --git a/rust/crates/truapi/src/v01.rs b/rust/crates/truapi/src/v01.rs index 8b34df5a..1174d9d7 100644 --- a/rust/crates/truapi/src/v01.rs +++ b/rust/crates/truapi/src/v01.rs @@ -12,6 +12,7 @@ mod payment; mod permissions; mod preimage; mod resource_allocation; +mod secrets; mod signing; mod statement_store; mod system; @@ -30,6 +31,7 @@ pub use payment::*; pub use permissions::*; pub use preimage::*; pub use resource_allocation::*; +pub use secrets::*; pub use signing::*; pub use statement_store::*; pub use system::*; diff --git a/rust/crates/truapi/src/v01/secrets.rs b/rust/crates/truapi/src/v01/secrets.rs new file mode 100644 index 00000000..1565263e --- /dev/null +++ b/rust/crates/truapi/src/v01/secrets.rs @@ -0,0 +1,83 @@ +use parity_scale_codec::{Decode, Encode}; + +/// Caller identity a backend requires, declared in its dotNS record (RFC 0025). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum CallerRequirement { + /// Product name only. Nothing the backend can verify. + None, + /// Product account key plus a signature over the canonical digest. + Signature, + /// The above plus a ring VRF proof and its contextual alias. + Personhood, +} + +/// Error from [`crate::api::Secrets::request`] (RFC 0025). +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostSecretError { + /// No authenticated session (RFC 0009). The host must not auto-prompt login. + NotConnected, + /// No record and no host default under that name. + UnknownSecret, + /// The record resolved but does not parse. + MalformedRecord, + /// The user declined consent or the signing confirmation. + Rejected, + /// The backend requires `Personhood` and the user is not a people-set member. + NotMember, + /// The backend could not be reached. + Transport, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + +/// One header on the outbound request. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SecretHeader { + /// Header name. + pub name: String, + /// Header value. + pub value: String, +} + +/// One query parameter appended to the record's fixed path. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct SecretQueryParam { + /// Parameter name. + pub name: String, + /// Parameter value. + pub value: String, +} + +/// Request to a backend holding a credential the product never sees (RFC 0025). +/// +/// The backend is resolved as `secret:` in `product`'s dotNS records, +/// falling back to a host default. That record fixes the endpoint, path, and +/// method, so the caller supplies only a query, headers, and a body. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostSecretRequest { + /// dotNS name whose records declare the backend. Usually the caller's own, + /// but naming another product is allowed. + pub product: String, + /// Secret name, resolved as `secret:` in that product's records. + pub name: String, + /// Appended to the record's fixed path as a query string. + pub query: Vec, + /// Headers to forward. The host strips any in the `X-Polkadot-` namespace. + pub headers: Vec, + /// Request body, if the record's method takes one. + pub body: Option>, +} + +/// Response returned by the backend, unmodified except for hop-by-hop headers. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostSecretResponse { + /// HTTP status the backend returned. + pub status: u16, + /// Response headers. + pub headers: Vec, + /// Response body. + pub body: Vec, +} diff --git a/rust/crates/truapi/src/versioned.rs b/rust/crates/truapi/src/versioned.rs index 9da72067..04cf4c9e 100644 --- a/rust/crates/truapi/src/versioned.rs +++ b/rust/crates/truapi/src/versioned.rs @@ -41,6 +41,7 @@ pub mod payment; pub mod permissions; pub mod preimage; pub mod resource_allocation; +pub mod secrets; pub mod signing; pub mod statement_store; pub mod system; diff --git a/rust/crates/truapi/src/versioned/secrets.rs b/rust/crates/truapi/src/versioned/secrets.rs new file mode 100644 index 00000000..e7fb353e --- /dev/null +++ b/rust/crates/truapi/src/versioned/secrets.rs @@ -0,0 +1,9 @@ +//! Versioned wrappers for [`Secrets`](crate::api::Secrets) methods. + +use crate::v01; + +truapi_macros::versioned_type! { + pub enum HostSecretRequest { V1 => v01::HostSecretRequest } + pub enum HostSecretResponse { V1 => v01::HostSecretResponse } + pub enum HostSecretError { V1 => v01::HostSecretError } +} From 19b97d7c687183a12c8888ce4de8a727bcc5b1a8 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Tue, 4 Aug 2026 17:00:05 +0100 Subject: [PATCH 4/5] Require personhood on every secrets request and drop host-provided backends --- docs/rfcs/0025-secrets.md | 201 ++++++++++---------------- rust/crates/truapi/src/api/secrets.rs | 6 +- rust/crates/truapi/src/v01/secrets.rs | 28 ++-- 3 files changed, 91 insertions(+), 144 deletions(-) diff --git a/docs/rfcs/0025-secrets.md b/docs/rfcs/0025-secrets.md index 59a7cdf0..fccd9b77 100644 --- a/docs/rfcs/0025-secrets.md +++ b/docs/rfcs/0025-secrets.md @@ -14,34 +14,31 @@ owner: "@BigTava" ## Summary -Add one method to a new `Secrets` trait, `request`, which sends a product's request to a **backend** that holds a credential and returns only the result. A backend is an HTTPS endpoint named in the product's dotNS text records, with a small set of host-provided defaults for the ones Parity operates. The record also states what caller identity the backend requires, which the host produces from the user's own credentials. +Add one method to a new `Secrets` trait, `request`, which sends a product's request to a **backend** that holds a credential and returns only the result. A backend is an HTTPS endpoint named in a dotNS text record, and every request carries a proof of personhood. -Nothing returns a credential to the product, and nothing transports one to the host, because a host on the user's machine cannot keep a secret from that user. Whether the credential belongs to Parity or to a product's deployer changes only who declared the backend, not the API, the record shape, or the trust model. +The credential never reaches the product or the host. The host runs on the user's machine, and one copy taken from one device would spend the deployer's account on behalf of every user. Therefore, a deployer must run the service, and a user who has not proved personhood cannot reach one at all. Other designs were considered, including escrowing the credential with a Parity-run resolver and encrypting it to an enclave attested by network nodes. ## Definitions -- **Backend**. An HTTPS endpoint that holds a credential and acts on the product's behalf. Distinct from the identity backend, which is always named in full. -- **Secret name**. What a product asks for. Resolves to a dotNS text record naming the backend, the one operation it will perform, and the caller proof it wants. Several names may sit in front of the same underlying credential, one per operation. -- **Caller proof**. Evidence about who is calling, produced by the host from the user's own credentials. The backend states which level it requires. -- **Product account**. An sr25519 account the host derives per product from the user's root secret (RFC-0022). -- **Ring VRF proof**. The anonymous bandersnatch proof of people-set membership from `create_account_proof` (RFC-0004). Proves membership without revealing which member. -- **Contextual alias**. The identifier `create_account_proof` derives from the member key and a `ProductProofContext`. The same member key under different contexts yields different, unlinkable aliases (RFC-0004). -- **`AutoSigning`**. The RFC-0010 capability handing the host a product's subtree secret key so it can sign locally without a round trip. +- **Backend**. An HTTPS endpoint declared in a dotNS text record. It holds a credential and uses it to perform the one operation that record declares, authorizing each request on the personhood proof attached to it. +- **Secret name**. What a product asks for, scoped to a dotNS name. Resolves to a text record naming the backend and the one operation it will perform. A product may declare as many as it needs, whether for different credentials or for several operations against the same one. +- **Caller proof**. The ring VRF proof and contextual alias attached to every request. +- **Product account**. An sr25519 account the host derives per product from the user's root secret ([RFC-0022](0022-account-derivations.md)). +- **Ring VRF proof**. The anonymous bandersnatch proof of people-set membership from `create_account_proof` ([RFC-0004](0004-ringlocation-redesign.md)). Proves membership without revealing which member. +- **Contextual alias**. The identifier `create_account_proof` derives from the member key and a `ProductProofContext`. The same member key under different contexts yields different, unlinkable aliases ([RFC-0004](0004-ringlocation-redesign.md)). ## Motivation -A funding product wants a meld.io API key for fiat onramp. A game product wants TURN credentials so two players can connect through a relay when their networks refuse a direct path. Neither can hold what they are asking for. [Meld's documentation](https://docs.meld.io/docs/meld-api/getting-started) states it plainly: "Always call Meld from your backend. Direct calls from a browser or mobile app expose your API key." A TURN relay secret mints unlimited credentials for a relay somebody pays bandwidth for. Both are long-lived credentials that must stay unknown to the person running the product. - -The two asks look different and are not. In both cases a credential lives somewhere, something uses it to do a job, and the product needs the result of that job. The only real variable is who runs that thing. +A funding product wants a meld.io API key for fiat onramp. A game product wants TURN credentials so two players can connect through a relay when their networks refuse a direct path. Neither can hold what they are asking for, because a product runs inside a host on the user's own machine and anything handed to either is readable by the person using it. [Meld's documentation](https://docs.meld.io/docs/meld-api/getting-started) states it plainly: "Always call Meld from your backend. Direct calls from a browser or mobile app expose your API key." A TURN relay secret mints unlimited credentials for a relay somebody pays bandwidth for. Both are long-lived credentials that must stay unknown to the person running the product. Requirements for a solution: 1. **Products never receive secret material.** What crosses the TrUAPI boundary is the result of an operation, or a credential that expires on its own. -2. **Parity holds no deployer credentials.** Using this must not require trusting Parity with a third party's key. -3. **Backends are declared per product.** Each product's records are its own namespace, so `meld` under one product is unrelated to `meld` under another. +2. **A product may declare many secrets.** Each product's records are its own namespace, so `meld` under one product is unrelated to `meld` under another, and one deployer may publish several names against the same credential. +3. **A product may reach a backend another product declared.** A shared service should not have to be redeclared by every consumer that wants it. 4. **A backend can tell its callers apart.** A publicly reachable endpoint must not mean anonymous unlimited use of the credential behind it. 5. **Nothing depends on the host's platform.** The desktop and web hosts have no equivalent of Apple App Attest or Google Play Integrity and must not be second-class. -6. **One mechanism.** A product using Parity's relay, a product using its own relay, and a product calling Meld should differ in configuration, not in code shape. +6. **One mechanism.** A product minting relay tickets and a product creating a Meld session should differ in configuration, not in code shape. ## Detailed Design @@ -53,6 +50,8 @@ For any host implementing this API: Every other decision in this RFC follows from that constraint. The user controls the host, so delivering a secret into it delivers the secret to them regardless of transport. +It does not cover the user's own keys, because losing one of those costs that user alone, while one copy of a deployer's credential taken from one device spends the deployer's account on behalf of every user. + ### Declaration and resolution A backend is named in the product's dotNS text records: @@ -62,30 +61,23 @@ Key: secret: Value: { "endpoint": "https://onramp.example.com", "path": "/meld/session", - "method": "POST", - "caller": "signature" + "method": "POST" } ``` -The credential never appears in the record. What is published is one operation the deployer is willing to spend it on, and what identity they want with the request. - -A request names the product whose records to read and the secret within them, so `secret:` is resolved under that product. The record it resolves to names the backend that holds it. The host falls back to its built-in defaults when the named product declares nothing under that name, and that fallback is the whole of the platform case: +The credential never appears in the record. What is published is one operation the deployer is willing to spend it on. -| Secret | Default backend | `caller` | Overridable | -| ------ | -------------------------------------------- | ------------ | ----------- | -| `turn` | The identity backend's `POST /v1/turn/issue` | `personhood` | Yes | - -A product wanting Parity's relay names `turn` and declares nothing. A product running its own relay publishes `secret:turn` and gets theirs. Same method, same response shape, no code change. +A request names the dotNS name whose records to read and the secret within them, so `secret:` resolves under that name. ```ts -// Parity's relay, no record needed. +// The deployer's own TURN relay. await truapi.secrets.request({ product: self, name: "turn" }); // The deployer's own Meld session backend. await truapi.secrets.request({ product: self, name: "meld-session", body }); ``` -Naming another product's dotNS name is allowed, following RFC-0004 and RFC-0023, which take a product identifier rather than assuming the caller's. Records are public and endpoints are publicly reachable, so refusing this would buy obscurity rather than isolation. What actually protects a backend is the caller proof and its own rate limiting, not who is permitted to name it. +Naming a dotNS name other than your own is allowed, which is how a product reaches a service another deployer publishes. It follows [RFC-0004](0004-ringlocation-redesign.md) and [RFC-0023](0023-account-sign-vrf.md), which take a product identifier rather than assuming the caller's. Records are public and endpoints are publicly reachable, so refusing it would buy obscurity rather than isolation. What actually protects a backend is the caller proof and its own rate limiting, not who is permitted to name it. Typed convenience such as a `getIceServers()` that parses the TURN response into `RTCIceServer[]` belongs in the product SDK, above this RFC. @@ -97,9 +89,9 @@ Added to a new `Secrets` trait: /// Send a request to a backend, which holds a credential the product never /// sees, and return its response. /// -/// The backend is resolved as `secret:` in `product`'s dotNS records, -/// falling back to a host default. That record fixes the endpoint, path, and -/// method, so the caller supplies only a body, query, and headers. +/// The backend is resolved as `secret:` in `product`'s dotNS records. +/// That record fixes the endpoint, path, and method, so the caller supplies +/// only a body, query, and headers. #[wire(request_id = 166)] async fn request( &self, @@ -114,11 +106,11 @@ async fn request( ```rust struct HostSecretRequest { - /// dotNS name whose records declare the backend. Usually the caller's - /// own, but naming another product is allowed. + /// dotNS name whose records declare the backend. Often the caller's own, + /// but naming another is how a product reaches a shared service. product: DotNsName, - /// Secret name, resolved as `secret:` in that product's records, - /// falling back to a host default. The record it finds names the backend. + /// Secret name, resolved as `secret:` in that name's records. + /// The record it finds names the backend. name: String, /// Appended to the record's fixed path as a query string. query: Vec<(String, String)>, @@ -134,7 +126,6 @@ struct BackendRecord { path: String, /// Fixed method. The product cannot vary it. method: String, - caller: CallerRequirement, } struct HostSecretResponse { @@ -143,27 +134,22 @@ struct HostSecretResponse { body: Bytes, } -/// Declared per backend in the dotNS record. -enum CallerRequirement { - None, - Signature, - Personhood, -} - enum HostSecretError { /// No authenticated session (RFC-0009). The host must not auto-prompt login. NotConnected, - /// No record and no host default under that name. + /// No record under that name. UnknownSecret, /// The record exists but does not parse, or names an unsupported field. MalformedRecord, /// The user declined consent or the signing confirmation. Rejected, - /// The backend requires `personhood` and the user is not a people-set - /// member. Mirrors `NotMember` from RFC-0004. + /// The user is not a people-set member, so no proof can be produced. + /// Mirrors `NotMember` from RFC-0004. NotMember, /// The endpoint could not be reached. Transport, + /// The response exceeded the host's limit and was discarded. + ResponseTooLarge, Unknown { reason: String }, } ``` @@ -173,85 +159,51 @@ The caller proof travels as request headers, so a backend verifies it without pa ```text X-Polkadot-Product dotNS name of the calling product, which may differ from the product whose record was resolved. Host-asserted. -X-Polkadot-Caller Product account public key. Signature and personhood. X-Polkadot-Timestamp Unix seconds, to bound replay. X-Polkadot-Nonce Random per request, to bound replay. -X-Polkadot-Signature Signature over the canonical digest. -X-Polkadot-Ring-Proof Ring VRF proof of people-set membership. Personhood only. -X-Polkadot-Alias Contextual alias for this backend. Personhood only. +X-Polkadot-Proof Ring VRF proof over the canonical digest. +X-Polkadot-Alias Contextual alias for this backend. ``` The canonical digest covers the method, the full request URL including query, the timestamp, the nonce, and a hash of the body. Backends reject a request whose timestamp falls outside their accepted window, and should reject a repeated nonce within it. ### Call semantics -The host resolves `secret:` from `product`'s dotNS records, falling back to a built-in default, and returns `UnknownSecret` if neither exists. It builds the request from the record's `endpoint`, `path`, and `method`, appending the caller's `query`. It obtains user consent, produces the caller proof the record requires, and attaches it, failing rather than downgrading to a weaker level. +The host resolves `secret:` from `product`'s dotNS records and returns `UnknownSecret` if there is none. It builds the request from the record's `endpoint`, `path`, and `method`, appending the caller's `query`. It obtains user consent, produces the caller proof, and attaches it. -The host MUST strip any caller-supplied header in the `X-Polkadot-` namespace before attaching its own, so a product cannot forge or displace the proof. The product never selects its own caller level: that comes from the record, which the deployer controls. +The host MUST strip any caller-supplied header in the `X-Polkadot-` namespace before attaching its own, so a product cannot forge or displace the proof. + +The host MUST bound the response it buffers and MUST return `ResponseTooLarge` rather than exceed that bound. A product may publish a record naming any endpoint and then call it, so the response is untrusted input regardless of who deployed the product, and an unbounded one exhausts the host through a call the user has already consented to. The same bound applies to the request body and header count, which the product supplies directly. The response is returned unmodified except for hop-by-hop headers. Products must not assume the request originated from the user's address, because the host makes it directly and the backend makes any upstream call from its own infrastructure. -### Identifying the caller +### Authorizing the caller A declared endpoint is publicly reachable, so without something more, anyone can spend the credential behind it by posting to it. -Three properties get conflated here. **Non-impersonation** means one caller cannot claim another's identifier. **Scarcity** means being someone new costs something. **Volume** means how much any one caller may do. Rate limiting only delivers volume, and volume limits are worthless without scarcity, because a limit per identity is no limit when identities are free. - -| `caller` | Host attaches | Backend gets | -| ------------ | --------------------------------------------- | ------------------------------------------------------- | -| `none` | Product name only | Nothing verifiable. Rate limit by IP. | -| `signature` | Product account key and a request signature | Non-impersonation and continuity. No scarcity. | -| `personhood` | The above plus a ring VRF proof and its alias | One verified human, one stable identifier, per backend. | - -`signature` is the default and costs nothing. The host signs the canonical digest with the product account (RFC-0022). The backend gets a key that is the same for that user on every visit and that no other backend can correlate, because per-product derivation already separates them. That is what Meld's `externalCustomerId` wants, and it is a hash of a wallet address today for the same reason. - -`personhood` adds scarcity through `create_account_proof` (RFC-0004), which proves people-set membership without revealing which member. The unlinkable identifier comes free with it: the call takes a `ProductProofContext { product_id, suffix }`, and the same member key under different contexts yields different, unlinkable contextual aliases. Setting `suffix` from the backend gives one stable alias per person per backend and an unrelated one everywhere else, so no separate nullifier construction is needed. - -This is deliberately the ring path and not `sign_vrf` (RFC-0023). That method produces an sr25519 VRF bound to the product account, for participants who are **not yet** people-set members. It is identity-bound rather than anonymous, and a non-member account is free to create, so it delivers neither the anonymity nor the scarcity this tier exists for. The two are complementary and only the ring path fits here. +Rate limiting alone does not fix that, because a limit per identity is no limit when identities are free. A product-account signature has the same gap: it proves the caller is consistently the same party, and a fresh keypair costs nothing. Scarcity is the property that matters, so every request carries a ring VRF proof from `create_account_proof` ([RFC-0004](0004-ringlocation-redesign.md)), which shows people-set membership without revealing which member. There is no weaker option to select and no configuration to get it. Producing the proof follows `create_account_proof`'s existing rules, so nothing new here governs when the user is asked to approve. -Two consequences matter. Verification happens against the People chain, so Parity is not in the path and no token format, key distribution, or availability dependency is involved. And nothing depends on the platform, so a ring proof works identically on a downloadable desktop host. That is why device attestation is not part of this design, and why the identity backend's own attestation is not reused: it covers mobile only, and its HS256 tokens are verifiable by nobody but itself. +The canonical digest is the message the proof is made over, so one artifact carries both personhood and request integrity and no separate signature is needed. -Which level to require is a judgement about payoff. Minting TURN tickets hands an attacker free relay bandwidth, which is directly monetisable, so it warrants `personhood`. Creating a Meld session hands an attacker a link to spend their own money into their own wallet, so `signature` is proportionate and `personhood` would exclude users for little gain. +The unlinkable identifier comes with it: the call takes a `ProductProofContext { product_id, suffix }`, and the same member key under different contexts yields different, unlinkable contextual aliases. Setting `suffix` from the backend gives one stable alias per person per backend and an unrelated one everywhere else, so no separate nullifier construction is needed. That is what Meld's `externalCustomerId` wants, and it is a hash of a wallet address today for the same reason. -### Authorization +This is deliberately the ring path and not `sign_vrf` ([RFC-0023](0023-account-sign-vrf.md)). That method produces an sr25519 VRF bound to the product account, for participants who are **not yet** people-set members. It is identity-bound rather than anonymous, and a non-member account is free to create, so it delivers neither the anonymity nor the scarcity this design needs. The two are complementary and only the ring path fits here. -Producing a caller proof follows the rules already governing the primitive it uses: local when `AutoSigning` (RFC-0010) covers the account, otherwise a per-call confirmation presented by the Account Holder. A `personhood` proof additionally follows `create_account_proof`'s rules and returns `NotMember` when the user is not in the ring. +A backend verifies against the People chain, so Parity is in neither the request path nor the verification path, and there is no token format or key distribution to agree. It also works identically on the desktop and web hosts, which is why device attestation is not used here: Apple App Attest and Google Play Integrity exist only on mobile. -What consent sits on top of that, for the outbound call itself, is unresolved. See Unresolved Questions. +The cost is that a non-member cannot reach any backend and gets `NotMember`. That is the deliberate trade: every backend is Sybil-resistant without its operator configuring anything, and the population still verifying is served by nothing here. ### Consuming-backend contract A backend that verifies these proofs MUST: -> Derive the caller identity it rate limits from the verified proof itself, never from a caller-supplied field. For `signature`, that is the key the signature verifies under. For `personhood`, that is the contextual alias carried by a ring proof checked against the current ring on the People chain. +> Derive the caller identity it rate limits from the contextual alias carried by a ring proof checked against the current ring on the People chain, never from a caller-supplied field. -`X-Polkadot-Product` is host-asserted and unverifiable, so a backend must never make a trust decision on it. It is a routing and diagnostics hint. In particular a backend cannot restrict itself to the product that declared it, because any product may name that record and the caller field asserting otherwise is unverifiable. A backend that ignores this contract gains nothing an attacker cannot forge, and the failure is silent, which is why it is stated normatively rather than left to implementers. +`X-Polkadot-Product` is host-asserted and unverifiable, so a backend must never make a trust decision on it. It is a routing and diagnostics hint. In particular a backend cannot restrict itself to the product that declared it, because any product may name that record and the header asserting otherwise is unverifiable. A backend that ignores this contract gains nothing an attacker cannot forge, and the failure is silent, which is why it is stated normatively rather than left to implementers. -### Flows +### Flow -Parity's relay, reached through the host default. No record is published, and the identity backend verifies a ring proof instead of the JWT it uses today. - -```mermaid -sequenceDiagram - participant P as Product - participant H as Host - participant IB as Identity Backend - participant T as TURN relay - - P->>H: secrets.request({ product: self, name: "turn" }) - H->>H: no secret:turn record, use the host default - H->>H: obtain user consent, build the caller proof - H->>IB: POST /v1/turn/issue + X-Polkadot-Ring-Proof, -Alias - IB->>IB: verify proof against the People chain - IB->>IB: HMAC(TURN_SECRET, ":") - IB-->>H: { servers, username, credential, expires_at } - H-->>P: HostSecretResponse - P->>T: allocate using the ticket - T-->>P: relay candidate - Note over P,T: A product running its own relay publishes secret:turn.
The same call then reaches its backend instead. -``` - -A deployer's own credential, reached through a declared record. The Meld key never leaves their infrastructure. +The deployer publishes the record once. At purchase time the backend attaches the Meld key and returns only the widget URL, so the key never leaves their infrastructure. ```mermaid sequenceDiagram @@ -259,17 +211,17 @@ sequenceDiagram participant N as dotNS records participant P as Product participant H as Host - participant S as Deployer's backend + participant S as Backend participant M as api.meld.io - D->>N: publish secret:meld-session = { endpoint, path, method, caller } - Note over D,S: The Meld key stays on the deployer's backend. It is never published. + D->>N: publish secret:meld-session = { endpoint, path, method } + Note over D,S: The Meld key stays on the backend. It is never published. P->>H: secrets.request({ product: self, name: "meld-session" }) H->>H: resolve secret:meld-session, obtain user consent - H->>H: sign canonical digest with the product account - H->>S: POST /session + X-Polkadot-Caller, -Signature - S->>S: verify signature, apply per-caller rate limit + H->>H: ring proof over the canonical digest + H->>S: POST /session + X-Polkadot-Proof, -Alias + S->>S: verify proof against the People chain, rate limit by alias S->>M: POST /crypto/session/widget, Authorization: Basic M-->>S: { serviceProviderWidgetUrl } S-->>H: { widgetUrl } @@ -279,13 +231,14 @@ sequenceDiagram ### Accounts Protocol companion -None. Both caller proofs reuse primitives that already have their companions, `sign_raw` and `create_account_proof`, so the Host and Account Holder boundary is unchanged. +None. The caller proof reuses `create_account_proof`, which already has one, so the Host and Account Holder boundary is unchanged. ## Implementation notes +- **The response bound is host policy.** This RFC requires one without fixing a number, since a relay ticket and a provider's JSON differ by orders of magnitude from whatever a future backend returns. - **Query values are the only caller-controlled part of the URL.** Endpoint, path, and method all come from the record, so encoding the query is the whole of the injection surface. -- **Conformance tests** worth writing against a mock backend: a caller cannot influence the resolved URL path or method, caller-supplied `X-Polkadot-` headers are stripped, a `personhood` backend returns `NotMember` rather than falling back to `signature`, a product record shadows a host default of the same name, and the same user yields the same contextual alias across sessions and different aliases across backends. -- **The TURN default is verifiable end to end** against a real relay: a ticket derived from the wrong secret produces `401` and no relay candidate. +- **Conformance tests** worth writing against a mock backend: a caller cannot influence the resolved URL path or method, caller-supplied `X-Polkadot-` headers are stripped, a non-member gets `NotMember` rather than an unproven request reaching the backend, and the same user yields the same contextual alias across sessions and different aliases across backends. +- **The relay path is verifiable end to end** against a real TURN server: a ticket derived from the wrong secret produces `401` and no relay candidate. ## Non-goals @@ -301,16 +254,15 @@ For credentials that belong to a **deployer or to Parity** and must stay unknown - **Deployers must run something.** There is no path here to shipping a product with a third-party credential and no infrastructure. For small products that may be the difference between shipping and not. - **A declared endpoint is publicly reachable.** Caller proofs raise the bar without making it private. Backends still need rate limiting, and the abuse cost lands on whoever runs them. -- **`signature` provides no scarcity.** Keypairs are free, so at that tier the operator is relying on the attacker's payoff being low. That is a judgement about a specific backend, not a guarantee. -- **`personhood` excludes non-members.** It rests on people-set membership, so it shuts out anyone still verifying. RFC-0023 exists precisely because that population needs a different path, and this tier has no equivalent for them. -- **Product identity is unverifiable, so a backend cannot restrict who invokes it.** Any product may name another's record, and the caller field carrying product identity is host-asserted. Backends gate on the caller proof and their own rate limits. What impersonation buys is calls against the backend's own endpoint, not possession of a credential. +- **Non-members cannot use this at all.** Every request needs people-set membership, so anyone still verifying is shut out of every backend, not just the sensitive ones. [RFC-0023](0023-account-sign-vrf.md) exists precisely because that population needs a different path, and this design has no equivalent for them. Making personhood mandatory buys Sybil resistance everywhere at that price. +- **Product identity is unverifiable, so a backend cannot restrict who invokes it.** Any product may name another's record, and the header carrying product identity is host-asserted. Backends gate on the caller proof and their own rate limits. What impersonation buys is calls against the backend's own endpoint, not possession of a credential. - **One record per operation.** A deployer needing several calls against the same credential publishes several names. That is the cost of the product not choosing paths. ## Alternatives ### A generic `get_host_secret(name)` -Rejected. It breaches the trust boundary by definition, and it cannot serve the TURN case anyway, because the identity backend holds no credential to return, only a minting endpoint. A flat namespace with no owner also lets two products each claim `meld`. +Rejected. It breaches the trust boundary by definition, and it cannot serve the TURN case anyway, because what a relay backend gives out is a ticket it mints, not a credential it stores. A flat namespace with no owner also lets two products each claim `meld`. ### Bake secrets into host distributions @@ -322,7 +274,7 @@ Rejected, and worth distinguishing from the accepted design because it looks sim ### Escrow the secret with a Parity-run resolver -Considered at length and set aside. The deployer would encrypt the secret to a resolver's published key, publish the ciphertext in the record, and the resolver would decrypt and attach it. It spares deployers from running anything, but it makes Parity the custodian of third-party payment credentials with the liability that follows, and concentrates every deployer's secret behind one breach. Confidential computing with remote attestation reduces that trust rather than removing it, at the cost of reproducible builds, enclave-hosted TLS egress, and re-attestation on every deploy. If requiring deployer-run infrastructure proves to block adoption, this is what to revisit. +Considered at length and set aside. The deployer would encrypt the secret to a resolver's published key, publish the ciphertext in the record, and the resolver would decrypt and attach it. It spares deployers from running anything, but it makes Parity the custodian of third-party payment credentials with the liability that follows, and concentrates every deployer's secret behind one breach. Confidential computing with remote attestation reduces that trust rather than removing it, at the cost of reproducible builds, enclave-hosted TLS egress, and re-attestation on every deploy. If requiring deployer-run infrastructure proves to block adoption, the network-run enclave below is the better version of this idea, since it removes the single custodian rather than hardening one. ### Encrypt secrets to every user's key @@ -334,7 +286,11 @@ Rejected. It attests app instances using Apple App Attest, Google Play Integrity ### Deliver results over the statement store -Rejected as the general transport. It offers durability across reloads and multi-device delivery, but it is a public broadcast medium, so it publishes durable metadata about which product called what and when, and RFC-0010 names that observer as the threat it defends against. It also adds propagation latency at the moment a user taps buy, needs a slot allowance, and bounds payload size. It remains plausible as an optional delivery mode for small latency-tolerant payloads. +Rejected as the general transport. It offers durability across reloads and multi-device delivery, but it is a public broadcast medium, so it publishes durable metadata about which product called what and when, and [RFC-0010](0010-allowance.md) names that observer as the threat it defends against. It also adds propagation latency at the moment a user taps buy, needs a slot allowance, and bounds payload size. It remains plausible as an optional delivery mode for small latency-tolerant payloads. + +### A network-run trusted execution environment + +Not rejected, and the direction to revisit. Instead of the deployer running a backend, the credential would be encrypted to an enclave whose attestation proves which code decrypts it, with the enclave operated by network nodes rather than by Parity or the deployer. That removes the custody objection to a Parity-run resolver and the requirement that every deployer run infrastructure. Polkadot parachains built for confidential compute, such as Integritee and Phala, exist for roughly this purpose. It is out of scope here because it needs a different record carrying ciphertext and an attestation policy, because attestation moves trust to a hardware vendor rather than removing it, and because nothing in this design is blocked waiting for it. ### Have the provider issue a client-safe credential @@ -342,19 +298,18 @@ Not rejected, and preferable where available. Meld Checkout accepts a `publicKey ## Prior Art and References -- **RFC-0004**, `create_account_proof`. The ring-VRF proof and the `ProductProofContext` whose suffix yields unlinkable contextual aliases, which the `personhood` tier is built from. Its `NotMember` error is mirrored here. -- **RFC-0010**, allowance and `AutoSigning`, which decides whether a caller proof needs a per-call confirmation. -- **RFC-0022**, account key derivations. Source of the product account the `signature` tier signs with. -- **RFC-0023**, `sign_vrf`. The complementary sr25519 path for participants who are not yet people-set members, and why it is not the primitive used here. -- **RFC-0024**, personhood as a product (in review). It adds an explicit `key_handle` to `create_account_proof` and deletes RFC-0004's host-side key selection, so the `personhood` tier here depends on whichever of the two lands. It also requires every proof context to be built with TrUAPI's product-scoped context function, which constrains how this RFC may derive its suffix. -- `POST /v1/turn/issue` in the identity backend. Already implemented, and the default `turn` backend. +- **[RFC-0004](0004-ringlocation-redesign.md)**, `create_account_proof`. The ring-VRF proof and the `ProductProofContext` whose suffix yields unlinkable contextual aliases, which the caller proof is built from. Its `NotMember` error is mirrored here. +- **[RFC-0010](0010-allowance.md)**, allowance and `AutoSigning`, which decides whether a caller proof needs a per-call confirmation. +- **[RFC-0022](0022-account-derivations.md)**, account key derivations. Source of the product account and the ring VRF domain the proof is made from. +- **[RFC-0023](0023-account-sign-vrf.md)**, `sign_vrf`. The complementary sr25519 path for participants who are not yet people-set members, and why it is not the primitive used here. +- **[RFC-0024](https://github.com/paritytech/truapi/pull/324)**, personhood as a product (in review). It adds an explicit `key_handle` to `create_account_proof` and deletes [RFC-0004](0004-ringlocation-redesign.md)'s host-side key selection, so the caller proof here depends on whichever of the two lands. It also requires every proof context to be built with TrUAPI's product-scoped context function, which constrains how this RFC may derive its suffix. +- `POST /v1/turn/issue` in the identity backend. An existing implementation of what a relay backend does here: it holds the relay secret and returns only a short-lived ticket. - [Meld API getting started](https://docs.meld.io/docs/meld-api/getting-started), for the backend-only constraint and the note that "Meld does not require IP or CORS allowlisting", which rules out origin restriction as a mitigation. ## Unresolved Questions -- **What user consent does this call require?** Reusing `RemotePermission::Remote { domains }` for the endpoint origin is the obvious fit, but it was written for a product reaching out directly, and here the host calls on the product's behalf. Open within that: whether consent is per backend or per call, whether the record's declared endpoint is shown at grant time, and whether `personhood` needs its own prompt given it discloses more than `signature`. -- **How the `ProductProofContext` suffix is derived from the backend.** It must bind in a way the backend operator can reproduce and a product cannot vary to farm fresh aliases. The endpoint origin is the obvious binding, which means changing endpoint resets every identifier. RFC-0004 leaves the suffix to the caller, and RFC-0024 requires contexts to use the product-scoped construction, so this needs settling against whichever lands. -- **Where the `key_handle` comes from if RFC-0024 lands.** That RFC deletes host-side key selection, so a `personhood` request would need a handle the product does not have and must not learn. The host supplying it from the registry is the obvious answer and is not specified here. -- **How host defaults are discovered.** A product needs to know whether `turn` exists before calling it, and hosts differ. This may want a companion to the existing `featureSupported` probe. -- **Whether the record needs a schema version.** One field now avoids a migration later, when `caller` grows variants. -- **Should the platform `turn` default really require `personhood`?** Minting relay tickets is directly monetisable, which argues yes, but it would lock every non-member out of WebRTC entirely. `signature` plus a tight per-caller quota may be the better trade, and this is a product decision rather than a protocol one. +- **What user consent does this call require?** Reusing `RemotePermission::Remote { domains }` for the endpoint origin is the obvious fit, but it was written for a product reaching out directly, and here the host calls on the product's behalf. Open within that: whether consent is per backend or per call, and whether the record's declared endpoint is shown at grant time. +- **How the `ProductProofContext` suffix is derived from the backend.** It must bind in a way the backend operator can reproduce and a product cannot vary to farm fresh aliases. The endpoint origin is the obvious binding, which means changing endpoint resets every identifier. [RFC-0004](0004-ringlocation-redesign.md) leaves the suffix to the caller, and [RFC-0024](https://github.com/paritytech/truapi/pull/324) requires contexts to use the product-scoped construction, so this needs settling against whichever lands. +- **Where the `key_handle` comes from if [RFC-0024](https://github.com/paritytech/truapi/pull/324) lands.** That RFC deletes host-side key selection, so a request would need a handle the product does not have and must not learn. The host supplying it from the registry is the obvious answer and is not specified here. +- **Whether the record needs a schema version.** One field now avoids a migration later, if the record ever grows beyond endpoint, path, and method. +- **Does requiring personhood everywhere cost too much?** It locks non-members out of WebRTC and onramp alike. The alternative is a per-record choice between a product-account signature and a ring proof, which restores configurability at the cost of every backend having to decide, and of a weaker default for anyone who picks wrong. diff --git a/rust/crates/truapi/src/api/secrets.rs b/rust/crates/truapi/src/api/secrets.rs index a86f96b6..f6310328 100644 --- a/rust/crates/truapi/src/api/secrets.rs +++ b/rust/crates/truapi/src/api/secrets.rs @@ -10,9 +10,9 @@ pub trait Secrets: Send + Sync { /// Send a request to a backend, which holds a credential the product never /// sees, and return its response. /// - /// The backend is resolved as `secret:` in `product`'s dotNS records, - /// falling back to a host default. That record fixes the endpoint, path, - /// and method, so the caller supplies only a query, headers, and a body. + /// The backend is resolved as `secret:` in `product`'s dotNS + /// records. That record fixes the endpoint, path, and method, so the + /// caller supplies only a query, headers, and a body. /// /// ```ts /// const result = await truapi.secrets.request({ diff --git a/rust/crates/truapi/src/v01/secrets.rs b/rust/crates/truapi/src/v01/secrets.rs index 1565263e..4a296ec1 100644 --- a/rust/crates/truapi/src/v01/secrets.rs +++ b/rust/crates/truapi/src/v01/secrets.rs @@ -1,31 +1,22 @@ use parity_scale_codec::{Decode, Encode}; -/// Caller identity a backend requires, declared in its dotNS record (RFC 0025). -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum CallerRequirement { - /// Product name only. Nothing the backend can verify. - None, - /// Product account key plus a signature over the canonical digest. - Signature, - /// The above plus a ring VRF proof and its contextual alias. - Personhood, -} - /// Error from [`crate::api::Secrets::request`] (RFC 0025). #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum HostSecretError { /// No authenticated session (RFC 0009). The host must not auto-prompt login. NotConnected, - /// No record and no host default under that name. + /// No record under that name. UnknownSecret, /// The record resolved but does not parse. MalformedRecord, /// The user declined consent or the signing confirmation. Rejected, - /// The backend requires `Personhood` and the user is not a people-set member. + /// The user is not a people-set member, so no caller proof can be produced. NotMember, /// The backend could not be reached. Transport, + /// The response exceeded the host's limit and was discarded. + ResponseTooLarge, /// Catch-all. Unknown { /// Human-readable failure reason. @@ -53,13 +44,14 @@ pub struct SecretQueryParam { /// Request to a backend holding a credential the product never sees (RFC 0025). /// -/// The backend is resolved as `secret:` in `product`'s dotNS records, -/// falling back to a host default. That record fixes the endpoint, path, and -/// method, so the caller supplies only a query, headers, and a body. +/// The backend is resolved as `secret:` in `product`'s dotNS records. +/// That record fixes the endpoint, path, and method, so the caller supplies +/// only a query, headers, and a body. The host attaches a ring VRF proof over +/// the canonical digest, plus the contextual alias for that backend. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostSecretRequest { - /// dotNS name whose records declare the backend. Usually the caller's own, - /// but naming another product is allowed. + /// dotNS name whose records declare the backend. Often the caller's own, + /// but naming another is how a product reaches a shared service. pub product: String, /// Secret name, resolved as `secret:` in that product's records. pub name: String, From 92294d3f3fc4f5a5f05df21f7b5b2b34efd781f0 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Tue, 4 Aug 2026 17:33:46 +0100 Subject: [PATCH 5/5] Reject non-public endpoints and align the secrets RFC with the shipped types --- docs/rfcs/0025-secrets.md | 94 +++++++++++++-------------- rust/crates/truapi/src/api/secrets.rs | 9 ++- rust/crates/truapi/src/lib.rs | 4 +- rust/crates/truapi/src/v01/secrets.rs | 20 +++--- 4 files changed, 64 insertions(+), 63 deletions(-) diff --git a/docs/rfcs/0025-secrets.md b/docs/rfcs/0025-secrets.md index fccd9b77..c7514046 100644 --- a/docs/rfcs/0025-secrets.md +++ b/docs/rfcs/0025-secrets.md @@ -31,6 +31,8 @@ The credential never reaches the product or the host. The host runs on the user' A funding product wants a meld.io API key for fiat onramp. A game product wants TURN credentials so two players can connect through a relay when their networks refuse a direct path. Neither can hold what they are asking for, because a product runs inside a host on the user's own machine and anything handed to either is readable by the person using it. [Meld's documentation](https://docs.meld.io/docs/meld-api/getting-started) states it plainly: "Always call Meld from your backend. Direct calls from a browser or mobile app expose your API key." A TURN relay secret mints unlimited credentials for a relay somebody pays bandwidth for. Both are long-lived credentials that must stay unknown to the person running the product. +Where a provider offers a credential that is safe in a client, none of this is needed. Meld Checkout takes a `publicKey` in the URL and requires no backend, at the cost of Meld's hosted UI in place of a custom provider and quote flow. That is worth checking before publishing a record. + Requirements for a solution: 1. **Products never receive secret material.** What crosses the TrUAPI boundary is the result of an operation, or a credential that expires on its own. @@ -70,11 +72,11 @@ The credential never appears in the record. What is published is one operation t A request names the dotNS name whose records to read and the secret within them, so `secret:` resolves under that name. ```ts -// The deployer's own TURN relay. -await truapi.secrets.request({ product: self, name: "turn" }); +// A TURN relay run by the deployer. +await truapi.secrets.request({ productId: myDotNsName, name: "turn" }); -// The deployer's own Meld session backend. -await truapi.secrets.request({ product: self, name: "meld-session", body }); +// A Meld session backend run by the deployer. +await truapi.secrets.request({ productId: myDotNsName, name: "meld-session", body }); ``` Naming a dotNS name other than your own is allowed, which is how a product reaches a service another deployer publishes. It follows [RFC-0004](0004-ringlocation-redesign.md) and [RFC-0023](0023-account-sign-vrf.md), which take a product identifier rather than assuming the caller's. Records are public and endpoints are publicly reachable, so refusing it would buy obscurity rather than isolation. What actually protects a backend is the caller proof and its own rate limiting, not who is permitted to name it. @@ -86,10 +88,9 @@ Typed convenience such as a `getIceServers()` that parses the TURN response into Added to a new `Secrets` trait: ```rust -/// Send a request to a backend, which holds a credential the product never -/// sees, and return its response. +/// Send a request to a backend and return its response. /// -/// The backend is resolved as `secret:` in `product`'s dotNS records. +/// The backend is resolved as `secret:` in the dotNS records of `product`. /// That record fixes the endpoint, path, and method, so the caller supplies /// only a body, query, and headers. #[wire(request_id = 166)] @@ -106,18 +107,23 @@ async fn request( ```rust struct HostSecretRequest { - /// dotNS name whose records declare the backend. Often the caller's own, + /// dotNS name whose records declare the backend. Often the calling product, /// but naming another is how a product reaches a shared service. - product: DotNsName, - /// Secret name, resolved as `secret:` in that name's records. + product_id: String, + /// Secret name, resolved as `secret:` in those records. /// The record it finds names the backend. name: String, - /// Appended to the record's fixed path as a query string. - query: Vec<(String, String)>, - headers: Vec<(String, String)>, - body: Option, + /// Appended to the fixed path as a query string. + query: Vec, + /// Headers to forward. The host strips any in the `X-Polkadot-` namespace. + headers: Vec, + /// Request body, if the declared method takes one. + body: Option>, } +struct SecretHeader { name: String, value: String } +struct SecretQueryParam { name: String, value: String } + /// The dotNS record, published by the deployer rather than sent over the wire. struct BackendRecord { /// Origin the request is sent to. Shown to the user at consent time. @@ -130,8 +136,8 @@ struct BackendRecord { struct HostSecretResponse { status: u16, - headers: Vec<(String, String)>, - body: Bytes, + headers: Vec, + body: Vec, } enum HostSecretError { @@ -139,7 +145,8 @@ enum HostSecretError { NotConnected, /// No record under that name. UnknownSecret, - /// The record exists but does not parse, or names an unsupported field. + /// The record resolved but does not parse, names an unsupported field, + /// or declares an endpoint the host refuses to fetch. MalformedRecord, /// The user declined consent or the signing confirmation. Rejected, @@ -148,8 +155,10 @@ enum HostSecretError { NotMember, /// The endpoint could not be reached. Transport, - /// The response exceeded the host's limit and was discarded. + /// The response exceeded the limit set by the host and was discarded. ResponseTooLarge, + /// The request body or header count exceeded the limit set by the host. + RequestTooLarge, Unknown { reason: String }, } ``` @@ -165,15 +174,17 @@ X-Polkadot-Proof Ring VRF proof over the canonical digest. X-Polkadot-Alias Contextual alias for this backend. ``` -The canonical digest covers the method, the full request URL including query, the timestamp, the nonce, and a hash of the body. Backends reject a request whose timestamp falls outside their accepted window, and should reject a repeated nonce within it. +The canonical digest covers the method, the full request URL including query, the timestamp, the nonce, and a hash of the body. Backends MUST reject a request whose timestamp falls outside their accepted window, and MUST reject a repeated nonce within it. A replayed request is a duplicated operation, which for a payment session is a duplicated charge. ### Call semantics The host resolves `secret:` from `product`'s dotNS records and returns `UnknownSecret` if there is none. It builds the request from the record's `endpoint`, `path`, and `method`, appending the caller's `query`. It obtains user consent, produces the caller proof, and attaches it. +The host MUST reject an endpoint that is not `https`, and MUST reject one resolving to a loopback, link-local, or private address, returning `MalformedRecord` in both cases. It MUST apply that check to the address it connects to rather than to the hostname, so a name that resolves differently on a second lookup cannot slip past it. Without this a product could publish `secret:x` naming `http://127.0.0.1:9944` under its own dotNS name and use the host as a proxy into the user's machine and local network, reaching what the browser's same-origin and local network protections would otherwise deny it. + The host MUST strip any caller-supplied header in the `X-Polkadot-` namespace before attaching its own, so a product cannot forge or displace the proof. -The host MUST bound the response it buffers and MUST return `ResponseTooLarge` rather than exceed that bound. A product may publish a record naming any endpoint and then call it, so the response is untrusted input regardless of who deployed the product, and an unbounded one exhausts the host through a call the user has already consented to. The same bound applies to the request body and header count, which the product supplies directly. +The host MUST bound the response it buffers and MUST return `ResponseTooLarge` rather than exceed that bound. A product may publish a record naming any endpoint and then call it, so the response is untrusted input regardless of who deployed the product, and an unbounded one exhausts the host through a call the user has already consented to. The same applies to the request body and header count, which the product supplies directly, returning `RequestTooLarge`. The response is returned unmodified except for hop-by-hop headers. Products must not assume the request originated from the user's address, because the host makes it directly and the backend makes any upstream call from its own infrastructure. @@ -189,7 +200,7 @@ The unlinkable identifier comes with it: the call takes a `ProductProofContext { This is deliberately the ring path and not `sign_vrf` ([RFC-0023](0023-account-sign-vrf.md)). That method produces an sr25519 VRF bound to the product account, for participants who are **not yet** people-set members. It is identity-bound rather than anonymous, and a non-member account is free to create, so it delivers neither the anonymity nor the scarcity this design needs. The two are complementary and only the ring path fits here. -A backend verifies against the People chain, so Parity is in neither the request path nor the verification path, and there is no token format or key distribution to agree. It also works identically on the desktop and web hosts, which is why device attestation is not used here: Apple App Attest and Google Play Integrity exist only on mobile. +A backend verifies against the People chain, so Parity is in neither the request path nor the verification path, and there is no key distribution to agree. The canonical digest is the one thing every verifier must reproduce identically, and it is not yet pinned to that precision. It also works identically on the desktop and web hosts, which is why device attestation is not used here: Apple App Attest and Google Play Integrity exist only on mobile. The cost is that a non-member cannot reach any backend and gets `NotMember`. That is the deliberate trade: every backend is Sybil-resistant without its operator configuring anything, and the population still verifying is served by nothing here. @@ -197,7 +208,7 @@ The cost is that a non-member cannot reach any backend and gets `NotMember`. Tha A backend that verifies these proofs MUST: -> Derive the caller identity it rate limits from the contextual alias carried by a ring proof checked against the current ring on the People chain, never from a caller-supplied field. +> Verify the ring proof against the current ring on the People chain, and verify that it was made over the digest of the request as received. Derive the caller identity it rate limits from the contextual alias that proof carries, never from a caller-supplied field. `X-Polkadot-Product` is host-asserted and unverifiable, so a backend must never make a trust decision on it. It is a routing and diagnostics hint. In particular a backend cannot restrict itself to the product that declared it, because any product may name that record and the header asserting otherwise is unverifiable. A backend that ignores this contract gains nothing an attacker cannot forge, and the failure is silent, which is why it is stated normatively rather than left to implementers. @@ -217,7 +228,7 @@ sequenceDiagram D->>N: publish secret:meld-session = { endpoint, path, method } Note over D,S: The Meld key stays on the backend. It is never published. - P->>H: secrets.request({ product: self, name: "meld-session" }) + P->>H: secrets.request({ productId: myDotNsName, name: "meld-session" }) H->>H: resolve secret:meld-session, obtain user consent H->>H: ring proof over the canonical digest H->>S: POST /session + X-Polkadot-Proof, -Alias @@ -246,7 +257,7 @@ For credentials that belong to a **deployer or to Parity** and must stay unknown **Not** for secrets that belong to the user. Those can be encrypted to the user's own key, and the objection driving this design does not apply to them. -**Not** a general outbound HTTP proxy. The endpoint is fixed by the record, and a product wanting arbitrary network access already has `RemotePermission::Remote`. +**Not** a general outbound HTTP proxy. The endpoint is fixed by the record, must be public HTTPS, and a product wanting arbitrary network access already has `RemotePermission::Remote`. **Not** a way to hide anything from the user. Results cross into the host and are therefore readable by whoever runs it. Only the credential stays out of reach. @@ -260,41 +271,29 @@ For credentials that belong to a **deployer or to Parity** and must stay unknown ## Alternatives -### A generic `get_host_secret(name)` +### The product calls the endpoint directly + +The closest thing to doing nothing. A product can already reach any origin with `RemotePermission::Remote`, so a deployer could publish no record and hardcode their endpoint instead. Two things would be missing. The endpoint would live in the bundle, so rotating it needs a redeploy and every consumer of a shared service needs its own copy. And nothing the product sends could be trusted, because a product can claim whatever identity it likes, which leaves the endpoint open to anyone who finds it. Record-based discovery and an unforgeable caller proof are the whole of what this method adds, and without both it should not exist. -Rejected. It breaches the trust boundary by definition, and it cannot serve the TURN case anyway, because what a relay backend gives out is a ticket it mints, not a credential it stores. A flat namespace with no owner also lets two products each claim `meld`. +### A network-run trusted execution environment + +Not rejected, and the direction to revisit. Instead of the deployer running a backend, the credential would be encrypted to an enclave whose attestation proves which code decrypts it, with the enclave operated by network nodes rather than by Parity or the deployer. That removes both the custody problem and the requirement that every deployer run infrastructure. Polkadot parachains built for confidential compute, such as Integritee and Phala, exist for roughly this purpose. It is out of scope here because it needs a different record carrying ciphertext and an attestation policy, because attestation moves trust to a hardware vendor rather than removing it, and because nothing in this design is blocked waiting for it. -### Bake secrets into host distributions +### Put the secret on the device -Rejected. A downloadable host makes any embedded secret public. This is not hypothetical, it is what ships today. +This fails in both the forms it takes. A generic `get_host_secret(name)` breaches the trust boundary by definition, and it cannot serve the TURN case anyway, because what a relay backend gives out is a ticket it mints rather than a credential it stores. A flat namespace with no owner also lets two products each claim `meld`. Baking the secret into the host distribution fails harder still, since a downloadable host makes any embedded secret public. ### Fetch the secret from the deployer's URL into the host -Rejected, and worth distinguishing from the accepted design because it looks similar. An endpoint that hands the plaintext credential to whoever asks is strictly worse than publishing the credential, because it adds a false sense of control. The host calls a backend to have work done, never to collect a key. +Worth distinguishing from the accepted design, because it looks similar. An endpoint that hands the plaintext credential to whoever asks is strictly worse than publishing the credential, because it adds a false sense of control. The host calls a backend to have work done, never to collect a key. ### Escrow the secret with a Parity-run resolver -Considered at length and set aside. The deployer would encrypt the secret to a resolver's published key, publish the ciphertext in the record, and the resolver would decrypt and attach it. It spares deployers from running anything, but it makes Parity the custodian of third-party payment credentials with the liability that follows, and concentrates every deployer's secret behind one breach. Confidential computing with remote attestation reduces that trust rather than removing it, at the cost of reproducible builds, enclave-hosted TLS egress, and re-attestation on every deploy. If requiring deployer-run infrastructure proves to block adoption, the network-run enclave below is the better version of this idea, since it removes the single custodian rather than hardening one. +Considered at length and set aside. The deployer would encrypt the secret to a resolver's published key, publish the ciphertext in the record, and the resolver would decrypt and attach it. It spares deployers from running anything, but it makes Parity the custodian of third-party payment credentials with the liability that follows, and concentrates every deployer's secret behind one breach. The network-run enclave above is the better form of the same idea, since it removes the single custodian rather than hardening one. ### Encrypt secrets to every user's key -Rejected. Authorising a user to decrypt gives that user the plaintext, which is the outcome the Meld case must avoid. The same objection defeats encrypting to a host key, since the host is the user's to control. It also requires enumerating users before they arrive, grows the record linearly, and cannot revoke what has already been decrypted. The scheme is correct for secrets that belong to the user, which is a non-goal here. - -### Identity-backend attestation tokens as the caller proof - -Rejected. It attests app instances using Apple App Attest, Google Play Integrity, and Android key attestation, none of which exist on the desktop or web hosts. Its tokens are HS256, so the identity backend is the only party able to verify them, and a deployer could not check one without new asymmetric signing, a published JWKS, and an audience claim. Personhood delivers stronger scarcity, works everywhere, and is verifiable against the People chain. - -### Deliver results over the statement store - -Rejected as the general transport. It offers durability across reloads and multi-device delivery, but it is a public broadcast medium, so it publishes durable metadata about which product called what and when, and [RFC-0010](0010-allowance.md) names that observer as the threat it defends against. It also adds propagation latency at the moment a user taps buy, needs a slot allowance, and bounds payload size. It remains plausible as an optional delivery mode for small latency-tolerant payloads. - -### A network-run trusted execution environment - -Not rejected, and the direction to revisit. Instead of the deployer running a backend, the credential would be encrypted to an enclave whose attestation proves which code decrypts it, with the enclave operated by network nodes rather than by Parity or the deployer. That removes the custody objection to a Parity-run resolver and the requirement that every deployer run infrastructure. Polkadot parachains built for confidential compute, such as Integritee and Phala, exist for roughly this purpose. It is out of scope here because it needs a different record carrying ciphertext and an attestation policy, because attestation moves trust to a hardware vendor rather than removing it, and because nothing in this design is blocked waiting for it. - -### Have the provider issue a client-safe credential - -Not rejected, and preferable where available. Meld Checkout accepts a `publicKey` in the URL and requires no backend, which would leave the funding product needing nothing from this RFC, at the cost of Meld's hosted UI in place of a custom provider and quote flow. Worth checking before declaring a backend. +Authorising a user to decrypt gives that user the plaintext, which is the outcome the Meld case must avoid. The same objection defeats encrypting to a host key, since the host is the user's to control. It also requires enumerating users before they arrive, grows the record linearly, and cannot revoke what has already been decrypted. The scheme is correct for secrets that belong to the user, which is a non-goal here. ## Prior Art and References @@ -308,6 +307,7 @@ Not rejected, and preferable where available. Meld Checkout accepts a `publicKey ## Unresolved Questions +- **How is the canonical digest constructed, exactly?** Every deployer writes an independent verifier, so field ordering, separators, the hash algorithm, query canonicalization, and the encoding of the proof and alias headers all have to be pinned rather than described. This RFC should not ship without test vectors a verifier can check itself against. - **What user consent does this call require?** Reusing `RemotePermission::Remote { domains }` for the endpoint origin is the obvious fit, but it was written for a product reaching out directly, and here the host calls on the product's behalf. Open within that: whether consent is per backend or per call, and whether the record's declared endpoint is shown at grant time. - **How the `ProductProofContext` suffix is derived from the backend.** It must bind in a way the backend operator can reproduce and a product cannot vary to farm fresh aliases. The endpoint origin is the obvious binding, which means changing endpoint resets every identifier. [RFC-0004](0004-ringlocation-redesign.md) leaves the suffix to the caller, and [RFC-0024](https://github.com/paritytech/truapi/pull/324) requires contexts to use the product-scoped construction, so this needs settling against whichever lands. - **Where the `key_handle` comes from if [RFC-0024](https://github.com/paritytech/truapi/pull/324) lands.** That RFC deletes host-side key selection, so a request would need a handle the product does not have and must not learn. The host supplying it from the registry is the obvious answer and is not specified here. diff --git a/rust/crates/truapi/src/api/secrets.rs b/rust/crates/truapi/src/api/secrets.rs index f6310328..9c4e1a4e 100644 --- a/rust/crates/truapi/src/api/secrets.rs +++ b/rust/crates/truapi/src/api/secrets.rs @@ -7,16 +7,15 @@ use crate::{CallContext, CallError}; /// Calls made with a credential the product never holds. #[crate::async_trait] pub trait Secrets: Send + Sync { - /// Send a request to a backend, which holds a credential the product never - /// sees, and return its response. + /// Send a request to a backend and return its response. /// - /// The backend is resolved as `secret:` in `product`'s dotNS - /// records. That record fixes the endpoint, path, and method, so the + /// The backend is resolved as `secret:` in the dotNS records of + /// `product_id`. That record fixes the endpoint, path, and method, so the /// caller supplies only a query, headers, and a body. /// /// ```ts /// const result = await truapi.secrets.request({ - /// product: "onramp.dot", + /// productId: "onramp.dot", /// name: "meld-session", /// query: [], /// headers: [{ name: "Content-Type", value: "application/json" }], diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df03..c05d8f51 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -37,8 +37,8 @@ pub mod latest { RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, RuntimeApi, RuntimeSpec, RuntimeType, - SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeVariant, TxPayloadExtension, + SecretHeader, SecretQueryParam, SignedStatement, Statement, StatementProof, + StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, TxPayloadExtension, }; /// Latest payload type of a versioned envelope. diff --git a/rust/crates/truapi/src/v01/secrets.rs b/rust/crates/truapi/src/v01/secrets.rs index 4a296ec1..d5a58926 100644 --- a/rust/crates/truapi/src/v01/secrets.rs +++ b/rust/crates/truapi/src/v01/secrets.rs @@ -7,7 +7,7 @@ pub enum HostSecretError { NotConnected, /// No record under that name. UnknownSecret, - /// The record resolved but does not parse. + /// The record resolved but does not parse, or names an unsupported field. MalformedRecord, /// The user declined consent or the signing confirmation. Rejected, @@ -15,8 +15,10 @@ pub enum HostSecretError { NotMember, /// The backend could not be reached. Transport, - /// The response exceeded the host's limit and was discarded. + /// The response exceeded the limit set by the host and was discarded. ResponseTooLarge, + /// The request body or header count exceeded the limit set by the host. + RequestTooLarge, /// Catch-all. Unknown { /// Human-readable failure reason. @@ -33,7 +35,7 @@ pub struct SecretHeader { pub value: String, } -/// One query parameter appended to the record's fixed path. +/// One query parameter appended to the fixed path in the record. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SecretQueryParam { /// Parameter name. @@ -44,22 +46,22 @@ pub struct SecretQueryParam { /// Request to a backend holding a credential the product never sees (RFC 0025). /// -/// The backend is resolved as `secret:` in `product`'s dotNS records. +/// The backend is resolved as `secret:` in the dotNS records of `product_id`. /// That record fixes the endpoint, path, and method, so the caller supplies /// only a query, headers, and a body. The host attaches a ring VRF proof over /// the canonical digest, plus the contextual alias for that backend. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostSecretRequest { - /// dotNS name whose records declare the backend. Often the caller's own, + /// dotNS name whose records declare the backend. Often the calling product, /// but naming another is how a product reaches a shared service. - pub product: String, - /// Secret name, resolved as `secret:` in that product's records. + pub product_id: String, + /// Secret name, resolved as `secret:` in those records. pub name: String, - /// Appended to the record's fixed path as a query string. + /// Appended to the fixed path as a query string. pub query: Vec, /// Headers to forward. The host strips any in the `X-Polkadot-` namespace. pub headers: Vec, - /// Request body, if the record's method takes one. + /// Request body, if the declared method takes one. pub body: Option>, }