From 08204d733e1acc96ca6079086d11cc461b381c11 Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 30 Jul 2026 18:45:21 +0700 Subject: [PATCH 1/7] People RFC --- docs/rfcs/0024-personhood-as-product.md | 459 ++++++++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 docs/rfcs/0024-personhood-as-product.md diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md new file mode 100644 index 000000000..022214155 --- /dev/null +++ b/docs/rfcs/0024-personhood-as-product.md @@ -0,0 +1,459 @@ +--- +title: "Proof of Personhood as a product" +owner: "@valentunn" +--- + +# RFC-0024: Proof of Personhood as a Product — Explicit Ring VRF Key Management + +| | | +| --------------- | ------------------------------------------------------------------------------------------------------------------- | +| **RFC Number** | 24 | +| **Start Date** | 2026-07-29 | +| **Description** | Make ring VRF member keys explicit, product-owned, and usable across products, so personhood can ship as a product | +| **Authors** | Valentin Sergeev | + +## Summary + +RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof` / `get_account_alias`. With an `onLoad` executable modality for global lifetime, and the Accounts Protocol companions, full and light personhood become a standalone product that no consumer — including the Host itself — has to know the key index of. + +Because a ring VRF proof is a bearer token for its context's alias, a proof is only ever issued when the caller owns either the key or the context. Cross-product alias use moves to transaction signing instead: `create_transaction`'s signer generalizes to include personhood-alias origins, so the Host produces the proof while satisfying the signer and never hands one out. + +It also resolves RFC-0022's deferral of well-known alias accounts (`score`, `resources`, `mob-rule`): every context is product-owned and constructed with TrUAPI's product-scoped context function, so there is no second context scheme. + +## Motivation + +### Personhood is welded into the Host + +RFC-0004 §"Host member-key selection" requires every Host to define the PoP ring collection internally, choose a member key corresponding to the requested `RingLocation`, fall back to the PoP key when correspondence is undeterminable, and tiebreak stably. `truapi-server` implements exactly that with the ring identities compiled in (`rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs`: `FULL_PERSON_COLLECTION`, `LITE_PERSON_COLLECTION`, `enum PersonKey { Full, Lite }`). + +So personhood cannot be shipped, versioned, or replaced independently of the Host: every change to how a person key is derived, registered, renewed, or recovered is a Host release. + +### What a personhood product must be able to do + +1. **Own** the full and light personhood ring VRF keys — under RFC-0022 the `peopl.dot` domain of the ring-VRF tree. +2. **Tell the Host and the Account Holder enough** to keep serving the app's own personhood-dependent features — coinage unload proofs, and the ring-VRF slot assignment behind PGAS / Bulletin / Statement Store allowance (RFC-0010). +3. **Lend its keys** to other products, so they can create proofs and read aliases. +4. **Lend its aliases** — without lending the proofs behind them. Every use of an alias is a signature under an alias origin; the alias must additionally be *set* on chain and *renewed* — after a suspension a fresh `set_alias` is required from scratch, while a ring-revision change still requires a proof but can ride an `AsPersonalAliasWithAccountRevised` origin alongside the alias update. + +The binding constraint across all four: **no consumer may know which key is used** — not the app, and not a calling product. + +### The obstacle + +The member keys serve three overlapping classes of work, and only one is not extractable: + +| Class | Examples | Extractable? | +| ---------------------------- | -------------------------------------------------- | ---------------------------------- | +| App-internal features | coinage unload proofs, PGAS / Bulletin / SSS slots | No — the app itself needs the key | +| Product-extractable features | game, mobrule, identity | Yes | +| Cross-product shared | set identity account, set score alias | Yes, but needs cross-product reach | + +The app-internal class is what forces a mechanism. This RFC picks a **registration call**: the product declares which of its keys is intended for which ring, and the Host uses that registration wherever it used a compiled-in key. The rejected alternative is in [Alternatives](#alternatives). + +### Remote Hosts cannot use ring VRF keys without the phone + +A remote (Desktop) Host cannot use a ring VRF key without a round trip to the Account Holder, and the phone is usually backgrounded. Two independent directions address this: the layered background-availability model designed for consent-free SSO requests (referenced, not specified here — see [Prior Art](#prior-art-and-references)), and an **AutoSigning extension** that transfers the product's ring VRF domain entropy so the Host can derive registered member secrets locally. + +## Stakeholders + +- **Personhood product developers** — the first consumer; owns the registry entries for the full and light personhood rings. +- **Product developers building on personhood** — score / identity / mobrule / game; consume foreign handles, foreign contexts, and alias accounts. +- **Host developers** — implement the registry, drop the compiled-in member-key selection, add the `onLoad` modality. +- **Account Holder developers (Mobile App)** — become the authoritative registry, implement the new message pairs, extend the AutoSigning payload, answer registrations from the background. +- **Chain / individuality developers** — on-chain contexts (`score`, `resources`, `mob-rule`) must be derived with TrUAPI's product-scoped context function rather than a parallel namespace. + +## Explanation + +### Terminology + +- **Ring VRF domain** — per RFC-0022, ring VRF keys live in their own tree rooted at `hash(root_entropy, "ring-vrf")`, with hard-only paths `//{productId}//{index}`. A product's *domain entropy* is the node at `//{productId}`; its member secrets are the children of that node. This tree is disjoint from the sr25519 product-account tree at `//product//{productId}/{index}`. +- **DerivationIndex** — per RFC-0022, `Either`; each domain has its own index space. +- **Key handle** — the public name of a registered key: `ProductAccountId { dot_ns_identifier: , derivation_index: }`. It names a derivation slot in the owner's ring VRF domain, not an sr25519 account. +- **Registry** — the set of `(handle, declared rings)` entries. The Account Holder is authoritative; the Host holds a synchronized copy. + +### Key management calls + +Two additions to the `Account` trait. + +```rust +type RingVrfPublicKey = [u8; 32]; + +/// A registry entry as returned to a caller. +struct RegisteredRingVrfKey { + /// Stable public name of the key. + handle: ProductAccountId, + /// Rings the owning product declared this key for. + rings: Vec, + /// `Some` when the caller owns the key, or has been granted public-key disclosure. + public_key: Option, +} + +/// How much of a registry entry the caller is asking for. +enum RingVrfKeyDisclosure { + /// Handle and declared rings only. + Anonymized, + /// Additionally the member public key. + PublicKey, +} + +/// Register a ring VRF key the calling product owns, declaring the ring it is +/// intended for. Registering the same `index` for an additional `ring` extends +/// the existing entry rather than creating a second one. +fn register_ring_vrf_key( + index: DerivationIndex, + ring: RingLocation, +) -> Result; + +/// List the registry entries owned by `owner` — the calling product or another one. +fn list_ring_vrf_keys( + owner: ProductId, + disclosure: RingVrfKeyDisclosure, +) -> Result, ListRingVrfKeysErr>; +``` + +- **A product may register only its own keys.** Ownership is the calling product id, never a parameter. Registration therefore needs no capability gate and no prompt: a product creating an entry in its own domain cannot affect anyone else. +- **A key may be registered for many rings**, and a product may hold several keys for one ring. Neither the API nor consumers assume 1:1. +- **Registration declares intent, not membership.** It means "this is the key I will use for that ring", not "the user is a person". Membership is still discovered only by attempting a proof, which returns `NotMember` (RFC-0004). This keeps the registry from being a personhood oracle. +- **The public key is owner-visible by default, permissioned cross-product.** The anonymized shape is what makes routine discovery cheap; a member public key is linkable across every ring it appears in, so it is disclosed only under a grant. + +RFC-0022 already pins `//peopl.dot//index_bytes(0)` as the full personhood key and `index_bytes(1)` as the light one. Under this RFC those constants are the personhood product's own implementation detail, expressed to everyone else as two registry entries. + +### Proofs and aliases take an explicit key handle + +RFC-0004's Host member-key selection contract is **deleted**: the Host no longer defines a PoP collection, infers correspondence, or has a fallback. + +```rust +fn create_account_proof( + key_handle: ProductAccountId, + context: ProductProofContext, + ring: RingLocation, + message: Vec, +) -> Result; + +fn get_account_alias( + key_handle: ProductAccountId, + context: ProductProofContext, + ring: RingLocation, +) -> Result; +``` + +`ring` stays a parameter even though the handle carries declared rings: a key may be registered for several, and the caller must say which one the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise, so a stale caller cannot obtain a proof against a ring the owner did not intend. + +RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host holds trivially now that key selection is not Host policy. + +### Errors + +```rust +enum RegisterRingVrfKeyErr { + /// No user is signed in (RFC-0009). + NotConnected, + RingNotFound, + Rejected, + Unknown { reason: String }, +} + +enum ListRingVrfKeysErr { + NotConnected, + /// `owner` is not the calling product and the caller has no grant for it. + Rejected, + Unknown { reason: String }, +} + +// Extensions to the RFC-0004 error sets. `HostAccountGetAliasError` gains +// `KeyNotRegistered` and `KeyNotInRing`; only proofs carry the last variant. +enum HostAccountCreateProofError { + RingNotFound, + NotMember, + /// `key_handle` has no registry entry. + KeyNotRegistered, + /// `key_handle` is registered, but not for the requested `ring`. + KeyNotInRing, + /// Neither `key_handle` nor `context` belongs to the calling product. + ForeignKeyInForeignContext, + Rejected, + Unknown { reason: String }, +} + +// Extensions to `HostCreateTransactionError` (RFC-0020). +enum HostCreateTransactionError { + // ... existing variants unchanged ... + /// An alias signer names a context the caller has no grant for. + AliasNotPermitted, + /// The call contains a `set_alias` whose target account is outside the + /// subtree of the signing context's owner. + AliasTargetNotOwned, +} +``` + +### Cross-product discovery + +The flow for a game product to produce a proof with the full personhood key, under its **own** airdrop context — abstracted by the product SDK, not by the Host: + +```mermaid +sequenceDiagram + participant G as game.dot + participant H as Host + participant P as peopl.dot registry + + G->>H: list_ring_vrf_keys("peopl.dot", Anonymized) + H-->>G: [ { handle: (peopl.dot, ?), rings: [People, PeopleLite] } ] + G->>G: select the entry whose rings contain the People ring + G->>H: create_account_proof(handle, game.dot/airdrop, People, message) + H-->>G: proof + contextual_alias + ring_index + ring_revision +``` + +The context is the caller's own, which is what makes this the permitted shape; see [proof scope](#a-proof-only-ever-binds-to-a-context-the-caller-owns). + +**No product may assume a key index of another product.** The index is the owner's implementation detail; consumers select by declared `RingLocation` and treat the handle as opaque. Hardcoding `(peopl.dot, 0)` breaks the moment the owner rotates or adds a key. This is the one rule a consuming product has to remember. + +### Every context is owned by exactly one product + +RFC-0004's `ProductProofContext { product_id, suffix }` and its derivation are unchanged: + +```rust +fn product_context_bytes(ctx: ProductProofContext) -> [u8; 32] { + blake2b256(utf8("product/") ++ utf8(ctx.product_id) ++ utf8("/") ++ ctx.suffix) +} +``` + +There is **no separate well-known-context namespace and no second context scheme.** Every context — including the ones that exist as on-chain constants — is a `ProductProofContext`, and its on-chain constant is the output of `product_context_bytes`. Chain-side definitions must be derived with this function; RFC-0004's `product_account_id_for_proof_context(product_id, suffix)` then applies unchanged, so no special encoding of a context string into a derivation suffix is needed. + +A context therefore has exactly one owner — the `product_id` mixed into its derivation. A context used by many products is not thereby owned by many; consumers name the owner's context, and only the owner can define one. **This supersedes RFC-0022 §"Well-known alias accounts"**, which describes `score`, `resources`, and `mob-rule` as owned by no product and outside the product-based construction, and defers their handling. They are assigned owners instead: **the score context is owned by the personhood product**, and DIMs coercible to the score system are its consumers. + +Access to another product's context is governed by the ordinary permission model below — there is no separate sharing declaration on a context. + +### A proof only ever binds to a context the caller owns + +Keys and contexts are independently owned, so three combinations are meaningful, and one of them is dangerous. + +| `key_handle` owner | `context` owner | Example | Allowed | +| ------------------ | --------------- | -------------------------------------------------------------- | ------- | +| caller | caller | the personhood product proving under its own context | yes | +| **foreign** | caller | a game product proving with the people key in its airdrop context | yes | +| caller | **foreign** | a caller's own key under someone else's context — not a member of the personhood ring, so it fails `NotMember` anyway | yes | +| **foreign** | **foreign** | a game product proving with the people key in the score context | **no** | + +> A Host MUST reject `create_account_proof` when neither `key_handle` nor `context` belongs to the calling product, with `ForeignKeyInForeignContext`. + +The reason is that **a proof is a bearer token for its context's alias.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and supplying a preimage instead would still be blind signing — so no inspection at proof time can tell what the proof will authorize. A caller holding a proof under the score context can therefore bind that alias to an account of its own, signing the `set_alias` with its own product account, needing nothing further from anyone. There is no downstream chokepoint either: the caller can submit the extrinsic without going through the Host at all. + +Denying the foreign/foreign combination removes the token. The resulting invariant is simple: **whoever holds a proof owns the context it binds to**, so the choice of which account an alias points at is always the context owner's own business. + +### Alias signing + +Denying that combination would also deny the legitimate case — a product acting under another product's alias, such as claiming score rewards — if proofs were the only route. They are not. `create_transaction` already promises to *"upon approval, fill all necessary transaction extensions to satisfy signer"*, so it is the natural place for an alias origin: the Host constructs the proof as part of satisfying the signer, which means it never hands one out, and it sees the whole call while doing so. + +RFC-0020 parametrized the transaction payload by signer type; the `ProductAccountId` signer generalizes to: + +```rust +enum TxSigner { + /// Sign as an ordinary product account. Today's behaviour. + ProductAccount(ProductAccountId), + /// Sign as a personhood alias whose account is already set on chain, + /// under an `AsPersonalAliasWithAccount`-style origin. + PersonalAliasWithAccount(ProductAccountId, ProductProofContext), + /// Sign as a personhood alias proven by ring VRF, under an + /// `AsPersonalAlias`-style origin. This is what `set_alias` uses. + PersonalAliasWithProof(ProductProofContext), +} +``` + +This does not change `create_transaction`'s semantics — the caller still supplies a call and the Host still fills whatever the signer requires. The proof simply becomes one of those things, produced by the Host rather than by the caller. + +The enforcement that was impossible at proof time is now available: + +> When the signer is `PersonalAliasWithProof` or `PersonalAliasWithAccount`, a Host MUST decode `call_data` and reject a `set_alias` whose target account is outside the subtree of `context.product_id`, with `AliasTargetNotOwned`. + +The Host must already understand the call well enough to build the origin's extensions, so the additional cost is reading one argument of one call. + +`ProductAccount(...)` keeps accepting a foreign `ProductAccountId` under a grant. Cross-product product-account signing has uses unrelated to ring VRF, so this RFC neither restricts nor bounds it; it is governed by the general permission model and by the separate work on account-access permissions. `get_account` likewise still accepts a foreign id, which a caller needs anyway to put the alias account into a `set_alias` call. + +What the two alias variants add is the *origin*, not merely access to a key: `PersonalAliasWithAccount` produces a transaction the chain sees as the personal alias acting, which a plain signature from the same account does not. And the protection this section is about is narrower than "no foreign signing" — it is that the *binding* of an alias cannot be redirected, which depends only on the proof never being lent and on the `set_alias` target being checked. + +The alias flow collapses accordingly: + +1. **Read the alias.** `get_account_alias(pop_handle, score_context, people_ring)`. The consuming product checks the ring revision on each use and renews when it has moved; nothing else watches for it. +2. **Bind or rebind if needed.** `create_transaction(set_alias(...), signer: PersonalAliasWithProof(score_context))`. After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. +3. **Act.** `create_transaction(action, signer: PersonalAliasWithAccount(alias_account, score_context))`. + +No cross-product proof is handed out at any step, and **in the happy path the user sees none of the three** — the requirement that shapes the permission model below. + +### The app's own personhood-dependent features + +On a successful registration the Host matches the declared `RingLocation` against its well-known table (People, People-Lite) by structural equality, records the handle as the corresponding person key, and uses it wherever it used `PersonKey::Full` / `PersonKey::Lite` — coinage unload proofs on the Host, ring-VRF slot assignment for Bulletin / SSS allowance and PGAS claims on the Account Holder (RFC-0010). Both components learn the mapping from the registry rather than from a compiled-in product id or index. The compiled-in ring table shrinks to a well-known-ring matcher used for feature routing, not key selection. + +**Contention.** If two products register for the same well-known ring, the Host MUST NOT pick silently. It resolves to the product the user designated as their personhood provider — a Host setting that defaults to the first registrar and is user-changeable — so a second product cannot silently displace the first. + +### Product shape + +The personhood product is not headless: it needs a **pocket card**, because personhood has user-facing state worth surfacing (recovery, suspension status, which products hold grants). It also needs a **global lifetime**, to answer registration and cross-product requests regardless of what the user is looking at. + +The existing manifest model fits: one executable manifest per modality, all sharing one globally-lived background script, with the enabled modalities determining the reachable TrUAPI surface. Today `worker` carries `includes: { chat, pocket }` — the UI surfaces it contributes. One addition: + +```ts +interface WorkerIncludes { + chat: boolean; + pocket: boolean; + /** Runs on host load with a global lifetime and contributes no UI surface. */ + onLoad: boolean; +} +``` + +The personhood product declares `{ pocket: true, onLoad: true }`. No capability flag gates the key-management calls: registration only ever touches the caller's own domain, and consuming a foreign key is governed by the permission model. + +`onLoad` is independently useful for products that genuinely contribute no UI. A product whose manifest declares `onLoad` and nothing else runs in the background and never shows itself; for those, the Host MUST disclose the fact at install time and list them in a user-reachable "runs in the background" inventory, since a headless globally-lived executable is otherwise indistinguishable from a Host feature. + +### Permission model + +The requirement is asymmetric: routine discovery should be cheap, and the powerful grants deliberate but not per-call. + +| Call | Own key / own context | Foreign | +| --------------------------------------------- | --------------------- | ----------------------------------------------------------- | +| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | +| `list_ring_vrf_keys(Anonymized)` | permissionless | requires a grant | +| `list_ring_vrf_keys(PublicKey)` | permissionless | requires a grant | +| `get_account_alias` | permissionless | requires a grant | +| `create_account_proof` | permissionless | grant for the foreign key; **refused** if the context is also foreign | +| `get_account` | permissionless | requires a grant | +| `create_transaction` · `ProductAccount` | permissionless | requires a grant — unchanged by this RFC | +| `create_transaction` · alias signers | permissionless | requires a grant for the context | + +The model is **user-approval driven**, per RFC-0002: a foreign access the user has not approved produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance: **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** Nothing else grants silent access. + +That declaration belongs to the product manifest, which is specified separately (see [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)). Two requirements on it from here: + +- The allowlist must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list later without a wire break. +- It should be expressible **per method or method category**, so "read my key handles" and "sign from my alias account" need not be one grant. + +Until that RFC lands, Hosts fall back to a one-time prompt per (caller, owner, call) triple, persisted per RFC-0002 — correct, but with consent surfaces the target UX does not want. + +### Accounts Protocol + +Ring VRF secrets derive from the user's root entropy, so every operation here ultimately belongs to the Account Holder. + +```rust +struct RegisterRingVrfKeyRequest { + calling_product_id: ProductId, + index: DerivationIndex, + ring: RingLocation, +} +struct RegisterRingVrfKeyResponse { + responding_to: SsoSessionRequestId, + payload: Result, +} + +struct ListRingVrfKeysRequest { + calling_product_id: ProductId, + owner: ProductId, + disclosure: RingVrfKeyDisclosure, +} +struct ListRingVrfKeysResponse { + responding_to: SsoSessionRequestId, + payload: Result, RingVrfError>, +} +``` + +A Host holding a current registry snapshot answers `list` locally and does not issue that request. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and `RingVrfError` gains `KeyNotRegistered` and `KeyNotInRing`. + +**Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host that holds the product's domain entropy answers the product immediately and mirrors the registration to the phone fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without that entropy the Host issues the request and waits. + +> **A Host MUST NOT derive a member secret for a `(product, index)` pair absent from its registry.** + +The reason this needs saying: domain entropy makes derivation *unconditional*. Given the entropy of `//peopl.dot`, a Host can compute the member secret at index 7, or 4711, or any other index, because derivation is pure arithmetic — nothing about holding the entropy distinguishes an index that means something from one that does not. The registry is what supplies that distinction. If a Host served a proof for an unregistered index, the phone would have no record that such a key exists: it could not include it in slot assignment, could not list it in the user's inventory, and could not answer "what is this key used for". So the entropy grants the Host the ability to **derive** a member secret the registry already lists; only registration — which always reaches the phone — brings a new key into **existence**. + +#### Answering while the phone is backgrounded + +Registration is consent-free from the user's point of view and not latency-critical, so it is served by the layered background-availability model already designed for consent-free SSO requests: handshake prefetch, foreground, a bounded hot window, a push-woken headless cold path, and a mandatory non-blocking degrade. That model is specified in its own document (linked under [Prior Art](#prior-art-and-references)) and is not restated here. + +Two consequences matter for this RFC. Prefetch should carry the registry snapshot, so a consumer of an already-registered key never pays a round trip. And every headless execution context has a system-enforced budget (~30 s, ~24 MB): deriving a `RingVrfPublicKey` fits comfortably, while producing a ring VRF **proof** may not — the second motivation for the extension below. + +#### AutoSigning extension + +RFC-0022 collapses RFC-0010's `AutoSigning` payload to the product-root secret key alone. It is extended to also transfer the product's ring VRF domain entropy: + +```rust +AutoSigning { + /// Secret key of `//product//{productId}`. + product_root_private_key: Sr25519SecretKey, + /// Entropy of the `//{productId}` node of the ring-VRF tree (RFC-0022). + /// Lets the Host derive the member secret of any *registered* key locally. + ring_vrf_domain_entropy: RingVrfEntropy, +} +``` + +No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. + +With this granted, a remote Host serves `create_account_proof` and `get_account_alias` — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: product A's proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning; A cannot grant it. + +### Migration and compatibility + +Nothing here ships with production consumers. RFC-0004's `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; `HostAccountCreateProofRequest` and `HostAccountGetAliasRequest` gain a field with their wire ids unchanged. The two new methods take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becomes conditionally accepted, which is purely additive. + +`ProductAccountTxPayload.signer` changes type from `ProductAccountId` to `TxSigner`, which is breaking at the SCALE layer for `create_transaction` (wire 30). It is a continuation of RFC-0020's second change — parametrizing the payload by signer type — rather than a reversal of its first: the `context` RFC-0020 removed was `TxPayloadContext` (metadata, token symbol, best block), and `TxSigner`'s `ProductProofContext` is a different type carrying which alias signs. `LegacyAccountTxPayload` is untouched, since a legacy account has no product subtree and therefore no alias. + +In the Accounts Protocol, two new message pairs, a field on each ring VRF request, and one field on `AutoSigning`: all breaking at the SCALE layer, landing together with RFC-0022. The AP signing companion mirrors `TxSigner` so the Account Holder can satisfy an alias origin when AutoSigning is not granted. `WorkerIncludes.onLoad` is additive. + +The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once the personhood product registers, and are **not** retained as a fallback: a silent fallback to a compiled-in key would resurrect the coupling this RFC removes and would mask registry-sync bugs as working proofs. + +## Drawbacks + +- **Removing the fallback makes personhood installable, and therefore missing.** A user without the personhood product installed has no people key at all: coinage unload and PGAS allowance stop working until they install it. Intended, but a real regression in default capability. +- **The Host must decode `set_alias` to enforce the alias-target rule.** That is real coupling to the individuality pallet: a call-encoding change breaks the check, and a check that silently stops matching fails open. The Host must already decode enough to build the origin's extensions, so this widens an existing dependency rather than creating one — but it is the price of having any enforcement point at all, and it should fail closed on an unrecognized call shape under an alias signer. +- **Bundling ring VRF entropy into AutoSigning widens one grant.** "Sign transactions without prompting me" and "produce personhood proofs offline" become one user decision, and the second is arguably the stronger. Accepted deliberately: two grants would mean two authorization surfaces for what a user experiences as one relationship with a product. +- **The registry is new distributed state.** Three parties must agree on it — the registering product, the caching Host, the owning Account Holder. A stale Host returns `KeyNotRegistered` for a key that exists. Registration being idempotent and the phone the only authority keeps this diagnosable, but it replaces a compile-time constant. +- **Registration leaks intent.** An anonymized listing still says "`peopl.dot` has a key it intends for the People ring". It does not prove membership, but a consumer learns the user has at least attempted full personhood before any proof is requested. This is the one privacy cost the design accepts for cheap discovery. +- **The silent happy path depends on the manifest RFC.** Until the allowlist exists, each cross-product call in the alias flow produces a one-time prompt. +- **The key handle overloads `ProductAccountId`.** The same type now names an sr25519 product account and a ring VRF derivation slot in a different tree at the same `(product, index)`. Accepted for the trivial alias-account mapping it buys. + +## Testing, Security, and Privacy + +**Testing.** + +- *Registry authority.* A Host holding domain entropy must refuse to derive an unregistered index. The single most important negative test here — it is what keeps the phone's inventory truthful. +- *Determinism.* For a fixed `(key_handle, context, ring)`, `get_account_alias` and the `contextual_alias` inside `create_account_proof` must agree across Hosts and across the proxied and AutoSigning-local paths; a locally-derived proof and a phone-produced one must be indistinguishable to a verifier. +- *Ring binding.* A handle registered for ring X, called with ring Y, must return `KeyNotInRing`. +- *Proof scope.* `create_account_proof` with both a foreign `key_handle` and a foreign `context` must return `ForeignKeyInForeignContext`, and the other three combinations must not be affected. Assert the absence of a proof, not just the error code. +- *Alias target.* `create_transaction` with an alias signer and a `set_alias` naming an account outside the context owner's subtree must return `AliasTargetNotOwned` — including when the target is the caller's own account, which is the case that motivated the rule. An unrecognized call shape under an alias signer must fail closed. +- *Fire-and-forget mirroring.* A registration served locally under AutoSigning must reach the phone, and re-notifying an entry the phone already holds must be a no-op rather than a duplicate entry. +- *Background availability.* Registration answered on the foreground, hot-window, and cold paths, plus the degrade, with the answer path fitting the smallest headless budget. +- *Provider contention.* Two products registering for the same well-known ring must not silently change the designated provider. +- *Context construction.* The on-chain score constant must equal `product_context_bytes` for the personhood product's score context — the test that keeps the two schemes from diverging again. + +**Security.** Products never receive member secrets; `RingVrfPublicKey` is the only key material crossing the TrUAPI boundary, and only under the owner's disclosure decision. Products also never receive a proof they could use blindly against a context they do not own, which is what makes the *binding* of an alias structurally protected rather than only permission-gated: the proof stays inside the Host, and the `set_alias` target is checked where it is produced. Cross-product product-account signing remains permission-gated and is out of scope here. AutoSigning with ring VRF entropy makes the Host a custodian of the material behind personhood proofs — RFC-0010's custody obligations at a higher blast radius, which Account Holders MUST present distinctly in the authorization UI. + +**Privacy.** Anonymized listing is the default cross-product shape specifically so discovery does not distribute public keys: a member public key is linkable across every ring it appears in. Contexts remain product-scoped, so RFC-0004's unlinkability guarantee is unchanged — a foreign context is reachable only under a grant the user or the owner made deliberately. Well-known contexts are enumerable by construction, which is not a regression: an alias is computable only with the member secret. + +## Alternatives + +- **Per-flow host callbacks instead of a registration call.** The Host would expose a higher-level call per internal flow ("allocate PGAS allowance") and the product would supply a handler, so only the relevant private key is touched. Rejected: it grows a new bidirectional contract for every internal feature the Host ever adds, couples the personhood product's release cycle to the Host's, and makes the product responsible for flows (slot-table bookkeeping, claim budgets) RFC-0010 deliberately put on the Account Holder. Registration adds one call and leaves every existing flow where it is. +- **A per-context `Shared` / `Private` scope**, with the Host rejecting an undeclared foreign context and restricting foreign account access to alias indices of shared contexts. Rejected: it introduces a second authorization mechanism next to the permission model, on a different axis (the context rather than the caller), and "a product may *name* this context" turned out to be an unclear thing to grant. Cross-product access is a permission question, answered in one place — and the scope would not have stopped the alias hijack anyway, since that needs no foreign account. +- **Enforcing the alias target at proof time**, by having the Host construct the whole `set_alias` payload behind a dedicated call so it knows what it is signing. Rejected in favour of generalizing the signer: `create_transaction` already receives the call and already owes the signer its extensions, so it needs no new method and no second call-construction path. +- **Inspecting the proof `message`**, with or without a caller-supplied preimage. Rejected as unimplementable: the message is a hash of the inherited implication, and trusting a preimage the caller supplies is still blind signing. +- **Constraining the alias target on chain**, so `set_alias` accepts only an account the runtime can derive from the proof's context. Attractive — it would remove the class of attack rather than one instance — but the mapping from a context to its alias account is a client-side HD derivation the runtime cannot verify; it only sees account ids. It also removes the deliberately convenient case of pointing an alias at a real product account. +- **The owner performing every binding itself**, never lending anything, with consumers calling a product-level operation on the personhood product. Rejected as needing a product-to-product invocation primitive TrUAPI does not have; the alias signer achieves the same protection through a call that already exists. +- **A dedicated "key manager" modality**, or a `capabilities.keyManager` flag gating registration. Rejected: registration only ever writes to the caller's own ring VRF domain, so there is nothing to gate. +- **Attestation thresholds / trusted verifiers** for silent access, instead of a product-id allowlist. Rejected for now in favour of the simpler flat list; the manifest RFC must keep the schema extensible so this remains available. +- **A distinct `RingVrfKeyId { product_id, index }`** instead of reusing `ProductAccountId` for handles. Rejected: a near-duplicate type, and it makes the alias-account mapping less obvious. +- **A `ProofContext` enum with a `PoP(WellKnownContextSuffix)` variant**, giving well-known contexts a `pop:`-prefixed namespace outside the product scheme. Rejected: it forks context derivation and alias-account mapping in two, which is the situation RFC-0022 left open and this RFC closes. + +## Prior Art and References + +- [RFC-0004 — Redesign `create_account_proof`](0004-ringlocation-redesign.md) — `RingLocation`, `ProductProofContext`, the context derivation, and the member-key selection contract this RFC deletes. Its "Out of scope: explicit member-key management … left to a future RFC" is this RFC. +- **RFC-0022 — Account key derivations** ([PR #296](https://github.com/paritytech/truapi/pull/296)) — the ring-VRF tree and its `//{productId}//{index}` paths, `Either` derivation indices, the reserved `peopl.dot` product identity, and the `AutoSigning` payload this RFC extends. Its deferral of well-known alias accounts is resolved here. +- **RFC-0023 — sr25519 VRF signing for product accounts** ([PR #301](https://github.com/paritytech/truapi/pull/301)) — the complementary non-member path: `sign_vrf` from a product account for participants not yet in the people set, where this RFC's ring VRF path serves members. +- [RFC-0002 — Permission Model for Host API](0002-permission-model.md) — the prompt-once / persist-indefinitely lifecycle every cross-product grant here reuses. +- [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206) — where the product-id allowlist is specified. +- [RFC-0009 — Unauthenticated Product Access](0009-unauthenticated-product-access.md) — `NotConnected` semantics. +- [RFC-0010 — W3S Allowance Management](0010-allowance.md) — AutoSigning and the PGAS / Bulletin / SSS flows that consume the person key. +- [RFC-0020 — `create_transaction` and its Accounts Protocol mirror](0020-create-transaction.md) — the pattern of specifying a TrUAPI call together with its AP companion, followed here. +- *SSO background availability — common model* — the layered availability ladder referenced above. **TODO: link the HackMD document.** +- `rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs` — the compiled-in selection this RFC removes. +- [Polkadot People Registry / Ring VRF](https://forum.polkadot.network/t/the-people-registry/12749) · [individuality#878](https://github.com/paritytech/individuality/pull/878) — alias-account assignment for derived product addresses. + +## Unresolved Questions + +1. **How does a Host resolve the key and ring for an alias signer?** `TxSigner`'s alias variants name a context but not a `key_handle` or a `RingLocation`, since the caller must not choose them. Resolving them from the context's owner and the designated personhood provider is the intent, but the exact rule — and what happens when the owner has several registered keys for the relevant ring — is unspecified. +2. **Is `set_alias` the only call an alias signer needs checked?** The target rule covers the known hijack. Whether other calls reachable under an alias origin can rebind or transfer the alias, and therefore need the same treatment, needs a pass over the pallet's extrinsics rather than an assumption. +3. **Who owns the `resources` and `mob-rule` contexts?** The score context is assigned to the personhood product. RFC-0022 lists two more well-known contexts, and every context needs exactly one owner. +4. **Does the personhood product's pocket card change what `onLoad` needs to disclose?** The disclosure and background-inventory rules were written for products with no UI at all; a product with a pocket card is visible, so the rules may apply only to `onLoad`-only manifests. + +Deferred to follow-up work: **revocation**, already deferred by RFC-0010 and made more urgent by the entropy transfer, including retraction of a registry entry by its owner; **key rotation and recovery**, which the registry makes expressible but whose effect on in-flight aliases is unspecified; and **provider competition**, for which the designation setting is only the minimal hook. From b8f8c68b80d859931ab4e7d62e5a2eda22638563 Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 30 Jul 2026 19:37:29 +0700 Subject: [PATCH 2/7] Improve --- docs/rfcs/0024-personhood-as-product.md | 250 +++++++++++------------- 1 file changed, 110 insertions(+), 140 deletions(-) diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index 022214155..b2503306d 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -14,32 +14,17 @@ owner: "@valentunn" ## Summary -RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof` / `get_account_alias`. With an `onLoad` executable modality for global lifetime, and the Accounts Protocol companions, full and light personhood become a standalone product that no consumer — including the Host itself — has to know the key index of. +RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof` / `get_account_alias`. With an `onLoad` executable modality for global lifetime and the Accounts Protocol companions, full and light personhood become a standalone product whose key index no consumer — including the Host — has to know. -Because a ring VRF proof is a bearer token for its context's alias, a proof is only ever issued when the caller owns either the key or the context. Cross-product alias use moves to transaction signing instead: `create_transaction`'s signer generalizes to include personhood-alias origins, so the Host produces the proof while satisfying the signer and never hands one out. - -It also resolves RFC-0022's deferral of well-known alias accounts (`score`, `resources`, `mob-rule`): every context is product-owned and constructed with TrUAPI's product-scoped context function, so there is no second context scheme. +Because a ring VRF proof is a bearer token for its context's alias, a proof is only issued when the caller owns either the key or the context. Cross-product alias use moves to transaction signing instead: `create_transaction`'s signer generalizes to include personhood-alias origins, so the Host produces the proof while satisfying the signer and never hands one out. The RFC also resolves RFC-0022's deferral of well-known alias accounts: every context is product-owned and built with TrUAPI's product-scoped context function, so there is no second context scheme. ## Motivation -### Personhood is welded into the Host - -RFC-0004 §"Host member-key selection" requires every Host to define the PoP ring collection internally, choose a member key corresponding to the requested `RingLocation`, fall back to the PoP key when correspondence is undeterminable, and tiebreak stably. `truapi-server` implements exactly that with the ring identities compiled in (`rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs`: `FULL_PERSON_COLLECTION`, `LITE_PERSON_COLLECTION`, `enum PersonKey { Full, Lite }`). - -So personhood cannot be shipped, versioned, or replaced independently of the Host: every change to how a person key is derived, registered, renewed, or recovered is a Host release. - -### What a personhood product must be able to do +**Personhood is welded into the Host.** RFC-0004 §"Host member-key selection" requires every Host to define the PoP ring collection internally, choose a member key corresponding to the requested `RingLocation`, fall back to the PoP key when correspondence is undeterminable, and tiebreak stably. `truapi-server` implements exactly that with the ring identities compiled in (`rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs`: `FULL_PERSON_COLLECTION`, `LITE_PERSON_COLLECTION`, `enum PersonKey { Full, Lite }`). So every change to how a person key is derived, registered, renewed, or recovered is a Host release. -1. **Own** the full and light personhood ring VRF keys — under RFC-0022 the `peopl.dot` domain of the ring-VRF tree. -2. **Tell the Host and the Account Holder enough** to keep serving the app's own personhood-dependent features — coinage unload proofs, and the ring-VRF slot assignment behind PGAS / Bulletin / Statement Store allowance (RFC-0010). -3. **Lend its keys** to other products, so they can create proofs and read aliases. -4. **Lend its aliases** — without lending the proofs behind them. Every use of an alias is a signature under an alias origin; the alias must additionally be *set* on chain and *renewed* — after a suspension a fresh `set_alias` is required from scratch, while a ring-revision change still requires a proof but can ride an `AsPersonalAliasWithAccountRevised` origin alongside the alias update. +A personhood product must instead own the full and light keys — under RFC-0022, the `peopl.dot` domain of the ring-VRF tree — while telling the Host and Account Holder enough to keep serving the app's own personhood-dependent features, and lending its keys and aliases to other products. The binding constraint across all of it: **no consumer may know which key is used**, not the app and not a calling product. -The binding constraint across all four: **no consumer may know which key is used** — not the app, and not a calling product. - -### The obstacle - -The member keys serve three overlapping classes of work, and only one is not extractable: +**The obstacle** is that the member keys serve three overlapping classes of work, and only one is not extractable: | Class | Examples | Extractable? | | ---------------------------- | -------------------------------------------------- | ---------------------------------- | @@ -47,17 +32,15 @@ The member keys serve three overlapping classes of work, and only one is not ext | Product-extractable features | game, mobrule, identity | Yes | | Cross-product shared | set identity account, set score alias | Yes, but needs cross-product reach | -The app-internal class is what forces a mechanism. This RFC picks a **registration call**: the product declares which of its keys is intended for which ring, and the Host uses that registration wherever it used a compiled-in key. The rejected alternative is in [Alternatives](#alternatives). - -### Remote Hosts cannot use ring VRF keys without the phone +The app-internal class forces a mechanism, and this RFC picks a **registration call**: the product declares which key is intended for which ring, and the Host uses that registration wherever it used a compiled-in key. The rejected alternative is in [Alternatives](#alternatives). -A remote (Desktop) Host cannot use a ring VRF key without a round trip to the Account Holder, and the phone is usually backgrounded. Two independent directions address this: the layered background-availability model designed for consent-free SSO requests (referenced, not specified here — see [Prior Art](#prior-art-and-references)), and an **AutoSigning extension** that transfers the product's ring VRF domain entropy so the Host can derive registered member secrets locally. +**Remote Hosts cannot reach ring VRF keys without the phone**, which is usually backgrounded. Two directions address it: the layered background-availability model designed for consent-free SSO requests (referenced, not respecified — see [Prior Art](#prior-art-and-references)), and an AutoSigning extension transferring the product's ring VRF domain entropy so the Host can derive registered member secrets locally. ## Stakeholders - **Personhood product developers** — the first consumer; owns the registry entries for the full and light personhood rings. -- **Product developers building on personhood** — score / identity / mobrule / game; consume foreign handles, foreign contexts, and alias accounts. -- **Host developers** — implement the registry, drop the compiled-in member-key selection, add the `onLoad` modality. +- **Product developers building on personhood** — score / identity / mobrule / game; consume foreign handles, foreign contexts, and alias origins. +- **Host developers** — implement the registry, drop the compiled-in member-key selection, generalize the transaction signer, add the `onLoad` modality. - **Account Holder developers (Mobile App)** — become the authoritative registry, implement the new message pairs, extend the AutoSigning payload, answer registrations from the background. - **Chain / individuality developers** — on-chain contexts (`score`, `resources`, `mob-rule`) must be derived with TrUAPI's product-scoped context function rather than a parallel namespace. @@ -65,9 +48,9 @@ A remote (Desktop) Host cannot use a ring VRF key without a round trip to the Ac ### Terminology -- **Ring VRF domain** — per RFC-0022, ring VRF keys live in their own tree rooted at `hash(root_entropy, "ring-vrf")`, with hard-only paths `//{productId}//{index}`. A product's *domain entropy* is the node at `//{productId}`; its member secrets are the children of that node. This tree is disjoint from the sr25519 product-account tree at `//product//{productId}/{index}`. +- **Ring VRF domain** — per RFC-0022, ring VRF keys live in their own tree rooted at `hash(root_entropy, "ring-vrf")`, with hard-only paths `//{productId}//{index}`. A product's *domain entropy* is the node at `//{productId}`. This tree is disjoint from the sr25519 product-account tree at `//product//{productId}/{index}`. - **DerivationIndex** — per RFC-0022, `Either`; each domain has its own index space. -- **Key handle** — the public name of a registered key: `ProductAccountId { dot_ns_identifier: , derivation_index: }`. It names a derivation slot in the owner's ring VRF domain, not an sr25519 account. +- **Key handle** — the public name of a registered key: `ProductAccountId { dot_ns_identifier: , derivation_index: }`. It names a slot in the owner's ring VRF domain, not an sr25519 account. - **Registry** — the set of `(handle, declared rings)` entries. The Account Holder is authoritative; the Host holds a synchronized copy. ### Key management calls @@ -110,10 +93,10 @@ fn list_ring_vrf_keys( ) -> Result, ListRingVrfKeysErr>; ``` -- **A product may register only its own keys.** Ownership is the calling product id, never a parameter. Registration therefore needs no capability gate and no prompt: a product creating an entry in its own domain cannot affect anyone else. -- **A key may be registered for many rings**, and a product may hold several keys for one ring. Neither the API nor consumers assume 1:1. -- **Registration declares intent, not membership.** It means "this is the key I will use for that ring", not "the user is a person". Membership is still discovered only by attempting a proof, which returns `NotMember` (RFC-0004). This keeps the registry from being a personhood oracle. -- **The public key is owner-visible by default, permissioned cross-product.** The anonymized shape is what makes routine discovery cheap; a member public key is linkable across every ring it appears in, so it is disclosed only under a grant. +- **A product may register only its own keys.** Ownership is the calling product id, never a parameter, so registration needs no capability gate and no prompt. +- **A key may be registered for many rings**, and a product may hold several keys for one ring. Nothing assumes 1:1. +- **Registration declares intent, not membership.** It means "this is the key I will use for that ring", not "the user is a person"; membership is still discovered only by attempting a proof, which returns `NotMember` (RFC-0004). This keeps the registry from being a personhood oracle. +- **The public key is owner-visible by default, permissioned cross-product**, because a member public key is linkable across every ring it appears in. RFC-0022 already pins `//peopl.dot//index_bytes(0)` as the full personhood key and `index_bytes(1)` as the light one. Under this RFC those constants are the personhood product's own implementation detail, expressed to everyone else as two registry entries. @@ -136,9 +119,7 @@ fn get_account_alias( ) -> Result; ``` -`ring` stays a parameter even though the handle carries declared rings: a key may be registered for several, and the caller must say which one the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise, so a stale caller cannot obtain a proof against a ring the owner did not intend. - -RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host holds trivially now that key selection is not Host policy. +`ring` stays a parameter even though the handle carries declared rings: a key may be registered for several, and the caller must say which the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise. RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host now holds trivially, since key selection is no longer Host policy. ### Errors @@ -178,15 +159,15 @@ enum HostCreateTransactionError { // ... existing variants unchanged ... /// An alias signer names a context the caller has no grant for. AliasNotPermitted, - /// The call contains a `set_alias` whose target account is outside the - /// subtree of the signing context's owner. - AliasTargetNotOwned, + /// The call contains a `set_alias` whose target is not the alias account + /// of the signing context. + AliasTargetMismatch, } ``` ### Cross-product discovery -The flow for a game product to produce a proof with the full personhood key, under its **own** airdrop context — abstracted by the product SDK, not by the Host: +A game product producing a proof with the full personhood key, under its **own** airdrop context — abstracted by the product SDK, not the Host: ```mermaid sequenceDiagram @@ -201,8 +182,6 @@ sequenceDiagram H-->>G: proof + contextual_alias + ring_index + ring_revision ``` -The context is the caller's own, which is what makes this the permitted shape; see [proof scope](#a-proof-only-ever-binds-to-a-context-the-caller-owns). - **No product may assume a key index of another product.** The index is the owner's implementation detail; consumers select by declared `RingLocation` and treat the handle as opaque. Hardcoding `(peopl.dot, 0)` breaks the moment the owner rotates or adds a key. This is the one rule a consuming product has to remember. ### Every context is owned by exactly one product @@ -215,79 +194,87 @@ fn product_context_bytes(ctx: ProductProofContext) -> [u8; 32] { } ``` -There is **no separate well-known-context namespace and no second context scheme.** Every context — including the ones that exist as on-chain constants — is a `ProductProofContext`, and its on-chain constant is the output of `product_context_bytes`. Chain-side definitions must be derived with this function; RFC-0004's `product_account_id_for_proof_context(product_id, suffix)` then applies unchanged, so no special encoding of a context string into a derivation suffix is needed. +There is **no separate well-known-context namespace and no second context scheme.** Every context — including those existing as on-chain constants — is a `ProductProofContext` whose on-chain constant is the output of `product_context_bytes`, so RFC-0004's `product_account_id_for_proof_context(product_id, suffix)` applies unchanged and no context string needs encoding into a derivation suffix. -A context therefore has exactly one owner — the `product_id` mixed into its derivation. A context used by many products is not thereby owned by many; consumers name the owner's context, and only the owner can define one. **This supersedes RFC-0022 §"Well-known alias accounts"**, which describes `score`, `resources`, and `mob-rule` as owned by no product and outside the product-based construction, and defers their handling. They are assigned owners instead: **the score context is owned by the personhood product**, and DIMs coercible to the score system are its consumers. +A context therefore has exactly one owner: the `product_id` mixed into its derivation. A context used by many products is not thereby owned by many — consumers name the owner's context, and only the owner can define one. Each context's alias account follows from the context alone, 1:1, through RFC-0004's `product_account_id_for_proof_context`. -Access to another product's context is governed by the ordinary permission model below — there is no separate sharing declaration on a context. +**This supersedes RFC-0022 §"Well-known alias accounts"**, which describes `score`, `resources`, and `mob-rule` as owned by no product, outside the product-based construction, and defers their handling. Under this RFC they are ordinary product-owned contexts; **the score context is owned by the personhood product**, and DIMs coercible to the score system are its consumers. Access to a foreign context is governed by the permission model below; there is no sharing declaration on a context. ### A proof only ever binds to a context the caller owns -Keys and contexts are independently owned, so three combinations are meaningful, and one of them is dangerous. +Keys and contexts are owned independently, and one of the four combinations is dangerous: -| `key_handle` owner | `context` owner | Example | Allowed | -| ------------------ | --------------- | -------------------------------------------------------------- | ------- | -| caller | caller | the personhood product proving under its own context | yes | -| **foreign** | caller | a game product proving with the people key in its airdrop context | yes | -| caller | **foreign** | a caller's own key under someone else's context — not a member of the personhood ring, so it fails `NotMember` anyway | yes | -| **foreign** | **foreign** | a game product proving with the people key in the score context | **no** | +| `key_handle` owner | `context` owner | Example | Allowed | +| ------------------ | --------------- | ---------------------------------------------------------- | ------- | +| caller | caller | the personhood product proving under its own context | yes | +| **foreign** | caller | a game proving with the people key in its airdrop context | yes | +| caller | **foreign** | own key under a foreign context; fails `NotMember` anyway | yes | +| **foreign** | **foreign** | a game proving with the people key in the score context | **no** | > A Host MUST reject `create_account_proof` when neither `key_handle` nor `context` belongs to the calling product, with `ForeignKeyInForeignContext`. -The reason is that **a proof is a bearer token for its context's alias.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and supplying a preimage instead would still be blind signing — so no inspection at proof time can tell what the proof will authorize. A caller holding a proof under the score context can therefore bind that alias to an account of its own, signing the `set_alias` with its own product account, needing nothing further from anyone. There is no downstream chokepoint either: the caller can submit the extrinsic without going through the Host at all. +The reason is that **a proof is a bearer token for its context's alias.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and accepting a caller-supplied preimage instead would still be blind signing — so nothing at proof time can tell what the proof will authorize. A caller holding a proof under the score context can therefore bind that alias to an account of its own, signing the `set_alias` with its own product account and needing nothing further from anyone; nor is there a downstream chokepoint, since it can submit the extrinsic without going through the Host at all. -Denying the foreign/foreign combination removes the token. The resulting invariant is simple: **whoever holds a proof owns the context it binds to**, so the choice of which account an alias points at is always the context owner's own business. +Denying the foreign/foreign combination removes the token, leaving a simple invariant: **whoever holds a proof owns the context it binds to**, so which account an alias points at is always the context owner's own business. ### Alias signing -Denying that combination would also deny the legitimate case — a product acting under another product's alias, such as claiming score rewards — if proofs were the only route. They are not. `create_transaction` already promises to *"upon approval, fill all necessary transaction extensions to satisfy signer"*, so it is the natural place for an alias origin: the Host constructs the proof as part of satisfying the signer, which means it never hands one out, and it sees the whole call while doing so. +That denial would also block the legitimate case — a product acting under another product's alias, such as claiming score rewards — if proofs were the only route. They are not. `create_transaction` already promises to *"upon approval, fill all necessary transaction extensions to satisfy signer"*, making it the natural place for an alias origin: the Host constructs the proof while satisfying the signer, so it never hands one out and sees the whole call while doing so. RFC-0020 parametrized the transaction payload by signer type; the `ProductAccountId` signer generalizes to: ```rust enum TxSigner { /// Sign as an ordinary product account. Today's behaviour. - ProductAccount(ProductAccountId), + ProductAccount { + account: ProductAccountId, + }, /// Sign as a personhood alias whose account is already set on chain, /// under an `AsPersonalAliasWithAccount`-style origin. - PersonalAliasWithAccount(ProductAccountId, ProductProofContext), + PersonalAliasWithAccount { + key_handle: ProductAccountId, + context: ProductProofContext, + ring: RingLocation, + }, /// Sign as a personhood alias proven by ring VRF, under an /// `AsPersonalAlias`-style origin. This is what `set_alias` uses. - PersonalAliasWithProof(ProductProofContext), + PersonalAliasWithProof { + key_handle: ProductAccountId, + context: ProductProofContext, + ring: RingLocation, + }, } ``` -This does not change `create_transaction`'s semantics — the caller still supplies a call and the Host still fills whatever the signer requires. The proof simply becomes one of those things, produced by the Host rather than by the caller. - -The enforcement that was impossible at proof time is now available: +Both alias variants carry the same three fields the proof itself needs, for the same reasons as `create_account_proof`: the `key_handle` names which registered key to prove with, and `ring` disambiguates when that handle is registered for several. Neither is secret — a caller discovers the handle through `list_ring_vrf_keys` and treats it as opaque — so there is nothing gained by having the Host infer them. `context` needs no companion alias-account field, because a context and its alias account are 1:1 under RFC-0004's `product_account_id_for_proof_context`. -> When the signer is `PersonalAliasWithProof` or `PersonalAliasWithAccount`, a Host MUST decode `call_data` and reject a `set_alias` whose target account is outside the subtree of `context.product_id`, with `AliasTargetNotOwned`. +Semantics are unchanged: the caller supplies a call, the Host fills whatever the signer requires. The proof simply becomes one of those things, produced by the Host rather than the caller — which makes the enforcement that was impossible at proof time available: -The Host must already understand the call well enough to build the origin's extensions, so the additional cost is reading one argument of one call. +> When the signer is `PersonalAliasWithProof` or `PersonalAliasWithAccount`, a Host MUST decode `call_data` and reject a `set_alias` whose target account is not the alias account of `context`, with `AliasTargetMismatch`. -`ProductAccount(...)` keeps accepting a foreign `ProductAccountId` under a grant. Cross-product product-account signing has uses unrelated to ring VRF, so this RFC neither restricts nor bounds it; it is governed by the general permission model and by the separate work on account-access permissions. `get_account` likewise still accepts a foreign id, which a caller needs anyway to put the alias account into a `set_alias` call. +The 1:1 mapping is what makes this an exact check rather than a containment test: for a given context there is exactly one legitimate target, so the Host recomputes it and compares. `set_alias` is the only call reachable under an alias origin that can rebind an alias, so it is the only one that needs the check. And since the Host must already understand the call well enough to build the origin's extensions, the extra cost is reading one argument. -What the two alias variants add is the *origin*, not merely access to a key: `PersonalAliasWithAccount` produces a transaction the chain sees as the personal alias acting, which a plain signature from the same account does not. And the protection this section is about is narrower than "no foreign signing" — it is that the *binding* of an alias cannot be redirected, which depends only on the proof never being lent and on the `set_alias` target being checked. +`ProductAccount(...)` keeps accepting a foreign `ProductAccountId` under a grant — cross-product product-account signing has uses unrelated to ring VRF, so this RFC neither restricts nor bounds it — and `get_account` likewise still accepts a foreign id, which a caller needs anyway to put the alias account into the `set_alias` call. What the alias variants add is the *origin*, not access to a key: `PersonalAliasWithAccount` produces a transaction the chain sees as the personal alias acting, which a plain signature from the same account does not. The protection here is correspondingly narrow — not "no foreign signing", but that an alias's *binding* cannot be redirected. -The alias flow collapses accordingly: +The alias flow is then: 1. **Read the alias.** `get_account_alias(pop_handle, score_context, people_ring)`. The consuming product checks the ring revision on each use and renews when it has moved; nothing else watches for it. -2. **Bind or rebind if needed.** `create_transaction(set_alias(...), signer: PersonalAliasWithProof(score_context))`. After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. -3. **Act.** `create_transaction(action, signer: PersonalAliasWithAccount(alias_account, score_context))`. +2. **Bind or rebind if needed.** `create_transaction(set_alias(...), signer: PersonalAliasWithProof { pop_handle, score_context, people_ring })`. After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. +3. **Act.** `create_transaction(action, signer: PersonalAliasWithAccount { pop_handle, score_context, people_ring })`. No cross-product proof is handed out at any step, and **in the happy path the user sees none of the three** — the requirement that shapes the permission model below. ### The app's own personhood-dependent features -On a successful registration the Host matches the declared `RingLocation` against its well-known table (People, People-Lite) by structural equality, records the handle as the corresponding person key, and uses it wherever it used `PersonKey::Full` / `PersonKey::Lite` — coinage unload proofs on the Host, ring-VRF slot assignment for Bulletin / SSS allowance and PGAS claims on the Account Holder (RFC-0010). Both components learn the mapping from the registry rather than from a compiled-in product id or index. The compiled-in ring table shrinks to a well-known-ring matcher used for feature routing, not key selection. +On a successful registration the Host matches the declared `RingLocation` against its well-known table (People, People-Lite) by structural equality, records the handle as the corresponding person key, and uses it wherever it used `PersonKey::Full` / `PersonKey::Lite` — coinage unload proofs on the Host, ring-VRF slot assignment for Bulletin / SSS allowance and PGAS claims on the Account Holder (RFC-0010). Both learn the mapping from the registry rather than a compiled-in product id or index, and the compiled-in ring table shrinks to a well-known-ring matcher used for feature routing, not key selection. -**Contention.** If two products register for the same well-known ring, the Host MUST NOT pick silently. It resolves to the product the user designated as their personhood provider — a Host setting that defaults to the first registrar and is user-changeable — so a second product cannot silently displace the first. +**Contention.** If two products register for the same well-known ring, the Host MUST NOT pick silently. It resolves to the product the user designated as their personhood provider — a Host setting defaulting to the first registrar and user-changeable — so a second product cannot silently displace the first. ### Product shape -The personhood product is not headless: it needs a **pocket card**, because personhood has user-facing state worth surfacing (recovery, suspension status, which products hold grants). It also needs a **global lifetime**, to answer registration and cross-product requests regardless of what the user is looking at. +The personhood product is not headless: it needs a **pocket card**, because personhood has user-facing state worth surfacing (recovery, suspension status, which products hold grants), and a **global lifetime**, to answer registration and cross-product requests regardless of what the user is looking at. -The existing manifest model fits: one executable manifest per modality, all sharing one globally-lived background script, with the enabled modalities determining the reachable TrUAPI surface. Today `worker` carries `includes: { chat, pocket }` — the UI surfaces it contributes. One addition: +The existing manifest model fits — one executable manifest per modality, all sharing one globally-lived background script, with the enabled modalities determining the reachable TrUAPI surface. Today `worker` carries `includes: { chat, pocket }`. One addition: ```ts interface WorkerIncludes { @@ -298,33 +285,26 @@ interface WorkerIncludes { } ``` -The personhood product declares `{ pocket: true, onLoad: true }`. No capability flag gates the key-management calls: registration only ever touches the caller's own domain, and consuming a foreign key is governed by the permission model. +The personhood product declares `{ pocket: true, onLoad: true }`. No capability flag gates the key-management calls: registration only touches the caller's own domain, and consuming a foreign key is governed by the permission model. -`onLoad` is independently useful for products that genuinely contribute no UI. A product whose manifest declares `onLoad` and nothing else runs in the background and never shows itself; for those, the Host MUST disclose the fact at install time and list them in a user-reachable "runs in the background" inventory, since a headless globally-lived executable is otherwise indistinguishable from a Host feature. +`onLoad` is independently useful for products that contribute no UI at all. Those run in the background and never show themselves, so the Host MUST disclose the fact at install time and list them in a user-reachable "runs in the background" inventory — a headless globally-lived executable is otherwise indistinguishable from a Host feature. ### Permission model The requirement is asymmetric: routine discovery should be cheap, and the powerful grants deliberate but not per-call. -| Call | Own key / own context | Foreign | -| --------------------------------------------- | --------------------- | ----------------------------------------------------------- | -| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | -| `list_ring_vrf_keys(Anonymized)` | permissionless | requires a grant | -| `list_ring_vrf_keys(PublicKey)` | permissionless | requires a grant | -| `get_account_alias` | permissionless | requires a grant | -| `create_account_proof` | permissionless | grant for the foreign key; **refused** if the context is also foreign | -| `get_account` | permissionless | requires a grant | -| `create_transaction` · `ProductAccount` | permissionless | requires a grant — unchanged by this RFC | -| `create_transaction` · alias signers | permissionless | requires a grant for the context | +| Call | Own key / own context | Foreign | +| ---------------------------------------- | --------------------- | ----------------------------------------------------------- | +| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | +| `list_ring_vrf_keys` (either disclosure) | permissionless | requires a grant | +| `get_account_alias`, `get_account` | permissionless | requires a grant | +| `create_account_proof` | permissionless | grant for the foreign key; **refused** if the context is also foreign | +| `create_transaction` · `ProductAccount` | permissionless | requires a grant — unchanged by this RFC | +| `create_transaction` · alias signers | permissionless | requires a grant for the context | -The model is **user-approval driven**, per RFC-0002: a foreign access the user has not approved produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance: **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** Nothing else grants silent access. +The model is **user-approval driven**, per RFC-0002: an unapproved foreign access produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance — **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** Nothing else grants silent access. -That declaration belongs to the product manifest, which is specified separately (see [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)). Two requirements on it from here: - -- The allowlist must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list later without a wire break. -- It should be expressible **per method or method category**, so "read my key handles" and "sign from my alias account" need not be one grant. - -Until that RFC lands, Hosts fall back to a one-time prompt per (caller, owner, call) triple, persisted per RFC-0002 — correct, but with consent surfaces the target UX does not want. +That declaration belongs to the product manifest, specified separately ([RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)), with two requirements from here: the allowlist must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list without a wire break; and it should be expressible **per method or category**, so "read my key handles" and "sign under my alias" need not be one grant. Until it lands, Hosts fall back to a one-time prompt per (caller, owner, call) triple, persisted per RFC-0002. ### Accounts Protocol @@ -352,23 +332,23 @@ struct ListRingVrfKeysResponse { } ``` -A Host holding a current registry snapshot answers `list` locally and does not issue that request. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and `RingVrfError` gains `KeyNotRegistered` and `KeyNotInRing`. +A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and `RingVrfError` gains `KeyNotRegistered` and `KeyNotInRing`. -**Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host that holds the product's domain entropy answers the product immediately and mirrors the registration to the phone fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without that entropy the Host issues the request and waits. +**Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host holding the product's domain entropy answers immediately and mirrors the registration fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without the entropy the Host issues the request and waits. > **A Host MUST NOT derive a member secret for a `(product, index)` pair absent from its registry.** -The reason this needs saying: domain entropy makes derivation *unconditional*. Given the entropy of `//peopl.dot`, a Host can compute the member secret at index 7, or 4711, or any other index, because derivation is pure arithmetic — nothing about holding the entropy distinguishes an index that means something from one that does not. The registry is what supplies that distinction. If a Host served a proof for an unregistered index, the phone would have no record that such a key exists: it could not include it in slot assignment, could not list it in the user's inventory, and could not answer "what is this key used for". So the entropy grants the Host the ability to **derive** a member secret the registry already lists; only registration — which always reaches the phone — brings a new key into **existence**. +This needs saying because domain entropy makes derivation *unconditional*: given the entropy of `//peopl.dot`, a Host can compute the member secret at index 7, or 4711, or any other, since derivation is pure arithmetic and nothing about holding the entropy distinguishes a meaningful index from a meaningless one. The registry supplies that distinction. Serve an unregistered index and the phone has no record the key exists — it cannot include it in slot assignment, list it in the inventory, or answer "what is this key used for". So the entropy lets a Host **derive** a key the registry already lists; only registration, which always reaches the phone, brings one into **existence**. #### Answering while the phone is backgrounded -Registration is consent-free from the user's point of view and not latency-critical, so it is served by the layered background-availability model already designed for consent-free SSO requests: handshake prefetch, foreground, a bounded hot window, a push-woken headless cold path, and a mandatory non-blocking degrade. That model is specified in its own document (linked under [Prior Art](#prior-art-and-references)) and is not restated here. +Registration is consent-free from the user's point of view and not latency-critical, so it is served by the layered background-availability model already designed for consent-free SSO requests — handshake prefetch, foreground, a bounded hot window, a push-woken headless cold path, and a mandatory non-blocking degrade. That model is specified in its own document (see [Prior Art](#prior-art-and-references)) and is not restated here. -Two consequences matter for this RFC. Prefetch should carry the registry snapshot, so a consumer of an already-registered key never pays a round trip. And every headless execution context has a system-enforced budget (~30 s, ~24 MB): deriving a `RingVrfPublicKey` fits comfortably, while producing a ring VRF **proof** may not — the second motivation for the extension below. +Two consequences belong to this RFC: prefetch should carry the registry snapshot, so a consumer of an already-registered key never pays a round trip; and every headless execution context has a system-enforced budget (~30 s, ~24 MB), which a `RingVrfPublicKey` derivation fits comfortably but a ring VRF **proof** may not — the second motivation for the extension below. #### AutoSigning extension -RFC-0022 collapses RFC-0010's `AutoSigning` payload to the product-root secret key alone. It is extended to also transfer the product's ring VRF domain entropy: +RFC-0022 collapses RFC-0010's `AutoSigning` payload to the product-root secret key alone. It is extended to also transfer the ring VRF domain entropy: ```rust AutoSigning { @@ -380,80 +360,70 @@ AutoSigning { } ``` -No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. - -With this granted, a remote Host serves `create_account_proof` and `get_account_alias` — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: product A's proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning; A cannot grant it. +No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. With this granted, a remote Host serves `create_account_proof`, `get_account_alias`, and alias signing — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: a proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning. ### Migration and compatibility -Nothing here ships with production consumers. RFC-0004's `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; `HostAccountCreateProofRequest` and `HostAccountGetAliasRequest` gain a field with their wire ids unchanged. The two new methods take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becomes conditionally accepted, which is purely additive. +Nothing here ships with production consumers. `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; their request types gain a field with wire ids unchanged. The two new methods take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becoming conditionally accepted is purely additive. -`ProductAccountTxPayload.signer` changes type from `ProductAccountId` to `TxSigner`, which is breaking at the SCALE layer for `create_transaction` (wire 30). It is a continuation of RFC-0020's second change — parametrizing the payload by signer type — rather than a reversal of its first: the `context` RFC-0020 removed was `TxPayloadContext` (metadata, token symbol, best block), and `TxSigner`'s `ProductProofContext` is a different type carrying which alias signs. `LegacyAccountTxPayload` is untouched, since a legacy account has no product subtree and therefore no alias. +`ProductAccountTxPayload.signer` changes type from `ProductAccountId` to `TxSigner`, breaking at the SCALE layer for `create_transaction` (wire 30). This continues RFC-0020's second change — parametrizing the payload by signer type — rather than reversing its first: the `context` RFC-0020 removed was `TxPayloadContext` (metadata, token symbol, best block), where `TxSigner`'s `ProductProofContext` carries which alias signs. `LegacyAccountTxPayload` is untouched, a legacy account having no product subtree and therefore no alias. -In the Accounts Protocol, two new message pairs, a field on each ring VRF request, and one field on `AutoSigning`: all breaking at the SCALE layer, landing together with RFC-0022. The AP signing companion mirrors `TxSigner` so the Account Holder can satisfy an alias origin when AutoSigning is not granted. `WorkerIncludes.onLoad` is additive. +In the Accounts Protocol: two new message pairs, a field on each ring VRF request, one field on `AutoSigning`, and a signing companion mirroring `TxSigner` so the Account Holder can satisfy an alias origin without AutoSigning — all breaking at the SCALE layer, landing together with RFC-0022. `WorkerIncludes.onLoad` is additive. -The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once the personhood product registers, and are **not** retained as a fallback: a silent fallback to a compiled-in key would resurrect the coupling this RFC removes and would mask registry-sync bugs as working proofs. +The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once the personhood product registers, and are **not** retained as a fallback: a silent fallback to a compiled-in key would resurrect the coupling this RFC removes and mask registry-sync bugs as working proofs. ## Drawbacks -- **Removing the fallback makes personhood installable, and therefore missing.** A user without the personhood product installed has no people key at all: coinage unload and PGAS allowance stop working until they install it. Intended, but a real regression in default capability. -- **The Host must decode `set_alias` to enforce the alias-target rule.** That is real coupling to the individuality pallet: a call-encoding change breaks the check, and a check that silently stops matching fails open. The Host must already decode enough to build the origin's extensions, so this widens an existing dependency rather than creating one — but it is the price of having any enforcement point at all, and it should fail closed on an unrecognized call shape under an alias signer. -- **Bundling ring VRF entropy into AutoSigning widens one grant.** "Sign transactions without prompting me" and "produce personhood proofs offline" become one user decision, and the second is arguably the stronger. Accepted deliberately: two grants would mean two authorization surfaces for what a user experiences as one relationship with a product. -- **The registry is new distributed state.** Three parties must agree on it — the registering product, the caching Host, the owning Account Holder. A stale Host returns `KeyNotRegistered` for a key that exists. Registration being idempotent and the phone the only authority keeps this diagnosable, but it replaces a compile-time constant. -- **Registration leaks intent.** An anonymized listing still says "`peopl.dot` has a key it intends for the People ring". It does not prove membership, but a consumer learns the user has at least attempted full personhood before any proof is requested. This is the one privacy cost the design accepts for cheap discovery. -- **The silent happy path depends on the manifest RFC.** Until the allowlist exists, each cross-product call in the alias flow produces a one-time prompt. -- **The key handle overloads `ProductAccountId`.** The same type now names an sr25519 product account and a ring VRF derivation slot in a different tree at the same `(product, index)`. Accepted for the trivial alias-account mapping it buys. +- **Removing the fallback makes personhood installable, and therefore missing.** A user without the product installed has no people key at all, so coinage unload and PGAS allowance stop working until they install it. Intended, but a real regression in default capability. +- **The Host must decode `set_alias` to enforce the alias-target rule.** Real coupling to the individuality pallet: a call-encoding change breaks the check, and a check that silently stops matching fails open. It widens an existing dependency rather than creating one — the Host already decodes enough to build the origin's extensions — but it must fail closed on an unrecognized call shape under an alias signer. +- **Bundling ring VRF entropy into AutoSigning widens one grant.** "Sign transactions without prompting me" and "produce personhood proofs offline" become one decision, and the second is arguably the stronger. Accepted deliberately: two grants would mean two authorization surfaces for what the user experiences as one relationship. +- **The registry is new distributed state**, agreed between the registering product, the caching Host, and the owning Account Holder; a stale Host returns `KeyNotRegistered` for a key that exists. Idempotent registration and a single authority keep it diagnosable, but it replaces a compile-time constant. +- **Registration leaks intent.** An anonymized listing still says "`peopl.dot` has a key it intends for the People ring" — not proof of membership, but a consumer learns the user has attempted full personhood before any proof is requested. The one privacy cost accepted for cheap discovery. +- **The silent happy path depends on the manifest RFC**: until the allowlist exists, each cross-product call in the alias flow produces a one-time prompt. +- **The key handle overloads `ProductAccountId`**, which now names both an sr25519 product account and a ring VRF slot in a different tree at the same `(product, index)`. ## Testing, Security, and Privacy **Testing.** -- *Registry authority.* A Host holding domain entropy must refuse to derive an unregistered index. The single most important negative test here — it is what keeps the phone's inventory truthful. +- *Registry authority.* A Host holding domain entropy must refuse to derive an unregistered index — the most important negative test here, since it is what keeps the phone's inventory truthful. - *Determinism.* For a fixed `(key_handle, context, ring)`, `get_account_alias` and the `contextual_alias` inside `create_account_proof` must agree across Hosts and across the proxied and AutoSigning-local paths; a locally-derived proof and a phone-produced one must be indistinguishable to a verifier. -- *Ring binding.* A handle registered for ring X, called with ring Y, must return `KeyNotInRing`. -- *Proof scope.* `create_account_proof` with both a foreign `key_handle` and a foreign `context` must return `ForeignKeyInForeignContext`, and the other three combinations must not be affected. Assert the absence of a proof, not just the error code. -- *Alias target.* `create_transaction` with an alias signer and a `set_alias` naming an account outside the context owner's subtree must return `AliasTargetNotOwned` — including when the target is the caller's own account, which is the case that motivated the rule. An unrecognized call shape under an alias signer must fail closed. -- *Fire-and-forget mirroring.* A registration served locally under AutoSigning must reach the phone, and re-notifying an entry the phone already holds must be a no-op rather than a duplicate entry. -- *Background availability.* Registration answered on the foreground, hot-window, and cold paths, plus the degrade, with the answer path fitting the smallest headless budget. +- *Ring binding.* A handle registered for ring X, called with ring Y, returns `KeyNotInRing`. +- *Proof scope.* Foreign `key_handle` **and** foreign `context` returns `ForeignKeyInForeignContext`, with the other three combinations unaffected. Assert the absence of a proof, not just the error code. +- *Alias target.* An alias signer whose `set_alias` names anything other than the context's alias account returns `AliasTargetMismatch` — including when the target is the caller's own account, the case that motivated the rule. An unrecognized call shape under an alias signer must fail closed. +- *Fire-and-forget mirroring.* A registration served locally under AutoSigning must reach the phone, and re-notifying an entry it already holds must be a no-op. +- *Background availability.* Registration answered on the foreground, hot-window, and cold paths plus the degrade, fitting the smallest headless budget. - *Provider contention.* Two products registering for the same well-known ring must not silently change the designated provider. -- *Context construction.* The on-chain score constant must equal `product_context_bytes` for the personhood product's score context — the test that keeps the two schemes from diverging again. +- *Context construction.* The on-chain score constant must equal `product_context_bytes` for the personhood product's score context — what keeps the two schemes from diverging again. -**Security.** Products never receive member secrets; `RingVrfPublicKey` is the only key material crossing the TrUAPI boundary, and only under the owner's disclosure decision. Products also never receive a proof they could use blindly against a context they do not own, which is what makes the *binding* of an alias structurally protected rather than only permission-gated: the proof stays inside the Host, and the `set_alias` target is checked where it is produced. Cross-product product-account signing remains permission-gated and is out of scope here. AutoSigning with ring VRF entropy makes the Host a custodian of the material behind personhood proofs — RFC-0010's custody obligations at a higher blast radius, which Account Holders MUST present distinctly in the authorization UI. +**Security.** Products never receive member secrets; `RingVrfPublicKey` is the only key material crossing the TrUAPI boundary, and only under the owner's disclosure decision. Nor do they receive a proof usable blindly against a context they do not own, which is what makes the *binding* of an alias structurally protected rather than only permission-gated: the proof stays inside the Host and the `set_alias` target is checked where it is produced. Cross-product product-account signing remains permission-gated and out of scope here. AutoSigning with ring VRF entropy makes the Host a custodian of the material behind personhood proofs — RFC-0010's custody obligations at a higher blast radius, which Account Holders MUST present distinctly in the authorization UI. -**Privacy.** Anonymized listing is the default cross-product shape specifically so discovery does not distribute public keys: a member public key is linkable across every ring it appears in. Contexts remain product-scoped, so RFC-0004's unlinkability guarantee is unchanged — a foreign context is reachable only under a grant the user or the owner made deliberately. Well-known contexts are enumerable by construction, which is not a regression: an alias is computable only with the member secret. +**Privacy.** Anonymized listing is the default cross-product shape specifically so discovery does not distribute public keys, a member public key being linkable across every ring it appears in. Contexts remain product-scoped, so RFC-0004's unlinkability guarantee is unchanged — a foreign context is reachable only under a deliberate grant. Well-known contexts are enumerable by construction, which is not a regression: an alias is computable only with the member secret. ## Alternatives -- **Per-flow host callbacks instead of a registration call.** The Host would expose a higher-level call per internal flow ("allocate PGAS allowance") and the product would supply a handler, so only the relevant private key is touched. Rejected: it grows a new bidirectional contract for every internal feature the Host ever adds, couples the personhood product's release cycle to the Host's, and makes the product responsible for flows (slot-table bookkeeping, claim budgets) RFC-0010 deliberately put on the Account Holder. Registration adds one call and leaves every existing flow where it is. -- **A per-context `Shared` / `Private` scope**, with the Host rejecting an undeclared foreign context and restricting foreign account access to alias indices of shared contexts. Rejected: it introduces a second authorization mechanism next to the permission model, on a different axis (the context rather than the caller), and "a product may *name* this context" turned out to be an unclear thing to grant. Cross-product access is a permission question, answered in one place — and the scope would not have stopped the alias hijack anyway, since that needs no foreign account. -- **Enforcing the alias target at proof time**, by having the Host construct the whole `set_alias` payload behind a dedicated call so it knows what it is signing. Rejected in favour of generalizing the signer: `create_transaction` already receives the call and already owes the signer its extensions, so it needs no new method and no second call-construction path. -- **Inspecting the proof `message`**, with or without a caller-supplied preimage. Rejected as unimplementable: the message is a hash of the inherited implication, and trusting a preimage the caller supplies is still blind signing. -- **Constraining the alias target on chain**, so `set_alias` accepts only an account the runtime can derive from the proof's context. Attractive — it would remove the class of attack rather than one instance — but the mapping from a context to its alias account is a client-side HD derivation the runtime cannot verify; it only sees account ids. It also removes the deliberately convenient case of pointing an alias at a real product account. -- **The owner performing every binding itself**, never lending anything, with consumers calling a product-level operation on the personhood product. Rejected as needing a product-to-product invocation primitive TrUAPI does not have; the alias signer achieves the same protection through a call that already exists. -- **A dedicated "key manager" modality**, or a `capabilities.keyManager` flag gating registration. Rejected: registration only ever writes to the caller's own ring VRF domain, so there is nothing to gate. -- **Attestation thresholds / trusted verifiers** for silent access, instead of a product-id allowlist. Rejected for now in favour of the simpler flat list; the manifest RFC must keep the schema extensible so this remains available. -- **A distinct `RingVrfKeyId { product_id, index }`** instead of reusing `ProductAccountId` for handles. Rejected: a near-duplicate type, and it makes the alias-account mapping less obvious. -- **A `ProofContext` enum with a `PoP(WellKnownContextSuffix)` variant**, giving well-known contexts a `pop:`-prefixed namespace outside the product scheme. Rejected: it forks context derivation and alias-account mapping in two, which is the situation RFC-0022 left open and this RFC closes. +- **Per-flow host callbacks instead of a registration call** — a Host call per internal flow with a product-supplied handler. Rejected: a new bidirectional contract for every internal feature the Host ever adds, coupled release cycles, and it hands the product flows (slot-table bookkeeping, claim budgets) RFC-0010 put on the Account Holder. +- **Enforcing the alias target at proof time**, with the Host constructing the whole `set_alias` payload behind a dedicated call. Rejected: `create_transaction` already receives the call and already owes the signer its extensions, so no new method and no second call-construction path is needed. +- **Inspecting the proof `message`**, with or without a caller-supplied preimage. Unimplementable: it is a hash of the inherited implication, and trusting a caller's preimage is still blind signing. +- **Attestation thresholds / trusted verifiers** instead of a product-id allowlist. Deferred rather than dismissed: the flat list is simpler, and the manifest RFC must keep the schema extensible. +- **A `ProofContext` enum with a `PoP(WellKnownContextSuffix)` variant.** Rejected: it forks context derivation and alias-account mapping in two — the situation RFC-0022 left open and this RFC closes. ## Prior Art and References - [RFC-0004 — Redesign `create_account_proof`](0004-ringlocation-redesign.md) — `RingLocation`, `ProductProofContext`, the context derivation, and the member-key selection contract this RFC deletes. Its "Out of scope: explicit member-key management … left to a future RFC" is this RFC. -- **RFC-0022 — Account key derivations** ([PR #296](https://github.com/paritytech/truapi/pull/296)) — the ring-VRF tree and its `//{productId}//{index}` paths, `Either` derivation indices, the reserved `peopl.dot` product identity, and the `AutoSigning` payload this RFC extends. Its deferral of well-known alias accounts is resolved here. -- **RFC-0023 — sr25519 VRF signing for product accounts** ([PR #301](https://github.com/paritytech/truapi/pull/301)) — the complementary non-member path: `sign_vrf` from a product account for participants not yet in the people set, where this RFC's ring VRF path serves members. -- [RFC-0002 — Permission Model for Host API](0002-permission-model.md) — the prompt-once / persist-indefinitely lifecycle every cross-product grant here reuses. -- [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206) — where the product-id allowlist is specified. -- [RFC-0009 — Unauthenticated Product Access](0009-unauthenticated-product-access.md) — `NotConnected` semantics. +- **RFC-0022 — Account key derivations** ([PR #296](https://github.com/paritytech/truapi/pull/296)) — the ring-VRF tree, `Either` indices, the reserved `peopl.dot` identity, and the `AutoSigning` payload this RFC extends. Its deferral of well-known alias accounts is resolved here. +- **RFC-0023 — sr25519 VRF signing for product accounts** ([PR #301](https://github.com/paritytech/truapi/pull/301)) — the complementary non-member path, where this RFC's ring VRF path serves members. +- [RFC-0020 — `create_transaction` and its AP mirror](0020-create-transaction.md) — the signer-parametrized payload this RFC generalizes, and the pattern of specifying a call together with its AP companion. - [RFC-0010 — W3S Allowance Management](0010-allowance.md) — AutoSigning and the PGAS / Bulletin / SSS flows that consume the person key. -- [RFC-0020 — `create_transaction` and its Accounts Protocol mirror](0020-create-transaction.md) — the pattern of specifying a TrUAPI call together with its AP companion, followed here. +- [RFC-0002 — Permission Model](0002-permission-model.md) — the prompt-once lifecycle every cross-product grant reuses · [RFC-0009](0009-unauthenticated-product-access.md) — `NotConnected` semantics · [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206) — where the allowlist is specified. - *SSO background availability — common model* — the layered availability ladder referenced above. **TODO: link the HackMD document.** - `rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs` — the compiled-in selection this RFC removes. - [Polkadot People Registry / Ring VRF](https://forum.polkadot.network/t/the-people-registry/12749) · [individuality#878](https://github.com/paritytech/individuality/pull/878) — alias-account assignment for derived product addresses. ## Unresolved Questions -1. **How does a Host resolve the key and ring for an alias signer?** `TxSigner`'s alias variants name a context but not a `key_handle` or a `RingLocation`, since the caller must not choose them. Resolving them from the context's owner and the designated personhood provider is the intent, but the exact rule — and what happens when the owner has several registered keys for the relevant ring — is unspecified. -2. **Is `set_alias` the only call an alias signer needs checked?** The target rule covers the known hijack. Whether other calls reachable under an alias origin can rebind or transfer the alias, and therefore need the same treatment, needs a pass over the pallet's extrinsics rather than an assumption. -3. **Who owns the `resources` and `mob-rule` contexts?** The score context is assigned to the personhood product. RFC-0022 lists two more well-known contexts, and every context needs exactly one owner. -4. **Does the personhood product's pocket card change what `onLoad` needs to disclose?** The disclosure and background-inventory rules were written for products with no UI at all; a product with a pocket card is visible, so the rules may apply only to `onLoad`-only manifests. +No open questions remain on the design above. Three items are deliberately deferred to follow-up work: -Deferred to follow-up work: **revocation**, already deferred by RFC-0010 and made more urgent by the entropy transfer, including retraction of a registry entry by its owner; **key rotation and recovery**, which the registry makes expressible but whose effect on in-flight aliases is unspecified; and **provider competition**, for which the designation setting is only the minimal hook. +- **Revocation** — already deferred by RFC-0010 and made more urgent by the entropy transfer, including retraction of a registry entry by its owner. +- **Key rotation and recovery** — the registry makes rotation expressible (register a new index, retire the old), but the effect on in-flight aliases is unspecified. +- **Provider competition** — once personhood is a product, more than one can exist; the provider-designation setting is only the minimal hook. From 3a16c89f8f5ced9ff3bfdca59332624616248c8b Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 30 Jul 2026 19:40:31 +0700 Subject: [PATCH 3/7] Simplify --- docs/rfcs/0024-personhood-as-product.md | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index b2503306d..c0c3a69ae 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -382,24 +382,6 @@ The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once - **The silent happy path depends on the manifest RFC**: until the allowlist exists, each cross-product call in the alias flow produces a one-time prompt. - **The key handle overloads `ProductAccountId`**, which now names both an sr25519 product account and a ring VRF slot in a different tree at the same `(product, index)`. -## Testing, Security, and Privacy - -**Testing.** - -- *Registry authority.* A Host holding domain entropy must refuse to derive an unregistered index — the most important negative test here, since it is what keeps the phone's inventory truthful. -- *Determinism.* For a fixed `(key_handle, context, ring)`, `get_account_alias` and the `contextual_alias` inside `create_account_proof` must agree across Hosts and across the proxied and AutoSigning-local paths; a locally-derived proof and a phone-produced one must be indistinguishable to a verifier. -- *Ring binding.* A handle registered for ring X, called with ring Y, returns `KeyNotInRing`. -- *Proof scope.* Foreign `key_handle` **and** foreign `context` returns `ForeignKeyInForeignContext`, with the other three combinations unaffected. Assert the absence of a proof, not just the error code. -- *Alias target.* An alias signer whose `set_alias` names anything other than the context's alias account returns `AliasTargetMismatch` — including when the target is the caller's own account, the case that motivated the rule. An unrecognized call shape under an alias signer must fail closed. -- *Fire-and-forget mirroring.* A registration served locally under AutoSigning must reach the phone, and re-notifying an entry it already holds must be a no-op. -- *Background availability.* Registration answered on the foreground, hot-window, and cold paths plus the degrade, fitting the smallest headless budget. -- *Provider contention.* Two products registering for the same well-known ring must not silently change the designated provider. -- *Context construction.* The on-chain score constant must equal `product_context_bytes` for the personhood product's score context — what keeps the two schemes from diverging again. - -**Security.** Products never receive member secrets; `RingVrfPublicKey` is the only key material crossing the TrUAPI boundary, and only under the owner's disclosure decision. Nor do they receive a proof usable blindly against a context they do not own, which is what makes the *binding* of an alias structurally protected rather than only permission-gated: the proof stays inside the Host and the `set_alias` target is checked where it is produced. Cross-product product-account signing remains permission-gated and out of scope here. AutoSigning with ring VRF entropy makes the Host a custodian of the material behind personhood proofs — RFC-0010's custody obligations at a higher blast radius, which Account Holders MUST present distinctly in the authorization UI. - -**Privacy.** Anonymized listing is the default cross-product shape specifically so discovery does not distribute public keys, a member public key being linkable across every ring it appears in. Contexts remain product-scoped, so RFC-0004's unlinkability guarantee is unchanged — a foreign context is reachable only under a deliberate grant. Well-known contexts are enumerable by construction, which is not a regression: an alias is computable only with the member secret. - ## Alternatives - **Per-flow host callbacks instead of a registration call** — a Host call per internal flow with a product-supplied handler. Rejected: a new bidirectional contract for every internal feature the Host ever adds, coupled release cycles, and it hands the product flows (slot-table bookkeeping, claim budgets) RFC-0010 put on the Account Holder. From 335896fc518901be926889bc50eb81fe352ce95a Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 30 Jul 2026 20:08:06 +0700 Subject: [PATCH 4/7] Add sign vrf & simplify --- docs/rfcs/0024-personhood-as-product.md | 148 ++++++++++-------------- 1 file changed, 60 insertions(+), 88 deletions(-) diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index c0c3a69ae..b16796b9f 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -14,9 +14,9 @@ owner: "@valentunn" ## Summary -RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof` / `get_account_alias`. With an `onLoad` executable modality for global lifetime and the Accounts Protocol companions, full and light personhood become a standalone product whose key index no consumer — including the Host — has to know. +RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof`, `get_account_alias`, and a new `ring_vrf_sign`. With an `onLoad` executable modality for global lifetime and the Accounts Protocol companions, full and light personhood become a standalone product whose key index no consumer — including the Host — has to know. -Because a ring VRF proof is a bearer token for its context's alias, a proof is only issued when the caller owns either the key or the context. Cross-product alias use moves to transaction signing instead: `create_transaction`'s signer generalizes to include personhood-alias origins, so the Host produces the proof while satisfying the signer and never hands one out. The RFC also resolves RFC-0022's deferral of well-known alias accounts: every context is product-owned and built with TrUAPI's product-scoped context function, so there is no second context scheme. +A proof is a bearer token for its context's alias and a signature is a bearer token for the key, and neither can be constrained by inspecting an opaque message. Cross-product use of a foreign key is therefore gated on the owning product having allowlisted the caller in its manifest, with no user-prompt fallback — an interim position, with a more expressive scheme left to follow-up work. The RFC also resolves RFC-0022's deferral of well-known alias accounts: every context is product-owned and built with TrUAPI's product-scoped context function, so there is no second context scheme. ## Motivation @@ -40,7 +40,7 @@ The app-internal class forces a mechanism, and this RFC picks a **registration c - **Personhood product developers** — the first consumer; owns the registry entries for the full and light personhood rings. - **Product developers building on personhood** — score / identity / mobrule / game; consume foreign handles, foreign contexts, and alias origins. -- **Host developers** — implement the registry, drop the compiled-in member-key selection, generalize the transaction signer, add the `onLoad` modality. +- **Host developers** — implement the registry, drop the compiled-in member-key selection, enforce the owner allowlist on proofs and signatures, add the `onLoad` modality. - **Account Holder developers (Mobile App)** — become the authoritative registry, implement the new message pairs, extend the AutoSigning payload, answer registrations from the background. - **Chain / individuality developers** — on-chain contexts (`score`, `resources`, `mob-rule`) must be derived with TrUAPI's product-scoped context function rather than a parallel namespace. @@ -55,7 +55,7 @@ The app-internal class forces a mechanism, and this RFC picks a **registration c ### Key management calls -Two additions to the `Account` trait. +Two additions to the `Account` trait, alongside the signing call in the next section. ```rust type RingVrfPublicKey = [u8; 32]; @@ -100,7 +100,7 @@ fn list_ring_vrf_keys( RFC-0022 already pins `//peopl.dot//index_bytes(0)` as the full personhood key and `index_bytes(1)` as the light one. Under this RFC those constants are the personhood product's own implementation detail, expressed to everyone else as two registry entries. -### Proofs and aliases take an explicit key handle +### Proofs, aliases, and signatures take an explicit key handle RFC-0004's Host member-key selection contract is **deleted**: the Host no longer defines a PoP collection, infers correspondence, or has a fallback. @@ -109,7 +109,7 @@ fn create_account_proof( key_handle: ProductAccountId, context: ProductProofContext, ring: RingLocation, - message: Vec, + message: Bytes, ) -> Result; fn get_account_alias( @@ -117,9 +117,19 @@ fn get_account_alias( context: ProductProofContext, ring: RingLocation, ) -> Result; + +/// Sign `message` with the member key itself, producing an ordinary signature +/// rather than an anonymous ring proof. Verified against the member public key, +/// so the signature is linkable and carries no ring or context. +fn ring_vrf_sign( + key_handle: ProductAccountId, + message: Bytes, +) -> Result; ``` -`ring` stays a parameter even though the handle carries declared rings: a key may be registered for several, and the caller must say which the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise. RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host now holds trivially, since key selection is no longer Host policy. +`ring` stays a parameter on the first two even though the handle carries declared rings: a key may be registered for several, and the caller must say which the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise. RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host now holds trivially, since key selection is no longer Host policy. + +`ring_vrf_sign` takes neither: it derives no alias and proves no membership, so there is nothing for a context or a ring to scope. A verifier needs the member public key, which is what makes `RingVrfKeyDisclosure::PublicKey` load-bearing rather than merely informational, and which makes every such signature linkable to every other use of that key. ### Errors @@ -148,26 +158,25 @@ enum HostAccountCreateProofError { KeyNotRegistered, /// `key_handle` is registered, but not for the requested `ring`. KeyNotInRing, - /// Neither `key_handle` nor `context` belongs to the calling product. - ForeignKeyInForeignContext, + /// `key_handle` is foreign and its owner has not allowlisted the caller. + NotAllowlisted, Rejected, Unknown { reason: String }, } -// Extensions to `HostCreateTransactionError` (RFC-0020). -enum HostCreateTransactionError { - // ... existing variants unchanged ... - /// An alias signer names a context the caller has no grant for. - AliasNotPermitted, - /// The call contains a `set_alias` whose target is not the alias account - /// of the signing context. - AliasTargetMismatch, +enum RingVrfSignErr { + NotConnected, + KeyNotRegistered, + /// `key_handle` is foreign and its owner has not allowlisted the caller. + NotAllowlisted, + Rejected, + Unknown { reason: String }, } ``` ### Cross-product discovery -A game product producing a proof with the full personhood key, under its **own** airdrop context — abstracted by the product SDK, not the Host: +A game product producing a proof with the full personhood key, under its own airdrop context — abstracted by the product SDK, not the Host. It works because `peopl.dot` has allowlisted `game.dot`; see [Using a foreign key](#using-a-foreign-key-means-trusting-the-caller). ```mermaid sequenceDiagram @@ -200,69 +209,31 @@ A context therefore has exactly one owner: the `product_id` mixed into its deriv **This supersedes RFC-0022 §"Well-known alias accounts"**, which describes `score`, `resources`, and `mob-rule` as owned by no product, outside the product-based construction, and defers their handling. Under this RFC they are ordinary product-owned contexts; **the score context is owned by the personhood product**, and DIMs coercible to the score system are its consumers. Access to a foreign context is governed by the permission model below; there is no sharing declaration on a context. -### A proof only ever binds to a context the caller owns - -Keys and contexts are owned independently, and one of the four combinations is dangerous: +### Using a foreign key means trusting the caller -| `key_handle` owner | `context` owner | Example | Allowed | -| ------------------ | --------------- | ---------------------------------------------------------- | ------- | -| caller | caller | the personhood product proving under its own context | yes | -| **foreign** | caller | a game proving with the people key in its airdrop context | yes | -| caller | **foreign** | own key under a foreign context; fails `NotMember` anyway | yes | -| **foreign** | **foreign** | a game proving with the people key in the score context | **no** | +Both `create_account_proof` and `ring_vrf_sign` hand the caller output produced with someone else's member key, and neither can be constrained by inspection. **A proof is a bearer token for its context's alias, and a signature is a bearer token for the key itself.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and accepting a caller-supplied preimage would still be blind signing — so nothing at call time can tell what the result will authorize. -> A Host MUST reject `create_account_proof` when neither `key_handle` nor `context` belongs to the calling product, with `ForeignKeyInForeignContext`. +The concrete consequence, worth stating because it is not obvious: a product holding a proof under the score context can build a `set_alias` binding that alias to an account of its own, sign it with its own product account, and submit it without involving the Host again. The score alias then resolves to an account it controls. Every check passes; the proof was the authority. `ring_vrf_sign` is the wider version of the same problem, since it has no context or ring to scope what the signature is good for. -The reason is that **a proof is a bearer token for its context's alias.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and accepting a caller-supplied preimage instead would still be blind signing — so nothing at proof time can tell what the proof will authorize. A caller holding a proof under the score context can therefore bind that alias to an account of its own, signing the `set_alias` with its own product account and needing nothing further from anyone; nor is there a downstream chokepoint, since it can submit the extrinsic without going through the Host at all. - -Denying the foreign/foreign combination removes the token, leaving a simple invariant: **whoever holds a proof owns the context it binds to**, so which account an alias points at is always the context owner's own business. - -### Alias signing - -That denial would also block the legitimate case — a product acting under another product's alias, such as claiming score rewards — if proofs were the only route. They are not. `create_transaction` already promises to *"upon approval, fill all necessary transaction extensions to satisfy signer"*, making it the natural place for an alias origin: the Host constructs the proof while satisfying the signer, so it never hands one out and sees the whole call while doing so. - -RFC-0020 parametrized the transaction payload by signer type; the `ProductAccountId` signer generalizes to: - -```rust -enum TxSigner { - /// Sign as an ordinary product account. Today's behaviour. - ProductAccount { - account: ProductAccountId, - }, - /// Sign as a personhood alias whose account is already set on chain, - /// under an `AsPersonalAliasWithAccount`-style origin. - PersonalAliasWithAccount { - key_handle: ProductAccountId, - context: ProductProofContext, - ring: RingLocation, - }, - /// Sign as a personhood alias proven by ring VRF, under an - /// `AsPersonalAlias`-style origin. This is what `set_alias` uses. - PersonalAliasWithProof { - key_handle: ProductAccountId, - context: ProductProofContext, - ring: RingLocation, - }, -} -``` +There is no way to bound this by structure at the call site. So it is bounded by **whom the owner trusts**: -Both alias variants carry the same three fields the proof itself needs, for the same reasons as `create_account_proof`: the `key_handle` names which registered key to prove with, and `ring` disambiguates when that handle is registered for several. Neither is secret — a caller discovers the handle through `list_ring_vrf_keys` and treats it as opaque — so there is nothing gained by having the Host infer them. `context` needs no companion alias-account field, because a context and its alias account are 1:1 under RFC-0004's `product_account_id_for_proof_context`. +> A Host MUST reject `create_account_proof` and `ring_vrf_sign` with a foreign `key_handle` unless the key's owning product has allowlisted the calling product in its manifest, with `NotAllowlisted`. -Semantics are unchanged: the caller supplies a call, the Host fills whatever the signer requires. The proof simply becomes one of those things, produced by the Host rather than the caller — which makes the enforcement that was impossible at proof time available: +The allowlist is the *only* authorization for these two calls. A user prompt is not a substitute and MUST NOT be offered as a fallback: consenting to an opaque message is not meaningful consent, and the risk being accepted is one only the key's owner is positioned to evaluate. This is a deliberate departure from the general permission model, where the allowlist merely avoids a prompt. -> When the signer is `PersonalAliasWithProof` or `PersonalAliasWithAccount`, a Host MUST decode `call_data` and reject a `set_alias` whose target account is not the alias account of `context`, with `AliasTargetMismatch`. +Foreign `get_account_alias` and foreign `get_account` are unaffected — reading an alias or an account id authorizes nothing — and `create_transaction` is unchanged, keeping its `signer: ProductAccountId` and accepting a foreign one under an ordinary grant. -The 1:1 mapping is what makes this an exact check rather than a containment test: for a given context there is exactly one legitimate target, so the Host recomputes it and compares. `set_alias` is the only call reachable under an alias origin that can rebind an alias, so it is the only one that needs the check. And since the Host must already understand the call well enough to build the origin's extensions, the extra cost is reading one argument. +This is the pragmatic position, not the durable one. It makes cross-product key use an all-or-nothing trust decision by the owner, when what the owner actually wants to express is narrower — "you may prove personhood for your own airdrop" rather than "you may do anything my key can do". [Future work](#unresolved-questions) records the shape a general solution would take. -`ProductAccount(...)` keeps accepting a foreign `ProductAccountId` under a grant — cross-product product-account signing has uses unrelated to ring VRF, so this RFC neither restricts nor bounds it — and `get_account` likewise still accepts a foreign id, which a caller needs anyway to put the alias account into the `set_alias` call. What the alias variants add is the *origin*, not access to a key: `PersonalAliasWithAccount` produces a transaction the chain sees as the personal alias acting, which a plain signature from the same account does not. The protection here is correspondingly narrow — not "no foreign signing", but that an alias's *binding* cannot be redirected. +### The alias flow -The alias flow is then: +Using an alias — claiming score rewards, say — is then: 1. **Read the alias.** `get_account_alias(pop_handle, score_context, people_ring)`. The consuming product checks the ring revision on each use and renews when it has moved; nothing else watches for it. -2. **Bind or rebind if needed.** `create_transaction(set_alias(...), signer: PersonalAliasWithProof { pop_handle, score_context, people_ring })`. After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. -3. **Act.** `create_transaction(action, signer: PersonalAliasWithAccount { pop_handle, score_context, people_ring })`. +2. **Bind or rebind if needed.** Build a `set_alias` from the alias account id (`get_account` on the context's alias index, which the context determines 1:1) and a proof (`create_account_proof`, requiring the allowlist above). After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. +3. **Submit.** `create_transaction` with the alias account's `ProductAccountId` as signer. -No cross-product proof is handed out at any step, and **in the happy path the user sees none of the three** — the requirement that shapes the permission model below. +At worst three cross-product requests, and **in the happy path the user sees none of them** — the requirement that shapes the permission model below. ### The app's own personhood-dependent features @@ -293,16 +264,15 @@ The personhood product declares `{ pocket: true, onLoad: true }`. No capability The requirement is asymmetric: routine discovery should be cheap, and the powerful grants deliberate but not per-call. -| Call | Own key / own context | Foreign | -| ---------------------------------------- | --------------------- | ----------------------------------------------------------- | -| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | -| `list_ring_vrf_keys` (either disclosure) | permissionless | requires a grant | -| `get_account_alias`, `get_account` | permissionless | requires a grant | -| `create_account_proof` | permissionless | grant for the foreign key; **refused** if the context is also foreign | -| `create_transaction` · `ProductAccount` | permissionless | requires a grant — unchanged by this RFC | -| `create_transaction` · alias signers | permissionless | requires a grant for the context | +| Call | Own key | Foreign key or context | +| ---------------------------------------- | ------- | ------------------------------------------------------------- | +| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | +| `list_ring_vrf_keys` (either disclosure) | permissionless | grant, or a user prompt | +| `get_account_alias`, `get_account` | permissionless | grant, or a user prompt | +| `create_account_proof`, `ring_vrf_sign` | permissionless | **owner's manifest allowlist only** — no prompt fallback | +| `create_transaction` | permissionless | grant, or a user prompt — unchanged by this RFC | -The model is **user-approval driven**, per RFC-0002: an unapproved foreign access produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance — **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** Nothing else grants silent access. +The model is **user-approval driven**, per RFC-0002: an unapproved foreign access produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance — **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** For proofs and signatures that allowlist is not an optimization but the whole gate, per the rule above. That declaration belongs to the product manifest, specified separately ([RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)), with two requirements from here: the allowlist must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list without a wire break; and it should be expressible **per method or category**, so "read my key handles" and "sign under my alias" need not be one grant. Until it lands, Hosts fall back to a one-time prompt per (caller, owner, call) triple, persisted per RFC-0002. @@ -332,7 +302,7 @@ struct ListRingVrfKeysResponse { } ``` -A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and `RingVrfError` gains `KeyNotRegistered` and `KeyNotInRing`. +A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and a `RingVrfSignRequest` / `Response` pair mirrors `ring_vrf_sign` with the same two fields plus `message`. `RingVrfError` gains `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`. **Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host holding the product's domain entropy answers immediately and mirrors the registration fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without the entropy the Host issues the request and waits. @@ -360,22 +330,22 @@ AutoSigning { } ``` -No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. With this granted, a remote Host serves `create_account_proof`, `get_account_alias`, and alias signing — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: a proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning. +No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. With this granted, a remote Host serves `create_account_proof`, `get_account_alias`, and `ring_vrf_sign` — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: a proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning. ### Migration and compatibility -Nothing here ships with production consumers. `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; their request types gain a field with wire ids unchanged. The two new methods take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becoming conditionally accepted is purely additive. +Nothing here ships with production consumers. `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; their request types gain a field with wire ids unchanged. The three new methods — `register_ring_vrf_key`, `list_ring_vrf_keys`, `ring_vrf_sign` — take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becoming conditionally accepted is purely additive. -`ProductAccountTxPayload.signer` changes type from `ProductAccountId` to `TxSigner`, breaking at the SCALE layer for `create_transaction` (wire 30). This continues RFC-0020's second change — parametrizing the payload by signer type — rather than reversing its first: the `context` RFC-0020 removed was `TxPayloadContext` (metadata, token symbol, best block), where `TxSigner`'s `ProductProofContext` carries which alias signs. `LegacyAccountTxPayload` is untouched, a legacy account having no product subtree and therefore no alias. +`create_transaction` (wire 30) is untouched: it keeps `signer: ProductAccountId` and every existing payload type. -In the Accounts Protocol: two new message pairs, a field on each ring VRF request, one field on `AutoSigning`, and a signing companion mirroring `TxSigner` so the Account Holder can satisfy an alias origin without AutoSigning — all breaking at the SCALE layer, landing together with RFC-0022. `WorkerIncludes.onLoad` is additive. +In the Accounts Protocol: three new message pairs, a field on each existing ring VRF request, and one field on `AutoSigning` — all breaking at the SCALE layer, landing together with RFC-0022. `WorkerIncludes.onLoad` is additive. The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once the personhood product registers, and are **not** retained as a fallback: a silent fallback to a compiled-in key would resurrect the coupling this RFC removes and mask registry-sync bugs as working proofs. ## Drawbacks - **Removing the fallback makes personhood installable, and therefore missing.** A user without the product installed has no people key at all, so coinage unload and PGAS allowance stop working until they install it. Intended, but a real regression in default capability. -- **The Host must decode `set_alias` to enforce the alias-target rule.** Real coupling to the individuality pallet: a call-encoding change breaks the check, and a check that silently stops matching fails open. It widens an existing dependency rather than creating one — the Host already decodes enough to build the origin's extensions — but it must fail closed on an unrecognized call shape under an alias signer. +- **Cross-product key use is an all-or-nothing trust decision.** An allowlisted product can do anything the owner's key can do — prove under any context, sign any message — when what an owner wants to express is narrower. The blind-signing risk is contained by whom the owner trusts rather than by what the caller can ask for, which is why this is explicitly an interim position. - **Bundling ring VRF entropy into AutoSigning widens one grant.** "Sign transactions without prompting me" and "produce personhood proofs offline" become one decision, and the second is arguably the stronger. Accepted deliberately: two grants would mean two authorization surfaces for what the user experiences as one relationship. - **The registry is new distributed state**, agreed between the registering product, the caching Host, and the owning Account Holder; a stale Host returns `KeyNotRegistered` for a key that exists. Idempotent registration and a single authority keep it diagnosable, but it replaces a compile-time constant. - **Registration leaks intent.** An anonymized listing still says "`peopl.dot` has a key it intends for the People ring" — not proof of membership, but a consumer learns the user has attempted full personhood before any proof is requested. The one privacy cost accepted for cheap discovery. @@ -385,8 +355,9 @@ The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once ## Alternatives - **Per-flow host callbacks instead of a registration call** — a Host call per internal flow with a product-supplied handler. Rejected: a new bidirectional contract for every internal feature the Host ever adds, coupled release cycles, and it hands the product flows (slot-table bookkeeping, claim budgets) RFC-0010 put on the Account Holder. -- **Enforcing the alias target at proof time**, with the Host constructing the whole `set_alias` payload behind a dedicated call. Rejected: `create_transaction` already receives the call and already owes the signer its extensions, so no new method and no second call-construction path is needed. -- **Inspecting the proof `message`**, with or without a caller-supplied preimage. Unimplementable: it is a hash of the inherited implication, and trusting a caller's preimage is still blind signing. +- **Inspecting the `message`**, with or without a caller-supplied preimage. Unimplementable: for an extrinsic it is a hash of the inherited implication, and trusting a caller's preimage is still blind signing. +- **Moving cross-product alias use into `create_transaction`**, by generalizing its signer to carry personhood-alias origins so the Host produces the proof while satisfying the signer and never hands one out, then checking the `set_alias` target it can now see. **Deferred, not rejected** — this is the direction a general solution takes, and it is the one worked out furthest. It was set aside for now because it only closes the cases whose call shape the Host can recognize: `set_alias` is one known call with one checkable argument, whereas `ring_vrf_sign` has no such structure, so the chokepoint would have to be rebuilt for every consequential call and could not cover raw signing at all. Shipping the allowlist first keeps the interim rule simple and uniform across both calls. +- **Constraining the alias target on chain**, so `set_alias` accepts only an account the runtime can derive from the proof's context. Attractive — it would remove the class rather than the instance — but the context-to-alias-account mapping is a client-side HD derivation the runtime cannot verify, and it removes the deliberately convenient case of pointing an alias at a real product account. - **Attestation thresholds / trusted verifiers** instead of a product-id allowlist. Deferred rather than dismissed: the flat list is simpler, and the manifest RFC must keep the schema extensible. - **A `ProofContext` enum with a `PoP(WellKnownContextSuffix)` variant.** Rejected: it forks context derivation and alias-account mapping in two — the situation RFC-0022 left open and this RFC closes. @@ -395,7 +366,7 @@ The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once - [RFC-0004 — Redesign `create_account_proof`](0004-ringlocation-redesign.md) — `RingLocation`, `ProductProofContext`, the context derivation, and the member-key selection contract this RFC deletes. Its "Out of scope: explicit member-key management … left to a future RFC" is this RFC. - **RFC-0022 — Account key derivations** ([PR #296](https://github.com/paritytech/truapi/pull/296)) — the ring-VRF tree, `Either` indices, the reserved `peopl.dot` identity, and the `AutoSigning` payload this RFC extends. Its deferral of well-known alias accounts is resolved here. - **RFC-0023 — sr25519 VRF signing for product accounts** ([PR #301](https://github.com/paritytech/truapi/pull/301)) — the complementary non-member path, where this RFC's ring VRF path serves members. -- [RFC-0020 — `create_transaction` and its AP mirror](0020-create-transaction.md) — the signer-parametrized payload this RFC generalizes, and the pattern of specifying a call together with its AP companion. +- [RFC-0020 — `create_transaction` and its AP mirror](0020-create-transaction.md) — the pattern of specifying a TrUAPI call together with its AP companion, followed here. - [RFC-0010 — W3S Allowance Management](0010-allowance.md) — AutoSigning and the PGAS / Bulletin / SSS flows that consume the person key. - [RFC-0002 — Permission Model](0002-permission-model.md) — the prompt-once lifecycle every cross-product grant reuses · [RFC-0009](0009-unauthenticated-product-access.md) — `NotConnected` semantics · [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206) — where the allowlist is specified. - *SSO background availability — common model* — the layered availability ladder referenced above. **TODO: link the HackMD document.** @@ -404,8 +375,9 @@ The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once ## Unresolved Questions -No open questions remain on the design above. Three items are deliberately deferred to follow-up work: +No open questions remain on the design above. Four items are deliberately deferred to follow-up work: +- **Expressing cross-product key use more narrowly than an allowlist.** The interim rule trades precision for time: an owner can say *who* may use its key but not *for what*. What an owner wants is closer to "you may prove personhood under your own airdrop context" than "you may do anything my key can do". The most developed candidate is in [Alternatives](#alternatives) — routing alias use through `create_transaction` so the Host sees the call — and any general answer has to cover `ring_vrf_sign`, where there is no call to inspect. - **Revocation** — already deferred by RFC-0010 and made more urgent by the entropy transfer, including retraction of a registry entry by its owner. - **Key rotation and recovery** — the registry makes rotation expressible (register a new index, retire the old), but the effect on in-flight aliases is unspecified. - **Provider competition** — once personhood is a product, more than one can exist; the provider-designation setting is only the minimal hook. From fa31c1776e9a0aa9a5ec9e20890cc452c58a87ff Mon Sep 17 00:00:00 2001 From: valentunn Date: Thu, 30 Jul 2026 20:13:33 +0700 Subject: [PATCH 5/7] Simplify --- docs/rfcs/0024-personhood-as-product.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index b16796b9f..8fe6093df 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -330,18 +330,6 @@ AutoSigning { } ``` -No registry snapshot travels with the grant: the Host accumulates registrations as it serves them and receives the rest through prefetch. With this granted, a remote Host serves `create_account_proof`, `get_account_alias`, and `ring_vrf_sign` — including a foreign product's — without touching the phone. **The grant comes from the key owner, not the caller**: a proof against `peopl.dot`'s key is served locally only because `peopl.dot` granted AutoSigning. - -### Migration and compatibility - -Nothing here ships with production consumers. `create_account_proof` (wire `request_id` 26) and `get_account_alias` (wire 24) have no external callers, so the leading `key_handle` is added in place rather than behind a new protocol version; their request types gain a field with wire ids unchanged. The three new methods — `register_ring_vrf_key`, `list_ring_vrf_keys`, `ring_vrf_sign` — take fresh append-only ids. `get_account` (wire 22) keeps its signature — a previously-rejected input becoming conditionally accepted is purely additive. - -`create_transaction` (wire 30) is untouched: it keeps `signer: ProductAccountId` and every existing payload type. - -In the Accounts Protocol: three new message pairs, a field on each existing ring VRF request, and one field on `AutoSigning` — all breaking at the SCALE layer, landing together with RFC-0022. `WorkerIncludes.onLoad` is additive. - -The compiled-in ring identities and `PersonKey { Full, Lite }` are removed once the personhood product registers, and are **not** retained as a fallback: a silent fallback to a compiled-in key would resurrect the coupling this RFC removes and mask registry-sync bugs as working proofs. - ## Drawbacks - **Removing the fallback makes personhood installable, and therefore missing.** A user without the product installed has no people key at all, so coinage unload and PGAS allowance stop working until they install it. Intended, but a real regression in default capability. From 2e6f38904c8e01e4a17fdbe612907971d01042fa Mon Sep 17 00:00:00 2001 From: valentunn Date: Tue, 4 Aug 2026 13:04:44 +0300 Subject: [PATCH 6/7] Fix comments --- docs/rfcs/0024-personhood-as-product.md | 83 ++++++++++++++++--------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index 8fe6093df..b6846de31 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -14,7 +14,9 @@ owner: "@valentunn" ## Summary -RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with an explicit, product-owned key registry: a product registers keys it owns against the rings it intends them for, other products discover those registrations by an anonymized handle, and the handle is passed to `create_account_proof`, `get_account_alias`, and a new `ring_vrf_sign`. With an `onLoad` executable modality for global lifetime and the Accounts Protocol companions, full and light personhood become a standalone product whose key index no consumer — including the Host — has to know. +RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with a hard-coded fallback to "the PoP ring". This RFC replaces that with **two separable changes**: an explicit `key_handle` parameter on `account_get_account_alias` and `account_create_account_proof`, deleting the selection contract; and a **registry** of `(handle, declared rings)` that the Host consults where no caller supplies a handle. A new `account_ring_vrf_sign` signs with the member key directly. + +With an `onLoad` executable modality for global lifetime and the Accounts Protocol companions, personhood's **key management and client-side surface** move into a product whose key index no consumer — including the Host — has to know. Rings, membership, onboarding, and suspension remain on chain and are untouched by this RFC; "personhood as a product" means the client side of it, not the protocol. A proof is a bearer token for its context's alias and a signature is a bearer token for the key, and neither can be constrained by inspecting an opaque message. Cross-product use of a foreign key is therefore gated on the owning product having allowlisted the caller in its manifest, with no user-prompt fallback — an interim position, with a more expressive scheme left to follow-up work. The RFC also resolves RFC-0022's deferral of well-known alias accounts: every context is product-owned and built with TrUAPI's product-scoped context function, so there is no second context scheme. @@ -32,7 +34,13 @@ A personhood product must instead own the full and light keys — under RFC-0022 | Product-extractable features | game, mobrule, identity | Yes | | Cross-product shared | set identity account, set score alias | Yes, but needs cross-product reach | -The app-internal class forces a mechanism, and this RFC picks a **registration call**: the product declares which key is intended for which ring, and the Host uses that registration wherever it used a compiled-in key. The rejected alternative is in [Alternatives](#alternatives). +These motivate two changes with very different costs, and they are **separable** — either can be accepted without the other. + +**The explicit key parameter is the cheap one, and it solves the motivating problem by itself.** Selection is fragile only because the key is missing from the request; once `key_handle` is present, alias determinism holds by construction rather than by cross-implementation agreement, and the selection contract can simply be deleted. No registry is required for that. + +**The registry answers a different question**: when the Host performs coinage unloading, or the Account Holder assigns a ring-VRF allowance slot, there is no caller to supply a handle, so something must tell them which handle is the person key. That is the load-bearing justification. The other things a registry buys are weaker — cross-product discovery could be convention, since RFC-0022 already pins index 0 as full and index 1 as light, and phone-side bookkeeping is a nice-to-have. Against that, the registry adds distributed state, an intent leak, and the unenforceable derivation rule noted below. + +Both are specified here because the app-internal flows need both, but a reviewer should be able to reject the registry without rejecting the parameter. The rejected alternative to the registry is in [Alternatives](#alternatives). **Remote Hosts cannot reach ring VRF keys without the phone**, which is usually backgrounded. Two directions address it: the layered background-availability model designed for consent-free SSO requests (referenced, not respecified — see [Prior Art](#prior-art-and-references)), and an AutoSigning extension transferring the product's ring VRF domain entropy so the Host can derive registered member secrets locally. @@ -42,7 +50,7 @@ The app-internal class forces a mechanism, and this RFC picks a **registration c - **Product developers building on personhood** — score / identity / mobrule / game; consume foreign handles, foreign contexts, and alias origins. - **Host developers** — implement the registry, drop the compiled-in member-key selection, enforce the owner allowlist on proofs and signatures, add the `onLoad` modality. - **Account Holder developers (Mobile App)** — become the authoritative registry, implement the new message pairs, extend the AutoSigning payload, answer registrations from the background. -- **Chain / individuality developers** — on-chain contexts (`score`, `resources`, `mob-rule`) must be derived with TrUAPI's product-scoped context function rather than a parallel namespace. +- **Chain / individuality developers** — on-chain contexts must be derived with TrUAPI's product-scoped context function rather than a parallel namespace. `score`, `resources`, and `mob-rule` are named here as examples, **not as a complete list**: coinage has its own contexts, including ones constructed at runtime from a base plus period and counter, and there is a dotNS gateway context. Every such context has to be migrated to the product-scoped construction, and enumerating them is part of that work rather than of this RFC. ## Explanation @@ -57,6 +65,8 @@ The app-internal class forces a mechanism, and this RFC picks a **registration c Two additions to the `Account` trait, alongside the signing call in the next section. +Naming throughout this RFC: prose uses the **wire** method name, which is the service prefix plus the trait method — `account_create_account_proof`, `account_get_account_alias`, `account_ring_vrf_sign` — while the Rust snippets show the trait methods those wire names dispatch to (`create_account_proof`, `get_account_alias`, `ring_vrf_sign`). Sibling RFCs use the older `host_account_*` spelling for the same calls. + ```rust type RingVrfPublicKey = [u8; 32]; @@ -129,7 +139,7 @@ fn ring_vrf_sign( `ring` stays a parameter on the first two even though the handle carries declared rings: a key may be registered for several, and the caller must say which the proof is against. The Host MUST verify `ring` appears in the handle's declared rings and return `KeyNotInRing` otherwise. RFC-0004's guarantee that `(key_handle, context, ring)` yields the same alias on every conforming Host now holds trivially, since key selection is no longer Host policy. -`ring_vrf_sign` takes neither: it derives no alias and proves no membership, so there is nothing for a context or a ring to scope. A verifier needs the member public key, which is what makes `RingVrfKeyDisclosure::PublicKey` load-bearing rather than merely informational, and which makes every such signature linkable to every other use of that key. +`account_ring_vrf_sign` takes neither: it derives no alias and proves no membership, so there is nothing for a context or a ring to scope. A verifier needs the member public key, which is what makes `RingVrfKeyDisclosure::PublicKey` load-bearing rather than merely informational, and which makes every such signature linkable to every other use of that key. ### Errors @@ -191,7 +201,13 @@ sequenceDiagram H-->>G: proof + contextual_alias + ring_index + ring_revision ``` -**No product may assume a key index of another product.** The index is the owner's implementation detail; consumers select by declared `RingLocation` and treat the handle as opaque. Hardcoding `(peopl.dot, 0)` breaks the moment the owner rotates or adds a key. This is the one rule a consuming product has to remember. +**Selection moves from on-chain state to declared intent, and that is a real change.** Today the Host derives both person keys and looks each up in the membership map, full before light, so full-versus-light is resolved against actual chain state. Here the consumer picks by the ring a key was *declared* for and only learns it chose wrong when the proof returns `NotMember`. + +No product needs "try full, fall back to light" today, so this RFC does not specify one. If a product ever does, the fallback belongs in the **product SDK**, not in the Host and not reimplemented per product — the Host no longer has the information to choose, and duplicating the retry across consumers is how the selection contract became fragile in the first place. + +**No product should assume a key index of another product.** The index is the owner's implementation detail; consumers select by declared `RingLocation` and treat the handle as opaque. Hardcoding `(peopl.dot, 0)` breaks the moment the owner adds a key. + +This is a **convention, not an enforceable rule**, and the RFC does not pretend otherwise. The index is part of the handle, so any caller that can list the registry can read it and hardcode it; `Anonymized` disclosure withholds the member public key, not the index. Hiding the index would mean the handle could no longer name a derivation slot, which is the whole point of it. So this lands as an implementation note for the **product SDK**, which should expose selection-by-ring and never surface a raw index to product code. ### Every context is owned by exactly one product @@ -211,17 +227,19 @@ A context therefore has exactly one owner: the `product_id` mixed into its deriv ### Using a foreign key means trusting the caller -Both `create_account_proof` and `ring_vrf_sign` hand the caller output produced with someone else's member key, and neither can be constrained by inspection. **A proof is a bearer token for its context's alias, and a signature is a bearer token for the key itself.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and accepting a caller-supplied preimage would still be blind signing — so nothing at call time can tell what the result will authorize. +Both `account_create_account_proof` and `account_ring_vrf_sign` hand the caller output produced with someone else's member key, and neither can be constrained by inspection. **A proof is a bearer token for its context's alias, and a signature is a bearer token for the key itself.** `message` is opaque — for an extrinsic it is a hash of the inherited implication, and accepting a caller-supplied preimage would still be blind signing — so nothing at call time can tell what the result will authorize. + +The concrete consequence, worth stating because it is not obvious: a product holding a proof for a context can build a `set_alias` binding that alias to an account of its own, sign it with its own product account, and submit it without involving the Host again. The alias then resolves to an account it controls. Every check passes; the proof was the authority. `account_ring_vrf_sign` is the wider version of the same problem, since it has no context or ring to scope what the signature is good for. -The concrete consequence, worth stating because it is not obvious: a product holding a proof under the score context can build a `set_alias` binding that alias to an account of its own, sign it with its own product account, and submit it without involving the Host again. The score alias then resolves to an account it controls. Every check passes; the proof was the authority. `ring_vrf_sign` is the wider version of the same problem, since it has no context or ring to scope what the signature is good for. +**This is not hypothetical, and it is not limited to the score context.** `pallet-alias-accounts` as already deployed in individuality takes the proof as a call argument, accepts **any** 32-byte context rather than an allowlisted set, works for both People and People Lite, and signs over `blake2_256(("alias-accounts", account, proof_valid_at))` — exactly the opaque hash described above. So every context reachable through that pallet is exposed, not one of them. The runtime should be adjusted so the contexts it accepts are aligned with TrUAPI's product-scoped derivation; until then the surface is wider than the alias flow below implies. There is no way to bound this by structure at the call site. So it is bounded by **whom the owner trusts**: -> A Host MUST reject `create_account_proof` and `ring_vrf_sign` with a foreign `key_handle` unless the key's owning product has allowlisted the calling product in its manifest, with `NotAllowlisted`. +> A Host MUST reject `account_create_account_proof` and `account_ring_vrf_sign` with a foreign `key_handle` unless the key's owning product has allowlisted the calling product in its manifest, with `NotAllowlisted`. The allowlist is the *only* authorization for these two calls. A user prompt is not a substitute and MUST NOT be offered as a fallback: consenting to an opaque message is not meaningful consent, and the risk being accepted is one only the key's owner is positioned to evaluate. This is a deliberate departure from the general permission model, where the allowlist merely avoids a prompt. -Foreign `get_account_alias` and foreign `get_account` are unaffected — reading an alias or an account id authorizes nothing — and `create_transaction` is unchanged, keeping its `signer: ProductAccountId` and accepting a foreign one under an ordinary grant. +Foreign `account_get_account_alias` and foreign `account_get_account` are unaffected — reading an alias or an account id authorizes nothing — and `signing_create_transaction` is unchanged, keeping its `signer: ProductAccountId` and accepting a foreign one under an ordinary grant. This is the pragmatic position, not the durable one. It makes cross-product key use an all-or-nothing trust decision by the owner, when what the owner actually wants to express is narrower — "you may prove personhood for your own airdrop" rather than "you may do anything my key can do". [Future work](#unresolved-questions) records the shape a general solution would take. @@ -230,8 +248,8 @@ This is the pragmatic position, not the durable one. It makes cross-product key Using an alias — claiming score rewards, say — is then: 1. **Read the alias.** `get_account_alias(pop_handle, score_context, people_ring)`. The consuming product checks the ring revision on each use and renews when it has moved; nothing else watches for it. -2. **Bind or rebind if needed.** Build a `set_alias` from the alias account id (`get_account` on the context's alias index, which the context determines 1:1) and a proof (`create_account_proof`, requiring the allowlist above). After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. -3. **Submit.** `create_transaction` with the alias account's `ProductAccountId` as signer. +2. **Bind or rebind if needed.** Build a `set_alias` from the alias account id (`account_get_account` on the context's alias index, which the context determines 1:1) and a proof (`account_create_account_proof`, requiring the allowlist above). After a suspension this is a fresh `set_alias`; on a ring-revision change the accompanying action can ride an `AsPersonalAliasWithAccountRevised` origin alongside the update. +3. **Submit.** `signing_create_transaction` with the alias account's `ProductAccountId` as signer. At worst three cross-product requests, and **in the happy path the user sees none of them** — the requirement that shapes the permission model below. @@ -262,19 +280,28 @@ The personhood product declares `{ pocket: true, onLoad: true }`. No capability ### Permission model -The requirement is asymmetric: routine discovery should be cheap, and the powerful grants deliberate but not per-call. +The calls this RFC touches fall into **two regimes with different rules**, and they must not be read as one. Everything that only ever *reads* follows the ordinary model; the two calls that produce a bearer token do not. + +**Regime A — reading. Ordinary RFC-0002 rules.** + +| Call | Own key | Foreign | +| -------------------------------------------------- | -------------- | ------------------------------------------------ | +| `account_register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | +| `account_list_ring_vrf_keys` (either disclosure) | permissionless | allowlist, else a one-time prompt | +| `account_get_account_alias`, `account_get_account` | permissionless | allowlist, else a one-time prompt | +| `signing_create_transaction` | permissionless | allowlist, else a one-time prompt — unchanged by this RFC | + +Here the model is **user-approval driven**: an unapproved foreign access produces a one-time prompt with the persist-once lifecycle, and the owner's allowlist merely avoids that prompt. **Until the manifest RFC lands, these calls fall back to a one-time prompt per (caller, owner, call) triple**, persisted per RFC-0002. + +**Regime B — producing a proof or a signature. Allowlist only.** -| Call | Own key | Foreign key or context | -| ---------------------------------------- | ------- | ------------------------------------------------------------- | -| `register_ring_vrf_key` | permissionless | n/a — a product registers only its own keys | -| `list_ring_vrf_keys` (either disclosure) | permissionless | grant, or a user prompt | -| `get_account_alias`, `get_account` | permissionless | grant, or a user prompt | -| `create_account_proof`, `ring_vrf_sign` | permissionless | **owner's manifest allowlist only** — no prompt fallback | -| `create_transaction` | permissionless | grant, or a user prompt — unchanged by this RFC | +| Call | Own key | Foreign | +| -------------------------------------------------------- | -------------- | ------------------------------------------- | +| `account_create_account_proof`, `account_ring_vrf_sign` | permissionless | **owner's manifest allowlist, or refused** | -The model is **user-approval driven**, per RFC-0002: an unapproved foreign access produces a one-time prompt with the persist-once lifecycle. The only way to avoid the prompt is for the *owner* to have allowed the caller in advance — **a product declares, in its manifest, the list of product ids it permits to access its data without a prompt.** For proofs and signatures that allowlist is not an optimization but the whole gate, per the rule above. +For these two the allowlist is not an optimization but the whole gate, per the rule above: a prompt is not a substitute and MUST NOT be offered, because consenting to an opaque message is not meaningful consent. **The interim fallback of the previous paragraph does not apply here** — the consequence is that foreign proofs and foreign signatures are simply **unavailable until the manifest RFC lands**, since there is nowhere yet to express the allowlist. Own-key use is unaffected and needs nothing. -That declaration belongs to the product manifest, specified separately ([RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)), with two requirements from here: the allowlist must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list without a wire break; and it should be expressible **per method or category**, so "read my key handles" and "sign under my alias" need not be one grant. Until it lands, Hosts fall back to a one-time prompt per (caller, owner, call) triple, persisted per RFC-0002. +The allowlist belongs to the product manifest, specified separately ([RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206)), with two requirements from here: it must be **structurally extensible**, so a richer scheme (per-method grants, attestation thresholds) can replace a flat product-id list without a wire break; and it should be expressible **per method or category**, so "read my key handles" and "produce a proof with my key" need not be one grant. ### Accounts Protocol @@ -302,7 +329,7 @@ struct ListRingVrfKeysResponse { } ``` -A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and a `RingVrfSignRequest` / `Response` pair mirrors `ring_vrf_sign` with the same two fields plus `message`. `RingVrfError` gains `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`. +A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and a `RingVrfSignRequest` / `Response` pair mirrors `account_ring_vrf_sign` with the same two fields plus `message`. `RingVrfError` gains `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`. **Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host holding the product's domain entropy answers immediately and mirrors the registration fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without the entropy the Host issues the request and waits. @@ -344,20 +371,20 @@ AutoSigning { - **Per-flow host callbacks instead of a registration call** — a Host call per internal flow with a product-supplied handler. Rejected: a new bidirectional contract for every internal feature the Host ever adds, coupled release cycles, and it hands the product flows (slot-table bookkeeping, claim budgets) RFC-0010 put on the Account Holder. - **Inspecting the `message`**, with or without a caller-supplied preimage. Unimplementable: for an extrinsic it is a hash of the inherited implication, and trusting a caller's preimage is still blind signing. -- **Moving cross-product alias use into `create_transaction`**, by generalizing its signer to carry personhood-alias origins so the Host produces the proof while satisfying the signer and never hands one out, then checking the `set_alias` target it can now see. **Deferred, not rejected** — this is the direction a general solution takes, and it is the one worked out furthest. It was set aside for now because it only closes the cases whose call shape the Host can recognize: `set_alias` is one known call with one checkable argument, whereas `ring_vrf_sign` has no such structure, so the chokepoint would have to be rebuilt for every consequential call and could not cover raw signing at all. Shipping the allowlist first keeps the interim rule simple and uniform across both calls. +- **Moving cross-product alias use into `signing_create_transaction`**, by generalizing its signer to carry personhood-alias origins so the Host produces the proof while satisfying the signer and never hands one out, then checking the `set_alias` target it can now see. **Deferred, not rejected** — this is the direction a general solution takes, and it is the one worked out furthest. It was set aside for now because it only closes the cases whose call shape the Host can recognize: `set_alias` is one known call with one checkable argument, whereas `account_ring_vrf_sign` has no such structure, so the chokepoint would have to be rebuilt for every consequential call and could not cover raw signing at all. Shipping the allowlist first keeps the interim rule simple and uniform across both calls. - **Constraining the alias target on chain**, so `set_alias` accepts only an account the runtime can derive from the proof's context. Attractive — it would remove the class rather than the instance — but the context-to-alias-account mapping is a client-side HD derivation the runtime cannot verify, and it removes the deliberately convenient case of pointing an alias at a real product account. - **Attestation thresholds / trusted verifiers** instead of a product-id allowlist. Deferred rather than dismissed: the flat list is simpler, and the manifest RFC must keep the schema extensible. - **A `ProofContext` enum with a `PoP(WellKnownContextSuffix)` variant.** Rejected: it forks context derivation and alias-account mapping in two — the situation RFC-0022 left open and this RFC closes. ## Prior Art and References -- [RFC-0004 — Redesign `create_account_proof`](0004-ringlocation-redesign.md) — `RingLocation`, `ProductProofContext`, the context derivation, and the member-key selection contract this RFC deletes. Its "Out of scope: explicit member-key management … left to a future RFC" is this RFC. +- [RFC-0004 — Redesign `account_create_account_proof`](0004-ringlocation-redesign.md) — `RingLocation`, `ProductProofContext`, the context derivation, and the member-key selection contract this RFC deletes. Its "Out of scope: explicit member-key management … left to a future RFC" is this RFC. - **RFC-0022 — Account key derivations** ([PR #296](https://github.com/paritytech/truapi/pull/296)) — the ring-VRF tree, `Either` indices, the reserved `peopl.dot` identity, and the `AutoSigning` payload this RFC extends. Its deferral of well-known alias accounts is resolved here. - **RFC-0023 — sr25519 VRF signing for product accounts** ([PR #301](https://github.com/paritytech/truapi/pull/301)) — the complementary non-member path, where this RFC's ring VRF path serves members. -- [RFC-0020 — `create_transaction` and its AP mirror](0020-create-transaction.md) — the pattern of specifying a TrUAPI call together with its AP companion, followed here. +- [RFC-0020 — `signing_create_transaction` and its AP mirror](0020-create-transaction.md) — the pattern of specifying a TrUAPI call together with its AP companion, followed here. - [RFC-0010 — W3S Allowance Management](0010-allowance.md) — AutoSigning and the PGAS / Bulletin / SSS flows that consume the person key. - [RFC-0002 — Permission Model](0002-permission-model.md) — the prompt-once lifecycle every cross-product grant reuses · [RFC-0009](0009-unauthenticated-product-access.md) — `NotConnected` semantics · [RFC: Product Manifest Format](https://github.com/paritytech/truapi/pull/206) — where the allowlist is specified. -- *SSO background availability — common model* — the layered availability ladder referenced above. **TODO: link the HackMD document.** +- [*SSO background availability — common model*](https://hackmd.io/rBEBjBzLQdOHvzwJkfufIQ) — the layered availability ladder the `onLoad` and AutoSigning sections lean on. - `rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs` — the compiled-in selection this RFC removes. - [Polkadot People Registry / Ring VRF](https://forum.polkadot.network/t/the-people-registry/12749) · [individuality#878](https://github.com/paritytech/individuality/pull/878) — alias-account assignment for derived product addresses. @@ -365,7 +392,7 @@ AutoSigning { No open questions remain on the design above. Four items are deliberately deferred to follow-up work: -- **Expressing cross-product key use more narrowly than an allowlist.** The interim rule trades precision for time: an owner can say *who* may use its key but not *for what*. What an owner wants is closer to "you may prove personhood under your own airdrop context" than "you may do anything my key can do". The most developed candidate is in [Alternatives](#alternatives) — routing alias use through `create_transaction` so the Host sees the call — and any general answer has to cover `ring_vrf_sign`, where there is no call to inspect. +- **Expressing cross-product key use more narrowly than an allowlist.** The interim rule trades precision for time: an owner can say *who* may use its key but not *for what*. What an owner wants is closer to "you may prove personhood under your own airdrop context" than "you may do anything my key can do". The most developed candidate is in [Alternatives](#alternatives) — routing alias use through `signing_create_transaction` so the Host sees the call — and any general answer has to cover `account_ring_vrf_sign`, where there is no call to inspect. - **Revocation** — already deferred by RFC-0010 and made more urgent by the entropy transfer, including retraction of a registry entry by its owner. -- **Key rotation and recovery** — the registry makes rotation expressible (register a new index, retire the old), but the effect on in-flight aliases is unspecified. +- **Key recovery.** Rotation is deliberately *not* in scope, and the registry's ability to express it (register a new index, retire the old) should not be read as an intention to use it. A person's alias is derived from their member key, so rotating the key changes that person's alias in **every context at once** — not merely in-flight ones — which is destructive rather than merely unspecified. The original PoP design treats the Bandersnatch key as something a person never changes, and individuality already handles the cases that do arise with `migrate_included_key` / `migrate_onboarding_key` plus an offchain worker that cleans up stale aliases. What remains open is recovery, which is a different problem from rotation. - **Provider competition** — once personhood is a product, more than one can exist; the provider-designation setting is only the minimal hook. From 89334220704c751e939e329237ff352dc8081e24 Mon Sep 17 00:00:00 2001 From: PG Herveou Date: Mon, 10 Aug 2026 17:57:18 +0200 Subject: [PATCH 7/7] feat: implement RFC-0024 ring VRF key management (#348) * feat: implement RFC-0024 ring VRF key management * fix: keep RFC-0024 responder wasm-compatible * fix: preserve reserved LitePeople allowance signing * test: simplify CLI battery failure policy --- .../diagnosis-reports/pairing-host-cli.md | 5 + .../diagnosis-reports/signing-host-cli.md | 5 + .../truapi-codegen/tests/golden/dispatcher.rs | 84 +++ .../tests/golden/host-callbacks.ts | 9 +- .../truapi-codegen/tests/golden/wire_table.rs | 30 + .../truapi-codegen/tests/golden_rust_emit.rs | 19 +- rust/crates/truapi-host-cli/README.md | 6 +- rust/crates/truapi-host-cli/SPEC.md | 7 +- .../truapi-host-cli/js/diagnosis.test.ts | 30 +- rust/crates/truapi-host-cli/js/diagnosis.ts | 6 +- .../crates/truapi-host-cli/js/ring-vrf-e2e.ts | 471 +++++++++++++ rust/crates/truapi-host-cli/js/runner.ts | 14 +- .../truapi-host-cli/js/scripts/battery.ts | 26 +- .../js/scripts/ring-vrf-smoke.ts | 76 +- rust/crates/truapi-platform/src/lib.rs | 14 + rust/crates/truapi-server/README.md | 15 +- .../truapi-server/src/generated/dispatcher.rs | 92 +++ .../truapi-server/src/generated/wire_table.rs | 30 + rust/crates/truapi-server/src/host_core.rs | 79 +++ .../src/host_logic/product_account.rs | 60 +- .../src/host_logic/sso/messages.rs | 332 ++++++++- .../src/host_logic/sso/messages/v1.rs | 31 +- rust/crates/truapi-server/src/native.rs | 50 +- .../truapi-server/src/native/reviews.rs | 47 ++ rust/crates/truapi-server/src/runtime.rs | 374 +++++++++- .../truapi-server/src/runtime/authority.rs | 108 ++- .../truapi-server/src/runtime/pairing_host.rs | 388 +++++++++- .../src/runtime/pairing_host/sso_channel.rs | 118 +++- .../src/runtime/ring_vrf_registry.rs | 665 ++++++++++++++++++ .../truapi-server/src/runtime/signing_host.rs | 480 ++++++++++--- .../src/runtime/signing_host/ring_vrf.rs | 105 +-- .../src/runtime/signing_host/sso_responder.rs | 155 +++- .../truapi-server/src/runtime/sso_remote.rs | 27 +- .../src/runtime/statement_allowance.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 23 +- .../truapi-server/tests/wire_result_shape.rs | 11 +- rust/crates/truapi/src/api/account.rs | 122 +++- rust/crates/truapi/src/lib.rs | 17 +- rust/crates/truapi/src/v01/account.rs | 118 +++- rust/crates/truapi/src/versioned/account.rs | 9 + 40 files changed, 3922 insertions(+), 338 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts create mode 100644 rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index 30ddd5620..91a935148 100644 --- a/explorer/diagnosis-reports/pairing-host-cli.md +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -2,6 +2,7 @@ | Method | Status | Details | | --- | --- | --- | +| `Account/ring_vrf_registry_e2e` | ✅ | | | `Account/connection_status_subscribe` | ✅ | | | `Account/get_account` | ✅ | | | `Account/get_account_alias` | ✅ | | @@ -10,6 +11,9 @@ | `Account/get_user_id` | ✅ | | | `Account/request_login` | ✅ | | | `Account/sign_vrf` | ✅ | | +| `Account/register_ring_vrf_key` | ✅ | | +| `Account/list_ring_vrf_keys` | ✅ | | +| `Account/ring_vrf_sign` | ✅ | | | `Chain/follow_head_subscribe` | ✅ | | | `Chain/get_head_header` | ✅ | | | `Chain/get_head_body` | ✅ | | @@ -68,3 +72,4 @@ | `System/navigate_to` | ✅ | | | `Theme/subscribe` | ✅ | | | `Resource Allocation/auto_signing_e2e` | ✅ | | +| `Account/auto_signing_ring_vrf_e2e` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md index 0fac59231..eca6b900c 100644 --- a/explorer/diagnosis-reports/signing-host-cli.md +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -2,6 +2,7 @@ | Method | Status | Details | | --- | --- | --- | +| `Account/ring_vrf_registry_e2e` | ✅ | | | `Account/connection_status_subscribe` | ✅ | | | `Account/get_account` | ✅ | | | `Account/get_account_alias` | ✅ | | @@ -10,6 +11,9 @@ | `Account/get_user_id` | ✅ | | | `Account/request_login` | ✅ | | | `Account/sign_vrf` | ✅ | | +| `Account/register_ring_vrf_key` | ✅ | | +| `Account/list_ring_vrf_keys` | ✅ | | +| `Account/ring_vrf_sign` | ✅ | | | `Chain/follow_head_subscribe` | ✅ | | | `Chain/get_head_header` | ✅ | | | `Chain/get_head_body` | ✅ | | @@ -68,3 +72,4 @@ | `System/navigate_to` | ✅ | | | `Theme/subscribe` | ✅ | | | `Resource Allocation/auto_signing_e2e` | ✅ | | +| `Account/auto_signing_ring_vrf_e2e` | ✅ | | diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index b11224efd..3fdda36e3 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -184,6 +184,90 @@ where }) }); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_REGISTER_RING_VRF_KEY, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRegisterRingVrfKeyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRegisterRingVrfKeyResponse = match host.register_ring_vrf_key(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_LIST_RING_VRF_KEYS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountListRingVrfKeysRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountListRingVrfKeysResponse = match host.list_ring_vrf_keys(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_RING_VRF_SIGN, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRingVrfSignRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRingVrfSignResponse = match host.ring_vrf_sign(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } { let host = host.clone(); dispatcher.on_request(wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, move |request_id: String, bytes: Vec| { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 98d2aa1c7..e835d0dc8 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -140,7 +140,11 @@ export type CoreStorageKey = /** * Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. */ - | { tag: "AutoSigningKeys"; value?: undefined }; + | { tag: "AutoSigningKeys"; value?: undefined } + /** + * Wallet-bound RFC-0024 ring-VRF registry snapshot. + */ + | { tag: "RingVrfRegistry"; value: { rootPublicKey: Uint8Array } }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -450,6 +454,9 @@ export const CoreStorageKey: S.Codec = S.lazy( productId: string; }>, AutoSigningKeys: S._void, + RingVrfRegistry: S.Struct({ rootPublicKey: S.Bytes(32) }) as S.Codec<{ + rootPublicKey: Uint8Array; + }>, }), ); diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d0427..b6555f1d9 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -466,6 +466,24 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `account_register_ring_vrf_key`. +pub const ACCOUNT_REGISTER_RING_VRF_KEY: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// Wire discriminants for `account_list_ring_vrf_keys`. +pub const ACCOUNT_LIST_RING_VRF_KEYS: RequestFrameIds = RequestFrameIds { + request_id: 168, + response_id: 169, +}; + +/// Wire discriminants for `account_ring_vrf_sign`. +pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { + request_id: 170, + response_id: 171, +}; + /// 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 +747,16 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "account_register_ring_vrf_key", + kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + }, + WireEntry { + method: "account_list_ring_vrf_keys", + kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + }, + WireEntry { + method: "account_ring_vrf_sign", + kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + }, ]; diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 50465b2b6..73bb0a598 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -93,6 +93,15 @@ fn workspace_root() -> PathBuf { .to_path_buf() } +fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { + let parent = workspace.join("target/codegen-test-tmp"); + fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); + tempfile::Builder::new() + .prefix("golden-") + .tempdir_in(parent) + .expect("workspace tempdir") +} + fn rustfmt_generated(files: &[PathBuf]) { if files.is_empty() { return; @@ -129,6 +138,8 @@ fn prettier_generated(workspace_root: &Path, files: &[PathBuf]) { "--", "prettier", "--write", + "--ignore-path", + "/dev/null", "--config", ]) .arg(workspace_root.join(".prettierrc")); @@ -153,7 +164,7 @@ fn golden_dispatcher_and_wire_table() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) @@ -207,11 +218,11 @@ fn golden_dispatcher_and_wire_table() { #[test] fn binary_emission_is_idempotent() { let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let run_once = || -> (String, String) { - let tmp = tempfile::tempdir().unwrap(); + let tmp = workspace_tempdir(&workspace); let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) .args([ "--input", @@ -242,7 +253,7 @@ fn golden_host_callbacks_ts() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let truapi_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let platform_json = produce_rustdoc_json_for_package( &workspace, diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index e02821425..f84646f3e 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -306,9 +306,9 @@ Five scripts ship under `js/scripts/`: - `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this remains available as an explicit `/script ` example. - `signing-smoke.ts` — a focused product-account signing check. -- `ring-vrf-smoke.ts` — calls `getAccountAlias` and `createAccountProof` - against the Paseo Next v2 LitePeople ring, then verifies both calls return - the same contextual alias. +- `ring-vrf-smoke.ts` — registers and lists an explicit RFC-0024 key, derives + its alias, verifies a fresh non-member key returns `NotMember` for a proof, + and exercises direct ring-VRF signing. - `preimage-smoke.ts` — a focused Bulletin preimage flow check. The generated examples are baked to the `truapi-playground.dot` product. With diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index c457733be..b0cc3a4d6 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -664,7 +664,7 @@ The top-level `--script` option does not update remembered `/script` state. | `battery.ts` | Run every generated Playground example and write the role-specific compatibility report. | | `whoami.ts` | Print the primary username. | | `signing-smoke.ts` | Focused product-account signing test. | -| `ring-vrf-smoke.ts` | Verify alias/proof behavior for the Paseo Next v2 LitePeople ring. | +| `ring-vrf-smoke.ts` | Verify RFC-0024 registration, listing, alias, non-membership proof, and direct signing behavior. | | `preimage-smoke.ts` | Exercise Bulletin preimage submission and lookup. | `battery.ts` writes to `explorer/diagnosis-reports/-cli.md` unless @@ -724,8 +724,9 @@ Before a signing host answers a link, it: 2. decodes the V2 handshake; 3. derives its RFC-0022 `uid.dot` identity account; 4. reads the pairing device Statement Store account from the proposal; -5. finds the signer's LitePeople ring through the `peopl.dot` index-1 key, - scanning back from the current ring; +5. finds the signer's LitePeople ring through the pairing-attestation bootstrap + `peopl.dot` index-1 key, scanning back from the current ring (RFC-0024 + operational key selection uses the registry instead); 6. grants or reuses Statement Store allowance for the identity account; 7. grants or reuses allowance for the pairing device; and 8. starts the real SSO responder. diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index e9adc87f8..4db0dd335 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -47,24 +47,20 @@ describe("generated-example battery", () => { ); }); - test("classifies only the committed unsupported CLI battery failures as expected", () => { - expect(expectedCliBatteryFailureReason("Chat")).toBe( - "Chat service not yet wired up by hosts", - ); - expect(expectedCliBatteryFailureReason("Coin Payment")).toBe( - "Coin Payment service not yet wired up by hosts", - ); - expect(expectedCliBatteryFailureReason("Payment")).toBe( - "Payment service not yet wired up by hosts", - ); - expect(expectedCliBatteryFailureReason("Signing")).toBe(undefined); - }); + test("classifies only the committed unsupported CLI services as expected", () => { + const unsupported = new Set(["Chat", "Coin Payment", "Payment"]); - test("does not ignore account proof failures", () => { - expect( - knownUnsupportedReason("Account", "Account/create_account_proof"), - ).toBe(undefined); - expect(expectedCliBatteryFailureReason("Account")).toBe(undefined); + for (const service of services) { + const expected = unsupported.has(service.name) + ? `${service.name} service not yet wired up by hosts` + : undefined; + expect( + knownUnsupportedReason(service.name, `${service.name}/example`), + ).toBe(expected); + expect( + expectedCliBatteryFailureReason({ serviceName: service.name }), + ).toBe(expected); + } }); test("prints failures as concise test-reporter rows", () => { diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index b4dff38d5..cdfe4029e 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -98,10 +98,10 @@ export function knownUnsupportedReason( } export function expectedCliBatteryFailureReason( - serviceName: string, + row: Pick, ): string | undefined { - if (SKIPPED_SERVICES.has(serviceName)) { - return `${serviceName} service not yet wired up by hosts`; + if (SKIPPED_SERVICES.has(row.serviceName)) { + return `${row.serviceName} service not yet wired up by hosts`; } return undefined; } diff --git a/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts new file mode 100644 index 000000000..0791de6b8 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts @@ -0,0 +1,471 @@ +import { existsSync, readFileSync } from "node:fs"; +import { + PASEO_NEXT_V2_INDIVIDUALITY, + type ProductAccountId, + type RegisteredRingVrfKey, + type RingLocation, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { + actionLines, + approvalLines, + newLinesSince, +} from "./auto-signing-e2e.ts"; +import type { DiagnosisRow } from "./diagnosis.ts"; + +const PEOPLE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652020202020"; +const PEOPLE_LITE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; +const ACCOUNT_ACCESS_ACTION = "access another product account"; +const PROOF_ACTION = "create account proof"; + +// These locate the real People collections while intentionally omitting the +// optional pallet junction. They therefore exercise ring operations without +// registering the battery product as an exact-match internal People provider. +const TEST_PEOPLE_LITE_RING: RingLocation = { + chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + junctions: [{ tag: "CollectionId", value: PEOPLE_LITE_COLLECTION_ID }], +}; + +const TEST_PEOPLE_RING: RingLocation = { + chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + junctions: [{ tag: "CollectionId", value: PEOPLE_COLLECTION_ID }], +}; + +function stringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function okValue(result: unknown, operation: string): T { + const candidate = result as { + isOk(): boolean; + value: T; + error: unknown; + }; + if (!candidate.isOk()) { + throw new Error(`${operation} failed: ${stringify(candidate.error)}`); + } + return candidate.value; +} + +function expectDomainError( + result: unknown, + expected: string, + operation: string, +): void { + const candidate = result as { + isErr(): boolean; + error: unknown; + }; + if (!candidate.isErr()) { + throw new Error(`${operation} unexpectedly succeeded`); + } + const error = candidate.error as { + tag?: string; + value?: { tag?: string; value?: { tag?: string } }; + }; + const actual = + error.tag === "Domain" && error.value?.tag === "V1" + ? error.value.value?.tag + : undefined; + if (actual !== expected) { + throw new Error( + `${operation} returned ${actual ?? "a non-domain error"}, expected ${expected}: ${stringify(candidate.error)}`, + ); + } +} + +function requireHexBytes(value: unknown, bytes: number, label: string): string { + if ( + typeof value !== "string" || + !new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(value) + ) { + throw new Error( + `${label} is not a ${bytes}-byte hex value: ${stringify(value)}`, + ); + } + return value; +} + +function sameIndex(left: unknown, right: unknown): boolean { + return stringify(left) === stringify(right); +} + +function findEntry( + entries: RegisteredRingVrfKey[], + handle: ProductAccountId, +): RegisteredRingVrfKey | undefined { + return entries.find( + (entry) => + entry.handle.dotNsIdentifier === handle.dotNsIdentifier && + sameIndex(entry.handle.derivationIndex, handle.derivationIndex), + ); +} + +function hasRing(entry: RegisteredRingVrfKey, ring: RingLocation): boolean { + return entry.rings.some( + (candidate) => stringify(candidate) === stringify(ring), + ); +} + +function transcriptReader(path: string): () => string[] { + return () => + existsSync(path) ? approvalLines(readFileSync(path, "utf8")) : []; +} + +function finish( + methodName: string, + startedAt: number, + status: DiagnosisRow["status"], + output: string, +): DiagnosisRow { + return { + id: `Account/${methodName}`, + serviceName: "Account", + methodName, + status, + output, + durationMs: Math.round(performance.now() - startedAt), + }; +} + +/** Exercise the RFC-0024 registry and authorization contract before examples run. */ +export async function runRingVrfRegistryE2e( + client: TrUApiClient, + productId: string, + approvalsLogPath: string | undefined, +): Promise { + const startedAt = performance.now(); + if (!approvalsLogPath) { + return finish( + "ring_vrf_registry_e2e", + startedAt, + "fail", + "TRUAPI_APPROVALS_LOG not set; cannot verify RFC-0024 prompt behavior", + ); + } + const readTranscript = transcriptReader(approvalsLogPath); + const index = { tag: "Left" as const, value: 0 }; + const handle: ProductAccountId = { + dotNsIdentifier: productId, + derivationIndex: index, + }; + const context = { + productId, + suffix: { tag: "Left" as const, value: 24 }, + }; + + try { + const unregistered = await client.account.ringVrfSign({ + keyHandle: { + dotNsIdentifier: productId, + derivationIndex: { tag: "Left", value: 4_242 }, + }, + message: "0x756e72656769737465726564", + }); + expectDomainError( + unregistered, + "KeyNotRegistered", + "unregistered ring_vrf_sign", + ); + + const registered = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "register_ring_vrf_key", + ); + requireHexBytes(registered, 32, "registered public key"); + const repeated = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "idempotent register_ring_vrf_key", + ); + if (repeated !== registered) { + throw new Error( + "idempotent registration returned a different public key", + ); + } + const secondRing = okValue( + await client.account.registerRingVrfKey({ index, ring: TEST_PEOPLE_RING }), + "multi-ring register_ring_vrf_key", + ); + if (secondRing !== registered) { + throw new Error( + "multi-ring registration returned a different public key", + ); + } + + const publicEntries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "PublicKey", + }), + "owned public list_ring_vrf_keys", + ); + const publicEntry = findEntry(publicEntries, handle); + if (!publicEntry || publicEntry.publicKey !== registered) { + throw new Error("owned public listing omitted the registered public key"); + } + if ( + !hasRing(publicEntry, TEST_PEOPLE_LITE_RING) || + !hasRing(publicEntry, TEST_PEOPLE_RING) + ) { + throw new Error( + "multi-ring registration was not preserved by the registry", + ); + } + + const anonymousEntries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "Anonymized", + }), + "owned anonymized list_ring_vrf_keys", + ); + const anonymousEntry = findEntry(anonymousEntries, handle); + if (!anonymousEntry || anonymousEntry.publicKey !== undefined) { + throw new Error( + "anonymized listing disclosed or omitted the registered key", + ); + } + + const alias = okValue<{ context: string; alias: string }>( + await client.account.getAccountAlias({ + keyHandle: handle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + }), + "owned get_account_alias", + ); + requireHexBytes(alias.context, 32, "alias context"); + if (!alias.alias.startsWith("0x") || alias.alias.length <= 2) { + throw new Error( + `get_account_alias returned an empty alias: ${stringify(alias)}`, + ); + } + + const proof = await client.account.createAccountProof({ + keyHandle: handle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + message: "0x7266633234", + }); + expectDomainError(proof, "NotMember", "owned create_account_proof"); + + const structurallyDifferentRing: RingLocation = { + chainId: TEST_PEOPLE_LITE_RING.chainId, + junctions: [ + { tag: "PalletInstance", value: 67 }, + ...TEST_PEOPLE_LITE_RING.junctions, + ], + }; + const wrongRingAlias = await client.account.getAccountAlias({ + keyHandle: handle, + context, + ringLocation: { + chainId: TEST_PEOPLE_LITE_RING.chainId, + junctions: [], + }, + }); + expectDomainError( + wrongRingAlias, + "KeyNotInRing", + "undeclared invalid ring alias", + ); + const wrongRing = await client.account.createAccountProof({ + keyHandle: handle, + context, + ringLocation: structurallyDifferentRing, + message: "0x7266633234", + }); + expectDomainError( + wrongRing, + "KeyNotInRing", + "structurally different ring proof", + ); + + const signature = okValue( + await client.account.ringVrfSign({ + keyHandle: handle, + message: "0x72666332342072696e6720767266207369676e6174757265", + }), + "owned ring_vrf_sign", + ); + requireHexBytes(signature, 64, "ring VRF signature"); + + const foreignOwner = `rfc24${Date.now().toString(36)}.dot`; + const beforeForeignRead = readTranscript(); + const foreignAnonymous = okValue( + await client.account.listRingVrfKeys({ + owner: foreignOwner, + disclosure: "Anonymized", + }), + "foreign anonymized list_ring_vrf_keys", + ); + if (foreignAnonymous.length !== 0) { + throw new Error("fresh foreign owner unexpectedly had registry entries"); + } + const readPrompts = actionLines( + newLinesSince(beforeForeignRead, readTranscript()), + ACCOUNT_ACCESS_ACTION, + ); + if (readPrompts.length !== 1) { + throw new Error( + `foreign listing produced ${readPrompts.length} account-access prompts, expected one`, + ); + } + const beforeSecondRead = readTranscript(); + okValue( + await client.account.listRingVrfKeys({ + owner: foreignOwner, + disclosure: "PublicKey", + }), + "foreign public list_ring_vrf_keys", + ); + if ( + actionLines( + newLinesSince(beforeSecondRead, readTranscript()), + ACCOUNT_ACCESS_ACTION, + ).length !== 0 + ) { + throw new Error( + "persisted foreign account access prompted more than once", + ); + } + + const foreignHandle: ProductAccountId = { + dotNsIdentifier: foreignOwner, + derivationIndex: { tag: "Left", value: 0 }, + }; + const foreignAlias = await client.account.getAccountAlias({ + keyHandle: foreignHandle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + }); + expectDomainError( + foreignAlias, + "KeyNotRegistered", + "granted foreign alias read", + ); + + const beforeBearerCalls = readTranscript(); + const foreignProof = await client.account.createAccountProof({ + keyHandle: foreignHandle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + message: "0x6e6f2070726f6d7074", + }); + expectDomainError( + foreignProof, + "NotAllowlisted", + "foreign create_account_proof", + ); + const foreignSignature = await client.account.ringVrfSign({ + keyHandle: foreignHandle, + message: "0x6e6f2070726f6d7074", + }); + expectDomainError( + foreignSignature, + "NotAllowlisted", + "foreign ring_vrf_sign", + ); + const bearerWindow = newLinesSince(beforeBearerCalls, readTranscript()); + if ( + actionLines(bearerWindow, PROOF_ACTION).length !== 0 || + actionLines(bearerWindow, ACCOUNT_ACCESS_ACTION).length !== 0 + ) { + throw new Error( + `foreign bearer-token calls consulted a prompt: ${bearerWindow.join("; ")}`, + ); + } + + return finish( + "ring_vrf_registry_e2e", + startedAt, + "pass", + "registry, exact rings, disclosure, alias/proof/signing, and foreign authorization verified", + ); + } catch (error) { + return finish( + "ring_vrf_registry_e2e", + startedAt, + "fail", + error instanceof Error ? error.message : String(error), + ); + } +} + +/** Verify an AutoSigning host can create and immediately use a new registry entry. */ +export async function runAutoSigningRingVrfE2e( + client: TrUApiClient, + productId: string, +): Promise { + const startedAt = performance.now(); + const index = { + tag: "Right" as const, + value: + "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" as const, + }; + const handle: ProductAccountId = { + dotNsIdentifier: productId, + derivationIndex: index, + }; + + try { + const publicKey = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "AutoSigning register_ring_vrf_key", + ); + requireHexBytes(publicKey, 32, "AutoSigning ring VRF public key"); + const entries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "PublicKey", + }), + "post-AutoSigning list_ring_vrf_keys", + ); + const entry = findEntry(entries, handle); + if ( + !entry || + entry.publicKey !== publicKey || + !hasRing(entry, TEST_PEOPLE_LITE_RING) + ) { + throw new Error( + "AutoSigning registration was not immediately visible in the registry", + ); + } + const signature = okValue( + await client.account.ringVrfSign({ + keyHandle: handle, + message: "0x6175746f7369676e696e672072696e6720767266", + }), + "post-AutoSigning ring_vrf_sign", + ); + requireHexBytes(signature, 64, "post-AutoSigning ring VRF signature"); + return finish( + "auto_signing_ring_vrf_e2e", + startedAt, + "pass", + "AutoSigning registration was immediately listed and usable for direct signing", + ); + } catch (error) { + return finish( + "auto_signing_ring_vrf_e2e", + startedAt, + "fail", + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts index 57fede855..a19ab6eb0 100644 --- a/rust/crates/truapi-host-cli/js/runner.ts +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -41,10 +41,7 @@ declare global { // Playground examples receive this helper from `runExample`; expose the // same contract to directly imported CLI scripts. // eslint-disable-next-line no-var - var assert: ( - condition: unknown, - ...message: unknown[] - ) => asserts condition; + var assert: (condition: unknown, ...message: unknown[]) => asserts condition; } const OPEN_TIMEOUT_MS = 15_000; @@ -65,7 +62,10 @@ async function main() { const context: HostContext = { productId, - productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + productAccount: (index = 0) => ({ + dotNsIdentifier: productId, + derivationIndex: { tag: "Left", value: index }, + }), }; globalThis.truapi = client; globalThis.host = context; @@ -101,7 +101,9 @@ async function main() { main().then( () => process.exit(0), (error) => { - console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + console.error( + `[script error] ${error instanceof Error ? error.stack : String(error)}`, + ); process.exit(1); }, ); diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts index ef2c032aa..46e0dd5dc 100644 --- a/rust/crates/truapi-host-cli/js/scripts/battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -21,6 +21,10 @@ import { expectedCliBatteryFailureReason, runDiagnosis, } from "../diagnosis.ts"; +import { + runAutoSigningRingVrfE2e, + runRingVrfRegistryE2e, +} from "../ring-vrf-e2e.ts"; const report = cliDiagnosisReportMetadata(process.env.TRUAPI_CLI_HOST_ROLE); const DEFAULT_REPORT_PATH = fileURLToPath( @@ -55,10 +59,19 @@ reporter.waitingForHost(HOST_READINESS_DELAY_MS); await new Promise((resolve) => setTimeout(resolve, HOST_READINESS_DELAY_MS)); const startedAt = performance.now(); -const rows = await runDiagnosis(truapi, { +const rows = []; +const ringVrfRegistry = await runRingVrfRegistryE2e( + truapi, + host.productId, + process.env.TRUAPI_APPROVALS_LOG, +); +reporter.result(ringVrfRegistry); +rows.push(ringVrfRegistry); +const diagnosisRows = await runDiagnosis(truapi, { ...options, onResult: (row) => reporter.result(row), }); +rows.push(...diagnosisRows); // Beyond the generated per-method examples: AutoSigning must make follow-up // sign_vrf calls prompt-free, observed through the host's approvals transcript. const autoSigning = await runAutoSigningE2e( @@ -68,6 +81,12 @@ const autoSigning = await runAutoSigningE2e( ); reporter.result(autoSigning); rows.push(autoSigning); +const autoSigningRingVrf = await runAutoSigningRingVrfE2e( + truapi, + host.productId, +); +reporter.result(autoSigningRingVrf); +rows.push(autoSigningRingVrf); reporter.finish(rows, Math.round(performance.now() - startedAt)); mkdirSync(dirname(REPORT_PATH), { recursive: true }); writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows)); @@ -75,9 +94,12 @@ reporter.reportSaved(REPORT_PATH); const failures = rows.filter((row) => row.status === "fail"); const unexpectedFailures = failures.filter( - (row) => !expectedCliBatteryFailureReason(row.serviceName), + (row) => !expectedCliBatteryFailureReason(row), ); if (unexpectedFailures.length > 0) { + for (const row of unexpectedFailures) { + console.error(`Unexpected failure: ${row.id}\n${row.output}`); + } throw new Error( `TrUAPI battery failed: ${unexpectedFailures.length} of ${rows.length} generated examples failed outside the known unsupported baseline`, ); diff --git a/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts index fab72a3ba..224018813 100644 --- a/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts +++ b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts @@ -5,12 +5,19 @@ const PEOPLE_COLLECTION_ID = "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; const PEOPLE_GENESIS = "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"; -const context = { productId: host.productId, suffix: "0x00" }; +const index = { tag: "Left" as const, value: 0 }; +const keyHandle = { + dotNsIdentifier: host.productId, + derivationIndex: index, +}; +const context = { productId: host.productId, suffix: index }; const ringLocation = { - chainId: PEOPLE_GENESIS, + chainId: PEOPLE_GENESIS as `0x${string}`, junctions: [ - { tag: "PalletInstance" as const, value: 67 }, - { tag: "CollectionId" as const, value: PEOPLE_COLLECTION_ID }, + { + tag: "CollectionId" as const, + value: PEOPLE_COLLECTION_ID as `0x${string}`, + }, ], }; @@ -24,30 +31,67 @@ if ( ); } -const aliasResult = await truapi.account.getAccountAlias({ context, ringLocation }); +const registration = await truapi.account.registerRingVrfKey({ + index, + ring: ringLocation, +}); +if (!registration.isOk()) { + throw new Error( + `registerRingVrfKey failed: ${JSON.stringify(registration.error)}`, + ); +} + +const listed = await truapi.account.listRingVrfKeys({ + owner: host.productId, + disclosure: "PublicKey", +}); +if (!listed.isOk()) { + throw new Error(`listRingVrfKeys failed: ${JSON.stringify(listed.error)}`); +} +const entry = listed.value.find( + (candidate) => + candidate.handle.dotNsIdentifier === host.productId && + candidate.handle.derivationIndex.tag === "Left" && + candidate.handle.derivationIndex.value === index.value, +); +if (!entry || entry.publicKey !== registration.value) { + throw new Error("registered key was not returned by listRingVrfKeys"); +} + +const aliasResult = await truapi.account.getAccountAlias({ + keyHandle, + context, + ringLocation, +}); if (!aliasResult.isOk()) { throw new Error(`getAccountAlias failed: ${JSON.stringify(aliasResult.error)}`); } const proofResult = await truapi.account.createAccountProof({ + keyHandle, context, ringLocation, message: "0x48656c6c6f", }); -if (!proofResult.isOk()) { - throw new Error(`createAccountProof failed: ${JSON.stringify(proofResult.error)}`); +if ( + !proofResult.isErr() || + proofResult.error.tag !== "Domain" || + proofResult.error.value.tag !== "V1" || + proofResult.error.value.value.tag !== "NotMember" +) { + throw new Error( + `createAccountProof did not report NotMember for the fresh key: ${JSON.stringify(proofResult)}`, + ); } -if (proofResult.value.contextualAlias.alias !== aliasResult.value.alias) { - throw new Error("alias and proof selected different ring members"); -} -if (proofResult.value.contextualAlias.context !== aliasResult.value.context) { - throw new Error("alias and proof used different context hashes"); -} -if (proofResult.value.proof.length <= 2) { - throw new Error("createAccountProof returned an empty proof"); +const signature = await truapi.account.ringVrfSign({ + keyHandle, + message: "0x48656c6c6f", +}); +if (!signature.isOk() || signature.value.length !== 130) { + throw new Error(`ringVrfSign failed: ${JSON.stringify(signature)}`); } console.log( - `RING_VRF_OK ring=${proofResult.value.ringIndex} revision=${proofResult.value.ringRevision} proofBytes=${(proofResult.value.proof.length - 2) / 2}`, + `RING_VRF_OK publicKey=${registration.value} alias=${aliasResult.value.alias} signatureBytes=64`, ); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index cbf5e2e8d..d6e3ab11b 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -540,6 +540,12 @@ pub enum CoreStorageKey { }, /// Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. AutoSigningKeys, + /// Wallet-bound RFC-0024 ring-VRF registry snapshot. + #[codec(index = 7)] + RingVrfRegistry { + /// Root account public key identifying the wallet that owns the registry. + root_public_key: [u8; 32], + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. /// @@ -586,6 +592,7 @@ pub fn describe_core_storage_key( CoreStorageKey::LastProcessedPairingStatement => ("LastProcessedPairingStatement", None), CoreStorageKey::AutoSigningKey { product_id } => ("AutoSigningKey", Some(product_id)), CoreStorageKey::AutoSigningKeys => ("AutoSigningKeys", None), + CoreStorageKey::RingVrfRegistry { .. } => ("RingVrfRegistry", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -701,6 +708,13 @@ mod tests { Some("product.dot"), ), (CoreStorageKey::AutoSigningKeys, "AutoSigningKeys", None), + ( + CoreStorageKey::RingVrfRegistry { + root_public_key: [0x42; 32], + }, + "RingVrfRegistry", + None, + ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index dffb7110d..02509c025 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -190,13 +190,14 @@ role-specific lifecycle, so no method exists on a role that can't mean it: - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session from host-held secret material. Its public identity is the RFC-0022 - `uid.dot` index-0 product account; full and lite person ring-VRF keys are - `peopl.dot` indices 0 and 1 under the keyed-hash `ring-vrf` tree. It resolves - RFC-0004 `RingLocation` values against the chain's `Members` pallet and pins - membership, ring pages, exponent, and revision reads to one finalized block - before creating an alias or proof. Full personhood is preferred over lite - personhood. Extrinsic-payload signing and v4 transaction construction work - from pre-encoded payload fields, so no chain metadata is needed; + `uid.dot` index-0 product account. RFC-0024 ring-VRF keys are explicit, + product-owned registry entries; aliases, proofs, direct signatures, and + internal personhood flows use the requested or user-selected registered key + without a compiled-in fallback. It resolves RFC-0004 `RingLocation` values + against the chain's `Members` pallet and pins membership, ring pages, + exponent, and revision reads to one finalized block before creating a proof. + Extrinsic-payload signing and v4 transaction construction work from + pre-encoded payload fields, so no chain metadata is needed; statement-store and Bulletin allowance allocation are native-only (wasm builds report them as unavailable). diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 1cefce408..e2e425257 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -209,6 +209,98 @@ where }, ); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_REGISTER_RING_VRF_KEY, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRegisterRingVrfKeyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRegisterRingVrfKeyResponse = match host.register_ring_vrf_key(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_LIST_RING_VRF_KEYS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountListRingVrfKeysRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountListRingVrfKeysResponse = match host.list_ring_vrf_keys(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_RING_VRF_SIGN, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRingVrfSignRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountRingVrfSignError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRingVrfSignResponse = + match host.ring_vrf_sign(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } { let host = host.clone(); dispatcher.on_request( diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d0427..b6555f1d9 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -466,6 +466,24 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `account_register_ring_vrf_key`. +pub const ACCOUNT_REGISTER_RING_VRF_KEY: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// Wire discriminants for `account_list_ring_vrf_keys`. +pub const ACCOUNT_LIST_RING_VRF_KEYS: RequestFrameIds = RequestFrameIds { + request_id: 168, + response_id: 169, +}; + +/// Wire discriminants for `account_ring_vrf_sign`. +pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { + request_id: 170, + response_id: 171, +}; + /// 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 +747,16 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "account_register_ring_vrf_key", + kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + }, + WireEntry { + method: "account_list_ring_vrf_keys", + kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + }, + WireEntry { + method: "account_ring_vrf_sign", + kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + }, ]; diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d226ea5e8..4f7ce5a25 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -140,6 +140,40 @@ impl PairingHostRuntime { .map_err(|reason| v01::GenericError { reason }) } + /// Registered providers available for an internal well-known-ring feature. + pub async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.pairing_host + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Current provider selected for an internal well-known-ring feature. + pub async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.pairing_host + .selected_ring_vrf_provider(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Select a registered provider for an internal well-known-ring feature. + pub async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), v01::GenericError> { + self.pairing_host + .select_ring_vrf_provider(ring, handle) + .await + .map_err(ring_vrf_admin_error) + } + /// Clear the canonical paired session and all capability caches/storage /// without sending a peer-disconnect notice. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.reset_session_state"))] @@ -336,6 +370,40 @@ impl SigningHostRuntime { }) } + /// Registered providers available for an internal well-known-ring feature. + pub async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.signing_host + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Current provider selected for an internal well-known-ring feature. + pub async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.signing_host + .selected_ring_vrf_provider(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Select a registered provider for an internal well-known-ring feature. + pub async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), v01::GenericError> { + self.signing_host + .select_ring_vrf_provider(ring, handle) + .await + .map_err(ring_vrf_admin_error) + } + /// Activate a wallet-local session from host-held secret material (raw /// BIP-39 entropy). #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] @@ -377,6 +445,17 @@ impl SigningHostRuntime { } } +fn ring_vrf_admin_error( + error: crate::host_logic::sso::messages::RingVrfError, +) -> v01::GenericError { + v01::GenericError { + reason: match error { + crate::host_logic::sso::messages::RingVrfError::Unknown { reason } => reason, + other => format!("{other:?}"), + }, + } +} + /// Product-scoped administration handle for host UI. /// /// Host UI should use this when it needs to inspect or update core-owned state diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 94e2da354..fcff402e1 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -4,8 +4,8 @@ //! accounts use one soft junction carrying the RFC-0022 32-byte derivation //! index, so a paired host can derive children from the subtree public key. //! Reserved built-ins additionally pin the `uid.dot` identity account and the -//! `peopl.dot` full/lite ring-VRF keyed-hash paths so activation, pairing, -//! registration, proof, allowance, and CLI code cannot drift. +//! legacy `peopl.dot` full/lite ring-VRF keyed-hash paths used by pairing +//! attestation. RFC-0024 operational key selection comes from the registry. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: //! @@ -106,13 +106,43 @@ pub fn derive_lite_person_ring_vrf_entropy(root_entropy: &[u8]) -> [u8; 32] { } fn derive_person_ring_vrf_entropy(root_entropy: &[u8], index: u32) -> [u8; 32] { + derive_ring_vrf_entropy( + root_entropy, + PERSONHOOD_PRODUCT_ID, + &truapi::v01::DerivationIndex::Left(index), + ) + .expect("the reserved personhood product id is a valid junction") +} + +/// Derive arbitrary RFC-0022 ring-VRF entropy: +/// `hash(root_entropy, "ring-vrf")//{product_id}//{derivation_index}`. +pub fn derive_ring_vrf_entropy( + root_entropy: &[u8], + product_id: &str, + derivation_index: &truapi::v01::DerivationIndex, +) -> Result<[u8; 32], ProductAccountError> { + let domain = derive_ring_vrf_domain_entropy(root_entropy, product_id)?; + Ok(derive_ring_vrf_entropy_from_domain( + &domain, + derivation_index, + )) +} + +/// Derive the RFC-0024 AutoSigning entropy for a product in the ring-VRF tree. +pub fn derive_ring_vrf_domain_entropy( + root_entropy: &[u8], + product_id: &str, +) -> Result<[u8; 32], ProductAccountError> { let root = blake2b256_keyed(root_entropy, RING_VRF_ROOT_KEY); - let domain = blake2b256_keyed( - &root, - &create_chain_code(PERSONHOOD_PRODUCT_ID) - .expect("the reserved personhood product id is a valid junction"), - ); - blake2b256_keyed(&domain, &index_bytes(index)) + Ok(blake2b256_keyed(&root, &create_chain_code(product_id)?)) +} + +/// Derive one registered member key from an RFC-0024 product-domain entropy. +pub fn derive_ring_vrf_entropy_from_domain( + domain_entropy: &[u8; 32], + derivation_index: &truapi::v01::DerivationIndex, +) -> [u8; 32] { + blake2b256_keyed(domain_entropy, &derivation_index_bytes(derivation_index)) } fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { @@ -368,6 +398,20 @@ mod tests { ); } + #[test] + fn ring_vrf_domain_entropy_derives_the_same_registered_key_as_the_root() { + use truapi::v01::DerivationIndex; + + let root_entropy: Vec = (1..=32).collect(); + let domain = derive_ring_vrf_domain_entropy(&root_entropy, "people-provider.dot").unwrap(); + for index in [DerivationIndex::Left(7), DerivationIndex::Right([0xEE; 32])] { + assert_eq!( + derive_ring_vrf_entropy_from_domain(&domain, &index), + derive_ring_vrf_entropy(&root_entropy, "people-provider.dot", &index).unwrap() + ); + } + } + #[test] fn identity_is_uid_dot_default_product_account_and_signs() { let entropy = [0xAB; 16]; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 942f4c157..f7a37954f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -319,17 +319,19 @@ pub struct SignVrfResponse { pub payload: Result, } -/// Failure returned by the Account Holder for a ring-VRF proof or alias request. -/// -/// Mirrors the identical error sets of `Account::create_account_proof` and -/// `Account::get_account_alias` (RFC 0004): the two operations perform the same -/// ring resolution and member-key selection, so they share these failure modes. +/// Failure returned by the Account Holder for RFC-0024 ring-VRF operations. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RingVrfError { /// The `RingLocation` did not resolve to a known ring. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The requested key handle is not registered. + KeyNotRegistered, + /// The requested key handle is not registered for the requested ring. + KeyNotInRing, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, /// User or Account Holder rejected the request. Rejected, /// Catch-all failure, carrying a diagnostic reason. @@ -341,13 +343,14 @@ pub enum RingVrfError { /// Request sent when a product asks the Account Holder for a contextual alias. /// -/// Used by `Account::get_account_alias`; `calling_product_id` names the caller -/// so the Account Holder can scope context derivation, while `context` and -/// `ring_location` select the member key and bind the derived alias (RFC 0004). +/// Used by `Account::get_account_alias`; `calling_product_id` names the caller, +/// `key_handle` selects a registered member key, and `context` binds the alias. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { /// Product id of the calling product. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Context that scopes the derived alias. pub context: ProductProofContext, /// Ring whose member key derives the alias. @@ -372,6 +375,8 @@ pub struct RingVrfAliasResponse { pub struct RingVrfProofRequest { /// Product id of the calling product. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Context that scopes the proof. pub context: ProductProofContext, /// Ring whose member key produces the proof. @@ -380,6 +385,66 @@ pub struct RingVrfProofRequest { pub message: Vec, } +/// Request to register a ring-VRF key with the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisterRingVrfKeyRequest { + /// Product id of the calling product and key owner. + pub calling_product_id: String, + /// Key derivation index within the owner's ring-VRF domain. + pub index: DerivationIndex, + /// Ring declared for the key. + pub ring: RingLocation, +} + +/// Response returned by the Account Holder for key registration. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisterRingVrfKeyResponse { + /// `message_id` of the registration request being answered. + pub responding_to: String, + /// Member public key or ring-VRF failure. + pub payload: Result<[u8; 32], RingVrfError>, +} + +/// Request to list registered ring-VRF keys. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ListRingVrfKeysRequest { + /// Product id of the calling product. + pub calling_product_id: String, + /// Product whose registry entries should be listed. + pub owner: String, + /// Disclosure requested by the caller. + pub disclosure: truapi::v01::RingVrfKeyDisclosure, +} + +/// Response returned by the Account Holder for registry listing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ListRingVrfKeysResponse { + /// `message_id` of the listing request being answered. + pub responding_to: String, + /// Registry entries or ring-VRF failure. + pub payload: Result, RingVrfError>, +} + +/// Request to sign bytes with a ring-VRF key. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfSignRequest { + /// Product id of the calling product. + pub calling_product_id: String, + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Message to sign. + pub message: Vec, +} + +/// Response returned by the Account Holder for direct ring-VRF signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfSignResponse { + /// `message_id` of the signing request being answered. + pub responding_to: String, + /// Signature bytes or ring-VRF failure. + pub payload: Result, RingVrfError>, +} + /// Response returned by the Account Holder for a ring-VRF proof request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfProofResponse { @@ -480,6 +545,8 @@ pub enum SsoAllocatedResource { AutoSigning { /// Private key of the product subtree root. product_root_private_key: [u8; 64], + /// Entropy of the product's ring-VRF domain. + ring_vrf_domain_entropy: [u8; 32], }, } @@ -587,6 +654,12 @@ pub enum SsoRemoteResponse { CreateTransaction(CreateTransactionResponse), /// Product subtree public-key response. ProductSubtree(ProductSubtreeResponse), + /// Ring-VRF key registration response. + RegisterRingVrfKey(RegisterRingVrfKeyResponse), + /// Ring-VRF key listing response. + ListRingVrfKeys(ListRingVrfKeysResponse), + /// Direct ring-VRF signing response. + RingVrfSign(RingVrfSignResponse), } impl SsoRemoteResponse { @@ -601,6 +674,9 @@ impl SsoRemoteResponse { Self::ResourceAllocation(_) => "resource-allocation", Self::CreateTransaction(_) => "create-transaction", Self::ProductSubtree(_) => "product-subtree", + Self::RegisterRingVrfKey(_) => "register-ring-vrf-key", + Self::ListRingVrfKeys(_) => "list-ring-vrf-keys", + Self::RingVrfSign(_) => "ring-vrf-sign", } } } @@ -648,8 +724,7 @@ pub fn decode_sso_session_statement( SsoStatementData::Response { .. } => Ok(None), SsoStatementData::Request { data, .. } => { for message in data { - let message = RemoteMessage::decode(&mut message.as_slice()) - .map_err(|err| format!("invalid SSO remote message: {err}"))?; + let message = decode_remote_message(&message)?; if matches!( &message.data, RemoteMessageData::V1(v1::RemoteMessage::Disconnected) @@ -724,10 +799,80 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::ProductSubtree(response)) } + v1::RemoteMessage::RegisterRingVrfKeyResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RegisterRingVrfKey(response)) + } + v1::RemoteMessage::ListRingVrfKeysResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ListRingVrfKeys(response)) + } + v1::RemoteMessage::RingVrfSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RingVrfSign(response)) + } _ => None, } } +/// Build an RFC-0024 ring-VRF key registration request for the Account Holder. +pub fn register_ring_vrf_key_message( + message_id: String, + calling_product_id: String, + index: DerivationIndex, + ring: RingLocation, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyRequest( + RegisterRingVrfKeyRequest { + calling_product_id, + index, + ring, + }, + )), + } +} + +/// Build an RFC-0024 ring-VRF key listing request for the Account Holder. +pub fn list_ring_vrf_keys_message( + message_id: String, + calling_product_id: String, + owner: String, + disclosure: truapi::v01::RingVrfKeyDisclosure, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysRequest( + ListRingVrfKeysRequest { + calling_product_id, + owner, + disclosure, + }, + )), + } +} + +/// Build an RFC-0024 direct ring-VRF signing request for the Account Holder. +pub fn ring_vrf_sign_message( + message_id: String, + calling_product_id: String, + key_handle: ProductAccountId, + message: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { + calling_product_id, + key_handle, + message, + })), + } +} + /// Build an RFC-0023 VRF-signing request for the Account Holder. pub fn sign_vrf_message( message_id: String, @@ -790,6 +935,7 @@ pub fn sign_raw_legacy_message( pub fn alias_request_message( message_id: String, calling_product_id: String, + key_handle: ProductAccountId, context: ProductProofContext, ring_location: RingLocation, ) -> RemoteMessage { @@ -798,6 +944,7 @@ pub fn alias_request_message( data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest( RingVrfAliasRequest { calling_product_id, + key_handle, context, ring_location, }, @@ -809,6 +956,7 @@ pub fn alias_request_message( pub fn proof_request_message( message_id: String, calling_product_id: String, + key_handle: ProductAccountId, context: ProductProofContext, ring_location: RingLocation, message: Vec, @@ -818,6 +966,7 @@ pub fn proof_request_message( data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofRequest( RingVrfProofRequest { calling_product_id, + key_handle, context, ring_location, message, @@ -947,10 +1096,7 @@ pub fn decode_incoming_sso_request( SsoStatementData::Request { request_id, data } => { let messages = data .iter() - .map(|message| { - RemoteMessage::decode(&mut message.as_slice()) - .map_err(|err| format!("invalid SSO remote message: {err}")) - }) + .map(|message| decode_remote_message(message)) .collect::, _>>() .map_err(|reason| SsoRequestDecodeError { request_id: Some(request_id.clone()), @@ -964,6 +1110,16 @@ pub fn decode_incoming_sso_request( } } +fn decode_remote_message(message: &[u8]) -> Result { + let mut input = message; + let decoded = RemoteMessage::decode(&mut input) + .map_err(|error| format!("invalid SSO remote message: {error}"))?; + if !input.is_empty() { + return Err("invalid SSO remote message: trailing bytes".to_string()); + } + Ok(decoded) +} + /// Build the signed transport acknowledgement for a peer-initiated request. pub fn build_signed_session_response_statement( session: &SsoSessionInfo, @@ -1126,6 +1282,14 @@ mod tests { #[test] fn late_remote_message_variants_match_host_papp_order() { + let ring_location = RingLocation { + chain_id: [0; 32], + junctions: vec![], + }; + let key_handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; let legacy_tx = create_transaction_legacy_message( String::new(), LegacyAccountTxPayload { @@ -1140,9 +1304,115 @@ mod tests { let legacy_raw = sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) .encode(); + let register = register_ring_vrf_key_message( + String::new(), + "caller.dot".to_string(), + DerivationIndex::Left(0), + ring_location.clone(), + ) + .encode(); + let register_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse( + RegisterRingVrfKeyResponse { + responding_to: String::new(), + payload: Ok([1; 32]), + }, + )), + } + .encode(); + let list = list_ring_vrf_keys_message( + String::new(), + "caller.dot".to_string(), + "peopl.dot".to_string(), + truapi::v01::RingVrfKeyDisclosure::Anonymized, + ) + .encode(); + let list_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse( + ListRingVrfKeysResponse { + responding_to: String::new(), + payload: Ok(Vec::new()), + }, + )), + } + .encode(); + let sign = + ring_vrf_sign_message(String::new(), "caller.dot".to_string(), key_handle, vec![]) + .encode(); + let sign_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse( + RingVrfSignResponse { + responding_to: String::new(), + payload: Ok(Vec::new()), + }, + )), + } + .encode(); assert_eq!(legacy_tx[..3], [0, 0, 9]); assert_eq!(legacy_raw[..3], [0, 0, 10]); + assert_eq!(register[..3], [0, 0, 18]); + assert_eq!(register_response[..3], [0, 0, 19]); + assert_eq!(list[..3], [0, 0, 20]); + assert_eq!(list_response[..3], [0, 0, 21]); + assert_eq!(sign[..3], [0, 0, 22]); + assert_eq!(sign_response[..3], [0, 0, 23]); + assert_eq!(RingVrfError::RingNotFound.encode()[0], 0); + assert_eq!(RingVrfError::NotMember.encode()[0], 1); + assert_eq!(RingVrfError::KeyNotRegistered.encode()[0], 2); + assert_eq!(RingVrfError::KeyNotInRing.encode()[0], 3); + assert_eq!(RingVrfError::NotAllowlisted.encode()[0], 4); + assert_eq!(RingVrfError::Rejected.encode()[0], 5); + assert_eq!( + RingVrfError::Unknown { + reason: String::new() + } + .encode()[0], + 6 + ); + } + + #[test] + fn rfc_0024_requests_match_android_scale_fixtures() { + let ring = RingLocation { + chain_id: [7; 32], + junctions: vec![ + RingLocationJunction::PalletInstance(9), + RingLocationJunction::CollectionId(b"pop:polkadot.network/people ".to_vec()), + ], + }; + let handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; + let messages = [ + v1::RemoteMessage::RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest { + calling_product_id: "game.dot".to_string(), + index: DerivationIndex::Left(4), + ring: ring.clone(), + }), + v1::RemoteMessage::ListRingVrfKeysRequest(ListRingVrfKeysRequest { + calling_product_id: "game.dot".to_string(), + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::PublicKey, + }), + v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { + calling_product_id: "game.dot".to_string(), + key_handle: handle, + message: (0..16).collect(), + }), + ]; + let expected = [ + "0x122067616d652e646f74000400000007070707070707070707070707070707070707070707070707070707070707070800090180706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652020202020", + "0x142067616d652e646f742470656f706c2e646f7401", + "0x162067616d652e646f742470656f706c2e646f74000000000040000102030405060708090a0b0c0d0e0f", + ]; + for (message, expected) in messages.into_iter().zip(expected) { + assert_eq!(format!("0x{}", hex::encode(message.encode())), expected); + } } #[test] @@ -1158,16 +1428,22 @@ mod tests { RingLocationJunction::CollectionId(b"pop".to_vec()), ], }; + let key_handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; let alias = alias_request_message( "m-alias".to_string(), "caller.dot".to_string(), + key_handle.clone(), context.clone(), ring_location.clone(), ); let proof = proof_request_message( "m-proof".to_string(), "caller.dot".to_string(), + key_handle, context, ring_location, b"vote".to_vec(), @@ -1175,11 +1451,11 @@ mod tests { assert_host_papp_0_8_11_fixture( alias, - "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f70", + "0x1c6d2d616c69617300032863616c6c65722e646f742470656f706c2e646f74000000000028766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f70", ); assert_host_papp_0_8_11_fixture( proof, - "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", + "0x1c6d2d70726f6f66000c2863616c6c65722e646f742470656f706c2e646f74000000000028766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", ); } @@ -1324,6 +1600,7 @@ mod tests { payload: Ok(vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: sequential_bytes(0), + ring_vrf_domain_entropy: sequential_bytes(64), }, )]), }, @@ -1332,12 +1609,28 @@ mod tests { assert_eq!( hex::encode(message.encode()), format!( - "046d0006047200040003{}", - hex::encode(sequential_bytes::<64>(0)) + "046d0006047200040003{}{}", + hex::encode(sequential_bytes::<64>(0)), + hex::encode(sequential_bytes::<32>(64)) ) ); } + #[test] + fn remote_message_decoder_rejects_trailing_bytes() { + let mut encoded = RemoteMessage { + message_id: "m".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + } + .encode(); + encoded.push(0); + + assert_eq!( + decode_remote_message(&encoded), + Err("invalid SSO remote message: trailing bytes".to_string()) + ); + } + #[test] fn allocated_resource_debug_redacts_private_material_through_all_wrappers() { let allowance = SsoAllocatedResource::StatementStoreAllowance { @@ -1370,6 +1663,7 @@ mod tests { payload: Ok(vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: auto_signing_secret, + ring_vrf_domain_entropy: [0x5A; 32], }, )]), }; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 6b28ca730..68f9c09dc 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -9,10 +9,11 @@ use parity_scale_codec::{Decode, Encode}; use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, - ProductSubtreeRequest, ProductSubtreeResponse, ResourceAllocationRequest, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, + RegisterRingVrfKeyRequest, RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, - RingVrfProofResponse, SignRawLegacyRequest, SignRawLegacyResponse, SignVrfRequest, - SignVrfResponse, SigningRequest, SigningResponse, + RingVrfProofResponse, RingVrfSignRequest, RingVrfSignResponse, SignRawLegacyRequest, + SignRawLegacyResponse, SignVrfRequest, SignVrfResponse, SigningRequest, SigningResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -79,4 +80,28 @@ pub enum RemoteMessage { #[codec(index = 17)] #[display("product_subtree_response")] ProductSubtreeResponse(ProductSubtreeResponse), + /// Register a ring-VRF key with the Account Holder. + #[codec(index = 18)] + #[display("register_ring_vrf_key")] + RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), + /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. + #[codec(index = 19)] + #[display("register_ring_vrf_key_response")] + RegisterRingVrfKeyResponse(RegisterRingVrfKeyResponse), + /// List registered ring-VRF keys. + #[codec(index = 20)] + #[display("list_ring_vrf_keys")] + ListRingVrfKeysRequest(ListRingVrfKeysRequest), + /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. + #[codec(index = 21)] + #[display("list_ring_vrf_keys_response")] + ListRingVrfKeysResponse(ListRingVrfKeysResponse), + /// Sign bytes with a registered ring-VRF key. + #[codec(index = 22)] + #[display("ring_vrf_sign")] + RingVrfSignRequest(RingVrfSignRequest), + /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. + #[codec(index = 23)] + #[display("ring_vrf_sign_response")] + RingVrfSignResponse(RingVrfSignResponse), } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 51f3da5ff..2ce695798 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -24,12 +24,15 @@ use truapi_platform::{ JsonRpcConnection, Navigation, Notifications, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, PlatformInfo, PreimageHost, ProductContext, ProductStorage, RuntimeConfigValidationError, SigningHostConfig, ThemeHost, UserConfirmation, - UserConfirmationReview, async_trait, + UserConfirmationReview, async_trait, normalize_product_identifier, }; pub mod reviews; -pub use reviews::NativeUserConfirmationReview; +pub use reviews::{ + NativeUserConfirmationReview, ProductAccountId as NativeProductAccountId, + RingLocation as NativeRingLocation, +}; use crate::SigningHostRuntime; use crate::host_logic::dotns; @@ -871,6 +874,45 @@ impl NativeTrUApiCore { .map_err(Into::into) } + /// List registered providers for a ring so host UI can present the RFC-0024 + /// personhood-provider setting. + pub fn ring_vrf_providers( + &self, + ring: NativeRingLocation, + ) -> Result, HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + futures::executor::block_on(self.runtime.ring_vrf_providers(&ring)) + .map(|providers| providers.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + /// Return the currently selected provider for a ring. + pub fn selected_ring_vrf_provider( + &self, + ring: NativeRingLocation, + ) -> Result, HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + futures::executor::block_on(self.runtime.selected_ring_vrf_provider(&ring)) + .map(|provider| provider.map(Into::into)) + .map_err(Into::into) + } + + /// Persist a user-selected provider after checking that the handle is + /// registered for the exact ring. + pub fn select_ring_vrf_provider( + &self, + ring: NativeRingLocation, + handle: NativeProductAccountId, + ) -> Result<(), HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + let mut handle: v01::ProductAccountId = + handle.try_into().map_err(native_ring_vrf_input_error)?; + handle.dot_ns_identifier = normalize_product_identifier(&handle.dot_ns_identifier) + .map_err(|error| native_ring_vrf_input_error(error.to_string()))?; + futures::executor::block_on(self.runtime.select_ring_vrf_provider(ring, handle)) + .map_err(Into::into) + } + /// Push a host theme update to active TrUAPI theme subscriptions. pub fn notify_theme_changed(&self, theme: HostTheme) { self.events.notify_theme_changed(theme.into()); @@ -895,6 +937,10 @@ impl NativeTrUApiCore { } } +fn native_ring_vrf_input_error(reason: String) -> HostRejection { + HostRejection::Rejected { reason } +} + /// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`) for /// the `tracing` output, which on native routes to stderr (system logs on /// iOS/Android). Most native diagnostics flow through `on_core_log` instead; diff --git a/rust/crates/truapi-server/src/native/reviews.rs b/rust/crates/truapi-server/src/native/reviews.rs index 432f4977e..d99111221 100644 --- a/rust/crates/truapi-server/src/native/reviews.rs +++ b/rust/crates/truapi-server/src/native/reviews.rs @@ -26,6 +26,19 @@ impl From for DerivationIndex { } } +impl TryFrom for v01::DerivationIndex { + type Error = String; + + fn try_from(index: DerivationIndex) -> Result { + match index { + DerivationIndex::Index(index) => Ok(Self::Left(index)), + DerivationIndex::Raw(raw) => raw.try_into().map(Self::Right).map_err(|raw: Vec| { + format!("raw derivation index must be 32 bytes, got {}", raw.len()) + }), + } + } +} + /// Product account identifier: dotNS domain plus derivation index. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct ProductAccountId { @@ -48,6 +61,17 @@ impl From for ProductAccountId { } } +impl TryFrom for v01::ProductAccountId { + type Error = String; + + fn try_from(account: ProductAccountId) -> Result { + Ok(Self { + dot_ns_identifier: account.dot_ns_identifier, + derivation_index: account.derivation_index.try_into()?, + }) + } +} + /// Raw data to sign — binary bytes or a string message. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum RawPayload { @@ -333,6 +357,15 @@ impl From for RingLocationJunction { } } +impl From for v01::RingLocationJunction { + fn from(junction: RingLocationJunction) -> Self { + match junction { + RingLocationJunction::PalletInstance(instance) => Self::PalletInstance(instance), + RingLocationJunction::CollectionId(id) => Self::CollectionId(id), + } + } +} + /// Locates a ring for ring VRF operations. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct RingLocation { @@ -351,6 +384,20 @@ impl From for RingLocation { } } +impl TryFrom for v01::RingLocation { + type Error = String; + + fn try_from(location: RingLocation) -> Result { + let chain_id = location.chain_id.try_into().map_err(|chain_id: Vec| { + format!("ring chain id must be 32 bytes, got {}", chain_id.len()) + })?; + Ok(Self { + chain_id, + junctions: location.junctions.into_iter().map(Into::into).collect(), + }) + } +} + /// A product-scoped proof context: a product and a context within it. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct ProductProofContext { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 632cd0086..58ec8f3f7 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,6 +15,7 @@ mod authority; pub(crate) mod bulletin_rpc; mod identity; mod pairing_host; +mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. pub(crate) mod services; mod signing_host; @@ -66,8 +67,9 @@ pub(crate) use signing_host::{ use authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, AuthoritySession, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use futures::{FutureExt, StreamExt, pin_mut}; @@ -82,7 +84,11 @@ use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, - HostAccountGetRequest, HostAccountGetResponse, HostAccountSignVrfError, + HostAccountGetRequest, HostAccountGetResponse, HostAccountListRingVrfKeysError, + HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, + HostAccountRegisterRingVrfKeyError, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignError, + HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, @@ -983,10 +989,17 @@ impl Account for ProductRuntimeHost { request: HostAccountGetAliasRequest, ) -> Result> { let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + key_handle, context, ring_location, }) = request; - + let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetAliasError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetAliasError::V1( v01::HostAccountGetAliasError::Rejected, @@ -1002,6 +1015,7 @@ impl Account for ProductRuntimeHost { &session, AccountAliasAuthorityRequest { calling_product_id, + key_handle, context, ring_location, }, @@ -1019,10 +1033,23 @@ impl Account for ProductRuntimeHost { request: HostAccountCreateProofRequest, ) -> Result> { let HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle, context, ring_location, message, }) = request; + let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { + CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; + if key_handle.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::NotAllowlisted, + ))); + } let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountCreateProofError::V1( @@ -1039,6 +1066,7 @@ impl Account for ProductRuntimeHost { &session, CreateProofAuthorityRequest { calling_product_id, + key_handle, context, ring_location, message, @@ -1052,6 +1080,135 @@ impl Account for ProductRuntimeHost { }) } + #[instrument(skip_all, fields(runtime.method = "account.register_ring_vrf_key"))] + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + request: HostAccountRegisterRingVrfKeyRequest, + ) -> Result> + { + let HostAccountRegisterRingVrfKeyRequest::V1(v01::HostAccountRegisterRingVrfKeyRequest { + index, + ring, + }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountRegisterRingVrfKeyError::V1( + v01::HostAccountRegisterRingVrfKeyError::NotConnected, + ))); + }; + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.register_ring_vrf_key( + &cx, + &session, + RegisterRingVrfKeyAuthorityRequest { + calling_product_id, + index, + ring, + }, + ), + ) + .await + .map(HostAccountRegisterRingVrfKeyResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountRegisterRingVrfKeyError::V1( + ring_vrf_register_error(err), + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.list_ring_vrf_keys"))] + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + request: HostAccountListRingVrfKeysRequest, + ) -> Result> + { + let HostAccountListRingVrfKeysRequest::V1(v01::HostAccountListRingVrfKeysRequest { + owner, + disclosure, + }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountListRingVrfKeysError::V1( + v01::HostAccountListRingVrfKeysError::NotConnected, + ))); + }; + let owner = normalize_product_identifier(&owner).map_err(|err| { + CallError::Domain(HostAccountListRingVrfKeysError::V1( + v01::HostAccountListRingVrfKeysError::Unknown { + reason: err.to_string(), + }, + )) + })?; + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.list_ring_vrf_keys( + &cx, + &session, + ListRingVrfKeysAuthorityRequest { + calling_product_id, + owner, + disclosure, + }, + ), + ) + .await + .map(HostAccountListRingVrfKeysResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountListRingVrfKeysError::V1(ring_vrf_list_error( + err, + ))) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.ring_vrf_sign"))] + async fn ring_vrf_sign( + &self, + cx: &CallContext, + request: HostAccountRingVrfSignRequest, + ) -> Result> { + let HostAccountRingVrfSignRequest::V1(mut request) = request; + request.key_handle = + Self::normalize_product_account_id(request.key_handle).map_err(|()| { + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotConnected, + ))); + }; + if request.key_handle.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotAllowlisted, + ))); + } + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.ring_vrf_sign( + &cx, + &session, + RingVrfSignAuthorityRequest { + calling_product_id, + key_handle: request.key_handle, + message: request.message, + }, + ), + ) + .await + .map(HostAccountRingVrfSignResponse::V1) + .map_err(|err| CallError::Domain(HostAccountRingVrfSignError::V1(ring_vrf_sign_error(err)))) + } + #[instrument(skip_all, fields(runtime.method = "account.sign_vrf"))] async fn sign_vrf( &self, @@ -1230,6 +1387,9 @@ fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { match err { RingVrfError::RingNotFound => v01::HostAccountGetAliasError::RingNotFound, RingVrfError::NotMember => v01::HostAccountGetAliasError::NotMember, + RingVrfError::KeyNotRegistered => v01::HostAccountGetAliasError::KeyNotRegistered, + RingVrfError::KeyNotInRing => v01::HostAccountGetAliasError::KeyNotInRing, + RingVrfError::NotAllowlisted => v01::HostAccountGetAliasError::Rejected, RingVrfError::Rejected => v01::HostAccountGetAliasError::Rejected, RingVrfError::Unknown { reason } => v01::HostAccountGetAliasError::Unknown { reason }, } @@ -1239,11 +1399,60 @@ fn ring_vrf_proof_error(err: RingVrfError) -> v01::HostAccountCreateProofError { match err { RingVrfError::RingNotFound => v01::HostAccountCreateProofError::RingNotFound, RingVrfError::NotMember => v01::HostAccountCreateProofError::NotMember, + RingVrfError::KeyNotRegistered => v01::HostAccountCreateProofError::KeyNotRegistered, + RingVrfError::KeyNotInRing => v01::HostAccountCreateProofError::KeyNotInRing, + RingVrfError::NotAllowlisted => v01::HostAccountCreateProofError::NotAllowlisted, RingVrfError::Rejected => v01::HostAccountCreateProofError::Rejected, RingVrfError::Unknown { reason } => v01::HostAccountCreateProofError::Unknown { reason }, } } +fn ring_vrf_register_error(err: RingVrfError) -> v01::HostAccountRegisterRingVrfKeyError { + match err { + RingVrfError::RingNotFound => v01::HostAccountRegisterRingVrfKeyError::RingNotFound, + RingVrfError::Rejected => v01::HostAccountRegisterRingVrfKeyError::Rejected, + RingVrfError::NotMember + | RingVrfError::KeyNotRegistered + | RingVrfError::KeyNotInRing + | RingVrfError::NotAllowlisted => v01::HostAccountRegisterRingVrfKeyError::Unknown { + reason: format!("{err:?}"), + }, + RingVrfError::Unknown { reason } => { + v01::HostAccountRegisterRingVrfKeyError::Unknown { reason } + } + } +} + +fn ring_vrf_list_error(err: RingVrfError) -> v01::HostAccountListRingVrfKeysError { + match err { + RingVrfError::Rejected => v01::HostAccountListRingVrfKeysError::Rejected, + RingVrfError::RingNotFound + | RingVrfError::NotMember + | RingVrfError::KeyNotRegistered + | RingVrfError::KeyNotInRing + | RingVrfError::NotAllowlisted => v01::HostAccountListRingVrfKeysError::Unknown { + reason: format!("{err:?}"), + }, + RingVrfError::Unknown { reason } => { + v01::HostAccountListRingVrfKeysError::Unknown { reason } + } + } +} + +fn ring_vrf_sign_error(err: RingVrfError) -> v01::HostAccountRingVrfSignError { + match err { + RingVrfError::KeyNotRegistered => v01::HostAccountRingVrfSignError::KeyNotRegistered, + RingVrfError::NotAllowlisted => v01::HostAccountRingVrfSignError::NotAllowlisted, + RingVrfError::Rejected => v01::HostAccountRingVrfSignError::Rejected, + RingVrfError::RingNotFound | RingVrfError::NotMember | RingVrfError::KeyNotInRing => { + v01::HostAccountRingVrfSignError::Unknown { + reason: format!("{err:?}"), + } + } + RingVrfError::Unknown { reason } => v01::HostAccountRingVrfSignError::Unknown { reason }, + } +} + fn signing_call_error( wrap: fn(v01::HostSignPayloadError) -> E, err: AuthorityError, @@ -4559,6 +4768,12 @@ mod tests { crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( crate::host_logic::sso::messages::SsoAllocatedResource::AutoSigning { product_root_private_key: subtree.secret.to_bytes(), + ring_vrf_domain_entropy: + crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(), }, ), ]), @@ -4615,6 +4830,12 @@ mod tests { crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( crate::host_logic::sso::messages::SsoAllocatedResource::AutoSigning { product_root_private_key, + ring_vrf_domain_entropy: + crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(), }, ), ]), @@ -4685,6 +4906,147 @@ mod tests { .expect("local AutoSigning VRF verifies"); } + #[test] + fn ring_vrf_sign_reports_not_connected_before_foreign_key_policy() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform::default()), + runtime_config("myapp.dot"), + test_spawner(), + ); + let request = HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: account_id("other.dot", 0), + message: b"not connected".to_vec(), + }); + + let error = + futures::executor::block_on(host.ring_vrf_sign(&CallContext::default(), request)) + .unwrap_err(); + + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotConnected + )) + )); + } + + #[test] + fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { + use verifiable::GenerateVerifiable; + use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + + let session = sso_session_info(); + let platform = Arc::new(StubPlatform::default()); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + platform.clone(), + ProductRuntimeHost::compat_host_config(), + ProductContext::new("myapp.dot".to_string()).unwrap(), + test_spawner(), + ); + install_pairing_session(&host, session.clone()); + + let root = + crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]) + .unwrap(); + let subtree = + crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") + .unwrap(); + let domain = crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(); + futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + &session, + pairing_host.current_session_lifecycle_epoch(), + "myapp.dot", + subtree.public.to_bytes(), + subtree.secret.to_bytes(), + domain, + )) + .unwrap(); + + let handle = account_id("myapp.dot", 7); + let request = HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: handle.clone(), + message: b"registered keys only".to_vec(), + }); + let error = + futures::executor::block_on(host.ring_vrf_sign(&CallContext::default(), request)) + .unwrap_err(); + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::KeyNotRegistered + )) + )); + + let ring = ring_location_fixture(); + let entropy = crate::host_logic::product_account::derive_ring_vrf_entropy_from_domain( + &domain, + &handle.derivation_index, + ); + let public_key = + crate::runtime::signing_host::ring_vrf::member_from_entropy(&entropy).unwrap(); + futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + &session, + handle.clone(), + ring, + public_key, + )) + .unwrap(); + + let message = b"registered keys only".to_vec(); + let response = futures::executor::block_on(host.ring_vrf_sign( + &CallContext::default(), + HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: handle, + message: message.clone(), + }), + )) + .unwrap(); + let HostAccountRingVrfSignResponse::V1(signature) = response; + let signature: [u8; 64] = signature + .try_into() + .expect("fixed-width ring-VRF signature"); + assert!(BandersnatchVrfVerifiable::verify_signature( + &signature, + &message, + &public_key + )); + assert!( + platform + .sent_rpc + .lock() + .expect("sent RPC mutex poisoned") + .is_empty(), + "registered AutoSigning ring-VRF use stays local" + ); + + let mismatched_handle = account_id("myapp.dot", 8); + futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + &session, + mismatched_handle.clone(), + ring_location_fixture(), + [0xFF; 32], + )) + .unwrap(); + let error = futures::executor::block_on(host.ring_vrf_sign( + &CallContext::default(), + HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: mismatched_handle, + message: b"reject mismatched registry state".to_vec(), + }), + )) + .unwrap_err(); + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::Unknown { reason } + )) if reason.contains("does not match the AutoSigning capability") + )); + } + #[test] fn auto_signing_rejects_persisted_key_for_unexpected_product_subtree() { let session = sso_session_info(); @@ -4808,6 +5170,7 @@ mod tests { "myapp.dot", subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .expect_err("the old AutoSigning allocation completion must be rejected"); let statement_store_result = @@ -4893,6 +5256,7 @@ mod tests { product_id, subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .unwrap(); futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( @@ -4974,6 +5338,7 @@ mod tests { "myapp.dot", first.public.to_bytes(), first.secret.to_bytes(), + [0x42; 32], )), Err(AuthorityError::Disconnected) )); @@ -5026,6 +5391,7 @@ mod tests { "myapp.dot", subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .unwrap(); futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index c1080ad1e..cf1cd08fa 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -8,11 +8,12 @@ use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostCreateTransactionResponse, HostRequestResourceAllocationRequest, - HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RingLocation, + HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, + HostAccountRingVrfSignResponse, HostCreateTransactionResponse, + HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; @@ -59,21 +60,25 @@ impl BulletinAllowanceKey { pub(crate) struct AutoSigningKey { #[debug("\"\"")] secret: [u8; 64], + #[debug("\"\"")] + ring_vrf_domain_entropy: [u8; 32], } impl AutoSigningKey { - pub(crate) fn from_secret_bytes(secret: Vec) -> Result { - let secret = secret - .try_into() - .map_err(|secret: Vec| AuthorityError::Unavailable { - reason: format!("AutoSigning key must be 64 bytes, got {}", secret.len()), - })?; - Ok(Self { secret }) + pub(crate) fn from_parts(secret: [u8; 64], ring_vrf_domain_entropy: [u8; 32]) -> Self { + Self { + secret, + ring_vrf_domain_entropy, + } } pub(crate) fn as_secret_bytes(&self) -> &[u8; 64] { &self.secret } + + pub(crate) fn ring_vrf_domain_entropy(&self) -> &[u8; 32] { + &self.ring_vrf_domain_entropy + } } /// Snapshot of an account-authority session selected by the authority. /// @@ -219,30 +224,67 @@ pub(crate) enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } -/// Contextual-alias request forwarded to the account authority (RFC 0004). +/// Contextual-alias request forwarded to the account authority. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AccountAliasAuthorityRequest { /// Calling product, so the Account Holder can scope context derivation. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring whose member key the Account Holder selects. + /// Ring the explicit key must be registered for. pub ring_location: RingLocation, } -/// Ring-VRF proof request forwarded to the account authority (RFC 0004). +/// Ring-VRF proof request forwarded to the account authority. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CreateProofAuthorityRequest { /// Calling product, so the Account Holder can scope context derivation. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring whose member key the Account Holder selects. + /// Ring the explicit key must be registered for. pub ring_location: RingLocation, /// Opaque message bound into the proof. pub message: Vec, } +/// Ring-VRF key registration request forwarded to the account authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegisterRingVrfKeyAuthorityRequest { + /// Calling product that owns the key. + pub calling_product_id: String, + /// Key derivation index within the caller's ring-VRF domain. + pub index: truapi::v01::DerivationIndex, + /// Declared ring for the key. + pub ring: RingLocation, +} + +/// Ring-VRF key listing request forwarded to the account authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ListRingVrfKeysAuthorityRequest { + /// Calling product requesting the list. + pub calling_product_id: String, + /// Owner product whose entries should be listed. + pub owner: String, + /// Disclosure requested by the caller. + pub disclosure: truapi::v01::RingVrfKeyDisclosure, +} + +/// Direct ring-VRF member-key signing request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RingVrfSignAuthorityRequest { + /// Calling product requesting the signature. + pub calling_product_id: String, + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Message to sign. + pub message: Vec, +} + /// Statement-store allowance signing material held by the authority layer. #[derive(Clone, PartialEq, Eq)] pub(crate) struct StatementStoreAllowanceKey { @@ -356,9 +398,9 @@ pub(crate) trait ProductAuthority: Send + Sync { request: CreateTransactionAuthorityRequest, ) -> Result; - /// Derive a product-scoped contextual alias for a ring (RFC 0004). + /// Derive a product-scoped contextual alias for an explicit registered key. /// - /// The Account Holder selects the member key for `ring_location` and derives + /// The Account Holder resolves `key_handle` from the registry and derives /// the alias bound to `context`; `create_proof` derives the same alias. async fn account_alias( &self, @@ -367,10 +409,10 @@ pub(crate) trait ProductAuthority: Send + Sync { request: AccountAliasAuthorityRequest, ) -> Result; - /// Create a ring-VRF proof bound to a context and message (RFC 0004). + /// Create a ring-VRF proof bound to a context and message. /// - /// Uses the same ring resolution and member-key selection as `account_alias`, - /// so the returned `contextual_alias` matches that method's output. + /// Uses the request's explicit registered key, so the returned + /// `contextual_alias` matches `account_alias` for the same inputs. async fn create_proof( &self, cx: &CallContext, @@ -378,6 +420,30 @@ pub(crate) trait ProductAuthority: Send + Sync { request: CreateProofAuthorityRequest, ) -> Result; + /// Register a ring-VRF key owned by the calling product. + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result; + + /// List registered ring-VRF keys. + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result; + + /// Sign bytes directly with a registered ring-VRF key. + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result; + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index b642f4709..8100db35a 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -20,19 +20,23 @@ use super::auth_state::AuthStateMachine; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; use super::connected_session_ui_info; use super::identity::resolve_session_identity_with_chain; use super::services::RuntimeServices; use super::sso_pairing::{SsoPairingFlow, SsoPairingOutcome}; -use super::sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}; +use super::sso_remote::{ + SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey, sso_message_id, +}; use super::statement_store_rpc::StatementStoreRpc; use crate::chain_runtime::ChainRuntime; use crate::host_logic::entropy::derive_product_entropy_from_source; use crate::host_logic::product_account::{ derivation_index_bytes, derive_product_keypair_from_subtree_secret, + derive_ring_vrf_entropy_from_domain, }; use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; use crate::host_logic::session_store::SessionStoreChangeNotifier; @@ -47,6 +51,13 @@ use truapi_platform::{ CoreStorageKey, PairingHostConfig, Platform, ProductContext, SignVrfReview, UserConfirmationReview, normalize_product_identifier, }; +use zeroize::Zeroizing; + +use super::ring_vrf_registry::{RingVrfRegistryStore, validate_owner_listing}; +use super::signing_host::ring_vrf::{ + ChainRingResolver, MemberCandidate, RingResolver, alias_from_entropy, context_bytes, + create_proof, member_from_entropy, sign_from_entropy, +}; /// Distinguishes all remote authority request entrypoints by wire label. #[derive(Clone, Copy, Debug, derive_more::Display)] @@ -153,11 +164,13 @@ struct PersistedAutoSigningKey { product_id: String, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], } impl Drop for PersistedAutoSigningKey { fn drop(&mut self) { self.secret.zeroize(); + self.ring_vrf_domain_entropy.zeroize(); } } @@ -181,17 +194,24 @@ fn decode_auto_signing_keys(blob: &[u8]) -> Result, fn validate_auto_signing_key( secret: [u8; 64], expected_product_subtree_public_key: [u8; 32], + ring_vrf_domain_entropy: [u8; 32], ) -> Result { - let secret_key = SecretKey::from_bytes(&secret).map_err(|_| AuthorityError::Unavailable { - reason: "AutoSigning capability contains an invalid subtree secret".to_string(), - })?; + let secret = Zeroizing::new(secret); + let ring_vrf_domain_entropy = Zeroizing::new(ring_vrf_domain_entropy); + let secret_key = + SecretKey::from_bytes(&secret[..]).map_err(|_| AuthorityError::Unavailable { + reason: "AutoSigning capability contains an invalid subtree secret".to_string(), + })?; if secret_key.to_public().to_bytes() != expected_product_subtree_public_key { return Err(AuthorityError::Unavailable { reason: "AutoSigning capability does not match the authenticated product subtree" .to_string(), }); } - AutoSigningKey::from_secret_bytes(secret.to_vec()) + Ok(AutoSigningKey::from_parts( + *secret, + *ring_vrf_domain_entropy, + )) } #[derive(Default)] @@ -253,6 +273,8 @@ pub(crate) struct PairingHost { bulletin_allowances: Mutex>, product_subtrees: Mutex>, auto_signing_keys: Mutex>, + ring_resolver: Arc, + ring_vrf_registry: Arc, /// Orders session-secret cache/storage writes against teardown and activation. session_secret_storage: futures::lock::Mutex<()>, session_store_activation: futures::lock::Mutex<()>, @@ -286,6 +308,8 @@ impl PairingHost { bulletin_allowances: Mutex::new(HashMap::new()), product_subtrees: Mutex::new(HashMap::new()), auto_signing_keys: Mutex::new(HashMap::new()), + ring_resolver: ChainRingResolver::new(services.chain.clone()), + ring_vrf_registry: RingVrfRegistryStore::new(services.platform.clone()), session_secret_storage: futures::lock::Mutex::new(()), session_store_activation: futures::lock::Mutex::new(()), session_lifecycle: Mutex::new(SessionLifecycle::default()), @@ -369,6 +393,43 @@ impl PairingHost { self.session_state.current().as_ref().map(authority_session) } + pub(crate) async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .providers(session.public_key, ring) + .await + } + + pub(crate) async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .selected_provider(session.public_key, ring) + .await + } + + pub(crate) async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .select_provider(session.public_key, ring, handle) + .await + } + /// Start the disconnect monitor when a session is already active. #[cfg(test)] pub(crate) fn start_remote_monitor_for_current_session(&self) { @@ -897,6 +958,7 @@ impl PairingHost { product_id: &str, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], ) -> Result<(), AuthorityError> { self.remember_auto_signing_key( session, @@ -904,10 +966,24 @@ impl PairingHost { product_id, expected_product_subtree_public_key, secret, + ring_vrf_domain_entropy, ) .await } + #[cfg(test)] + pub(crate) async fn register_ring_vrf_key_for_tests( + &self, + session: &SessionInfo, + handle: v01::ProductAccountId, + ring: v01::RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + self.ring_vrf_registry + .register(session.public_key, handle, ring, public_key) + .await + } + #[cfg(test)] pub(crate) fn capability_cache_sizes_for_tests(&self) -> (usize, usize, usize, usize) { ( @@ -1501,8 +1577,13 @@ impl PairingHost { product_id: &str, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], ) -> Result<(), AuthorityError> { - let key = validate_auto_signing_key(secret, expected_product_subtree_public_key)?; + let key = validate_auto_signing_key( + secret, + expected_product_subtree_public_key, + ring_vrf_domain_entropy, + )?; let owner = AutoSigningOwner::from_session(session); let cache_key = (owner.clone(), product_id.to_string()); let _storage_guard = self.session_secret_storage.lock().await; @@ -1530,6 +1611,7 @@ impl PairingHost { product_id: product_id.to_string(), expected_product_subtree_public_key, secret, + ring_vrf_domain_entropy, }); if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { return Err(AuthorityError::Disconnected); @@ -1657,6 +1739,7 @@ impl PairingHost { let key = match validate_auto_signing_key( persisted.secret, persisted.expected_product_subtree_public_key, + persisted.ring_vrf_domain_entropy, ) { Ok(key) => key, Err(err) => { @@ -1694,6 +1777,94 @@ impl PairingHost { subtrees.retain(|(key, _), _| *key != session_key); } + fn require_owned_ring_vrf_key( + calling_product_id: &str, + handle: &v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let caller = normalize_product_identifier(calling_product_id).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if caller != handle.dot_ns_identifier { + return Err(RingVrfError::NotAllowlisted); + } + Ok(()) + } + + async fn local_ring_vrf_entropy( + &self, + session: &SessionInfo, + handle: &v01::ProductAccountId, + ) -> Result>, RingVrfError> { + let Some(auto_signing) = self + .auto_signing_key(session, &handle.dot_ns_identifier) + .await + .map_err(RingVrfError::from)? + else { + return Ok(None); + }; + let entry = self + .ring_vrf_registry + .entry(session.public_key, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + auto_signing.ring_vrf_domain_entropy(), + &handle.derivation_index, + )); + if entry.public_key != Some(member_from_entropy(&entropy)?) { + return Err(RingVrfError::Unknown { + reason: "registered ring-VRF public key does not match the AutoSigning capability" + .to_string(), + }); + } + Ok(Some(entropy)) + } + + async fn local_ring_vrf_entropy_for_ring( + &self, + session: &SessionInfo, + handle: &v01::ProductAccountId, + ring: &v01::RingLocation, + ) -> Result>, RingVrfError> { + let Some(entropy) = self.local_ring_vrf_entropy(session, handle).await? else { + return Ok(None); + }; + let entry = self + .ring_vrf_registry + .entry(session.public_key, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + if !entry.rings.contains(ring) { + return Err(RingVrfError::KeyNotInRing); + } + Ok(Some(entropy)) + } + + fn mirror_ring_vrf_registration( + &self, + session: SessionInfo, + request: RegisterRingVrfKeyAuthorityRequest, + ) { + let weak_self = self.weak_self.clone(); + (self.spawner)(Box::pin(async move { + let Some(host) = weak_self.upgrade() else { + return; + }; + let cx = CallContext::with_request_id(format!( + "ring-vrf-registration-mirror:{}", + sso_message_id() + )); + if let Err(error) = host + .remote_register_ring_vrf_key(&cx, &session, request) + .await + { + warn!(?error, "ring-VRF registration mirror failed"); + } + })); + } + async fn product_subtree_public_key( &self, cx: &CallContext, @@ -1788,8 +1959,27 @@ impl PairingHost { session: &AuthoritySession, request: AccountAliasAuthorityRequest, ) -> Result { - let session = self.current_private_session(session)?; - self.remote_account_alias(cx, &session, request).await + let private_session = self.current_private_session(session)?; + if request.calling_product_id == request.key_handle.dot_ns_identifier + && let Some(entropy) = self + .local_ring_vrf_entropy_for_ring( + &private_session, + &request.key_handle, + &request.ring_location, + ) + .await? + { + self.ring_resolver.validate(&request.ring_location).await?; + self.current_private_session(session)?; + let context = context_bytes(&request.context); + let alias = alias_from_entropy(&entropy, &context)?; + return Ok(v01::ContextualAlias { + context, + alias: alias.to_vec(), + }); + } + self.remote_account_alias(cx, &private_session, request) + .await } async fn create_proof( @@ -1798,8 +1988,146 @@ impl PairingHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { - let session = self.current_private_session(session)?; - self.remote_create_proof(cx, &session, request).await + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let private_session = self.current_private_session(session)?; + if let Some(entropy) = self + .local_ring_vrf_entropy_for_ring( + &private_session, + &request.key_handle, + &request.ring_location, + ) + .await? + { + let member = member_from_entropy(&entropy)?; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &[MemberCandidate { member }]) + .await?; + self.current_private_session(session)?; + let context = context_bytes(&request.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + return Ok(v01::HostAccountCreateProofResponse { + proof, + contextual_alias: v01::ContextualAlias { + context, + alias: alias.to_vec(), + }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, + }); + } + self.remote_create_proof(cx, &private_session, request) + .await + } + + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + let private_session = self.current_private_session(session)?; + let handle = v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&request.calling_product_id).map_err( + |error| RingVrfError::Unknown { + reason: error.to_string(), + }, + )?, + derivation_index: request.index.clone(), + }; + if let Some(auto_signing) = self + .auto_signing_key(&private_session, &request.calling_product_id) + .await + .map_err(RingVrfError::from)? + { + self.ring_resolver.validate(&request.ring).await?; + self.current_private_session(session)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + auto_signing.ring_vrf_domain_entropy(), + &request.index, + )); + let public_key = member_from_entropy(&entropy)?; + self.ring_vrf_registry + .register( + private_session.public_key, + handle, + request.ring.clone(), + public_key, + ) + .await?; + self.current_private_session(session)?; + self.mirror_ring_vrf_registration(private_session, request); + return Ok(public_key); + } + let public_key = self + .remote_register_ring_vrf_key(cx, &private_session, request.clone()) + .await?; + self.ring_vrf_registry + .register(private_session.public_key, handle, request.ring, public_key) + .await?; + self.current_private_session(session)?; + Ok(public_key) + } + + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + let private_session = self.current_private_session(session)?; + let owner = normalize_product_identifier(&request.owner).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if request.calling_product_id == owner + && let Some(mut entries) = self + .ring_vrf_registry + .complete_owner_entries(private_session.public_key, &owner) + .await? + { + self.current_private_session(session)?; + apply_ring_vrf_disclosure(&mut entries, request.disclosure); + return Ok(entries); + } + let requested_disclosure = request.disclosure; + let mut remote_request = request; + if remote_request.calling_product_id == owner { + remote_request.disclosure = v01::RingVrfKeyDisclosure::PublicKey; + } + let mut entries = self + .remote_list_ring_vrf_keys(cx, &private_session, remote_request) + .await?; + validate_owner_listing(&owner, &entries)?; + if entries.iter().all(|entry| entry.public_key.is_some()) { + entries = self + .ring_vrf_registry + .reconcile_owner(private_session.public_key, &owner, entries) + .await?; + } + self.current_private_session(session)?; + apply_ring_vrf_disclosure(&mut entries, requested_disclosure); + Ok(entries) + } + + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let private_session = self.current_private_session(session)?; + if let Some(entropy) = self + .local_ring_vrf_entropy(&private_session, &request.key_handle) + .await? + { + self.current_private_session(session)?; + return sign_from_entropy(&entropy, &request.message); + } + self.remote_ring_vrf_sign(cx, &private_session, request) + .await } async fn allocate_resources( @@ -1886,6 +2214,17 @@ impl PairingHost { } } +fn apply_ring_vrf_disclosure( + entries: &mut [v01::RegisteredRingVrfKey], + disclosure: v01::RingVrfKeyDisclosure, +) { + if disclosure == v01::RingVrfKeyDisclosure::Anonymized { + for entry in entries { + entry.public_key = None; + } + } +} + fn login_error_reason(err: &CallError) -> String { match err { CallError::Domain(HostRequestLoginError::V1(v01::HostRequestLoginError::Unknown { @@ -2004,6 +2343,33 @@ impl ProductAuthority for PairingHost { PairingHost::create_proof(self, cx, session, request).await } + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + PairingHost::register_ring_vrf_key(self, cx, session, request).await + } + + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + PairingHost::list_ring_vrf_keys(self, cx, session, request).await + } + + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + PairingHost::ring_vrf_sign(self, cx, session, request).await + } + async fn allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index b928eacd9..13494c0cb 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -2,8 +2,10 @@ use super::super::authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, BulletinAllowanceKey, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, }; use super::super::sso_remote::{ RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, @@ -18,8 +20,9 @@ use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RingVrfError, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_legacy_message, - create_transaction_message, decode_sso_session_statement, product_subtree_request_message, - proof_request_message, resource_allocation_message, sign_payload_message, + create_transaction_message, decode_sso_session_statement, list_ring_vrf_keys_message, + product_subtree_request_message, proof_request_message, register_ring_vrf_key_message, + resource_allocation_message, ring_vrf_sign_message, sign_payload_message, sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -33,6 +36,12 @@ const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signi const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; const UNEXPECTED_SSO_ALIAS_RESPONSE: &str = "Unexpected SSO response for account alias request"; const UNEXPECTED_SSO_PROOF_RESPONSE: &str = "Unexpected SSO response for ring-VRF proof request"; +const UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE: &str = + "Unexpected SSO response for ring-VRF key registration request"; +const UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE: &str = + "Unexpected SSO response for ring-VRF key listing request"; +const UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE: &str = + "Unexpected SSO response for ring-VRF signing request"; fn unexpected_response_reason(context: &str, response_kind: &str) -> String { format!("{context}: {response_kind}") @@ -46,6 +55,12 @@ enum RemoteAction { RingVrfAlias, #[display("ring-vrf-proof")] RingVrfProof, + #[display("register-ring-vrf-key")] + RegisterRingVrfKey, + #[display("list-ring-vrf-keys")] + ListRingVrfKeys, + #[display("ring-vrf-sign")] + RingVrfSign, #[display("sign-vrf")] SignVrf, #[display("resource-allocation")] @@ -482,6 +497,7 @@ impl PairingHost { let message = alias_request_message( message_id, request.calling_product_id, + request.key_handle, request.context, request.ring_location, ); @@ -509,6 +525,7 @@ impl PairingHost { let message = proof_request_message( message_id, request.calling_product_id, + request.key_handle, request.context, request.ring_location, request.message, @@ -526,6 +543,96 @@ impl PairingHost { response.payload } + /// Forward a ring-VRF key registration request to the paired signing host. + pub(super) async fn remote_register_ring_vrf_key( + &self, + cx: &CallContext, + session: &SessionInfo, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + let message_id = sso_message_id(); + let message = register_ring_vrf_key_message( + message_id, + request.calling_product_id, + request.index, + request.ring, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::RegisterRingVrfKey, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::RegisterRingVrfKey(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + + /// Forward a ring-VRF key listing request to the paired signing host. + pub(super) async fn remote_list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &SessionInfo, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + let message_id = sso_message_id(); + let message = list_ring_vrf_keys_message( + message_id, + request.calling_product_id, + request.owner, + request.disclosure, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ListRingVrfKeys, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::ListRingVrfKeys(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + + /// Forward a direct ring-VRF signing request to the paired signing host. + pub(super) async fn remote_ring_vrf_sign( + &self, + cx: &CallContext, + session: &SessionInfo, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + let message_id = sso_message_id(); + let message = ring_vrf_sign_message( + message_id, + request.calling_product_id, + request.key_handle, + request.message, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::RingVrfSign, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::RingVrfSign(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + /// Ask the paired signing host to allocate product resources, caching any /// returned allowance keys. pub(super) async fn remote_allocate_resources( @@ -799,6 +906,7 @@ impl PairingHost { SsoAllocatedResource::SmartContractAllowance => {} SsoAllocatedResource::AutoSigning { product_root_private_key, + ring_vrf_domain_entropy, } => { let expected_product_subtree_public_key = self .remote_product_subtree_public_key(cx, session, product_id.to_string()) @@ -809,6 +917,7 @@ impl PairingHost { product_id, expected_product_subtree_public_key, *product_root_private_key, + *ring_vrf_domain_entropy, ) .await?; } @@ -901,6 +1010,7 @@ mod tests { let private_key = [0xA5; 64]; let resource = SsoAllocatedResource::AutoSigning { product_root_private_key: private_key, + ring_vrf_domain_entropy: [0x5A; 32], }; let resource_reason = unexpected_response_reason( "Unexpected statement-store allowance response resource", diff --git a/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs b/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs new file mode 100644 index 000000000..80be57f06 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs @@ -0,0 +1,665 @@ +//! Durable, wallet-scoped RFC-0024 ring-VRF registry snapshots. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use parity_scale_codec::{Decode, Encode}; +use truapi::v01::{ProductAccountId, RegisteredRingVrfKey, RingLocation}; +use truapi_platform::{CoreStorageKey, Platform, normalize_product_identifier}; + +use crate::host_logic::sso::messages::RingVrfError; + +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +struct SelectedProvider { + ring: RingLocation, + handle: ProductAccountId, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Encode, Decode)] +struct RegistrySnapshot { + entries: Vec, + /// Owners for which `entries` is a complete Account Holder snapshot. + complete_owners: Vec, + /// First registration wins until the user selects another provider. + selected_providers: Vec, +} + +/// Shared durable repository used by both account-authority roles. +pub(super) struct RingVrfRegistryStore { + platform: Arc, + cache: Mutex>, + storage_guard: futures::lock::Mutex<()>, +} + +impl RingVrfRegistryStore { + pub(super) fn new(platform: Arc) -> Arc { + Arc::new(Self { + platform, + cache: Mutex::new(HashMap::new()), + storage_guard: futures::lock::Mutex::new(()), + }) + } + + pub(super) async fn entry( + &self, + root_public_key: [u8; 32], + handle: &ProductAccountId, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .find(|entry| entry.handle == *handle)) + } + + pub(super) async fn complete_owner_entries( + &self, + root_public_key: [u8; 32], + owner: &str, + ) -> Result>, RingVrfError> { + let snapshot = self.snapshot(root_public_key).await?; + if !snapshot.complete_owners.iter().any(|item| item == owner) { + return Ok(None); + } + Ok(Some( + snapshot + .entries + .into_iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .collect(), + )) + } + + pub(super) async fn owner_entries( + &self, + root_public_key: [u8; 32], + owner: &str, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .collect()) + } + + pub(super) async fn register( + &self, + root_public_key: [u8; 32], + handle: ProductAccountId, + ring: RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + if let Some(entry) = snapshot + .entries + .iter_mut() + .find(|entry| entry.handle == handle) + { + if entry.public_key != Some(public_key) { + return Err(invalid_registry( + "registered key handle has a conflicting public key", + )); + } + if !entry.rings.contains(&ring) { + entry.rings.push(ring.clone()); + } + } else { + snapshot.entries.push(RegisteredRingVrfKey { + handle: handle.clone(), + rings: vec![ring.clone()], + public_key: Some(public_key), + }); + } + if !snapshot + .selected_providers + .iter() + .any(|provider| provider.ring == ring) + { + snapshot + .selected_providers + .push(SelectedProvider { ring, handle }); + } + self.persist_under_guard(root_public_key, snapshot).await + } + + /// Reconcile one owner's complete response with locally registered keys. + /// + /// RFC-0024 has no revocation operation, so a remote response cannot + /// invalidate an entry already accepted by this host. This also prevents a + /// list response created before a fire-and-forget registration mirror from + /// removing that local registration. + pub(super) async fn reconcile_owner( + &self, + root_public_key: [u8; 32], + owner: &str, + entries: Vec, + ) -> Result, RingVrfError> { + validate_authoritative_owner_entries(owner, &entries)?; + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + let mut owner_entries = snapshot + .entries + .iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .cloned() + .collect::>(); + for entry in entries { + if let Some(existing) = owner_entries + .iter_mut() + .find(|existing| existing.handle == entry.handle) + { + if existing.public_key != entry.public_key { + return Err(invalid_registry_listing( + "owner snapshot conflicts with a locally registered public key", + )); + } + for ring in entry.rings { + if !existing.rings.contains(&ring) { + existing.rings.push(ring); + } + } + } else { + owner_entries.push(entry); + } + } + snapshot + .entries + .retain(|entry| entry.handle.dot_ns_identifier != owner); + snapshot.entries.extend(owner_entries.iter().cloned()); + if !snapshot.complete_owners.iter().any(|item| item == owner) { + snapshot.complete_owners.push(owner.to_string()); + } + snapshot.selected_providers.retain(|provider| { + snapshot.entries.iter().any(|entry| { + entry.handle == provider.handle && entry.rings.contains(&provider.ring) + }) + }); + for entry in &snapshot.entries { + for ring in &entry.rings { + if !snapshot + .selected_providers + .iter() + .any(|provider| provider.ring == *ring) + { + snapshot.selected_providers.push(SelectedProvider { + ring: ring.clone(), + handle: entry.handle.clone(), + }); + } + } + } + self.persist_under_guard(root_public_key, snapshot).await?; + Ok(owner_entries) + } + + pub(super) async fn selected_provider( + &self, + root_public_key: [u8; 32], + ring: &RingLocation, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .selected_providers + .into_iter() + .find(|provider| provider.ring == *ring) + .map(|provider| provider.handle)) + } + + pub(super) async fn providers( + &self, + root_public_key: [u8; 32], + ring: &RingLocation, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .filter(|entry| entry.rings.contains(ring)) + .map(|entry| entry.handle) + .collect()) + } + + /// Persist a user-selected provider after validating its registration. + pub(super) async fn select_provider( + &self, + root_public_key: [u8; 32], + ring: RingLocation, + handle: ProductAccountId, + ) -> Result<(), RingVrfError> { + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + let registered = snapshot + .entries + .iter() + .any(|entry| entry.handle == handle && entry.rings.contains(&ring)); + if !registered { + return Err(RingVrfError::KeyNotInRing); + } + snapshot + .selected_providers + .retain(|provider| provider.ring != ring); + snapshot + .selected_providers + .push(SelectedProvider { ring, handle }); + self.persist_under_guard(root_public_key, snapshot).await + } + + async fn snapshot(&self, root_public_key: [u8; 32]) -> Result { + if let Some(snapshot) = self + .cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .get(&root_public_key) + .cloned() + { + return Ok(snapshot); + } + let _guard = self.storage_guard.lock().await; + self.load_under_guard(root_public_key).await + } + + async fn load_under_guard( + &self, + root_public_key: [u8; 32], + ) -> Result { + if let Some(snapshot) = self + .cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .get(&root_public_key) + .cloned() + { + return Ok(snapshot); + } + let key = CoreStorageKey::RingVrfRegistry { root_public_key }; + let snapshot = match self + .platform + .read_core_storage(key.clone()) + .await + .map_err(storage_error)? + { + Some(blob) => match decode_snapshot(&blob) { + Ok(snapshot) => snapshot, + Err(error) => { + let _ = self.platform.clear_core_storage(key).await; + return Err(error); + } + }, + None => RegistrySnapshot::default(), + }; + self.cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .insert(root_public_key, snapshot.clone()); + Ok(snapshot) + } + + async fn persist_under_guard( + &self, + root_public_key: [u8; 32], + snapshot: RegistrySnapshot, + ) -> Result<(), RingVrfError> { + validate_snapshot(&snapshot)?; + self.platform + .write_core_storage( + CoreStorageKey::RingVrfRegistry { root_public_key }, + snapshot.encode(), + ) + .await + .map_err(storage_error)?; + self.cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .insert(root_public_key, snapshot); + Ok(()) + } +} + +fn decode_snapshot(blob: &[u8]) -> Result { + let mut input = blob; + let snapshot = RegistrySnapshot::decode(&mut input).map_err(|error| RingVrfError::Unknown { + reason: format!("invalid persisted ring-VRF registry: {error}"), + })?; + if !input.is_empty() { + return Err(RingVrfError::Unknown { + reason: "invalid persisted ring-VRF registry: trailing bytes".to_string(), + }); + } + validate_snapshot(&snapshot)?; + Ok(snapshot) +} + +fn validate_snapshot(snapshot: &RegistrySnapshot) -> Result<(), RingVrfError> { + let mut handles = HashSet::new(); + for entry in &snapshot.entries { + let owner = normalize_product_identifier(&entry.handle.dot_ns_identifier) + .map_err(|error| invalid_registry(error.to_string()))?; + if owner != entry.handle.dot_ns_identifier { + return Err(invalid_registry("non-canonical key owner")); + } + if entry.public_key.is_none() { + return Err(invalid_registry("stored entry is missing its public key")); + } + if entry.rings.is_empty() { + return Err(invalid_registry("stored entry has no declared rings")); + } + if !handles.insert(entry.handle.encode()) { + return Err(invalid_registry("duplicate key handle")); + } + let mut rings = HashSet::new(); + if entry.rings.iter().any(|ring| !rings.insert(ring.encode())) { + return Err(invalid_registry("duplicate declared ring")); + } + } + let mut owners = HashSet::new(); + for owner in &snapshot.complete_owners { + let canonical = normalize_product_identifier(owner) + .map_err(|error| invalid_registry(error.to_string()))?; + if canonical != *owner || !owners.insert(owner) { + return Err(invalid_registry("invalid complete owner")); + } + } + let mut provider_rings = HashSet::new(); + for provider in &snapshot.selected_providers { + if !provider_rings.insert(provider.ring.encode()) { + return Err(invalid_registry("duplicate selected provider")); + } + if !snapshot + .entries + .iter() + .any(|entry| entry.handle == provider.handle && entry.rings.contains(&provider.ring)) + { + return Err(invalid_registry( + "selected provider is not registered for its ring", + )); + } + } + Ok(()) +} + +pub(super) fn validate_owner_listing( + owner: &str, + entries: &[RegisteredRingVrfKey], +) -> Result<(), RingVrfError> { + let canonical_owner = normalize_product_identifier(owner) + .map_err(|error| invalid_registry_listing(error.to_string()))?; + if canonical_owner != owner { + return Err(invalid_registry_listing("non-canonical listing owner")); + } + let mut handles = HashSet::new(); + for entry in entries { + if entry.handle.dot_ns_identifier != owner { + return Err(invalid_registry_listing( + "owner listing contains a foreign key handle", + )); + } + if entry.rings.is_empty() { + return Err(invalid_registry_listing( + "owner listing contains a key with no rings", + )); + } + if !handles.insert(entry.handle.encode()) { + return Err(invalid_registry_listing( + "owner listing contains a duplicate key handle", + )); + } + let mut rings = HashSet::new(); + if entry.rings.iter().any(|ring| !rings.insert(ring.encode())) { + return Err(invalid_registry_listing( + "owner listing contains a duplicate declared ring", + )); + } + } + Ok(()) +} + +fn validate_authoritative_owner_entries( + owner: &str, + entries: &[RegisteredRingVrfKey], +) -> Result<(), RingVrfError> { + validate_owner_listing(owner, entries)?; + if entries.iter().any(|entry| entry.public_key.is_none()) { + return Err(invalid_registry_listing( + "authoritative owner snapshot contains a key without its public key", + )); + } + Ok(()) +} + +fn storage_error(error: truapi::v01::GenericError) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("ring-VRF registry storage failed: {}", error.reason), + } +} + +fn invalid_registry(reason: impl Into) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("invalid persisted ring-VRF registry: {}", reason.into()), + } +} + +fn invalid_registry_listing(reason: impl Into) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("invalid ring-VRF registry listing: {}", reason.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::StubPlatform; + + fn handle(owner: &str, index: u32) -> ProductAccountId { + ProductAccountId { + dot_ns_identifier: owner.to_string(), + derivation_index: truapi::v01::DerivationIndex::Left(index), + } + } + + fn ring(byte: u8) -> RingLocation { + RingLocation { + chain_id: [byte; 32], + junctions: vec![truapi::v01::RingLocationJunction::PalletInstance(byte)], + } + } + + #[test] + fn registry_is_wallet_scoped_durable_and_registration_is_idempotent() { + let platform = Arc::new(StubPlatform::default()); + let store = RingVrfRegistryStore::new(platform.clone()); + let first_root = [1; 32]; + let second_root = [2; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(first_root, key.clone(), ring(1), [9; 32])) + .unwrap(); + futures::executor::block_on(store.register(first_root, key.clone(), ring(1), [9; 32])) + .unwrap(); + futures::executor::block_on(store.register(first_root, key.clone(), ring(2), [9; 32])) + .unwrap(); + + let entry = futures::executor::block_on(store.entry(first_root, &key)) + .unwrap() + .unwrap(); + assert_eq!(entry.rings, vec![ring(1), ring(2)]); + assert!( + futures::executor::block_on(store.entry(second_root, &key)) + .unwrap() + .is_none() + ); + + let reloaded = RingVrfRegistryStore::new(platform); + assert_eq!( + futures::executor::block_on(reloaded.entry(first_root, &key)).unwrap(), + Some(entry) + ); + } + + #[test] + fn first_registrar_remains_selected_until_explicitly_changed() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let location = ring(1); + let first = handle("first.dot", 0); + let second = handle("second.dot", 0); + futures::executor::block_on(store.register(root, first.clone(), location.clone(), [1; 32])) + .unwrap(); + futures::executor::block_on(store.register( + root, + second.clone(), + location.clone(), + [2; 32], + )) + .unwrap(); + assert_eq!( + futures::executor::block_on(store.selected_provider(root, &location)).unwrap(), + Some(first.clone()) + ); + assert_eq!( + futures::executor::block_on(store.providers(root, &location)).unwrap(), + vec![first.clone(), second.clone()] + ); + futures::executor::block_on(store.select_provider(root, location.clone(), second.clone())) + .unwrap(); + assert_eq!( + futures::executor::block_on(store.selected_provider(root, &location)).unwrap(), + Some(second) + ); + } + + #[test] + fn registration_rejects_a_conflicting_public_key_for_the_same_handle() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(root, key.clone(), ring(1), [1; 32])).unwrap(); + + let error = + futures::executor::block_on(store.register(root, key.clone(), ring(2), [2; 32])) + .unwrap_err(); + + assert!(matches!( + error, + RingVrfError::Unknown { reason } + if reason.contains("conflicting public key") + )); + assert_eq!( + futures::executor::block_on(store.entry(root, &key)) + .unwrap() + .unwrap() + .rings, + vec![ring(1)] + ); + } + + #[test] + fn stale_owner_snapshot_preserves_a_local_registration() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let local = handle("owner.dot", 7); + let remote = RegisteredRingVrfKey { + handle: handle("owner.dot", 8), + rings: vec![ring(2)], + public_key: Some([2; 32]), + }; + futures::executor::block_on(store.register(root, local.clone(), ring(1), [1; 32])).unwrap(); + + let reconciled = futures::executor::block_on(store.reconcile_owner( + root, + "owner.dot", + vec![remote.clone()], + )) + .unwrap(); + + assert_eq!( + reconciled, + vec![ + RegisteredRingVrfKey { + handle: local, + rings: vec![ring(1)], + public_key: Some([1; 32]), + }, + remote, + ] + ); + assert_eq!( + futures::executor::block_on(store.complete_owner_entries(root, "owner.dot")).unwrap(), + Some(reconciled) + ); + } + + #[test] + fn owner_snapshot_rejects_a_conflicting_local_registration() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(root, key.clone(), ring(1), [1; 32])).unwrap(); + + let error = futures::executor::block_on(store.reconcile_owner( + root, + "owner.dot", + vec![RegisteredRingVrfKey { + handle: key.clone(), + rings: vec![ring(1)], + public_key: Some([2; 32]), + }], + )) + .unwrap_err(); + + assert!(matches!( + error, + RingVrfError::Unknown { reason } if reason.contains("conflicts with a locally registered public key") + )); + assert_eq!( + futures::executor::block_on(store.entry(root, &key)) + .unwrap() + .unwrap() + .public_key, + Some([1; 32]) + ); + } + + #[test] + fn persisted_entries_must_declare_at_least_one_ring() { + let snapshot = RegistrySnapshot { + entries: vec![RegisteredRingVrfKey { + handle: handle("owner.dot", 0), + rings: vec![], + public_key: Some([1; 32]), + }], + ..RegistrySnapshot::default() + }; + + assert!(matches!( + decode_snapshot(&snapshot.encode()), + Err(RingVrfError::Unknown { reason }) if reason.contains("no declared rings") + )); + } + + #[test] + fn owner_listing_rejects_foreign_and_duplicate_entries() { + let owner = "owner.dot"; + let entry = RegisteredRingVrfKey { + handle: handle(owner, 0), + rings: vec![ring(1)], + public_key: None, + }; + assert!(validate_owner_listing(owner, std::slice::from_ref(&entry)).is_ok()); + + let mut foreign = entry.clone(); + foreign.handle = handle("foreign.dot", 0); + assert!(matches!( + validate_owner_listing(owner, &[foreign]), + Err(RingVrfError::Unknown { reason }) if reason.contains("foreign key handle") + )); + assert!(matches!( + validate_owner_listing(owner, &[entry.clone(), entry]), + Err(RingVrfError::Unknown { reason }) if reason.contains("duplicate key handle") + )); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 6b85647a2..44db144f1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -13,7 +13,7 @@ //! Statement Store and Bulletin allowance keys (native only). mod local_activation; -mod ring_vrf; +pub(super) mod ring_vrf; mod sso_responder; use std::collections::HashSet; @@ -28,33 +28,38 @@ pub(crate) use sso_responder::respond_to_pairing; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, ProductAuthority, - SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, - authority_session_validation_id, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session_validation_id, }; +use super::ring_vrf_registry::RingVrfRegistryStore; use super::{RuntimeServices, connected_session_ui_info, validate_vrf_transcript}; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{ Sr25519Signer, build_signed_extrinsic_v4, build_signed_extrinsic_v4_with_signature, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::derive_lite_person_ring_vrf_entropy; use crate::host_logic::product_account::{ ProductAccountError, SR25519_SIGNING_CONTEXT, derivation_index_bytes, derive_identity_keypair, - derive_product_keypair, derive_product_subtree_keypair, derive_root_keypair_from_entropy, + derive_product_keypair, derive_product_subtree_keypair, derive_ring_vrf_entropy, + derive_root_keypair_from_entropy, }; use crate::host_logic::session::{SessionInfo, SessionState}; use crate::host_logic::sso::messages::{OnExistingAllowancePolicy, RingVrfError}; use crate::host_logic::transaction::{extrinsic_payload_extensions, extrinsic_payload_preimage}; use crate::runtime::auth_state::AuthStateMachine; use ring_vrf::{ - ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes, - create_proof, key_for_collection, member_from_entropy, person_entropy, + ChainRingResolver, MemberCandidate, RingResolver, alias_from_entropy, context_bytes, + create_proof, member_from_entropy, sign_from_entropy, }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{ - CreateProofReview, PermissionAuthorizationStatus, Platform, ProductContext, SignVrfReview, - UserConfirmationReview, normalize_product_identifier, + PermissionAuthorizationStatus, Platform, ProductContext, SignVrfReview, UserConfirmationReview, + normalize_product_identifier, }; use zeroize::Zeroizing; @@ -99,6 +104,8 @@ pub(crate) struct SigningHost { /// lifecycle mutex also makes session replacement and snapshot creation /// atomic with respect to generation changes. local_grants: Mutex, + /// Durable RFC-0024 registry, scoped by the active wallet root. + ring_vrf_registry: Arc, } impl SigningHost { @@ -110,10 +117,11 @@ impl SigningHost { services, platform: platform.clone(), session_state: SessionState::new(), - auth_state: AuthStateMachine::new(platform), + auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), + ring_vrf_registry: RingVrfRegistryStore::new(platform), }) } @@ -132,10 +140,11 @@ impl SigningHost { services, platform: platform.clone(), session_state: SessionState::new(), - auth_state: AuthStateMachine::new(platform), + auth_state: AuthStateMachine::new(platform.clone()), ring_resolver, root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), + ring_vrf_registry: RingVrfRegistryStore::new(platform), }) } @@ -337,50 +346,147 @@ impl SigningHost { Ok((current, state.activation_generation)) } - fn person_entropy( + fn ring_vrf_entropy( &self, session: &AuthoritySession, - key: PersonKey, + handle: &v01::ProductAccountId, ) -> Result, RingVrfError> { self.require_current_session(session)?; let root = self.root_entropy()?; - Ok(person_entropy(&root, key)) + derive_ring_vrf_entropy(&root, &handle.dot_ns_identifier, &handle.derivation_index) + .map(Zeroizing::new) + .map_err(|err| RingVrfError::Unknown { + reason: err.to_string(), + }) } - fn member_candidates( + /// Wallet-internal allowance proofs retain Android's reserved `peopl.dot/1` key. + /// Product-facing RFC-0024 operations resolve only explicitly registered handles. + #[cfg(not(target_arch = "wasm32"))] + fn reserved_lite_person_entropy( &self, session: &AuthoritySession, - ) -> Result<[MemberCandidate; 2], RingVrfError> { - let full_entropy = self.person_entropy(session, PersonKey::Full)?; - let lite_entropy = self.person_entropy(session, PersonKey::Lite)?; - Ok([ - MemberCandidate { - key: PersonKey::Full, - member: member_from_entropy(&full_entropy)?, - }, - MemberCandidate { - key: PersonKey::Lite, - member: member_from_entropy(&lite_entropy)?, - }, - ]) + ) -> Result, AuthorityError> { + self.require_current_session(session)?; + let root = self.root_entropy()?; + Ok(Zeroizing::new(derive_lite_person_ring_vrf_entropy(&root))) } - async fn confirm_ring_vrf_if_cross_product( + async fn registered_ring_vrf_entry( &self, - calling_product_id: &str, - target_product_id: &str, - review: UserConfirmationReview, + session: &AuthoritySession, + handle: &v01::ProductAccountId, + ) -> Result, RingVrfError> { + self.require_current_session(session)?; + self.ring_vrf_registry + .entry(session.public_key, handle) + .await + } + + async fn resolve_ring_vrf_key_for_ring( + &self, + session: &AuthoritySession, + handle: &v01::ProductAccountId, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let entry = self + .registered_ring_vrf_entry(session, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + if !entry.rings.contains(ring) { + return Err(RingVrfError::KeyNotInRing); + } + let entropy = self.ring_vrf_entropy(session, handle)?; + Self::require_matching_registered_public_key(&entry, &entropy)?; + Ok(entropy) + } + + async fn resolve_registered_ring_vrf_key( + &self, + session: &AuthoritySession, + handle: &v01::ProductAccountId, + ) -> Result, RingVrfError> { + let entry = self + .registered_ring_vrf_entry(session, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + let entropy = self.ring_vrf_entropy(session, handle)?; + Self::require_matching_registered_public_key(&entry, &entropy)?; + Ok(entropy) + } + + fn require_matching_registered_public_key( + entry: &v01::RegisteredRingVrfKey, + entropy: &[u8; 32], ) -> Result<(), RingVrfError> { - if calling_product_id == target_product_id { - return Ok(()); + if entry.public_key != Some(member_from_entropy(entropy)?) { + return Err(RingVrfError::Unknown { + reason: "registered ring-VRF public key does not match the active wallet" + .to_string(), + }); } - match self.platform.confirm_user_action(review).await { - Ok(true) => Ok(()), - Ok(false) => Err(RingVrfError::Rejected), - Err(err) => Err(RingVrfError::Unknown { - reason: format!("confirmation failed: {}", err.reason), - }), + Ok(()) + } + + fn ring_vrf_member_candidate( + &self, + entropy: &[u8; 32], + ) -> Result { + Ok(MemberCandidate { + member: member_from_entropy(entropy)?, + }) + } + + fn require_owned_ring_vrf_key( + calling_product_id: &str, + handle: &v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let caller = normalize_product_identifier(calling_product_id).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if caller != handle.dot_ns_identifier { + return Err(RingVrfError::NotAllowlisted); } + Ok(()) + } + + pub(crate) async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.current_local_session().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .providers(session.public_key, ring) + .await + } + + pub(crate) async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.current_local_session().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .selected_provider(session.public_key, ring) + .await + } + + pub(crate) async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let session = self.current_local_session().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .select_provider(session.public_key, ring, handle) + .await } } @@ -601,7 +707,7 @@ impl ProductAuthority for SigningHost { match super::account_access_authorization( &self.services, &request.calling_product_id, - &request.context.product_id, + &request.key_handle.dot_ns_identifier, ) .await { @@ -616,9 +722,11 @@ impl ProductAuthority for SigningHost { }); } } - let collection = self.ring_resolver.validate(&request.ring_location).await?; + let entropy = self + .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) + .await?; + self.ring_resolver.validate(&request.ring_location).await?; let context = context_bytes(&request.context); - let entropy = self.person_entropy(session, key_for_collection(&collection))?; let alias = alias_from_entropy(&entropy, &context)?; Ok(v01::ContextualAlias { context, @@ -633,25 +741,18 @@ impl ProductAuthority for SigningHost { request: CreateProofAuthorityRequest, ) -> Result { self.require_current_session(session)?; - self.confirm_ring_vrf_if_cross_product( - &request.calling_product_id, - &request.context.product_id, - UserConfirmationReview::CreateProof(CreateProofReview { - calling_product_id: request.calling_product_id.clone(), - context: request.context.clone(), - ring_location: request.ring_location.clone(), - message: request.message.clone(), - }), - ) - .await?; - let candidates = self.member_candidates(session)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let entropy = self + .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) + .await?; + let candidate = self.ring_vrf_member_candidate(&entropy)?; let resolved = self .ring_resolver - .resolve(&request.ring_location, &candidates) + .resolve(&request.ring_location, &[candidate]) .await?; // Reject a stale request if the local session disconnected or changed // while its chain snapshot was being resolved. - let entropy = self.person_entropy(session, resolved.selected.key)?; + self.require_current_session(session)?; let context = context_bytes(&request.context); let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; Ok(v01::HostAccountCreateProofResponse { @@ -665,6 +766,89 @@ impl ProductAuthority for SigningHost { }) } + async fn register_ring_vrf_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + self.require_current_session(session)?; + self.ring_resolver.validate(&request.ring).await?; + + let handle = v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&request.calling_product_id).map_err( + |err| RingVrfError::Unknown { + reason: err.to_string(), + }, + )?, + derivation_index: request.index, + }; + let entropy = self.ring_vrf_entropy(session, &handle)?; + let public_key = member_from_entropy(&entropy)?; + self.ring_vrf_registry + .register(session.public_key, handle, request.ring, public_key) + .await?; + Ok(public_key) + } + + async fn list_ring_vrf_keys( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + self.require_current_session(session)?; + let owner = + normalize_product_identifier(&request.owner).map_err(|err| RingVrfError::Unknown { + reason: err.to_string(), + })?; + if request.calling_product_id != owner { + match super::account_access_authorization( + &self.services, + &request.calling_product_id, + &owner, + ) + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => return Err(RingVrfError::Rejected), + Err(err) => { + return Err(RingVrfError::Unknown { + reason: err.to_string(), + }); + } + } + } + + let mut entries = self + .ring_vrf_registry + .owner_entries(session.public_key, &owner) + .await?; + if request.disclosure == v01::RingVrfKeyDisclosure::Anonymized { + for entry in &mut entries { + entry.public_key = None; + } + } + Ok(entries) + } + + async fn ring_vrf_sign( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + self.require_current_session(session)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let entropy = self + .resolve_registered_ring_vrf_key(session, &request.key_handle) + .await?; + sign_from_entropy(&entropy, &request.message) + } + async fn allocate_resources( &self, _cx: &CallContext, @@ -913,21 +1097,21 @@ mod tests { use std::sync::Arc; use super::super::authority::{ - AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, - CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; - use super::ring_vrf::{ - MemberCandidate, PersonKey, ResolvedRing, RingResolver, member_from_entropy, person_entropy, - }; + use super::ring_vrf::{MemberCandidate, ResolvedRing, RingResolver, member_from_entropy}; use super::{ BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, SR25519_SIGNING_CONTEXT, raw_payload_bytes, }; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ - derive_identity_keypair, derive_product_keypair, derive_root_keypair_from_entropy, - index_bytes, + derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, + derive_root_keypair_from_entropy, index_bytes, }; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, @@ -965,7 +1149,7 @@ mod tests { ) -> Result { assert!( candidates.contains(&self.ring.selected), - "signing host offered the selected person key" + "signing host offered the explicitly registered key" ); Ok(self.ring.clone()) } @@ -1042,14 +1226,22 @@ mod tests { } } + fn full_person_key_handle() -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(0), + } + } + fn full_person_ring_resolver() -> Arc { - let full_entropy = person_entropy(&ENTROPY, PersonKey::Full); + let full_entropy = + derive_ring_vrf_entropy(&ENTROPY, "peopl.dot", &v01::DerivationIndex::Left(0)) + .expect("full-person entropy"); let full_member = member_from_entropy(&full_entropy).expect("full-person member"); Arc::new(StubRingResolver { collection: *b"pop:polkadot.network/people ", ring: ResolvedRing { selected: MemberCandidate { - key: PersonKey::Full, member: full_member, }, ring_index: 7, @@ -1060,8 +1252,53 @@ mod tests { }) } + fn full_person_ring_location() -> v01::RingLocation { + v01::RingLocation { + chain_id: [0x22; 32], + junctions: vec![ + v01::RingLocationJunction::PalletInstance(42), + v01::RingLocationJunction::CollectionId( + b"pop:polkadot.network/people ".to_vec(), + ), + ], + } + } + + fn register_full_person_key( + authority: &SigningHostRole, + session: &AuthoritySession, + ring: &v01::RingLocation, + ) { + futures::executor::block_on(authority.register_ring_vrf_key( + &CallContext::default(), + session, + RegisterRingVrfKeyAuthorityRequest { + calling_product_id: "peopl.dot".to_string(), + index: v01::DerivationIndex::Left(0), + ring: ring.clone(), + }, + )) + .expect("full person key registration succeeds"); + } + + #[test] + fn internal_allowances_use_the_reserved_lite_person_handle() { + let (_, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let actual = authority + .reserved_lite_person_entropy(&session) + .expect("reserved key derives"); + let expected = + derive_ring_vrf_entropy(&ENTROPY, "peopl.dot", &v01::DerivationIndex::Left(1)) + .expect("reserved RFC-0024 handle derives"); + + assert_eq!(*actual, expected); + } + #[test] - fn ring_alias_and_proof_share_the_selected_person_key() { + fn ring_alias_and_proof_share_the_explicit_registered_key() { let resolver = full_person_ring_resolver(); let platform: Arc = Arc::new(StubPlatform::default()); let authority = SigningHostRole::new_with_ring_resolver(platform, resolver); @@ -1073,21 +1310,15 @@ mod tests { product_id: "myapp.dot".to_string(), suffix: v01::DerivationIndex::Left(0), }; - let ring_location = v01::RingLocation { - chain_id: [0x22; 32], - junctions: vec![ - v01::RingLocationJunction::PalletInstance(42), - v01::RingLocationJunction::CollectionId( - b"pop:polkadot.network/people ".to_vec(), - ), - ], - }; + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); let alias = futures::executor::block_on(authority.account_alias( &cx, &session, AccountAliasAuthorityRequest { - calling_product_id: "myapp.dot".to_string(), + calling_product_id: "peopl.dot".to_string(), + key_handle: full_person_key_handle(), context: context.clone(), ring_location: ring_location.clone(), }, @@ -1097,7 +1328,8 @@ mod tests { &cx, &session, CreateProofAuthorityRequest { - calling_product_id: "myapp.dot".to_string(), + calling_product_id: "peopl.dot".to_string(), + key_handle: full_person_key_handle(), context, ring_location, message: b"prove me".to_vec(), @@ -1112,7 +1344,76 @@ mod tests { } #[test] - fn cross_product_ring_requests_use_their_respective_authorization_paths() { + fn alias_checks_the_exact_registry_ring_before_resolving_it() { + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = + SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let registered_ring = full_person_ring_location(); + register_full_person_key(&authority, &session, ®istered_ring); + + let error = futures::executor::block_on(authority.account_alias( + &CallContext::default(), + &session, + AccountAliasAuthorityRequest { + calling_product_id: "peopl.dot".to_string(), + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: v01::DerivationIndex::Left(0), + }, + ring_location: v01::RingLocation { + chain_id: registered_ring.chain_id, + junctions: vec![], + }, + }, + )) + .unwrap_err(); + + assert_eq!(error, RingVrfError::KeyNotInRing); + } + + #[test] + fn direct_signing_rejects_registry_public_key_mismatched_with_wallet() { + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = + SigningHostRole::new_with_ring_resolver(platform, full_person_ring_resolver()); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let handle = v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(8), + }; + futures::executor::block_on(authority.ring_vrf_registry.register( + session.public_key, + handle.clone(), + full_person_ring_location(), + [0xFF; 32], + )) + .expect("synthetic registry entry persists"); + + let error = futures::executor::block_on(authority.ring_vrf_sign( + &CallContext::default(), + &session, + RingVrfSignAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + key_handle: handle, + message: b"reject mismatched registry state".to_vec(), + }, + )) + .unwrap_err(); + + assert!(matches!( + error, + RingVrfError::Unknown { reason } if reason.contains("does not match the active wallet") + )); + } + + #[test] + fn foreign_alias_prompts_but_foreign_proof_is_refused_without_a_prompt() { let platform = Arc::new(StubPlatform::default()); let authority = SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); @@ -1124,16 +1425,15 @@ mod tests { product_id: "other.dot".to_string(), suffix: v01::DerivationIndex::Left(0), }; - let ring_location = v01::RingLocation { - chain_id: [0x22; 32], - junctions: vec![v01::RingLocationJunction::PalletInstance(42)], - }; + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); let alias = futures::executor::block_on(authority.account_alias( &cx, &session, AccountAliasAuthorityRequest { calling_product_id: "myapp.dot".to_string(), + key_handle: full_person_key_handle(), context: context.clone(), ring_location: ring_location.clone(), }, @@ -1145,12 +1445,13 @@ mod tests { &session, CreateProofAuthorityRequest { calling_product_id: "myapp.dot".to_string(), + key_handle: full_person_key_handle(), context, ring_location, message: b"prove me".to_vec(), }, )); - assert_eq!(proof, Err(RingVrfError::Rejected)); + assert_eq!(proof, Err(RingVrfError::NotAllowlisted)); assert_eq!( platform .account_access_reviews @@ -1175,17 +1476,14 @@ mod tests { let cx = CallContext::default(); let request = AccountAliasAuthorityRequest { calling_product_id: "myapp.dot".to_string(), + key_handle: full_person_key_handle(), context: v01::ProductProofContext { product_id: "other.dot".to_string(), suffix: v01::DerivationIndex::Left(0), }, - ring_location: v01::RingLocation { - chain_id: [0x22; 32], - junctions: vec![v01::RingLocationJunction::CollectionId( - b"pop:polkadot.network/people ".to_vec(), - )], - }, + ring_location: full_person_ring_location(), }; + register_full_person_key(&authority, &session, &request.ring_location); futures::executor::block_on(authority.account_alias(&cx, &session, request.clone())) .expect("first alias succeeds"); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index 7b745bd41..42ab21a55 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -2,11 +2,14 @@ //! //! The resolver mirrors Nova's RFC-0004 implementation: it validates the //! requested Members pallet from runtime metadata, pins every storage read to -//! one finalized block, selects the full-person key before the lite-person key, -//! and returns the ring members, exponent, and revision from that snapshot. +//! one finalized block, and returns the ring members, exponent, and revision +//! from that snapshot. use std::sync::Arc; +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::product_account::derivation_index_bytes; +use crate::host_logic::sso::messages::RingVrfError; use async_trait::async_trait; use subxt::dynamic; use subxt::ext::scale_decode::DecodeAsType; @@ -14,46 +17,28 @@ use truapi::v01::{ProductProofContext, RingLocation, RingLocationJunction}; use verifiable::GenerateVerifiable; use verifiable::ring::RingDomainSize; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; -use zeroize::Zeroizing; - -use crate::chain_runtime::ChainRuntime; -use crate::host_logic::product_account::{ - derivation_index_bytes, derive_full_person_ring_vrf_entropy, - derive_lite_person_ring_vrf_entropy, -}; -use crate::host_logic::sso::messages::RingVrfError; const MEMBERS_PALLET: &str = "Members"; -const FULL_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people "; -const LITE_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people-lite"; type RingMember = ::Member; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum PersonKey { - Full, - Lite, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct MemberCandidate { - pub(super) key: PersonKey, - pub(super) member: [u8; 32], +pub(in crate::runtime) struct MemberCandidate { + pub(in crate::runtime) member: [u8; 32], } #[derive(Clone, Debug, PartialEq, Eq)] -pub(super) struct ResolvedRing { - pub(super) selected: MemberCandidate, - pub(super) ring_index: u32, - pub(super) ring_revision: u32, - pub(super) domain_size: RingDomainSize, - pub(super) members: Vec<[u8; 32]>, +pub(in crate::runtime) struct ResolvedRing { + pub(in crate::runtime) selected: MemberCandidate, + pub(in crate::runtime) ring_index: u32, + pub(in crate::runtime) ring_revision: u32, + pub(in crate::runtime) domain_size: RingDomainSize, + pub(in crate::runtime) members: Vec<[u8; 32]>, } #[async_trait] -pub(super) trait RingResolver: Send + Sync { - /// Validate the chain and Members pallet, returning the requested - /// collection (or the RFC-0004 full-person fallback). +pub(in crate::runtime) trait RingResolver: Send + Sync { + /// Validate the chain and Members pallet, returning the requested collection. async fn validate(&self, location: &RingLocation) -> Result<[u8; 32], RingVrfError>; /// Resolve a current, single-block ring snapshot and select the first @@ -65,12 +50,12 @@ pub(super) trait RingResolver: Send + Sync { ) -> Result; } -pub(super) struct ChainRingResolver { +pub(in crate::runtime) struct ChainRingResolver { chain: ChainRuntime, } impl ChainRingResolver { - pub(super) fn new(chain: ChainRuntime) -> Arc { + pub(in crate::runtime) fn new(chain: ChainRuntime) -> Arc { Arc::new(Self { chain }) } @@ -227,7 +212,7 @@ impl RingResolver for ChainRingResolver { } } -pub(super) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { +pub(in crate::runtime) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { let suffix = derivation_index_bytes(&context.suffix); let mut input = Vec::with_capacity(9 + context.product_id.len() + suffix.len()); input.extend_from_slice(b"product/"); @@ -237,14 +222,9 @@ pub(super) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { blake2b_256(&input, None) } -pub(super) fn person_entropy(root_entropy: &[u8], key: PersonKey) -> Zeroizing<[u8; 32]> { - Zeroizing::new(match key { - PersonKey::Full => derive_full_person_ring_vrf_entropy(root_entropy), - PersonKey::Lite => derive_lite_person_ring_vrf_entropy(root_entropy), - }) -} - -pub(super) fn member_from_entropy(entropy: &[u8; 32]) -> Result<[u8; 32], RingVrfError> { +pub(in crate::runtime) fn member_from_entropy( + entropy: &[u8; 32], +) -> Result<[u8; 32], RingVrfError> { use parity_scale_codec::Encode; let secret = BandersnatchVrfVerifiable::new_secret(*entropy); @@ -259,7 +239,17 @@ pub(super) fn member_from_entropy(entropy: &[u8; 32]) -> Result<[u8; 32], RingVr }) } -pub(super) fn alias_from_entropy( +pub(in crate::runtime) fn sign_from_entropy( + entropy: &[u8; 32], + message: &[u8], +) -> Result, RingVrfError> { + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + BandersnatchVrfVerifiable::sign(&secret, message) + .map(|signature| signature.to_vec()) + .map_err(unknown) +} + +pub(in crate::runtime) fn alias_from_entropy( entropy: &[u8; 32], context: &[u8], ) -> Result<[u8; 32], RingVrfError> { @@ -267,7 +257,7 @@ pub(super) fn alias_from_entropy( BandersnatchVrfVerifiable::alias_in_context(&secret, context).map_err(unknown) } -pub(super) fn create_proof( +pub(in crate::runtime) fn create_proof( entropy: &[u8; 32], resolved: &ResolvedRing, context: &[u8], @@ -295,14 +285,6 @@ pub(super) fn create_proof( Ok((proof.to_vec(), alias)) } -pub(super) fn key_for_collection(collection: &[u8; 32]) -> PersonKey { - if collection == &LITE_PERSON_COLLECTION { - PersonKey::Lite - } else { - PersonKey::Full - } -} - fn collection_id(location: &RingLocation) -> Result<[u8; 32], RingVrfError> { location .junctions @@ -311,12 +293,10 @@ fn collection_id(location: &RingLocation) -> Result<[u8; 32], RingVrfError> { RingLocationJunction::CollectionId(value) => Some(value), RingLocationJunction::PalletInstance(_) => None, }) - .map_or(Ok(FULL_PERSON_COLLECTION), |value| { - value - .as_slice() - .try_into() - .map_err(|_| RingVrfError::RingNotFound) - }) + .ok_or(RingVrfError::RingNotFound)? + .as_slice() + .try_into() + .map_err(|_| RingVrfError::RingNotFound) } fn pallet_instance(location: &RingLocation) -> Option { @@ -442,19 +422,12 @@ mod tests { } #[test] - fn collection_selects_corresponding_person_key() { - assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); - assert_eq!(key_for_collection(&LITE_PERSON_COLLECTION), PersonKey::Lite); - assert_eq!(key_for_collection(&[0xff; 32]), PersonKey::Full); - } - - #[test] - fn missing_collection_defaults_to_full_personhood() { + fn missing_collection_is_not_a_ring_location() { let location = RingLocation { chain_id: [0; 32], junctions: vec![RingLocationJunction::PalletInstance(42)], }; - assert_eq!(collection_id(&location), Ok(FULL_PERSON_COLLECTION)); + assert_eq!(collection_id(&location), Err(RingVrfError::RingNotFound)); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index f1b822ad8..23c570ee6 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -27,21 +27,19 @@ use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; -use crate::host_logic::product_account::{ - ProductAccountError, derive_identity_keypair, derive_root_keypair_from_entropy, - product_public_key_to_address, -}; #[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::product_account::derive_sr25519_hard_path; use crate::host_logic::product_account::{ - derive_lite_person_ring_vrf_entropy, derive_sr25519_hard_path, + ProductAccountError, derive_identity_keypair, derive_ring_vrf_domain_entropy, + derive_root_keypair_from_entropy, product_public_key_to_address, }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, RingVrfError, - RingVrfProofResponse, SignRawLegacyResponse, SignVrfResponse, SigningPayloadResponseData, - SigningRequest, SigningResponse, SsoAllocatableResource, SsoAllocatedResource, - SsoAllocationOutcome, SsoResponseCode, build_outgoing_request_statement, + RingVrfProofResponse, RingVrfSignResponse, SignRawLegacyResponse, SignVrfResponse, + SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, + SsoAllocatedResource, SsoAllocationOutcome, SsoResponseCode, build_outgoing_request_statement, build_signed_session_response_statement, decode_incoming_sso_request, v1, }; use crate::host_logic::sso::pairing::{ @@ -52,7 +50,8 @@ use crate::host_logic::sso::pairing::{ use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; use crate::runtime::authority::{ AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, - CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, + CreateTransactionAuthorityRequest, ListRingVrfKeysAuthorityRequest, ProductAuthority, + RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use crate::runtime::services::RuntimeServices; @@ -476,6 +475,15 @@ fn remote_response_result(message: &RemoteMessageData) -> ResponseResult { v1::RemoteMessage::RingVrfProofResponse(response) => { response.payload.as_ref().err().map(ring_vrf_error_reason) } + v1::RemoteMessage::RegisterRingVrfKeyResponse(response) => { + response.payload.as_ref().err().map(ring_vrf_error_reason) + } + v1::RemoteMessage::ListRingVrfKeysResponse(response) => { + response.payload.as_ref().err().map(ring_vrf_error_reason) + } + v1::RemoteMessage::RingVrfSignResponse(response) => { + response.payload.as_ref().err().map(ring_vrf_error_reason) + } v1::RemoteMessage::ResourceAllocationResponse(response) => { return resource_allocation_payload_result(&response.payload, &[]); } @@ -602,6 +610,9 @@ fn ring_vrf_error_reason(error: &RingVrfError) -> String { match error { RingVrfError::RingNotFound => "RingNotFound".to_string(), RingVrfError::NotMember => "NotMember".to_string(), + RingVrfError::KeyNotRegistered => "KeyNotRegistered".to_string(), + RingVrfError::KeyNotInRing => "KeyNotInRing".to_string(), + RingVrfError::NotAllowlisted => "NotAllowlisted".to_string(), RingVrfError::Rejected => "Rejected".to_string(), RingVrfError::Unknown { reason } => format!("Unknown: {reason}"), } @@ -655,6 +666,27 @@ async fn answer_remote_message( payload, }) } + v1::RemoteMessage::RegisterRingVrfKeyRequest(request) => { + let payload = register_ring_vrf_key_response(signing_host, request).await; + v1::RemoteMessage::RegisterRingVrfKeyResponse(messages::RegisterRingVrfKeyResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::ListRingVrfKeysRequest(request) => { + let payload = list_ring_vrf_keys_response(signing_host, request).await; + v1::RemoteMessage::ListRingVrfKeysResponse(messages::ListRingVrfKeysResponse { + responding_to: message_id, + payload, + }) + } + v1::RemoteMessage::RingVrfSignRequest(request) => { + let payload = ring_vrf_sign_response(signing_host, request).await; + v1::RemoteMessage::RingVrfSignResponse(RingVrfSignResponse { + responding_to: message_id, + payload, + }) + } v1::RemoteMessage::ResourceAllocationRequest(request) => { let answer = resource_allocation_response(services, signing_host, request).await; if let Err(reason) = &answer.payload { @@ -732,6 +764,9 @@ async fn answer_remote_message( | v1::RemoteMessage::SignResponse(_) | v1::RemoteMessage::RingVrfAliasResponse(_) | v1::RemoteMessage::RingVrfProofResponse(_) + | v1::RemoteMessage::RegisterRingVrfKeyResponse(_) + | v1::RemoteMessage::ListRingVrfKeysResponse(_) + | v1::RemoteMessage::RingVrfSignResponse(_) | v1::RemoteMessage::ResourceAllocationResponse(_) | v1::RemoteMessage::CreateTransactionResponse(_) | v1::RemoteMessage::SignRawLegacyResponse(_) @@ -810,14 +845,22 @@ async fn resource_allocation_response( SsoAllocatableResource::SmartContractAllowance(_) => { Ok(SsoAllocationOutcome::NotAvailable) } - SsoAllocatableResource::AutoSigning => signing_host - .product_subtree_secret(&request.calling_product_id) - .map(|product_root_private_key| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { + SsoAllocatableResource::AutoSigning => (|| -> Result<_, AllowanceAllocationError> { + let product_root_private_key = signing_host + .product_subtree_secret(&request.calling_product_id) + .map_err(AllowanceAllocationError::Authority)?; + let root_entropy = signing_host.root_entropy()?; + let ring_vrf_domain_entropy = + derive_ring_vrf_domain_entropy(&root_entropy, &request.calling_product_id) + .map_err(super::product_authority_error) + .map_err(AllowanceAllocationError::Authority)?; + Ok(SsoAllocationOutcome::Allocated( + SsoAllocatedResource::AutoSigning { product_root_private_key, - }) - }) - .map_err(AllowanceAllocationError::Authority), + ring_vrf_domain_entropy, + }, + )) + })(), }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -864,7 +907,10 @@ pub(super) async fn allocate_statement_store_allowance( let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; let target = allowance.public.to_bytes(); - let bandersnatch = derive_lite_person_ring_vrf_entropy(&entropy); + let session = signing_host + .current_session() + .ok_or(AuthorityError::Disconnected)?; + let bandersnatch = *signing_host.reserved_lite_person_entropy(&session)?; let rpc = statement_allowance::rpc::RpcClient::new( services .statement_store @@ -959,7 +1005,10 @@ pub(super) async fn allocate_bulletin_allowance( ); let metadata = fetch_metadata(&people_rpc).await?; let chain_state = fetch_chain_state(&people_rpc).await?; - let bandersnatch = derive_lite_person_ring_vrf_entropy(&entropy); + let session = signing_host + .current_session() + .ok_or(AuthorityError::Disconnected)?; + let bandersnatch = *signing_host.reserved_lite_person_entropy(&session)?; let current = statement_allowance::ring::read_current_ring_index(&people_rpc).await?; let ring = find_including_ring(&people_rpc, &metadata, bandersnatch, current) .await? @@ -1201,6 +1250,7 @@ async fn account_alias_response( &session, AccountAliasAuthorityRequest { calling_product_id: request.calling_product_id, + key_handle: request.key_handle, context: request.context, ring_location: request.ring_location, }, @@ -1222,6 +1272,7 @@ async fn create_proof_response( &session, CreateProofAuthorityRequest { calling_product_id: request.calling_product_id, + key_handle: request.key_handle, context: request.context, ring_location: request.ring_location, message: request.message, @@ -1230,6 +1281,66 @@ async fn create_proof_response( .await } +async fn register_ring_vrf_key_response( + signing_host: &Arc, + request: messages::RegisterRingVrfKeyRequest, +) -> Result { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + signing_host + .register_ring_vrf_key( + &CallContext::default(), + &session, + RegisterRingVrfKeyAuthorityRequest { + calling_product_id: request.calling_product_id, + index: request.index, + ring: request.ring, + }, + ) + .await +} + +async fn list_ring_vrf_keys_response( + signing_host: &Arc, + request: messages::ListRingVrfKeysRequest, +) -> Result, RingVrfError> { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + signing_host + .list_ring_vrf_keys( + &CallContext::default(), + &session, + ListRingVrfKeysAuthorityRequest { + calling_product_id: request.calling_product_id, + owner: request.owner, + disclosure: request.disclosure, + }, + ) + .await +} + +async fn ring_vrf_sign_response( + signing_host: &Arc, + request: messages::RingVrfSignRequest, +) -> Result, RingVrfError> { + let session = signing_host + .current_session() + .ok_or_else(disconnected_ring_vrf)?; + signing_host + .ring_vrf_sign( + &CallContext::default(), + &session, + RingVrfSignAuthorityRequest { + calling_product_id: request.calling_product_id, + key_handle: request.key_handle, + message: request.message, + }, + ) + .await +} + fn disconnected_ring_vrf() -> RingVrfError { RingVrfError::Unknown { reason: "signing host session is not active".to_string(), @@ -1332,6 +1443,10 @@ mod tests { "alias-1".to_string(), v1::RemoteMessage::RingVrfAliasRequest(messages::RingVrfAliasRequest { calling_product_id: "myapp.dot".to_string(), + key_handle: api::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: api::DerivationIndex::Left(0), + }, context: api::ProductProofContext { product_id: "other.dot".to_string(), suffix: api::DerivationIndex::Left(0), @@ -1490,6 +1605,9 @@ mod tests { let expected_secret = signing_host .product_subtree_secret("myapp.dot") .expect("product subtree secret derives"); + let expected_ring_vrf_domain_entropy = + derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot") + .expect("ring-VRF domain entropy derives"); let response = futures::executor::block_on(answer_remote_message( &services, @@ -1512,6 +1630,7 @@ mod tests { vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: expected_secret, + ring_vrf_domain_entropy: expected_ring_vrf_domain_entropy, } )] ); diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 37dfe6f02..e1f06ea3e 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -26,6 +26,10 @@ use truapi::{CancellationReason, CancellationToken}; /// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: /// const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// The statement store keeps only the highest-priority statement on a channel. +/// Expiry's lower 32 bits are the tie-breaker for statements expiring in the +/// same second, so every submission from this process must advance them. +static LAST_SSO_STATEMENT_EXPIRY: Mutex = Mutex::new(0); /// Disconnect reason reported when the local session logs out mid-request. pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; /// Disconnect reason reported when the paired signing host announces a disconnect. @@ -424,7 +428,17 @@ pub(super) fn sso_message_id() -> String { /// high 32 bits, seven days from now. pub(super) fn fresh_statement_expiry() -> u64 { let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); - timestamp << 32 + let expiry_floor = timestamp << 32; + let mut last = LAST_SSO_STATEMENT_EXPIRY + .lock() + .expect("SSO statement expiry mutex poisoned"); + let expiry = next_statement_expiry(*last, expiry_floor); + *last = expiry; + expiry +} + +fn next_statement_expiry(last: u64, expiry_floor: u64) -> u64 { + expiry_floor.max(last.saturating_add(1)) } #[cfg(test)] @@ -448,6 +462,17 @@ mod tests { assert!(second.bytes().all(is_nanoid_safe_byte)); } + #[test] + fn fresh_statement_expiry_is_strictly_monotonic() { + let first = fresh_statement_expiry(); + let second = fresh_statement_expiry(); + + assert!(second > first); + let floor = 42_u64 << 32; + assert_eq!(next_statement_expiry(floor, floor), floor + 1); + assert_eq!(next_statement_expiry(floor + 7, floor), floor + 8); + } + fn is_nanoid_safe_byte(value: u8) -> bool { value.is_ascii_alphanumeric() || value == b'_' || value == b'-' } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index ba9f71e8e..a195810b1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -2,7 +2,7 @@ //! //! Mirrors how an iOS/web client obtains statement-store allowance from the real //! People chain: build the `Resources.set_statement_store_account` call, prove -//! LitePeople membership with the RFC-0022 `peopl.dot` index-1 ring-VRF key, +//! LitePeople membership with the caller's registry-selected ring-VRF key, //! and submit the resulting unsigned General (v5) extrinsic. Native only //! (needs the `verifiable` prover and live chain reads). diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index a9331624d..f36d97a40 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -138,7 +138,7 @@ pub(crate) enum SsoResponseScript { /// Peer acknowledges the request and replies with `response`. Success { session: SessionInfo, - response: RemoteMessage, + response: Box, }, /// Peer acknowledges the request and then sends `Disconnected`. PeerDisconnect { session: SessionInfo }, @@ -371,7 +371,7 @@ pub(crate) fn sso_success_response_script( ) -> SsoResponseScript { SsoResponseScript::Success { session: session.clone(), - response, + response: Box::new(response), } } @@ -619,6 +619,10 @@ pub(crate) fn ring_location_fixture() -> v01::RingLocation { /// Contextual-alias request fixture for `product_id`. pub(crate) fn account_alias_request(product_id: &str) -> HostAccountGetAliasRequest { HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: product_proof_context(product_id), ring_location: ring_location_fixture(), }) @@ -627,6 +631,10 @@ pub(crate) fn account_alias_request(product_id: &str) -> HostAccountGetAliasRequ /// Ring-VRF proof request fixture for `product_id`. pub(crate) fn create_proof_request(product_id: &str) -> HostAccountCreateProofRequest { HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: product_proof_context(product_id), ring_location: ring_location_fixture(), message: vec![4, 5, 6], @@ -952,6 +960,15 @@ fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> Remot RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(response)) => { response.responding_to = message_id.to_string(); } + RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse(response)) => { + response.responding_to = message_id.to_string(); + } RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) => { response.responding_to = message_id.to_string(); } @@ -1007,7 +1024,7 @@ fn sso_scripted_responses( 4 => match script { SsoResponseScript::Success { session, response } => { let (_, request) = submitted_sso_request(&sent, &session); - let response = retarget_sso_response(response, &request.message_id); + let response = retarget_sso_response(*response, &request.message_id); Some(( new_statements_frame( "peer-sub", diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 4ce821934..aeb3eec52 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -180,9 +180,13 @@ fn version_index(version: u8) -> u8 { } #[test] -fn account_proof_declined_confirmation_returns_rejected() { +fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { let core = make_core(); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: v01::ProductProofContext { product_id: "myapp.dot".to_string(), suffix: v01::DerivationIndex::Left(0), @@ -207,10 +211,9 @@ fn account_proof_declined_confirmation_returns_rejected() { ); assert_eq!(response.request_id, "p:account-proof"); assert_eq!(response.payload.id, ids.response_id); - // The wire-shape platform declines the confirmation prompt, so the proof - // request maps to a `Rejected` domain error in the standard Result-Err envelope. + // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign key. let expected = versioned_result_err_payload(account::HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::Rejected, + v01::HostAccountCreateProofError::NotAllowlisted, )); assert_eq!(response.payload.value, expected); } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index dd8a448c2..5bebd1030 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -4,7 +4,11 @@ use crate::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, - HostAccountGetRequest, HostAccountGetResponse, HostAccountSignVrfError, + HostAccountGetRequest, HostAccountGetResponse, HostAccountListRingVrfKeysError, + HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, + HostAccountRegisterRingVrfKeyError, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignError, + HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, @@ -70,17 +74,27 @@ pub trait Account: Send + Sync { /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// /// const PEOPLE_COLLECTION_ID = - /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; + /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465" as const; + /// const keyHandle = { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: { tag: "Left" as const, value: 0 }, + /// }; + /// const ringLocation = { + /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// junctions: [ + /// { tag: "CollectionId" as const, value: PEOPLE_COLLECTION_ID }, + /// ], + /// }; + /// const registration = await truapi.account.registerRingVrfKey({ + /// index: keyHandle.derivationIndex, + /// ring: ringLocation, + /// }); + /// assert(registration.isOk(), "registerRingVrfKey failed:", registration); /// /// const result = await truapi.account.getAccountAlias({ + /// keyHandle, /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, - /// ringLocation: { - /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, - /// junctions: [ - /// { tag: "PalletInstance", value: 67 }, - /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, - /// ], - /// }, + /// ringLocation, /// }); /// assert(result.isOk(), "getAccountAlias failed:", result); /// console.log("account alias:", result.value); @@ -94,7 +108,7 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } - /// Generate a ring VRF proof; the host selects the member key for the ring. + /// Generate a ring VRF proof with an explicitly registered member key. /// /// ```ts /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; @@ -103,18 +117,28 @@ pub trait Account: Send + Sync { /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; /// /// const result = await truapi.account.createAccountProof({ + /// keyHandle: { + /// dotNsIdentifier: "peopl.dot", + /// derivationIndex: { tag: "Left", value: 1 }, + /// }, /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, /// ringLocation: { /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// junctions: [ - /// { tag: "PalletInstance", value: 67 }, /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, /// ], /// }, /// message: "0x48656c6c6f", /// }); - /// assert(result.isOk(), "createAccountProof failed:", result); - /// console.log("account proof created:", result.value); + /// assert(result.isErr(), "foreign createAccountProof unexpectedly succeeded:", result); + /// assert( + /// result.error.tag === "Domain" && + /// result.error.value.tag === "V1" && + /// result.error.value.value.tag === "NotAllowlisted", + /// "foreign createAccountProof did not return NotAllowlisted:", + /// result, + /// ); + /// console.log("foreign account proof refused without prompting"); /// ``` #[wire(request_id = 26)] async fn create_account_proof( @@ -156,6 +180,78 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Register a ring-VRF key owned by the calling product. + /// + /// ```ts + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// + /// const PEOPLE_COLLECTION_ID = + /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; + /// + /// const result = await truapi.account.registerRingVrfKey({ + /// index: { tag: "Left", value: 0 }, + /// ring: { + /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// junctions: [ + /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, + /// ], + /// }, + /// }); + /// assert(result.isOk(), "registerRingVrfKey failed:", result); + /// console.log("ring VRF public key:", result.value); + /// ``` + #[wire(request_id = 166)] + async fn register_ring_vrf_key( + &self, + _cx: &CallContext, + _request: HostAccountRegisterRingVrfKeyRequest, + ) -> Result> + { + Err(CallError::unavailable()) + } + + /// List registered ring-VRF keys owned by a product. + /// + /// ```ts + /// const result = await truapi.account.listRingVrfKeys({ + /// owner: "truapi-playground.dot", + /// disclosure: "PublicKey", + /// }); + /// assert(result.isOk(), "listRingVrfKeys failed:", result); + /// console.log("registered ring VRF keys:", result.value); + /// ``` + #[wire(request_id = 168)] + async fn list_ring_vrf_keys( + &self, + _cx: &CallContext, + _request: HostAccountListRingVrfKeysRequest, + ) -> Result> + { + Err(CallError::unavailable()) + } + + /// Sign bytes directly with a registered ring-VRF member key. + /// + /// ```ts + /// const result = await truapi.account.ringVrfSign({ + /// keyHandle: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: { tag: "Left", value: 0 }, + /// }, + /// message: "0x48656c6c6f", + /// }); + /// assert(result.isOk(), "ringVrfSign failed:", result); + /// console.log("ring VRF signature:", result.value); + /// ``` + #[wire(request_id = 170)] + async fn ring_vrf_sign( + &self, + _cx: &CallContext, + _request: HostAccountRingVrfSignRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + /// List non-product accounts the user owns. /// /// Current hosts do not expose non-product accounts, so the list is empty. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df037..9ed39b741 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -33,12 +33,12 @@ pub mod latest { pub use crate::v01::{ AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, DerivationIndex, GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, - ProductAccountId, ProductProofContext, RawPayload, RemotePermission, + ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, - RemoteStatementStoreSubscribeRequest, RingLocation, RuntimeApi, RuntimeSpec, RuntimeType, - SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeVariant, TxPayloadExtension, + RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, + RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, + StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, TxPayloadExtension, }; /// Latest payload type of a versioned envelope. @@ -50,6 +50,15 @@ pub mod latest { /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; + /// Ring-VRF key registration result. + pub type HostAccountRegisterRingVrfKeyResponse = + LatestOf; + /// Ring-VRF registry listing result. + pub type HostAccountListRingVrfKeysResponse = + LatestOf; + /// Direct ring-VRF key signing result. + pub type HostAccountRingVrfSignResponse = + LatestOf; /// Legacy account listing result. pub type HostGetLegacyAccountsResponse = LatestOf; diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index fe5e3c535..f43afcdf1 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -90,14 +90,66 @@ pub struct ProductProofContext { /// Request to create a ring VRF proof. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountCreateProofRequest { + /// Ring-VRF key handle naming the member key to use. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring to generate the proof against; the host selects the member key. + /// Ring to generate the proof against. pub ring_location: RingLocation, /// Opaque message bound into the proof. pub message: Vec, } +/// Ring-VRF member public key. +pub type RingVrfPublicKey = [u8; 32]; + +/// A registered ring-VRF key entry. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisteredRingVrfKey { + /// Stable public name of the key. + pub handle: ProductAccountId, + /// Rings the owning product declared this key for. + pub rings: Vec, + /// Present when the caller owns the key or requested/granted disclosure. + pub public_key: Option, +} + +/// How much of a registry entry the caller asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum RingVrfKeyDisclosure { + /// Handle and declared rings only. + Anonymized, + /// Include the member public key. + PublicKey, +} + +/// Request to register a ring-VRF key owned by the calling product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountRegisterRingVrfKeyRequest { + /// Key derivation index within the caller's ring-VRF domain. + pub index: DerivationIndex, + /// Ring this key is declared for. + pub ring: RingLocation, +} + +/// Request to list registered ring-VRF keys for an owner product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountListRingVrfKeysRequest { + /// Product whose registry entries should be listed. + pub owner: String, + /// Disclosure level requested by the caller. + pub disclosure: RingVrfKeyDisclosure, +} + +/// Request to sign bytes with a registered ring-VRF key. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountRingVrfSignRequest { + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Opaque message to sign. + pub message: Vec, +} + /// User's authentication state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub enum HostAccountConnectionStatusSubscribeItem { @@ -156,8 +208,14 @@ pub enum HostAccountGetError { pub enum HostAccountCreateProofError { /// Ring not available at the specified location. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The key handle is not registered. + KeyNotRegistered, + /// The key handle is not registered for the requested ring. + KeyNotInRing, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, /// User or host rejected. Rejected, /// Catch-all. @@ -172,8 +230,12 @@ pub enum HostAccountCreateProofError { pub enum HostAccountGetAliasError { /// Ring not available at the specified location. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The key handle is not registered. + KeyNotRegistered, + /// The key handle is not registered for the requested ring. + KeyNotInRing, /// User or host rejected. Rejected, /// Catch-all. @@ -221,12 +283,62 @@ pub enum HostGetUserIdError { /// Request to retrieve the contextual alias for a context and ring. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountGetAliasRequest { + /// Ring-VRF key handle naming the member key to use. + pub key_handle: ProductAccountId, /// Product-scoped context to derive the alias for. pub context: ProductProofContext, /// Ring whose member key the host should use; matches `create_proof`. pub ring_location: RingLocation, } +/// Error returned when ring-VRF key registration fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountRegisterRingVrfKeyError { + /// User is not logged in. + NotConnected, + /// Ring not available at the specified location. + RingNotFound, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Error returned when listing ring-VRF keys fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountListRingVrfKeysError { + /// User is not logged in. + NotConnected, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Error returned when direct ring-VRF key signing fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountRingVrfSignError { + /// User is not logged in. + NotConnected, + /// The key handle is not registered. + KeyNotRegistered, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + /// Response containing a ring VRF proof and the values needed to verify it /// against a downstream precompile. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index d2579f746..fd0bfd607 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -12,6 +12,15 @@ truapi_macros::versioned_type! { pub enum HostAccountCreateProofRequest { V1 => v01::HostAccountCreateProofRequest } pub enum HostAccountCreateProofResponse { V1 => v01::HostAccountCreateProofResponse } pub enum HostAccountCreateProofError { V1 => v01::HostAccountCreateProofError } + pub enum HostAccountRegisterRingVrfKeyRequest { V1 => v01::HostAccountRegisterRingVrfKeyRequest } + pub enum HostAccountRegisterRingVrfKeyResponse { V1 => v01::RingVrfPublicKey } + pub enum HostAccountRegisterRingVrfKeyError { V1 => v01::HostAccountRegisterRingVrfKeyError } + pub enum HostAccountListRingVrfKeysRequest { V1 => v01::HostAccountListRingVrfKeysRequest } + pub enum HostAccountListRingVrfKeysResponse { V1 => Vec } + pub enum HostAccountListRingVrfKeysError { V1 => v01::HostAccountListRingVrfKeysError } + pub enum HostAccountRingVrfSignRequest { V1 => v01::HostAccountRingVrfSignRequest } + pub enum HostAccountRingVrfSignResponse { V1 => Vec } + pub enum HostAccountRingVrfSignError { V1 => v01::HostAccountRingVrfSignError } pub enum HostAccountSignVrfRequest { V1 => v01::HostAccountSignVrfRequest } pub enum HostAccountSignVrfResponse { V1 => v01::VrfSignature } pub enum HostAccountSignVrfError { V1 => v01::HostAccountSignVrfError }