diff --git a/.changeset/foreign-ring-vrf-key-prompt.md b/.changeset/foreign-ring-vrf-key-prompt.md new file mode 100644 index 000000000..221064517 --- /dev/null +++ b/.changeset/foreign-ring-vrf-key-prompt.md @@ -0,0 +1,11 @@ +--- +"@parity/truapi-host": minor +--- + +A product using another product's registered ring-VRF key reaches the user as a +`ForeignRingVrfKey` confirmation, naming the calling product, the owning product, +and whether the key would produce a context-scoped proof or an unscoped member-key +signature. Hosts must present it per call and must not persist the answer, so one +approval never becomes a standing grant over the owner's key. A declined request is +`Rejected`; `NotAllowlisted` is now reserved for an owner's manifest allowlist +refusing the caller. diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 39d23e8b1..d89edf0c3 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -219,8 +219,14 @@ class MyBridge(private val webView: WebView) : HostBridge { // One confirmation callback for every reviewed core action. Switch on the // review variant (SignPayload / SignRaw / CreateTransaction / AccountAlias / - // ResourceAllocation / PreimageSubmit / ...) to render the prompt with its - // typed fields. + // ResourceAllocation / PreimageSubmit / ForeignRingVrfKey / ...) to render the + // prompt with its typed fields. + // + // ForeignRingVrfKey is the one variant a host must not remember: it authorizes + // one product's use of another product's ring-VRF key for a single message, and + // the core asks again on every call. Show the calling product, the owning + // product, and whether the output is a context-scoped proof or an unscoped, + // linkable member-key signature. override suspend fun confirmUserAction(review: UserConfirmationReview): Boolean { return withContext(Dispatchers.Main) { /* show prompt; */ false } } diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index acb3afced..03ad06c66 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -18,7 +18,7 @@ RFC-0004 makes the Host pick a ring VRF member key on the caller's behalf, with 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. +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. No manifest carries that field yet, so the interim gate is a per-call prompt that is never persisted — a weaker gate, accepted so the flow works at all, 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 @@ -48,7 +48,7 @@ Both are specified here because the app-internal flows need both, but a reviewer - **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. +- **Host developers** — implement the registry, drop the compiled-in member-key selection, gate foreign-key proofs and signatures on the owner's allowlist and, until a manifest carries one, on a per-call prompt that is not persisted, 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. @@ -168,7 +168,8 @@ enum HostAccountCreateProofError { KeyNotRegistered, /// `key_handle` is registered, but not for the requested `ring`. KeyNotInRing, - /// `key_handle` is foreign and its owner has not allowlisted the caller. + /// `key_handle` is foreign and its owner's allowlist refuses the caller. A + /// request the user declines is `Rejected`, not this. NotAllowlisted, Rejected, Unknown { reason: String }, @@ -177,7 +178,8 @@ enum HostAccountCreateProofError { enum RingVrfSignErr { NotConnected, KeyNotRegistered, - /// `key_handle` is foreign and its owner has not allowlisted the caller. + /// `key_handle` is foreign and its owner's allowlist refuses the caller. A + /// request the user declines is `Rejected`, not this. NotAllowlisted, Rejected, Unknown { reason: String }, @@ -235,9 +237,11 @@ The concrete consequence, worth stating because it is not obvious: a product hol 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`. +> A Host MUST NOT let `account_create_account_proof` or `account_ring_vrf_sign` use a foreign `key_handle` unless the key's owning product has allowlisted the calling product in its manifest, returning `NotAllowlisted` when the allowlist refuses. Until a manifest carries that field, a Host MUST put the request to the user, per call, and MUST NOT persist the answer; a declined request is `Rejected`. -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. +The allowlist is the durable authorization for these two calls, and the per-call prompt is an interim stand-in for it, not a parallel path: the moment an owner's manifest can express the allowlist, the allowlist decides and no prompt is shown. + +**The prompt is a weaker gate than the allowlist, deliberately accepted.** Consenting to an opaque `message` is not meaningful consent, and the risk is one only the key's owner is positioned to evaluate — a user cannot tell a personhood proof for an airdrop from one that rebinds an alias. What the prompt does buy is that the use is *visible* and *deliberate* rather than silent, and that the interim state is a working cross-product flow instead of a dead one. Two properties keep it honest: the answer is never persisted, so every use is a fresh decision; and the review names the calling product, the owning product, and which of the two outputs is being produced, so the linkable-signature case is not presented as the narrower proof case. 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. @@ -293,13 +297,15 @@ The calls this RFC touches fall into **two regimes with different rules**, and t 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.** +**Regime B — producing a proof or a signature. Allowlist, with a per-call prompt until one exists.** + +| Call | Own key | Foreign | +| ------------------------------------------------------- | -------------- | ------------------------------------------------------------- | +| `account_create_account_proof`, `account_ring_vrf_sign` | permissionless | **owner's manifest allowlist; until then a per-call prompt** | -| 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 the whole gate rather than an optimization, so the interim prompt differs from Regime A's in one way that matters: it is **never persisted**. Regime A's prompt establishes a standing relationship between two products; this one authorizes a single message, because that is the granularity at which the risk exists. A host that persisted it would convert one decision into a standing grant over every later use of the owner's key. -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. +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. diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 8067bb51e..7b498089c 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -211,6 +211,12 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable { func confirmUserAction(review: UserConfirmationReview) async throws -> Bool { // Switch on the review variant (.signPayload, .createTransaction, ...) // to render the confirmation prompt with its typed fields. + // + // `.foreignRingVrfKey` is the one variant a host must not remember: it + // authorizes one product's use of another product's ring-VRF key for a + // single message, and the core asks again on every call. Show the calling + // product, the owning product, and whether the output is a context-scoped + // proof or an unscoped, linkable member-key signature. await MainActor.run { /* render review; */ false } } diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index 08ecbf22d..95c943a5b 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -780,6 +780,101 @@ public func FfiConverterTypeCreateProofReview_lower(_ value: CreateProofReview) } +/** + * Review shown before a product uses another product's registered ring-VRF key + * (RFC-0024). + * + * Both outputs are bearer tokens the caller can spend without the Host: a proof + * for its context's alias, a signature for the key itself. `message` is opaque, + * so nothing here establishes what the result will authorize — the decision is + * whether the calling product is trusted with the owner's key at all. + */ +public struct ForeignRingVrfKeyReview: Equatable, Hashable { + /** + * Product making the request. + */ + public var callingProductId: String + /** + * Registered key the request would use, owned by another product. + */ + public var keyHandle: ProductAccountId + /** + * What the key would produce. + */ + public var keyUse: ForeignRingVrfUse + /** + * Opaque message bound into the proof or signature. + */ + public var message: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Product making the request. + */callingProductId: String, + /** + * Registered key the request would use, owned by another product. + */keyHandle: ProductAccountId, + /** + * What the key would produce. + */keyUse: ForeignRingVrfUse, + /** + * Opaque message bound into the proof or signature. + */message: Data) { + self.callingProductId = callingProductId + self.keyHandle = keyHandle + self.keyUse = keyUse + self.message = message + } + + + + +} + +#if compiler(>=6) +extension ForeignRingVrfKeyReview: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeForeignRingVrfKeyReview: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ForeignRingVrfKeyReview { + return + try ForeignRingVrfKeyReview( + callingProductId: FfiConverterString.read(from: &buf), + keyHandle: FfiConverterTypeProductAccountId.read(from: &buf), + keyUse: FfiConverterTypeForeignRingVrfUse.read(from: &buf), + message: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: ForeignRingVrfKeyReview, into buf: inout [UInt8]) { + FfiConverterString.write(value.callingProductId, into: &buf) + FfiConverterTypeProductAccountId.write(value.keyHandle, into: &buf) + FfiConverterTypeForeignRingVrfUse.write(value.keyUse, into: &buf) + FfiConverterData.write(value.message, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeForeignRingVrfKeyReview_lift(_ buf: RustBuffer) throws -> ForeignRingVrfKeyReview { + return try FfiConverterTypeForeignRingVrfKeyReview.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeForeignRingVrfKeyReview_lower(_ value: ForeignRingVrfKeyReview) -> RustBuffer { + return FfiConverterTypeForeignRingVrfKeyReview.lower(value) +} + + /** * One chain a host serves: a protocol chain role mapped to the concrete * chain of the host's configured environment. @@ -1546,6 +1641,92 @@ public func FfiConverterTypeCreateTransactionReview_lower(_ value: CreateTransac +/** + * What a foreign registered ring-VRF key would produce. + */ + +public enum ForeignRingVrfUse: Equatable, Hashable { + + /** + * A ring-VRF proof, scoped to a context and a ring. + */ + case proof( + /** + * Product-scoped context the proof's alias is bound to. + */context: ProductProofContext, + /** + * Ring the proof is generated against. + */ringLocation: RingLocation + ) + /** + * A member-key signature. Linkable to every other use of the key, with no + * context or ring bounding what it authorizes. + */ + case signature + + + + + +} + +#if compiler(>=6) +extension ForeignRingVrfUse: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeForeignRingVrfUse: FfiConverterRustBuffer { + typealias SwiftType = ForeignRingVrfUse + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ForeignRingVrfUse { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .proof(context: try FfiConverterTypeProductProofContext.read(from: &buf), ringLocation: try FfiConverterTypeRingLocation.read(from: &buf) + ) + + case 2: return .signature + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: ForeignRingVrfUse, into buf: inout [UInt8]) { + switch value { + + + case let .proof(context,ringLocation): + writeInt(&buf, Int32(1)) + FfiConverterTypeProductProofContext.write(context, into: &buf) + FfiConverterTypeRingLocation.write(ringLocation, into: &buf) + + + case .signature: + writeInt(&buf, Int32(2)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeForeignRingVrfUse_lift(_ buf: RustBuffer) throws -> ForeignRingVrfUse { + return try FfiConverterTypeForeignRingVrfUse.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeForeignRingVrfUse_lower(_ value: ForeignRingVrfUse) -> RustBuffer { + return FfiConverterTypeForeignRingVrfUse.lower(value) +} + + + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -2035,6 +2216,11 @@ public enum UserConfirmationReview: Equatable, Hashable { */ case accountAccess(AccountAccessReview ) + /** + * Allow a product to use another product's registered ring-VRF key. + */ + case foreignRingVrfKey(ForeignRingVrfKeyReview + ) /** * Sign an RFC-0023 VRF transcript with a product account. */ @@ -2091,7 +2277,10 @@ public struct FfiConverterTypeUserConfirmationReview: FfiConverterRustBuffer { case 10: return .accountAccess(try FfiConverterTypeAccountAccessReview.read(from: &buf) ) - case 11: return .signVrf(try FfiConverterTypeSignVrfReview.read(from: &buf) + case 11: return .foreignRingVrfKey(try FfiConverterTypeForeignRingVrfKeyReview.read(from: &buf) + ) + + case 12: return .signVrf(try FfiConverterTypeSignVrfReview.read(from: &buf) ) default: throw UniffiInternalError.unexpectedEnumCase @@ -2152,8 +2341,13 @@ public struct FfiConverterTypeUserConfirmationReview: FfiConverterRustBuffer { FfiConverterTypeAccountAccessReview.write(v1, into: &buf) - case let .signVrf(v1): + case let .foreignRingVrfKey(v1): writeInt(&buf, Int32(11)) + FfiConverterTypeForeignRingVrfKeyReview.write(v1, into: &buf) + + + case let .signVrf(v1): + writeInt(&buf, Int32(12)) FfiConverterTypeSignVrfReview.write(v1, into: &buf) } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index ca8b8941b..4b83b9001 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -195,6 +195,54 @@ export type CreateTransactionReview = */ | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; +/** + * Review shown before a product uses another product's registered ring-VRF key + * (RFC-0024). + * + * Both outputs are bearer tokens the caller can spend without the Host: a proof + * for its context's alias, a signature for the key itself. `message` is opaque, + * so nothing here establishes what the result will authorize — the decision is + * whether the calling product is trusted with the owner's key at all. + */ +export interface ForeignRingVrfKeyReview { + /** + * Product making the request. + */ + callingProductId: string; + + /** + * Registered key the request would use, owned by another product. + */ + keyHandle: ProductAccountId; + + /** + * What the key would produce. + */ + keyUse: ForeignRingVrfUse; + + /** + * Opaque message bound into the proof or signature. + */ + message: Uint8Array; +} + +/** + * What a foreign registered ring-VRF key would produce. + */ +export type ForeignRingVrfUse = + /** + * A ring-VRF proof, scoped to a context and a ring. + */ + | { + tag: "Proof"; + value: { context: ProductProofContext; ringLocation: RingLocation }; + } + /** + * A member-key signature. Linkable to every other use of the key, with no + * context or ring bounding what it authorizes. + */ + | { tag: "Signature"; value?: undefined }; + /** * One chain a host serves: a protocol chain role mapped to the concrete * chain of the host's configured environment. @@ -452,6 +500,10 @@ export type UserConfirmationReview = * Allow a product to access another product account. */ | { tag: "AccountAccess"; value: AccountAccessReview } + /** + * Allow a product to use another product's registered ring-VRF key. + */ + | { tag: "ForeignRingVrfKey"; value: ForeignRingVrfKeyReview } /** * Sign an RFC-0023 VRF transcript with a product account. */ @@ -554,6 +606,42 @@ export const CreateTransactionReview: S.Codec = S.lazy( }), ); +/** + * Review shown before a product uses another product's registered ring-VRF key + * (RFC-0024). + * + * Both outputs are bearer tokens the caller can spend without the Host: a proof + * for its context's alias, a signature for the key itself. `message` is opaque, + * so nothing here establishes what the result will authorize — the decision is + * whether the calling product is trusted with the owner's key at all. + */ +export const ForeignRingVrfKeyReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ + callingProductId: S.str, + keyHandle: ProductAccountId, + keyUse: ForeignRingVrfUse, + message: S.Bytes(), + }) as S.Codec, +); + +/** + * What a foreign registered ring-VRF key would produce. + */ +export const ForeignRingVrfUse: S.Codec = S.lazy( + (): S.Codec => + S.TaggedUnion({ + Proof: S.Struct({ + context: ProductProofContext, + ringLocation: RingLocation, + }) as S.Codec<{ + context: ProductProofContext; + ringLocation: RingLocation; + }>, + Signature: S._void, + }), +); + /** * One chain a host serves: a protocol chain role mapped to the concrete * chain of the host's configured environment. @@ -736,6 +824,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( ResourceAllocation: ResourceAllocationReview, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview, + ForeignRingVrfKey: ForeignRingVrfKeyReview, SignVrf: SignVrfReview, }), ); diff --git a/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts index 7ab5ec4d4..57cb5ce0e 100644 --- a/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts +++ b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts @@ -19,6 +19,7 @@ const PEOPLE_LITE_COLLECTION_ID = "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; const ACCOUNT_ACCESS_ACTION = "access another product account"; const PROOF_ACTION = "create account proof"; +const FOREIGN_KEY_ACTION = "use another product's ring-VRF key"; // These locate the real People collections while intentionally omitting the // optional pallet junction. They therefore exercise ring operations without @@ -365,29 +366,35 @@ export async function runRingVrfRegistryE2e( keyHandle: foreignHandle, context, ringLocation: TEST_PEOPLE_LITE_RING, - message: "0x6e6f2070726f6d7074", + message: "0x70726f6d7074", }); + // Past the gate on this auto-approving host, so the refusal now comes from + // the registry: the foreign handle has no entry. expectDomainError( foreignProof, - "NotAllowlisted", - "foreign create_account_proof", + "KeyNotRegistered", + "approved foreign create_account_proof", ); const foreignSignature = await client.account.ringVrfSign({ keyHandle: foreignHandle, - message: "0x6e6f2070726f6d7074", + message: "0x70726f6d7074", }); expectDomainError( foreignSignature, - "NotAllowlisted", - "foreign ring_vrf_sign", + "KeyNotRegistered", + "approved foreign ring_vrf_sign", ); const bearerWindow = newLinesSince(beforeBearerCalls, readTranscript()); - if ( - actionLines(bearerWindow, PROOF_ACTION).length !== 0 || - actionLines(bearerWindow, ACCOUNT_ACCESS_ACTION).length !== 0 - ) { + const bearerPrompts = actionLines(bearerWindow, FOREIGN_KEY_ACTION); + // One per call: the answer is never persisted, so the second call asks again. + if (bearerPrompts.length !== 2) { + throw new Error( + `expected both foreign bearer-token calls to ask, saw ${bearerPrompts.length}: ${bearerWindow.join("; ")}`, + ); + } + if (actionLines(bearerWindow, ACCOUNT_ACCESS_ACTION).length !== 0) { throw new Error( - `foreign bearer-token calls consulted a prompt: ${bearerWindow.join("; ")}`, + `foreign key use consulted the account-access grant: ${bearerWindow.join("; ")}`, ); } diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 1ea295ca0..69d3ed86a 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -22,9 +22,9 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex as AsyncMutex; use truapi::latest as api; use truapi_platform::{ - AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, JsonRpcConnection, Navigation, - Notifications, Permissions, PreimageHost, ProductStorage, ProductStorageKey, SessionUiInfo, - ThemeHost, UserConfirmation, UserConfirmationReview, + AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, ForeignRingVrfUse, + JsonRpcConnection, Navigation, Notifications, Permissions, PreimageHost, ProductStorage, + ProductStorageKey, SessionUiInfo, ThemeHost, UserConfirmation, UserConfirmationReview, }; use crate::chain::WsChainProvider; @@ -774,6 +774,26 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { review.requesting_product_id, review.target_product_id ), ), + UserConfirmationReview::ForeignRingVrfKey(review) => { + let produced = match &review.key_use { + ForeignRingVrfUse::Proof { context, .. } => { + format!("a ring-VRF proof in the {} context", context.product_id) + } + ForeignRingVrfUse::Signature => { + "a member-key signature, linkable and unscoped".to_string() + } + }; + ( + "use another product's ring-VRF key", + format!( + "Product {} requested {produced} with the {} key at index {:?}. \ + The message is opaque, so what the result authorizes cannot be shown.", + review.calling_product_id, + review.key_handle.dot_ns_identifier, + review.key_handle.derivation_index, + ), + ) + } } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 3b953dce1..e87712754 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1143,6 +1143,42 @@ pub struct CreateProofReview { pub message: Vec, } +/// What a foreign registered ring-VRF key would produce. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ForeignRingVrfUse { + /// A ring-VRF proof, scoped to a context and a ring. + Proof { + /// Product-scoped context the proof's alias is bound to. + context: ProductProofContext, + /// Ring the proof is generated against. + ring_location: RingLocation, + }, + /// A member-key signature. Linkable to every other use of the key, with no + /// context or ring bounding what it authorizes. + Signature, +} + +/// Review shown before a product uses another product's registered ring-VRF key +/// (RFC-0024). +/// +/// Both outputs are bearer tokens the caller can spend without the Host: a proof +/// for its context's alias, a signature for the key itself. `message` is opaque, +/// so nothing here establishes what the result will authorize — the decision is +/// whether the calling product is trusted with the owner's key at all. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ForeignRingVrfKeyReview { + /// Product making the request. + pub calling_product_id: String, + /// Registered key the request would use, owned by another product. + pub key_handle: ProductAccountId, + /// What the key would produce. + pub key_use: ForeignRingVrfUse, + /// Opaque message bound into the proof or signature. + pub message: Vec, +} + /// Review shown before signing an RFC-0023 VRF transcript. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] #[cfg_attr(feature = "uniffi", derive(uniffi::Record))] @@ -1216,6 +1252,8 @@ pub enum UserConfirmationReview { PreimageSubmit(PreimageSubmitReview), /// Allow a product to access another product account. AccountAccess(AccountAccessReview), + /// Allow a product to use another product's registered ring-VRF key. + ForeignRingVrfKey(ForeignRingVrfKeyReview), /// Sign an RFC-0023 VRF transcript with a product account. SignVrf(SignVrfReview), } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 80fcda211..819c56881 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -172,10 +172,11 @@ use truapi::{CallContext, CallError, CancellationReason, Subscription}; use truapi::{latest, v01}; use truapi_platform::Platform; use truapi_platform::{ - AccountAccessReview, CreateTransactionReview, IdentityDisclosureReview, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, - ProductContext, ProductStorageKey, ResourceAllocationReview, SessionUiInfo, SignPayloadReview, - SignRawReview, UserConfirmationReview, normalize_product_identifier, + AccountAccessReview, CreateTransactionReview, ForeignRingVrfKeyReview, ForeignRingVrfUse, + IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + PreimageSubmitReview, ProductContext, ProductStorageKey, ResourceAllocationReview, + SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, }; /// Error reason surfaced to products when a remote permission is not granted. @@ -742,6 +743,60 @@ enum AccountAccessAuthorizationError { Confirmation(v01::GenericError), } +/// Authorize a request that would use another product's registered ring-VRF key. +/// +/// RFC-0024 assigns this decision to the key owner, expressed as an allowlist in +/// its manifest. No manifest carries that field yet, so the core asks the user +/// per call. The answer is deliberately **not** persisted: the risk is specific to +/// the message being signed, and a stored grant would authorize every later use of +/// the key. Requests for the caller's own key are authorized without a prompt. +pub(crate) async fn foreign_ring_vrf_key_authorization( + platform: &dyn Platform, + calling_product_id: &str, + key_handle: &v01::ProductAccountId, + key_use: ForeignRingVrfUse, + message: &[u8], +) -> Result<(), RingVrfError> { + let caller = normalize_product_identifier(calling_product_id).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + // Both sides are normalized before comparison. A request arriving over a + // pairing session carries a handle the peer supplied, so an unnormalized owner + // id would otherwise ask the user about a key the caller already owns. + let owner = normalize_product_identifier(&key_handle.dot_ns_identifier).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if caller == owner { + return Ok(()); + } + + let confirmed = platform + .confirm_user_action(UserConfirmationReview::ForeignRingVrfKey( + ForeignRingVrfKeyReview { + calling_product_id: caller, + key_handle: v01::ProductAccountId { + dot_ns_identifier: owner, + derivation_index: key_handle.derivation_index.clone(), + }, + key_use, + message: message.to_vec(), + }, + )) + .await + .map_err(|error| RingVrfError::Unknown { + reason: format!("foreign ring-VRF key confirmation failed: {error:?}"), + })?; + if confirmed { + Ok(()) + } else { + Err(RingVrfError::Rejected) + } +} + fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { let raw = signer .strip_prefix("0x") @@ -1035,12 +1090,6 @@ impl Account for ProductRuntimeHost { }, )) })?; - 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( v01::HostAccountCreateProofError::Rejected, @@ -1175,11 +1224,6 @@ impl Account for ProductRuntimeHost { 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( diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index c49563d22..a3c032b46 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -48,7 +48,7 @@ use tracing::{instrument, warn}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{ - CoreStorageKey, PairingHostConfig, Platform, ProductContext, SignVrfReview, + CoreStorageKey, ForeignRingVrfUse, PairingHostConfig, Platform, ProductContext, SignVrfReview, UserConfirmationReview, normalize_product_identifier, }; use zeroize::Zeroizing; @@ -1791,19 +1791,23 @@ impl PairingHost { subtrees.retain(|(key, _), _| *key != session_key); } - fn require_owned_ring_vrf_key( + /// Authorize use of a locally held `handle`, prompting the user when it + /// belongs to another product. + async fn authorize_ring_vrf_key_use( + &self, calling_product_id: &str, handle: &v01::ProductAccountId, + key_use: ForeignRingVrfUse, + message: &[u8], ) -> 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(()) + super::foreign_ring_vrf_key_authorization( + self.platform.as_ref(), + calling_product_id, + handle, + key_use, + message, + ) + .await } async fn local_ring_vrf_entropy( @@ -2002,7 +2006,6 @@ impl PairingHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { - 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( @@ -2012,6 +2015,19 @@ impl PairingHost { ) .await? { + // The key is held here, so this is where the user decides. A remote + // key is authorized by the host that holds it, on the far side of + // `remote_create_proof`. + self.authorize_ring_vrf_key_use( + &request.calling_product_id, + &request.key_handle, + ForeignRingVrfUse::Proof { + context: request.context.clone(), + ring_location: request.ring_location.clone(), + }, + &request.message, + ) + .await?; let member = member_from_entropy(&entropy)?; let resolved = self .ring_resolver @@ -2131,12 +2147,18 @@ impl PairingHost { 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.authorize_ring_vrf_key_use( + &request.calling_product_id, + &request.key_handle, + ForeignRingVrfUse::Signature, + &request.message, + ) + .await?; self.current_private_session(session)?; return sign_from_entropy(&entropy, &request.message); } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 50048ccb3..10dad4ace 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -63,8 +63,8 @@ use ring_vrf::{ use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{ - PermissionAuthorizationStatus, Platform, ProductContext, SignVrfReview, UserConfirmationReview, - normalize_product_identifier, + ForeignRingVrfUse, PermissionAuthorizationStatus, Platform, ProductContext, SignVrfReview, + UserConfirmationReview, normalize_product_identifier, }; use zeroize::Zeroizing; @@ -448,19 +448,24 @@ impl SigningHost { }) } - fn require_owned_ring_vrf_key( + /// Authorize use of `handle`, prompting the user when it belongs to another + /// product. The prompt runs here rather than at the wire frontend so requests + /// arriving over a pairing session are gated on the device holding the key. + async fn authorize_ring_vrf_key_use( + &self, calling_product_id: &str, handle: &v01::ProductAccountId, + key_use: ForeignRingVrfUse, + message: &[u8], ) -> 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(()) + super::foreign_ring_vrf_key_authorization( + self.services.platform.as_ref(), + calling_product_id, + handle, + key_use, + message, + ) + .await } pub(crate) async fn ring_vrf_providers( @@ -784,7 +789,16 @@ impl ProductAuthority for SigningHost { request: CreateProofAuthorityRequest, ) -> Result { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.authorize_ring_vrf_key_use( + &request.calling_product_id, + &request.key_handle, + ForeignRingVrfUse::Proof { + context: request.context.clone(), + ring_location: request.ring_location.clone(), + }, + &request.message, + ) + .await?; let entropy = self .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) .await?; @@ -885,7 +899,13 @@ impl ProductAuthority for SigningHost { request: RingVrfSignAuthorityRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + self.authorize_ring_vrf_key_use( + &request.calling_product_id, + &request.key_handle, + ForeignRingVrfUse::Signature, + &request.message, + ) + .await?; let entropy = self .resolve_registered_ring_vrf_key(session, &request.key_handle) .await?; @@ -1216,7 +1236,9 @@ mod tests { }; use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; use truapi::{CallContext, CallError, v01}; - use truapi_platform::{HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig}; + use truapi_platform::{ + ForeignRingVrfUse, HostInfo, Platform, PlatformInfo, ProductContext, SigningHostConfig, + }; use verifiable::ring::RingDomainSize; const ENTROPY: [u8; 16] = [0xAB; 16]; @@ -1507,7 +1529,7 @@ mod tests { } #[test] - fn foreign_alias_prompts_but_foreign_proof_is_refused_without_a_prompt() { + fn a_denied_foreign_key_prompt_refuses_the_proof_and_the_signature() { let platform = Arc::new(StubPlatform::default()); let authority = SigningHostRole::new_with_ring_resolver(platform.clone(), full_person_ring_resolver()); @@ -1522,30 +1544,118 @@ mod tests { let ring_location = full_person_ring_location(); register_full_person_key(&authority, &session, &ring_location); - let alias = futures::executor::block_on(authority.account_alias( + let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - AccountAliasAuthorityRequest { + CreateProofAuthorityRequest { calling_product_id: "myapp.dot".to_string(), key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring_location.clone(), + context, + ring_location, + message: b"prove me".to_vec(), }, )); - assert_eq!(alias, Err(RingVrfError::Rejected)); + assert_eq!(proof, Err(RingVrfError::Rejected)); - let proof = futures::executor::block_on(authority.create_proof( + let signature = futures::executor::block_on(authority.ring_vrf_sign( &cx, &session, - CreateProofAuthorityRequest { + RingVrfSignAuthorityRequest { calling_product_id: "myapp.dot".to_string(), key_handle: full_person_key_handle(), - context, + message: b"sign me".to_vec(), + }, + )); + assert_eq!(signature, Err(RingVrfError::Rejected)); + + // Both calls asked, and neither answer was cached: a second identical + // request must prompt again rather than reuse the refusal. + let reviews = platform + .foreign_ring_vrf_key_reviews + .lock() + .expect("foreign ring-VRF key review list mutex poisoned"); + assert_eq!(reviews.len(), 2); + assert_eq!(reviews[0].calling_product_id, "myapp.dot"); + assert_eq!( + reviews[0].key_handle.dot_ns_identifier, + full_person_key_handle().dot_ns_identifier + ); + assert!(matches!( + reviews[0].key_use, + ForeignRingVrfUse::Proof { .. } + )); + assert_eq!(reviews[0].message, b"prove me".to_vec()); + assert!(matches!(reviews[1].key_use, ForeignRingVrfUse::Signature)); + assert_eq!(reviews[1].message, b"sign me".to_vec()); + } + + #[test] + fn an_approved_foreign_key_prompt_yields_the_owner_s_own_signature() { + let platform = Arc::new(StubPlatform { + foreign_ring_vrf_key_confirmed: true, + ..StubPlatform::default() + }); + let authority = + SigningHostRole::new_with_ring_resolver(platform.clone(), 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 cx = CallContext::default(); + let ring_location = full_person_ring_location(); + register_full_person_key(&authority, &session, &ring_location); + + let owner = full_person_key_handle().dot_ns_identifier; + let sign = |calling_product_id: &str| { + futures::executor::block_on(authority.ring_vrf_sign( + &cx, + &session, + RingVrfSignAuthorityRequest { + calling_product_id: calling_product_id.to_string(), + key_handle: full_person_key_handle(), + message: b"sign me".to_vec(), + }, + )) + }; + + let foreign = sign("myapp.dot").expect("approved foreign signature"); + let own = sign(&owner).expect("own signature"); + assert_eq!(foreign, own); + + // Only the foreign caller was asked; the owner is authorized implicitly. + let reviews = platform + .foreign_ring_vrf_key_reviews + .lock() + .expect("foreign ring-VRF key review list mutex poisoned"); + assert_eq!(reviews.len(), 1); + assert_eq!(reviews[0].calling_product_id, "myapp.dot"); + } + + #[test] + fn foreign_alias_is_still_gated_by_the_account_access_grant() { + let platform = Arc::new(StubPlatform::default()); + let authority = + SigningHostRole::new_with_ring_resolver(platform.clone(), 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 cx = CallContext::default(); + 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: v01::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, ring_location, - message: b"prove me".to_vec(), }, )); - assert_eq!(proof, Err(RingVrfError::NotAllowlisted)); + assert_eq!(alias, Err(RingVrfError::Rejected)); assert_eq!( platform .account_access_reviews diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index e8ac1cca8..e9e5c4c3b 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -26,12 +26,12 @@ use truapi::versioned::account::{HostAccountCreateProofRequest, HostAccountGetAl use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ AccountAccessReview, AuthPresenter, AuthState, ChainProvider, - CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, - JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, - PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, - ProductContext, ProductStorage as PlatformProductStorage, ResourceAllocationReview, - SignVrfReview, StatementStoreProductSignReview, ThemeHost, UserConfirmation, - UserConfirmationReview, + CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, + ForeignRingVrfKeyReview, HostInfo, JsonRpcConnection, Navigation as PlatformNavigation, + Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage as PlatformProductStorage, + ResourceAllocationReview, SignVrfReview, StatementStoreProductSignReview, ThemeHost, + UserConfirmation, UserConfirmationReview, }; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey}; @@ -71,6 +71,9 @@ pub(crate) struct StubPlatform { pub(crate) account_access_confirmed: bool, pub(crate) account_access_error: Option<&'static str>, pub(crate) account_access_reviews: Arc>>, + pub(crate) foreign_ring_vrf_key_confirmed: bool, + pub(crate) foreign_ring_vrf_key_error: Option<&'static str>, + pub(crate) foreign_ring_vrf_key_reviews: Arc>>, pub(crate) identity_disclosure_confirmed: bool, pub(crate) identity_disclosure_error: Option<&'static str>, pub(crate) identity_disclosure_calls: Arc, @@ -1429,6 +1432,16 @@ impl UserConfirmation for StubPlatform { .push(review); (self.account_access_error, self.account_access_confirmed) } + UserConfirmationReview::ForeignRingVrfKey(review) => { + self.foreign_ring_vrf_key_reviews + .lock() + .expect("foreign ring-VRF key review list mutex poisoned") + .push(review); + ( + self.foreign_ring_vrf_key_error, + self.foreign_ring_vrf_key_confirmed, + ) + } UserConfirmationReview::IdentityDisclosure(_) => { self.identity_disclosure_calls .fetch_add(1, Ordering::SeqCst); diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 6b496a9ab..7dda15da6 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -237,7 +237,7 @@ fn version_index(version: u8) -> u8 { } #[test] -fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { +fn a_foreign_account_proof_the_user_declines_comes_back_rejected() { let core = make_core(); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { key_handle: v01::ProductAccountId { @@ -268,9 +268,12 @@ fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { ); assert_eq!(response.request_id, "p:account-proof"); assert_eq!(response.payload.id, ids.response_id); - // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign key. + // A foreign key handle reaches the user as a confirmation. This host declines + // it, and a declined confirmation is `Rejected` rather than `NotAllowlisted`: + // the latter is reserved for an owner's manifest allowlist refusing the caller + // outright, which no manifest expresses yet. let expected = versioned_result_err_payload(account::HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::NotAllowlisted, + v01::HostAccountCreateProofError::Rejected, )); 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 e06597b5d..4611b0d5f 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -132,15 +132,21 @@ pub trait Account: Send + Sync { /// }, /// message: "0x48656c6c6f", /// }); - /// 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"); + /// // `peopl.dot` owns this key, so the Host asks the user. Approving yields a + /// // proof; declining is `Rejected`. Both conform, so this accepts either and + /// // fails only on an outcome that means the gate is missing or misreported. + /// if (result.isErr()) { + /// assert( + /// result.error.tag === "Domain" && + /// result.error.value.tag === "V1" && + /// result.error.value.value.tag === "Rejected", + /// "a declined foreign createAccountProof must be Rejected:", + /// result, + /// ); + /// console.log("foreign account proof declined by the user"); + /// } else { + /// console.log("foreign account proof approved:", result.value.contextualAlias.alias); + /// } /// ``` #[wire(request_id = 26)] async fn create_account_proof( diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 56fadf35a..ae22ca3e1 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -220,9 +220,11 @@ pub enum HostAccountCreateProofError { KeyNotRegistered, /// The key handle is not registered for the requested ring. KeyNotInRing, - /// The foreign key owner has not allowlisted the caller. + /// The foreign key owner's manifest allowlist refuses the caller. No manifest + /// carries that field yet, so a foreign key is put to the user instead and a + /// declined request is [`Self::Rejected`]. NotAllowlisted, - /// User or host rejected. + /// User or host rejected. Covers a declined foreign-key confirmation. Rejected, /// Catch-all. Unknown { @@ -334,9 +336,11 @@ pub enum HostAccountRingVrfSignError { NotConnected, /// The key handle is not registered. KeyNotRegistered, - /// The foreign key owner has not allowlisted the caller. + /// The foreign key owner's manifest allowlist refuses the caller. No manifest + /// carries that field yet, so a foreign key is put to the user instead and a + /// declined request is [`Self::Rejected`]. NotAllowlisted, - /// User or host rejected. + /// User or host rejected. Covers a declined foreign-key confirmation. Rejected, /// Catch-all. Unknown {