diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md new file mode 100644 index 00000000..b6846de3 --- /dev/null +++ b/docs/rfcs/0024-personhood-as-product.md @@ -0,0 +1,398 @@ +--- +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 **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. + +## 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 every change to how a person key is derived, registered, renewed, or recovered is a Host release. + +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 obstacle** is that 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 | + +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. + +## 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 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 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 + +### 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}`. 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 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, 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]; + +/// 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, 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. + +### 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. + +```rust +fn create_account_proof( + key_handle: ProductAccountId, + context: ProductProofContext, + ring: RingLocation, + message: Bytes, +) -> Result; + +fn get_account_alias( + key_handle: ProductAccountId, + 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 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. + +`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 + +```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, + /// `key_handle` is foreign and its owner has not allowlisted the caller. + NotAllowlisted, + Rejected, + Unknown { reason: String }, +} + +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. It works because `peopl.dot` has allowlisted `game.dot`; see [Using a foreign key](#using-a-foreign-key-means-trusting-the-caller). + +```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 +``` + +**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 + +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 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. Each context's alias account follows from the context alone, 1:1, through RFC-0004's `product_account_id_for_proof_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. + +### Using a foreign key means trusting the caller + +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. + +**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 `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 `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. + +### The alias flow + +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 (`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. + +### 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 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 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), 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 }`. 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 touches the caller's own domain, and consuming a foreign key is governed by the permission model. + +`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 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 | +| -------------------------------------------------------- | -------------- | ------------------------------------------- | +| `account_create_account_proof`, `account_ring_vrf_sign` | permissionless | **owner's manifest allowlist, or refused** | + +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. + +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 + +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. `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. + +> **A Host MUST NOT derive a member secret for a `(product, index)` pair absent from its registry.** + +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 (see [Prior Art](#prior-art-and-references)) and is not restated here. + +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 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, +} +``` + +## 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. +- **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. +- **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)`. + +## 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. +- **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 `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 `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 — `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*](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. + +## Unresolved Questions + +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 `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 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. diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index 30ddd562..91a93514 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 0fac5923..eca6b900 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 b11224ef..3fdda36e 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 98d2aa1c..e835d0dc 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 7360d042..b6555f1d 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 50465b2b..73bb0a59 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 e0282142..f84646f3 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 c457733b..b0cc3a4d 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 e9adc87f..4db0dd33 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 b4dff38d..cdfe4029 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 00000000..0791de6b --- /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 57fede85..a19ab6eb 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 ef2c032a..46e0dd5d 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 fab72a3b..22401881 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 cbf5e2e8..d6e3ab11 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 dffb7110..02509c02 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 1cefce40..e2e42525 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 7360d042..b6555f1d 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 d226ea5e..4f7ce5a2 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 94e2da35..fcff402e 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 942f4c15..f7a37954 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 6b28ca73..68f9c09d 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 51f3da5f..2ce69579 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 432f4977..d9911122 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 632cd008..58ec8f3f 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 c1080ad1..cf1cd08f 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 b642f470..8100db35 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 b928eacd..13494c0c 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 00000000..80be57f0 --- /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 6b85647a..44db144f 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 7b745bd4..42ab21a5 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 f1b822ad..23c570ee 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 37dfe6f0..e1f06ea3 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 ba9f71e8..a195810b 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 a9331624..f36d97a4 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 4ce82193..aeb3eec5 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 dd8a448c..5bebd103 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 a138df03..9ed39b74 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 fe5e3c53..f43afcdf 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 d2579f74..fd0bfd60 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 }