From 3a6354a71ee7002a76a55b2776b010a804c9bd35 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 6 Aug 2026 05:40:26 +0000 Subject: [PATCH 1/4] feat: implement RFC-0024 ring VRF key management --- .../diagnosis-reports/pairing-host-cli.md | 17 +- .../diagnosis-reports/signing-host-cli.md | 17 +- .../truapi-codegen/tests/golden/dispatcher.rs | 84 +++ .../tests/golden/host-callbacks.ts | 9 +- .../truapi-codegen/tests/golden/wire_table.rs | 30 + .../truapi-codegen/tests/golden_rust_emit.rs | 19 +- rust/crates/truapi-host-cli/README.md | 6 +- rust/crates/truapi-host-cli/SPEC.md | 7 +- .../truapi-host-cli/js/diagnosis.test.ts | 150 +++- rust/crates/truapi-host-cli/js/diagnosis.ts | 105 ++- .../crates/truapi-host-cli/js/ring-vrf-e2e.ts | 471 +++++++++++++ rust/crates/truapi-host-cli/js/runner.ts | 14 +- .../truapi-host-cli/js/scripts/battery.ts | 26 +- .../js/scripts/ring-vrf-smoke.ts | 76 +- rust/crates/truapi-platform/src/lib.rs | 14 + rust/crates/truapi-server/README.md | 15 +- .../truapi-server/src/generated/dispatcher.rs | 92 +++ .../truapi-server/src/generated/wire_table.rs | 30 + rust/crates/truapi-server/src/host_core.rs | 79 +++ .../src/host_logic/product_account.rs | 60 +- .../src/host_logic/sso/messages.rs | 332 ++++++++- .../src/host_logic/sso/messages/v1.rs | 31 +- rust/crates/truapi-server/src/native.rs | 50 +- .../truapi-server/src/native/reviews.rs | 47 ++ rust/crates/truapi-server/src/runtime.rs | 374 +++++++++- .../truapi-server/src/runtime/authority.rs | 108 ++- .../truapi-server/src/runtime/pairing_host.rs | 388 +++++++++- .../src/runtime/pairing_host/sso_channel.rs | 118 +++- .../src/runtime/ring_vrf_registry.rs | 665 ++++++++++++++++++ .../truapi-server/src/runtime/services.rs | 3 + .../truapi-server/src/runtime/signing_host.rs | 547 +++++++++++--- .../src/runtime/signing_host/ring_vrf.rs | 106 ++- .../src/runtime/signing_host/sso_responder.rs | 149 +++- .../truapi-server/src/runtime/sso_remote.rs | 27 +- .../src/runtime/statement_allowance.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 23 +- .../truapi-server/tests/wire_result_shape.rs | 11 +- rust/crates/truapi/src/api/account.rs | 122 +++- rust/crates/truapi/src/lib.rs | 17 +- rust/crates/truapi/src/v01/account.rs | 118 +++- rust/crates/truapi/src/versioned/account.rs | 9 + 41 files changed, 4228 insertions(+), 340 deletions(-) create mode 100644 rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts create mode 100644 rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index 30ddd5620..8475d20f8 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` | ✅ | | @@ -50,21 +54,22 @@ | `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | | `Permissions/request_device_permission` | ✅ | | | `Permissions/request_remote_permission` | ✅ | | -| `Preimage/lookup_subscribe` | ✅ | | -| `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ✅ | | +| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | | `Signing/sign_raw_with_legacy_account` | ✅ | | | `Signing/sign_payload_with_legacy_account` | ✅ | | | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | -| `Statement Store/subscribe` | ✅ | | +| `Statement Store/subscribe` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `Statement Store/create_proof` | ✅ | | -| `Statement Store/submit` | ✅ | | -| `Statement Store/create_proof_authorized` | ✅ | | +| `Statement Store/submit` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/create_proof_authorized` | ❌ | createProof failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `System/handshake` | ✅ | | | `System/feature_supported` | ✅ | | | `System/navigate_to` | ✅ | | | `Theme/subscribe` | ✅ | | | `Resource Allocation/auto_signing_e2e` | ✅ | | +| `Account/auto_signing_ring_vrf_e2e` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md index 0fac59231..d4d953efd 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` | ✅ | | @@ -50,21 +54,22 @@ | `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | | `Permissions/request_device_permission` | ✅ | | | `Permissions/request_remote_permission` | ✅ | | -| `Preimage/lookup_subscribe` | ✅ | | -| `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ✅ | | +| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } } | +| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } } | +| `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | | `Signing/sign_raw_with_legacy_account` | ✅ | | | `Signing/sign_payload_with_legacy_account` | ✅ | | | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | -| `Statement Store/subscribe` | ✅ | | +| `Statement Store/subscribe` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `Statement Store/create_proof` | ✅ | | -| `Statement Store/submit` | ✅ | | -| `Statement Store/create_proof_authorized` | ✅ | | +| `Statement Store/submit` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/create_proof_authorized` | ❌ | createProof failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | | `System/handshake` | ✅ | | | `System/feature_supported` | ✅ | | | `System/navigate_to` | ✅ | | | `Theme/subscribe` | ✅ | | | `Resource Allocation/auto_signing_e2e` | ✅ | | +| `Account/auto_signing_ring_vrf_e2e` | ✅ | | diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index b11224efd..3fdda36e3 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -184,6 +184,90 @@ where }) }); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_REGISTER_RING_VRF_KEY, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRegisterRingVrfKeyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRegisterRingVrfKeyResponse = match host.register_ring_vrf_key(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_LIST_RING_VRF_KEYS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountListRingVrfKeysRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountListRingVrfKeysResponse = match host.list_ring_vrf_keys(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_RING_VRF_SIGN, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRingVrfSignRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRingVrfSignResponse = match host.ring_vrf_sign(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } { let host = host.clone(); dispatcher.on_request(wire_table::ACCOUNT_GET_LEGACY_ACCOUNTS, move |request_id: String, bytes: Vec| { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 98d2aa1c7..e835d0dc8 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -140,7 +140,11 @@ export type CoreStorageKey = /** * Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. */ - | { tag: "AutoSigningKeys"; value?: undefined }; + | { tag: "AutoSigningKeys"; value?: undefined } + /** + * Wallet-bound RFC-0024 ring-VRF registry snapshot. + */ + | { tag: "RingVrfRegistry"; value: { rootPublicKey: Uint8Array } }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -450,6 +454,9 @@ export const CoreStorageKey: S.Codec = S.lazy( productId: string; }>, AutoSigningKeys: S._void, + RingVrfRegistry: S.Struct({ rootPublicKey: S.Bytes(32) }) as S.Codec<{ + rootPublicKey: Uint8Array; + }>, }), ); diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 7360d0427..b6555f1d9 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -466,6 +466,24 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `account_register_ring_vrf_key`. +pub const ACCOUNT_REGISTER_RING_VRF_KEY: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// Wire discriminants for `account_list_ring_vrf_keys`. +pub const ACCOUNT_LIST_RING_VRF_KEYS: RequestFrameIds = RequestFrameIds { + request_id: 168, + response_id: 169, +}; + +/// Wire discriminants for `account_ring_vrf_sign`. +pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { + request_id: 170, + response_id: 171, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +747,16 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "account_register_ring_vrf_key", + kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + }, + WireEntry { + method: "account_list_ring_vrf_keys", + kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + }, + WireEntry { + method: "account_ring_vrf_sign", + kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + }, ]; diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 50465b2b6..73bb0a598 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -93,6 +93,15 @@ fn workspace_root() -> PathBuf { .to_path_buf() } +fn workspace_tempdir(workspace: &Path) -> tempfile::TempDir { + let parent = workspace.join("target/codegen-test-tmp"); + fs::create_dir_all(&parent).expect("create workspace codegen temp directory"); + tempfile::Builder::new() + .prefix("golden-") + .tempdir_in(parent) + .expect("workspace tempdir") +} + fn rustfmt_generated(files: &[PathBuf]) { if files.is_empty() { return; @@ -129,6 +138,8 @@ fn prettier_generated(workspace_root: &Path, files: &[PathBuf]) { "--", "prettier", "--write", + "--ignore-path", + "/dev/null", "--config", ]) .arg(workspace_root.join(".prettierrc")); @@ -153,7 +164,7 @@ fn golden_dispatcher_and_wire_table() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let out = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) @@ -207,11 +218,11 @@ fn golden_dispatcher_and_wire_table() { #[test] fn binary_emission_is_idempotent() { let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let rustdoc_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let run_once = || -> (String, String) { - let tmp = tempfile::tempdir().unwrap(); + let tmp = workspace_tempdir(&workspace); let status = Command::new(env!("CARGO_BIN_EXE_truapi-codegen")) .args([ "--input", @@ -242,7 +253,7 @@ fn golden_host_callbacks_ts() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace = workspace_root(); - let tempdir = tempfile::tempdir().expect("tempdir"); + let tempdir = workspace_tempdir(&workspace); let truapi_json = produce_rustdoc_json(&workspace, &tempdir.path().join("rustdoc-target")); let platform_json = produce_rustdoc_json_for_package( &workspace, diff --git a/rust/crates/truapi-host-cli/README.md b/rust/crates/truapi-host-cli/README.md index e02821425..f84646f3e 100644 --- a/rust/crates/truapi-host-cli/README.md +++ b/rust/crates/truapi-host-cli/README.md @@ -306,9 +306,9 @@ Five scripts ship under `js/scripts/`: - `whoami.ts` — calls `getUserId` and prints `WHOAMI `; this remains available as an explicit `/script ` example. - `signing-smoke.ts` — a focused product-account signing check. -- `ring-vrf-smoke.ts` — calls `getAccountAlias` and `createAccountProof` - against the Paseo Next v2 LitePeople ring, then verifies both calls return - the same contextual alias. +- `ring-vrf-smoke.ts` — registers and lists an explicit RFC-0024 key, derives + its alias, verifies a fresh non-member key returns `NotMember` for a proof, + and exercises direct ring-VRF signing. - `preimage-smoke.ts` — a focused Bulletin preimage flow check. The generated examples are baked to the `truapi-playground.dot` product. With diff --git a/rust/crates/truapi-host-cli/SPEC.md b/rust/crates/truapi-host-cli/SPEC.md index c457733be..b0cc3a4d6 100644 --- a/rust/crates/truapi-host-cli/SPEC.md +++ b/rust/crates/truapi-host-cli/SPEC.md @@ -664,7 +664,7 @@ The top-level `--script` option does not update remembered `/script` state. | `battery.ts` | Run every generated Playground example and write the role-specific compatibility report. | | `whoami.ts` | Print the primary username. | | `signing-smoke.ts` | Focused product-account signing test. | -| `ring-vrf-smoke.ts` | Verify alias/proof behavior for the Paseo Next v2 LitePeople ring. | +| `ring-vrf-smoke.ts` | Verify RFC-0024 registration, listing, alias, non-membership proof, and direct signing behavior. | | `preimage-smoke.ts` | Exercise Bulletin preimage submission and lookup. | `battery.ts` writes to `explorer/diagnosis-reports/-cli.md` unless @@ -724,8 +724,9 @@ Before a signing host answers a link, it: 2. decodes the V2 handshake; 3. derives its RFC-0022 `uid.dot` identity account; 4. reads the pairing device Statement Store account from the proposal; -5. finds the signer's LitePeople ring through the `peopl.dot` index-1 key, - scanning back from the current ring; +5. finds the signer's LitePeople ring through the pairing-attestation bootstrap + `peopl.dot` index-1 key, scanning back from the current ring (RFC-0024 + operational key selection uses the registry instead); 6. grants or reuses Statement Store allowance for the identity account; 7. grants or reuses allowance for the pairing device; and 8. starts the real SSO responder. diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index e9adc87f8..a4ad52d60 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -48,23 +48,140 @@ 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); + expect( + expectedCliBatteryFailureReason( + failedRow("Chat/create_room", "unavailable"), + ), + ).toBe("Chat service not yet wired up by hosts"); + expect( + expectedCliBatteryFailureReason( + failedRow("Coin Payment/create_purse", "unavailable"), + ), + ).toBe("Coin Payment service not yet wired up by hosts"); + expect( + expectedCliBatteryFailureReason( + failedRow("Payment/top_up", "unavailable"), + ), + ).toBe("Payment service not yet wired up by hosts"); + expect( + expectedCliBatteryFailureReason( + failedRow("Signing/sign_raw", "Rejected"), + ), + ).toBe(undefined); + }); + + test("accepts only the exact RFC-0024 provider-absent battery failures", () => { + const reason = + "RFC-0024 People Lite provider is not installed in the CLI battery"; + expect( + expectedCliBatteryFailureReason( + failedRow( + "Resource Allocation/request", + `statement-store or bulletin allowance was not allocated: ${JSON.stringify( + { + outcomes: [ + "NotAvailable", + "NotAvailable", + "NotAvailable", + "Allocated", + ], + }, + null, + 2, + )}`, + ), + ), + ).toBe(reason); + for (const id of [ + "Statement Store/subscribe", + "Statement Store/submit", + "Statement Store/create_proof_authorized", + ]) { + expect( + expectedCliBatteryFailureReason( + failedRow( + id, + 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } }', + ), + ), + ).toBe(reason); + } + for (const id of ["Preimage/lookup_subscribe", "Preimage/submit"]) { + for (const providerFailure of [ + "no ring-VRF provider is registered for People Lite", + "bulletin allowance is not available", + ]) { + expect( + expectedCliBatteryFailureReason( + failedRow( + id, + `submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "${providerFailure}" } } } } }`, + ), + ), + ).toBe(reason); + } + } + expect( + expectedCliBatteryFailureReason( + failedRow("Resource Allocation/request", "request timed out"), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow( + "Resource Allocation/request", + 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } }', + ), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow( + "Resource Allocation/request", + 'statement-store or bulletin allowance was not allocated: { "outcomes": [ "Allocated", "NotAvailable", "NotAvailable", "Allocated" ] }', + ), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow("Statement Store/submit", "subscription failed"), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow( + "Statement Store/submit", + 'statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] }', + ), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow( + "Preimage/submit", + 'submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no allowance" } } } } }', + ), + ), + ).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow( + "Account/register_ring_vrf_key", + 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } }', + ), + ), + ).toBe(undefined); }); test("does not ignore account proof failures", () => { expect( knownUnsupportedReason("Account", "Account/create_account_proof"), ).toBe(undefined); - expect(expectedCliBatteryFailureReason("Account")).toBe(undefined); + expect( + expectedCliBatteryFailureReason( + failedRow("Account/create_account_proof", "NotMember"), + ), + ).toBe(undefined); }); test("prints failures as concise test-reporter rows", () => { @@ -189,3 +306,14 @@ function row( durationMs, }; } + +function failedRow( + id: string, + output: string, +): Pick { + return { + id, + serviceName: id.slice(0, id.indexOf("/")), + output, + }; +} diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index b4dff38d5..e62496408 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -18,6 +18,15 @@ const UNARY_TIMEOUT_MS = 10_000; const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +const STATEMENT_PROVIDER_DEPENDENT_METHODS = new Set([ + "Statement Store/subscribe", + "Statement Store/submit", + "Statement Store/create_proof_authorized", +]); +const PREIMAGE_PROVIDER_DEPENDENT_METHODS = new Set([ + "Preimage/lookup_subscribe", + "Preimage/submit", +]); const LONG_TIMEOUT_METHODS = new Set([ "Account/get_account", "Account/get_account_alias", @@ -98,14 +107,104 @@ 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`; + } + const noPeopleProvider = + "RFC-0024 People Lite provider is not installed in the CLI battery"; + const explicitlyReportsNoProvider = row.output.includes( + "no ring-VRF provider is registered for People Lite", + ); + if ( + row.id === "Resource Allocation/request" && + (explicitlyReportsNoProvider || + isProviderAbsentAllocationFailure(row.output)) + ) { + return noPeopleProvider; + } + if ( + STATEMENT_PROVIDER_DEPENDENT_METHODS.has(row.id) && + (explicitlyReportsNoProvider || isUnableToSignFailure(row.output)) + ) { + return noPeopleProvider; + } + if ( + PREIMAGE_PROVIDER_DEPENDENT_METHODS.has(row.id) && + (isDomainUnknownFailure( + row.output, + "no ring-VRF provider is registered for People Lite", + ) || + isDomainUnknownFailure(row.output, "bulletin allowance is not available")) + ) { + return noPeopleProvider; } return undefined; } +function isProviderAbsentAllocationFailure(output: string): boolean { + if ( + !output.startsWith( + "statement-store or bulletin allowance was not allocated:", + ) + ) { + return false; + } + const payload = parseTrailingJson(output); + if (!isRecord(payload) || Object.keys(payload).length !== 1) return false; + return ( + Array.isArray(payload.outcomes) && + payload.outcomes.length === 4 && + payload.outcomes[0] === "NotAvailable" && + payload.outcomes[1] === "NotAvailable" && + payload.outcomes[2] === "NotAvailable" && + payload.outcomes[3] === "Allocated" + ); +} + +function isUnableToSignFailure(output: string): boolean { + const payload = parseTrailingJson(output); + if (!isRecord(payload) || !isRecord(payload.error)) return false; + const error = payload.error; + return ( + error.tag === "Domain" && + isRecord(error.value) && + error.value.tag === "V1" && + isRecord(error.value.value) && + error.value.value.tag === "UnableToSign" + ); +} + +function isDomainUnknownFailure(output: string, reason: string): boolean { + const payload = parseTrailingJson(output); + if (!isRecord(payload) || !isRecord(payload.error)) return false; + const error = payload.error; + return ( + error.tag === "Domain" && + isRecord(error.value) && + error.value.tag === "V1" && + isRecord(error.value.value) && + error.value.value.tag === "Unknown" && + isRecord(error.value.value.value) && + error.value.value.value.reason === reason + ); +} + +function parseTrailingJson(output: string): unknown { + const jsonStart = output.indexOf("{"); + if (jsonStart < 0) return undefined; + try { + return JSON.parse(output.slice(jsonStart)); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + async function runOne( client: TrUApiClient, test: DiagnosisCase, diff --git a/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts new file mode 100644 index 000000000..0791de6b8 --- /dev/null +++ b/rust/crates/truapi-host-cli/js/ring-vrf-e2e.ts @@ -0,0 +1,471 @@ +import { existsSync, readFileSync } from "node:fs"; +import { + PASEO_NEXT_V2_INDIVIDUALITY, + type ProductAccountId, + type RegisteredRingVrfKey, + type RingLocation, + type TrUApiClient, +} from "../../../../js/packages/truapi/src/index.ts"; +import { + actionLines, + approvalLines, + newLinesSince, +} from "./auto-signing-e2e.ts"; +import type { DiagnosisRow } from "./diagnosis.ts"; + +const PEOPLE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652020202020"; +const PEOPLE_LITE_COLLECTION_ID = + "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; +const ACCOUNT_ACCESS_ACTION = "access another product account"; +const PROOF_ACTION = "create account proof"; + +// These locate the real People collections while intentionally omitting the +// optional pallet junction. They therefore exercise ring operations without +// registering the battery product as an exact-match internal People provider. +const TEST_PEOPLE_LITE_RING: RingLocation = { + chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + junctions: [{ tag: "CollectionId", value: PEOPLE_LITE_COLLECTION_ID }], +}; + +const TEST_PEOPLE_RING: RingLocation = { + chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + junctions: [{ tag: "CollectionId", value: PEOPLE_COLLECTION_ID }], +}; + +function stringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function okValue(result: unknown, operation: string): T { + const candidate = result as { + isOk(): boolean; + value: T; + error: unknown; + }; + if (!candidate.isOk()) { + throw new Error(`${operation} failed: ${stringify(candidate.error)}`); + } + return candidate.value; +} + +function expectDomainError( + result: unknown, + expected: string, + operation: string, +): void { + const candidate = result as { + isErr(): boolean; + error: unknown; + }; + if (!candidate.isErr()) { + throw new Error(`${operation} unexpectedly succeeded`); + } + const error = candidate.error as { + tag?: string; + value?: { tag?: string; value?: { tag?: string } }; + }; + const actual = + error.tag === "Domain" && error.value?.tag === "V1" + ? error.value.value?.tag + : undefined; + if (actual !== expected) { + throw new Error( + `${operation} returned ${actual ?? "a non-domain error"}, expected ${expected}: ${stringify(candidate.error)}`, + ); + } +} + +function requireHexBytes(value: unknown, bytes: number, label: string): string { + if ( + typeof value !== "string" || + !new RegExp(`^0x[0-9a-fA-F]{${bytes * 2}}$`).test(value) + ) { + throw new Error( + `${label} is not a ${bytes}-byte hex value: ${stringify(value)}`, + ); + } + return value; +} + +function sameIndex(left: unknown, right: unknown): boolean { + return stringify(left) === stringify(right); +} + +function findEntry( + entries: RegisteredRingVrfKey[], + handle: ProductAccountId, +): RegisteredRingVrfKey | undefined { + return entries.find( + (entry) => + entry.handle.dotNsIdentifier === handle.dotNsIdentifier && + sameIndex(entry.handle.derivationIndex, handle.derivationIndex), + ); +} + +function hasRing(entry: RegisteredRingVrfKey, ring: RingLocation): boolean { + return entry.rings.some( + (candidate) => stringify(candidate) === stringify(ring), + ); +} + +function transcriptReader(path: string): () => string[] { + return () => + existsSync(path) ? approvalLines(readFileSync(path, "utf8")) : []; +} + +function finish( + methodName: string, + startedAt: number, + status: DiagnosisRow["status"], + output: string, +): DiagnosisRow { + return { + id: `Account/${methodName}`, + serviceName: "Account", + methodName, + status, + output, + durationMs: Math.round(performance.now() - startedAt), + }; +} + +/** Exercise the RFC-0024 registry and authorization contract before examples run. */ +export async function runRingVrfRegistryE2e( + client: TrUApiClient, + productId: string, + approvalsLogPath: string | undefined, +): Promise { + const startedAt = performance.now(); + if (!approvalsLogPath) { + return finish( + "ring_vrf_registry_e2e", + startedAt, + "fail", + "TRUAPI_APPROVALS_LOG not set; cannot verify RFC-0024 prompt behavior", + ); + } + const readTranscript = transcriptReader(approvalsLogPath); + const index = { tag: "Left" as const, value: 0 }; + const handle: ProductAccountId = { + dotNsIdentifier: productId, + derivationIndex: index, + }; + const context = { + productId, + suffix: { tag: "Left" as const, value: 24 }, + }; + + try { + const unregistered = await client.account.ringVrfSign({ + keyHandle: { + dotNsIdentifier: productId, + derivationIndex: { tag: "Left", value: 4_242 }, + }, + message: "0x756e72656769737465726564", + }); + expectDomainError( + unregistered, + "KeyNotRegistered", + "unregistered ring_vrf_sign", + ); + + const registered = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "register_ring_vrf_key", + ); + requireHexBytes(registered, 32, "registered public key"); + const repeated = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "idempotent register_ring_vrf_key", + ); + if (repeated !== registered) { + throw new Error( + "idempotent registration returned a different public key", + ); + } + const secondRing = okValue( + await client.account.registerRingVrfKey({ index, ring: TEST_PEOPLE_RING }), + "multi-ring register_ring_vrf_key", + ); + if (secondRing !== registered) { + throw new Error( + "multi-ring registration returned a different public key", + ); + } + + const publicEntries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "PublicKey", + }), + "owned public list_ring_vrf_keys", + ); + const publicEntry = findEntry(publicEntries, handle); + if (!publicEntry || publicEntry.publicKey !== registered) { + throw new Error("owned public listing omitted the registered public key"); + } + if ( + !hasRing(publicEntry, TEST_PEOPLE_LITE_RING) || + !hasRing(publicEntry, TEST_PEOPLE_RING) + ) { + throw new Error( + "multi-ring registration was not preserved by the registry", + ); + } + + const anonymousEntries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "Anonymized", + }), + "owned anonymized list_ring_vrf_keys", + ); + const anonymousEntry = findEntry(anonymousEntries, handle); + if (!anonymousEntry || anonymousEntry.publicKey !== undefined) { + throw new Error( + "anonymized listing disclosed or omitted the registered key", + ); + } + + const alias = okValue<{ context: string; alias: string }>( + await client.account.getAccountAlias({ + keyHandle: handle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + }), + "owned get_account_alias", + ); + requireHexBytes(alias.context, 32, "alias context"); + if (!alias.alias.startsWith("0x") || alias.alias.length <= 2) { + throw new Error( + `get_account_alias returned an empty alias: ${stringify(alias)}`, + ); + } + + const proof = await client.account.createAccountProof({ + keyHandle: handle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + message: "0x7266633234", + }); + expectDomainError(proof, "NotMember", "owned create_account_proof"); + + const structurallyDifferentRing: RingLocation = { + chainId: TEST_PEOPLE_LITE_RING.chainId, + junctions: [ + { tag: "PalletInstance", value: 67 }, + ...TEST_PEOPLE_LITE_RING.junctions, + ], + }; + const wrongRingAlias = await client.account.getAccountAlias({ + keyHandle: handle, + context, + ringLocation: { + chainId: TEST_PEOPLE_LITE_RING.chainId, + junctions: [], + }, + }); + expectDomainError( + wrongRingAlias, + "KeyNotInRing", + "undeclared invalid ring alias", + ); + const wrongRing = await client.account.createAccountProof({ + keyHandle: handle, + context, + ringLocation: structurallyDifferentRing, + message: "0x7266633234", + }); + expectDomainError( + wrongRing, + "KeyNotInRing", + "structurally different ring proof", + ); + + const signature = okValue( + await client.account.ringVrfSign({ + keyHandle: handle, + message: "0x72666332342072696e6720767266207369676e6174757265", + }), + "owned ring_vrf_sign", + ); + requireHexBytes(signature, 64, "ring VRF signature"); + + const foreignOwner = `rfc24${Date.now().toString(36)}.dot`; + const beforeForeignRead = readTranscript(); + const foreignAnonymous = okValue( + await client.account.listRingVrfKeys({ + owner: foreignOwner, + disclosure: "Anonymized", + }), + "foreign anonymized list_ring_vrf_keys", + ); + if (foreignAnonymous.length !== 0) { + throw new Error("fresh foreign owner unexpectedly had registry entries"); + } + const readPrompts = actionLines( + newLinesSince(beforeForeignRead, readTranscript()), + ACCOUNT_ACCESS_ACTION, + ); + if (readPrompts.length !== 1) { + throw new Error( + `foreign listing produced ${readPrompts.length} account-access prompts, expected one`, + ); + } + const beforeSecondRead = readTranscript(); + okValue( + await client.account.listRingVrfKeys({ + owner: foreignOwner, + disclosure: "PublicKey", + }), + "foreign public list_ring_vrf_keys", + ); + if ( + actionLines( + newLinesSince(beforeSecondRead, readTranscript()), + ACCOUNT_ACCESS_ACTION, + ).length !== 0 + ) { + throw new Error( + "persisted foreign account access prompted more than once", + ); + } + + const foreignHandle: ProductAccountId = { + dotNsIdentifier: foreignOwner, + derivationIndex: { tag: "Left", value: 0 }, + }; + const foreignAlias = await client.account.getAccountAlias({ + keyHandle: foreignHandle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + }); + expectDomainError( + foreignAlias, + "KeyNotRegistered", + "granted foreign alias read", + ); + + const beforeBearerCalls = readTranscript(); + const foreignProof = await client.account.createAccountProof({ + keyHandle: foreignHandle, + context, + ringLocation: TEST_PEOPLE_LITE_RING, + message: "0x6e6f2070726f6d7074", + }); + expectDomainError( + foreignProof, + "NotAllowlisted", + "foreign create_account_proof", + ); + const foreignSignature = await client.account.ringVrfSign({ + keyHandle: foreignHandle, + message: "0x6e6f2070726f6d7074", + }); + expectDomainError( + foreignSignature, + "NotAllowlisted", + "foreign ring_vrf_sign", + ); + const bearerWindow = newLinesSince(beforeBearerCalls, readTranscript()); + if ( + actionLines(bearerWindow, PROOF_ACTION).length !== 0 || + actionLines(bearerWindow, ACCOUNT_ACCESS_ACTION).length !== 0 + ) { + throw new Error( + `foreign bearer-token calls consulted a prompt: ${bearerWindow.join("; ")}`, + ); + } + + return finish( + "ring_vrf_registry_e2e", + startedAt, + "pass", + "registry, exact rings, disclosure, alias/proof/signing, and foreign authorization verified", + ); + } catch (error) { + return finish( + "ring_vrf_registry_e2e", + startedAt, + "fail", + error instanceof Error ? error.message : String(error), + ); + } +} + +/** Verify an AutoSigning host can create and immediately use a new registry entry. */ +export async function runAutoSigningRingVrfE2e( + client: TrUApiClient, + productId: string, +): Promise { + const startedAt = performance.now(); + const index = { + tag: "Right" as const, + value: + "0x000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" as const, + }; + const handle: ProductAccountId = { + dotNsIdentifier: productId, + derivationIndex: index, + }; + + try { + const publicKey = okValue( + await client.account.registerRingVrfKey({ + index, + ring: TEST_PEOPLE_LITE_RING, + }), + "AutoSigning register_ring_vrf_key", + ); + requireHexBytes(publicKey, 32, "AutoSigning ring VRF public key"); + const entries = okValue( + await client.account.listRingVrfKeys({ + owner: productId, + disclosure: "PublicKey", + }), + "post-AutoSigning list_ring_vrf_keys", + ); + const entry = findEntry(entries, handle); + if ( + !entry || + entry.publicKey !== publicKey || + !hasRing(entry, TEST_PEOPLE_LITE_RING) + ) { + throw new Error( + "AutoSigning registration was not immediately visible in the registry", + ); + } + const signature = okValue( + await client.account.ringVrfSign({ + keyHandle: handle, + message: "0x6175746f7369676e696e672072696e6720767266", + }), + "post-AutoSigning ring_vrf_sign", + ); + requireHexBytes(signature, 64, "post-AutoSigning ring VRF signature"); + return finish( + "auto_signing_ring_vrf_e2e", + startedAt, + "pass", + "AutoSigning registration was immediately listed and usable for direct signing", + ); + } catch (error) { + return finish( + "auto_signing_ring_vrf_e2e", + startedAt, + "fail", + error instanceof Error ? error.message : String(error), + ); + } +} diff --git a/rust/crates/truapi-host-cli/js/runner.ts b/rust/crates/truapi-host-cli/js/runner.ts index 57fede855..a19ab6eb0 100644 --- a/rust/crates/truapi-host-cli/js/runner.ts +++ b/rust/crates/truapi-host-cli/js/runner.ts @@ -41,10 +41,7 @@ declare global { // Playground examples receive this helper from `runExample`; expose the // same contract to directly imported CLI scripts. // eslint-disable-next-line no-var - var assert: ( - condition: unknown, - ...message: unknown[] - ) => asserts condition; + var assert: (condition: unknown, ...message: unknown[]) => asserts condition; } const OPEN_TIMEOUT_MS = 15_000; @@ -65,7 +62,10 @@ async function main() { const context: HostContext = { productId, - productAccount: (index = 0) => ({ dotNsIdentifier: productId, derivationIndex: index }), + productAccount: (index = 0) => ({ + dotNsIdentifier: productId, + derivationIndex: { tag: "Left", value: index }, + }), }; globalThis.truapi = client; globalThis.host = context; @@ -101,7 +101,9 @@ async function main() { main().then( () => process.exit(0), (error) => { - console.error(`[script error] ${error instanceof Error ? error.stack : String(error)}`); + console.error( + `[script error] ${error instanceof Error ? error.stack : String(error)}`, + ); process.exit(1); }, ); diff --git a/rust/crates/truapi-host-cli/js/scripts/battery.ts b/rust/crates/truapi-host-cli/js/scripts/battery.ts index ef2c032aa..46e0dd5dc 100644 --- a/rust/crates/truapi-host-cli/js/scripts/battery.ts +++ b/rust/crates/truapi-host-cli/js/scripts/battery.ts @@ -21,6 +21,10 @@ import { expectedCliBatteryFailureReason, runDiagnosis, } from "../diagnosis.ts"; +import { + runAutoSigningRingVrfE2e, + runRingVrfRegistryE2e, +} from "../ring-vrf-e2e.ts"; const report = cliDiagnosisReportMetadata(process.env.TRUAPI_CLI_HOST_ROLE); const DEFAULT_REPORT_PATH = fileURLToPath( @@ -55,10 +59,19 @@ reporter.waitingForHost(HOST_READINESS_DELAY_MS); await new Promise((resolve) => setTimeout(resolve, HOST_READINESS_DELAY_MS)); const startedAt = performance.now(); -const rows = await runDiagnosis(truapi, { +const rows = []; +const ringVrfRegistry = await runRingVrfRegistryE2e( + truapi, + host.productId, + process.env.TRUAPI_APPROVALS_LOG, +); +reporter.result(ringVrfRegistry); +rows.push(ringVrfRegistry); +const diagnosisRows = await runDiagnosis(truapi, { ...options, onResult: (row) => reporter.result(row), }); +rows.push(...diagnosisRows); // Beyond the generated per-method examples: AutoSigning must make follow-up // sign_vrf calls prompt-free, observed through the host's approvals transcript. const autoSigning = await runAutoSigningE2e( @@ -68,6 +81,12 @@ const autoSigning = await runAutoSigningE2e( ); reporter.result(autoSigning); rows.push(autoSigning); +const autoSigningRingVrf = await runAutoSigningRingVrfE2e( + truapi, + host.productId, +); +reporter.result(autoSigningRingVrf); +rows.push(autoSigningRingVrf); reporter.finish(rows, Math.round(performance.now() - startedAt)); mkdirSync(dirname(REPORT_PATH), { recursive: true }); writeFileSync(REPORT_PATH, renderDiagnosisReport(report.title, rows)); @@ -75,9 +94,12 @@ reporter.reportSaved(REPORT_PATH); const failures = rows.filter((row) => row.status === "fail"); const unexpectedFailures = failures.filter( - (row) => !expectedCliBatteryFailureReason(row.serviceName), + (row) => !expectedCliBatteryFailureReason(row), ); if (unexpectedFailures.length > 0) { + for (const row of unexpectedFailures) { + console.error(`Unexpected failure: ${row.id}\n${row.output}`); + } throw new Error( `TrUAPI battery failed: ${unexpectedFailures.length} of ${rows.length} generated examples failed outside the known unsupported baseline`, ); diff --git a/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts index fab72a3ba..224018813 100644 --- a/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts +++ b/rust/crates/truapi-host-cli/js/scripts/ring-vrf-smoke.ts @@ -5,12 +5,19 @@ const PEOPLE_COLLECTION_ID = "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; const PEOPLE_GENESIS = "0xc5af1826b31493f08b7e2a823842f98575b806a784126f28da9608c68665afa5"; -const context = { productId: host.productId, suffix: "0x00" }; +const index = { tag: "Left" as const, value: 0 }; +const keyHandle = { + dotNsIdentifier: host.productId, + derivationIndex: index, +}; +const context = { productId: host.productId, suffix: index }; const ringLocation = { - chainId: PEOPLE_GENESIS, + chainId: PEOPLE_GENESIS as `0x${string}`, junctions: [ - { tag: "PalletInstance" as const, value: 67 }, - { tag: "CollectionId" as const, value: PEOPLE_COLLECTION_ID }, + { + tag: "CollectionId" as const, + value: PEOPLE_COLLECTION_ID as `0x${string}`, + }, ], }; @@ -24,30 +31,67 @@ if ( ); } -const aliasResult = await truapi.account.getAccountAlias({ context, ringLocation }); +const registration = await truapi.account.registerRingVrfKey({ + index, + ring: ringLocation, +}); +if (!registration.isOk()) { + throw new Error( + `registerRingVrfKey failed: ${JSON.stringify(registration.error)}`, + ); +} + +const listed = await truapi.account.listRingVrfKeys({ + owner: host.productId, + disclosure: "PublicKey", +}); +if (!listed.isOk()) { + throw new Error(`listRingVrfKeys failed: ${JSON.stringify(listed.error)}`); +} +const entry = listed.value.find( + (candidate) => + candidate.handle.dotNsIdentifier === host.productId && + candidate.handle.derivationIndex.tag === "Left" && + candidate.handle.derivationIndex.value === index.value, +); +if (!entry || entry.publicKey !== registration.value) { + throw new Error("registered key was not returned by listRingVrfKeys"); +} + +const aliasResult = await truapi.account.getAccountAlias({ + keyHandle, + context, + ringLocation, +}); if (!aliasResult.isOk()) { throw new Error(`getAccountAlias failed: ${JSON.stringify(aliasResult.error)}`); } const proofResult = await truapi.account.createAccountProof({ + keyHandle, context, ringLocation, message: "0x48656c6c6f", }); -if (!proofResult.isOk()) { - throw new Error(`createAccountProof failed: ${JSON.stringify(proofResult.error)}`); +if ( + !proofResult.isErr() || + proofResult.error.tag !== "Domain" || + proofResult.error.value.tag !== "V1" || + proofResult.error.value.value.tag !== "NotMember" +) { + throw new Error( + `createAccountProof did not report NotMember for the fresh key: ${JSON.stringify(proofResult)}`, + ); } -if (proofResult.value.contextualAlias.alias !== aliasResult.value.alias) { - throw new Error("alias and proof selected different ring members"); -} -if (proofResult.value.contextualAlias.context !== aliasResult.value.context) { - throw new Error("alias and proof used different context hashes"); -} -if (proofResult.value.proof.length <= 2) { - throw new Error("createAccountProof returned an empty proof"); +const signature = await truapi.account.ringVrfSign({ + keyHandle, + message: "0x48656c6c6f", +}); +if (!signature.isOk() || signature.value.length !== 130) { + throw new Error(`ringVrfSign failed: ${JSON.stringify(signature)}`); } console.log( - `RING_VRF_OK ring=${proofResult.value.ringIndex} revision=${proofResult.value.ringRevision} proofBytes=${(proofResult.value.proof.length - 2) / 2}`, + `RING_VRF_OK publicKey=${registration.value} alias=${aliasResult.value.alias} signatureBytes=64`, ); diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index cbf5e2e8d..d6e3ab11b 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -540,6 +540,12 @@ pub enum CoreStorageKey { }, /// Wallet-bound RFC-0010 AutoSigning capabilities for the active pairing. AutoSigningKeys, + /// Wallet-bound RFC-0024 ring-VRF registry snapshot. + #[codec(index = 7)] + RingVrfRegistry { + /// Root account public key identifying the wallet that owns the registry. + root_public_key: [u8; 32], + }, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. /// @@ -586,6 +592,7 @@ pub fn describe_core_storage_key( CoreStorageKey::LastProcessedPairingStatement => ("LastProcessedPairingStatement", None), CoreStorageKey::AutoSigningKey { product_id } => ("AutoSigningKey", Some(product_id)), CoreStorageKey::AutoSigningKeys => ("AutoSigningKeys", None), + CoreStorageKey::RingVrfRegistry { .. } => ("RingVrfRegistry", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -701,6 +708,13 @@ mod tests { Some("product.dot"), ), (CoreStorageKey::AutoSigningKeys, "AutoSigningKeys", None), + ( + CoreStorageKey::RingVrfRegistry { + root_public_key: [0x42; 32], + }, + "RingVrfRegistry", + None, + ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index dffb7110d..02509c025 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -190,13 +190,14 @@ role-specific lifecycle, so no method exists on a role that can't mean it: - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session from host-held secret material. Its public identity is the RFC-0022 - `uid.dot` index-0 product account; full and lite person ring-VRF keys are - `peopl.dot` indices 0 and 1 under the keyed-hash `ring-vrf` tree. It resolves - RFC-0004 `RingLocation` values against the chain's `Members` pallet and pins - membership, ring pages, exponent, and revision reads to one finalized block - before creating an alias or proof. Full personhood is preferred over lite - personhood. Extrinsic-payload signing and v4 transaction construction work - from pre-encoded payload fields, so no chain metadata is needed; + `uid.dot` index-0 product account. RFC-0024 ring-VRF keys are explicit, + product-owned registry entries; aliases, proofs, direct signatures, and + internal personhood flows use the requested or user-selected registered key + without a compiled-in fallback. It resolves RFC-0004 `RingLocation` values + against the chain's `Members` pallet and pins membership, ring pages, + exponent, and revision reads to one finalized block before creating a proof. + Extrinsic-payload signing and v4 transaction construction work from + pre-encoded payload fields, so no chain metadata is needed; statement-store and Bulletin allowance allocation are native-only (wasm builds report them as unavailable). diff --git a/rust/crates/truapi-server/src/generated/dispatcher.rs b/rust/crates/truapi-server/src/generated/dispatcher.rs index 1cefce408..e2e425257 100644 --- a/rust/crates/truapi-server/src/generated/dispatcher.rs +++ b/rust/crates/truapi-server/src/generated/dispatcher.rs @@ -209,6 +209,98 @@ where }, ); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_REGISTER_RING_VRF_KEY, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRegisterRingVrfKeyRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRegisterRingVrfKeyResponse = match host.register_ring_vrf_key(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_LIST_RING_VRF_KEYS, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountListRingVrfKeysRequest = match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError = + truapi::CallError::MalformedFrame { reason: err.to_string() }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountListRingVrfKeysResponse = match host.list_ring_vrf_keys(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }); + } + { + let host = host.clone(); + dispatcher.on_request( + wire_table::ACCOUNT_RING_VRF_SIGN, + move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostAccountRingVrfSignRequest = + match Decode::decode(&mut &bytes[..]) { + Ok(request) => request, + Err(err) => { + let error: truapi::CallError< + versioned::account::HostAccountRingVrfSignError, + > = truapi::CallError::MalformedFrame { + reason: err.to_string(), + }; + return Ok(encode_versioned_err_payload( + error, + ::LATEST, + )); + } + }; + let target_version = request.version(); + let cx = CallContext::with_request_id(request_id.clone()); + let response: versioned::account::HostAccountRingVrfSignResponse = + match host.ring_vrf_sign(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload(err, target_version)); + } + }; + Ok(encode_versioned_ok_payload(response)) + }) + }, + ); + } { let host = host.clone(); dispatcher.on_request( diff --git a/rust/crates/truapi-server/src/generated/wire_table.rs b/rust/crates/truapi-server/src/generated/wire_table.rs index 7360d0427..b6555f1d9 100644 --- a/rust/crates/truapi-server/src/generated/wire_table.rs +++ b/rust/crates/truapi-server/src/generated/wire_table.rs @@ -466,6 +466,24 @@ pub const ACCOUNT_SIGN_VRF: RequestFrameIds = RequestFrameIds { response_id: 165, }; +/// Wire discriminants for `account_register_ring_vrf_key`. +pub const ACCOUNT_REGISTER_RING_VRF_KEY: RequestFrameIds = RequestFrameIds { + request_id: 166, + response_id: 167, +}; + +/// Wire discriminants for `account_list_ring_vrf_keys`. +pub const ACCOUNT_LIST_RING_VRF_KEYS: RequestFrameIds = RequestFrameIds { + request_id: 168, + response_id: 169, +}; + +/// Wire discriminants for `account_ring_vrf_sign`. +pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { + request_id: 170, + response_id: 171, +}; + /// The full wire table. Ordering is part of the wire protocol; /// only ever append. Removed methods leave their slot empty. pub const WIRE_TABLE: &[WireEntry] = &[ @@ -729,4 +747,16 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_sign_vrf", kind: WireKind::Request(ACCOUNT_SIGN_VRF), }, + WireEntry { + method: "account_register_ring_vrf_key", + kind: WireKind::Request(ACCOUNT_REGISTER_RING_VRF_KEY), + }, + WireEntry { + method: "account_list_ring_vrf_keys", + kind: WireKind::Request(ACCOUNT_LIST_RING_VRF_KEYS), + }, + WireEntry { + method: "account_ring_vrf_sign", + kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), + }, ]; diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d226ea5e8..4f7ce5a25 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -140,6 +140,40 @@ impl PairingHostRuntime { .map_err(|reason| v01::GenericError { reason }) } + /// Registered providers available for an internal well-known-ring feature. + pub async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.pairing_host + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Current provider selected for an internal well-known-ring feature. + pub async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.pairing_host + .selected_ring_vrf_provider(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Select a registered provider for an internal well-known-ring feature. + pub async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), v01::GenericError> { + self.pairing_host + .select_ring_vrf_provider(ring, handle) + .await + .map_err(ring_vrf_admin_error) + } + /// Clear the canonical paired session and all capability caches/storage /// without sending a peer-disconnect notice. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.reset_session_state"))] @@ -336,6 +370,40 @@ impl SigningHostRuntime { }) } + /// Registered providers available for an internal well-known-ring feature. + pub async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.signing_host + .ring_vrf_providers(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Current provider selected for an internal well-known-ring feature. + pub async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, v01::GenericError> { + self.signing_host + .selected_ring_vrf_provider(ring) + .await + .map_err(ring_vrf_admin_error) + } + + /// Select a registered provider for an internal well-known-ring feature. + pub async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), v01::GenericError> { + self.signing_host + .select_ring_vrf_provider(ring, handle) + .await + .map_err(ring_vrf_admin_error) + } + /// Activate a wallet-local session from host-held secret material (raw /// BIP-39 entropy). #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] @@ -377,6 +445,17 @@ impl SigningHostRuntime { } } +fn ring_vrf_admin_error( + error: crate::host_logic::sso::messages::RingVrfError, +) -> v01::GenericError { + v01::GenericError { + reason: match error { + crate::host_logic::sso::messages::RingVrfError::Unknown { reason } => reason, + other => format!("{other:?}"), + }, + } +} + /// Product-scoped administration handle for host UI. /// /// Host UI should use this when it needs to inspect or update core-owned state diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 94e2da354..fcff402e1 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -4,8 +4,8 @@ //! accounts use one soft junction carrying the RFC-0022 32-byte derivation //! index, so a paired host can derive children from the subtree public key. //! Reserved built-ins additionally pin the `uid.dot` identity account and the -//! `peopl.dot` full/lite ring-VRF keyed-hash paths so activation, pairing, -//! registration, proof, allowance, and CLI code cannot drift. +//! legacy `peopl.dot` full/lite ring-VRF keyed-hash paths used by pairing +//! attestation. RFC-0024 operational key selection comes from the registry. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: //! @@ -106,13 +106,43 @@ pub fn derive_lite_person_ring_vrf_entropy(root_entropy: &[u8]) -> [u8; 32] { } fn derive_person_ring_vrf_entropy(root_entropy: &[u8], index: u32) -> [u8; 32] { + derive_ring_vrf_entropy( + root_entropy, + PERSONHOOD_PRODUCT_ID, + &truapi::v01::DerivationIndex::Left(index), + ) + .expect("the reserved personhood product id is a valid junction") +} + +/// Derive arbitrary RFC-0022 ring-VRF entropy: +/// `hash(root_entropy, "ring-vrf")//{product_id}//{derivation_index}`. +pub fn derive_ring_vrf_entropy( + root_entropy: &[u8], + product_id: &str, + derivation_index: &truapi::v01::DerivationIndex, +) -> Result<[u8; 32], ProductAccountError> { + let domain = derive_ring_vrf_domain_entropy(root_entropy, product_id)?; + Ok(derive_ring_vrf_entropy_from_domain( + &domain, + derivation_index, + )) +} + +/// Derive the RFC-0024 AutoSigning entropy for a product in the ring-VRF tree. +pub fn derive_ring_vrf_domain_entropy( + root_entropy: &[u8], + product_id: &str, +) -> Result<[u8; 32], ProductAccountError> { let root = blake2b256_keyed(root_entropy, RING_VRF_ROOT_KEY); - let domain = blake2b256_keyed( - &root, - &create_chain_code(PERSONHOOD_PRODUCT_ID) - .expect("the reserved personhood product id is a valid junction"), - ); - blake2b256_keyed(&domain, &index_bytes(index)) + Ok(blake2b256_keyed(&root, &create_chain_code(product_id)?)) +} + +/// Derive one registered member key from an RFC-0024 product-domain entropy. +pub fn derive_ring_vrf_entropy_from_domain( + domain_entropy: &[u8; 32], + derivation_index: &truapi::v01::DerivationIndex, +) -> [u8; 32] { + blake2b256_keyed(domain_entropy, &derivation_index_bytes(derivation_index)) } fn blake2b256_keyed(message: &[u8], key: &[u8]) -> [u8; 32] { @@ -368,6 +398,20 @@ mod tests { ); } + #[test] + fn ring_vrf_domain_entropy_derives_the_same_registered_key_as_the_root() { + use truapi::v01::DerivationIndex; + + let root_entropy: Vec = (1..=32).collect(); + let domain = derive_ring_vrf_domain_entropy(&root_entropy, "people-provider.dot").unwrap(); + for index in [DerivationIndex::Left(7), DerivationIndex::Right([0xEE; 32])] { + assert_eq!( + derive_ring_vrf_entropy_from_domain(&domain, &index), + derive_ring_vrf_entropy(&root_entropy, "people-provider.dot", &index).unwrap() + ); + } + } + #[test] fn identity_is_uid_dot_default_product_account_and_signs() { let entropy = [0xAB; 16]; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 942f4c157..f7a37954f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -319,17 +319,19 @@ pub struct SignVrfResponse { pub payload: Result, } -/// Failure returned by the Account Holder for a ring-VRF proof or alias request. -/// -/// Mirrors the identical error sets of `Account::create_account_proof` and -/// `Account::get_account_alias` (RFC 0004): the two operations perform the same -/// ring resolution and member-key selection, so they share these failure modes. +/// Failure returned by the Account Holder for RFC-0024 ring-VRF operations. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RingVrfError { /// The `RingLocation` did not resolve to a known ring. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The requested key handle is not registered. + KeyNotRegistered, + /// The requested key handle is not registered for the requested ring. + KeyNotInRing, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, /// User or Account Holder rejected the request. Rejected, /// Catch-all failure, carrying a diagnostic reason. @@ -341,13 +343,14 @@ pub enum RingVrfError { /// Request sent when a product asks the Account Holder for a contextual alias. /// -/// Used by `Account::get_account_alias`; `calling_product_id` names the caller -/// so the Account Holder can scope context derivation, while `context` and -/// `ring_location` select the member key and bind the derived alias (RFC 0004). +/// Used by `Account::get_account_alias`; `calling_product_id` names the caller, +/// `key_handle` selects a registered member key, and `context` binds the alias. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfAliasRequest { /// Product id of the calling product. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Context that scopes the derived alias. pub context: ProductProofContext, /// Ring whose member key derives the alias. @@ -372,6 +375,8 @@ pub struct RingVrfAliasResponse { pub struct RingVrfProofRequest { /// Product id of the calling product. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Context that scopes the proof. pub context: ProductProofContext, /// Ring whose member key produces the proof. @@ -380,6 +385,66 @@ pub struct RingVrfProofRequest { pub message: Vec, } +/// Request to register a ring-VRF key with the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisterRingVrfKeyRequest { + /// Product id of the calling product and key owner. + pub calling_product_id: String, + /// Key derivation index within the owner's ring-VRF domain. + pub index: DerivationIndex, + /// Ring declared for the key. + pub ring: RingLocation, +} + +/// Response returned by the Account Holder for key registration. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisterRingVrfKeyResponse { + /// `message_id` of the registration request being answered. + pub responding_to: String, + /// Member public key or ring-VRF failure. + pub payload: Result<[u8; 32], RingVrfError>, +} + +/// Request to list registered ring-VRF keys. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ListRingVrfKeysRequest { + /// Product id of the calling product. + pub calling_product_id: String, + /// Product whose registry entries should be listed. + pub owner: String, + /// Disclosure requested by the caller. + pub disclosure: truapi::v01::RingVrfKeyDisclosure, +} + +/// Response returned by the Account Holder for registry listing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ListRingVrfKeysResponse { + /// `message_id` of the listing request being answered. + pub responding_to: String, + /// Registry entries or ring-VRF failure. + pub payload: Result, RingVrfError>, +} + +/// Request to sign bytes with a ring-VRF key. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfSignRequest { + /// Product id of the calling product. + pub calling_product_id: String, + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Message to sign. + pub message: Vec, +} + +/// Response returned by the Account Holder for direct ring-VRF signing. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RingVrfSignResponse { + /// `message_id` of the signing request being answered. + pub responding_to: String, + /// Signature bytes or ring-VRF failure. + pub payload: Result, RingVrfError>, +} + /// Response returned by the Account Holder for a ring-VRF proof request. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RingVrfProofResponse { @@ -480,6 +545,8 @@ pub enum SsoAllocatedResource { AutoSigning { /// Private key of the product subtree root. product_root_private_key: [u8; 64], + /// Entropy of the product's ring-VRF domain. + ring_vrf_domain_entropy: [u8; 32], }, } @@ -587,6 +654,12 @@ pub enum SsoRemoteResponse { CreateTransaction(CreateTransactionResponse), /// Product subtree public-key response. ProductSubtree(ProductSubtreeResponse), + /// Ring-VRF key registration response. + RegisterRingVrfKey(RegisterRingVrfKeyResponse), + /// Ring-VRF key listing response. + ListRingVrfKeys(ListRingVrfKeysResponse), + /// Direct ring-VRF signing response. + RingVrfSign(RingVrfSignResponse), } impl SsoRemoteResponse { @@ -601,6 +674,9 @@ impl SsoRemoteResponse { Self::ResourceAllocation(_) => "resource-allocation", Self::CreateTransaction(_) => "create-transaction", Self::ProductSubtree(_) => "product-subtree", + Self::RegisterRingVrfKey(_) => "register-ring-vrf-key", + Self::ListRingVrfKeys(_) => "list-ring-vrf-keys", + Self::RingVrfSign(_) => "ring-vrf-sign", } } } @@ -648,8 +724,7 @@ pub fn decode_sso_session_statement( SsoStatementData::Response { .. } => Ok(None), SsoStatementData::Request { data, .. } => { for message in data { - let message = RemoteMessage::decode(&mut message.as_slice()) - .map_err(|err| format!("invalid SSO remote message: {err}"))?; + let message = decode_remote_message(&message)?; if matches!( &message.data, RemoteMessageData::V1(v1::RemoteMessage::Disconnected) @@ -724,10 +799,80 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::ProductSubtree(response)) } + v1::RemoteMessage::RegisterRingVrfKeyResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RegisterRingVrfKey(response)) + } + v1::RemoteMessage::ListRingVrfKeysResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ListRingVrfKeys(response)) + } + v1::RemoteMessage::RingVrfSignResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::RingVrfSign(response)) + } _ => None, } } +/// Build an RFC-0024 ring-VRF key registration request for the Account Holder. +pub fn register_ring_vrf_key_message( + message_id: String, + calling_product_id: String, + index: DerivationIndex, + ring: RingLocation, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyRequest( + RegisterRingVrfKeyRequest { + calling_product_id, + index, + ring, + }, + )), + } +} + +/// Build an RFC-0024 ring-VRF key listing request for the Account Holder. +pub fn list_ring_vrf_keys_message( + message_id: String, + calling_product_id: String, + owner: String, + disclosure: truapi::v01::RingVrfKeyDisclosure, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysRequest( + ListRingVrfKeysRequest { + calling_product_id, + owner, + disclosure, + }, + )), + } +} + +/// Build an RFC-0024 direct ring-VRF signing request for the Account Holder. +pub fn ring_vrf_sign_message( + message_id: String, + calling_product_id: String, + key_handle: ProductAccountId, + message: Vec, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { + calling_product_id, + key_handle, + message, + })), + } +} + /// Build an RFC-0023 VRF-signing request for the Account Holder. pub fn sign_vrf_message( message_id: String, @@ -790,6 +935,7 @@ pub fn sign_raw_legacy_message( pub fn alias_request_message( message_id: String, calling_product_id: String, + key_handle: ProductAccountId, context: ProductProofContext, ring_location: RingLocation, ) -> RemoteMessage { @@ -798,6 +944,7 @@ pub fn alias_request_message( data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest( RingVrfAliasRequest { calling_product_id, + key_handle, context, ring_location, }, @@ -809,6 +956,7 @@ pub fn alias_request_message( pub fn proof_request_message( message_id: String, calling_product_id: String, + key_handle: ProductAccountId, context: ProductProofContext, ring_location: RingLocation, message: Vec, @@ -818,6 +966,7 @@ pub fn proof_request_message( data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofRequest( RingVrfProofRequest { calling_product_id, + key_handle, context, ring_location, message, @@ -947,10 +1096,7 @@ pub fn decode_incoming_sso_request( SsoStatementData::Request { request_id, data } => { let messages = data .iter() - .map(|message| { - RemoteMessage::decode(&mut message.as_slice()) - .map_err(|err| format!("invalid SSO remote message: {err}")) - }) + .map(|message| decode_remote_message(message)) .collect::, _>>() .map_err(|reason| SsoRequestDecodeError { request_id: Some(request_id.clone()), @@ -964,6 +1110,16 @@ pub fn decode_incoming_sso_request( } } +fn decode_remote_message(message: &[u8]) -> Result { + let mut input = message; + let decoded = RemoteMessage::decode(&mut input) + .map_err(|error| format!("invalid SSO remote message: {error}"))?; + if !input.is_empty() { + return Err("invalid SSO remote message: trailing bytes".to_string()); + } + Ok(decoded) +} + /// Build the signed transport acknowledgement for a peer-initiated request. pub fn build_signed_session_response_statement( session: &SsoSessionInfo, @@ -1126,6 +1282,14 @@ mod tests { #[test] fn late_remote_message_variants_match_host_papp_order() { + let ring_location = RingLocation { + chain_id: [0; 32], + junctions: vec![], + }; + let key_handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; let legacy_tx = create_transaction_legacy_message( String::new(), LegacyAccountTxPayload { @@ -1140,9 +1304,115 @@ mod tests { let legacy_raw = sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) .encode(); + let register = register_ring_vrf_key_message( + String::new(), + "caller.dot".to_string(), + DerivationIndex::Left(0), + ring_location.clone(), + ) + .encode(); + let register_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse( + RegisterRingVrfKeyResponse { + responding_to: String::new(), + payload: Ok([1; 32]), + }, + )), + } + .encode(); + let list = list_ring_vrf_keys_message( + String::new(), + "caller.dot".to_string(), + "peopl.dot".to_string(), + truapi::v01::RingVrfKeyDisclosure::Anonymized, + ) + .encode(); + let list_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse( + ListRingVrfKeysResponse { + responding_to: String::new(), + payload: Ok(Vec::new()), + }, + )), + } + .encode(); + let sign = + ring_vrf_sign_message(String::new(), "caller.dot".to_string(), key_handle, vec![]) + .encode(); + let sign_response = RemoteMessage { + message_id: String::new(), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse( + RingVrfSignResponse { + responding_to: String::new(), + payload: Ok(Vec::new()), + }, + )), + } + .encode(); assert_eq!(legacy_tx[..3], [0, 0, 9]); assert_eq!(legacy_raw[..3], [0, 0, 10]); + assert_eq!(register[..3], [0, 0, 18]); + assert_eq!(register_response[..3], [0, 0, 19]); + assert_eq!(list[..3], [0, 0, 20]); + assert_eq!(list_response[..3], [0, 0, 21]); + assert_eq!(sign[..3], [0, 0, 22]); + assert_eq!(sign_response[..3], [0, 0, 23]); + assert_eq!(RingVrfError::RingNotFound.encode()[0], 0); + assert_eq!(RingVrfError::NotMember.encode()[0], 1); + assert_eq!(RingVrfError::KeyNotRegistered.encode()[0], 2); + assert_eq!(RingVrfError::KeyNotInRing.encode()[0], 3); + assert_eq!(RingVrfError::NotAllowlisted.encode()[0], 4); + assert_eq!(RingVrfError::Rejected.encode()[0], 5); + assert_eq!( + RingVrfError::Unknown { + reason: String::new() + } + .encode()[0], + 6 + ); + } + + #[test] + fn rfc_0024_requests_match_android_scale_fixtures() { + let ring = RingLocation { + chain_id: [7; 32], + junctions: vec![ + RingLocationJunction::PalletInstance(9), + RingLocationJunction::CollectionId(b"pop:polkadot.network/people ".to_vec()), + ], + }; + let handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; + let messages = [ + v1::RemoteMessage::RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest { + calling_product_id: "game.dot".to_string(), + index: DerivationIndex::Left(4), + ring: ring.clone(), + }), + v1::RemoteMessage::ListRingVrfKeysRequest(ListRingVrfKeysRequest { + calling_product_id: "game.dot".to_string(), + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::PublicKey, + }), + v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { + calling_product_id: "game.dot".to_string(), + key_handle: handle, + message: (0..16).collect(), + }), + ]; + let expected = [ + "0x122067616d652e646f74000400000007070707070707070707070707070707070707070707070707070707070707070800090180706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652020202020", + "0x142067616d652e646f742470656f706c2e646f7401", + "0x162067616d652e646f742470656f706c2e646f74000000000040000102030405060708090a0b0c0d0e0f", + ]; + for (message, expected) in messages.into_iter().zip(expected) { + assert_eq!(format!("0x{}", hex::encode(message.encode())), expected); + } } #[test] @@ -1158,16 +1428,22 @@ mod tests { RingLocationJunction::CollectionId(b"pop".to_vec()), ], }; + let key_handle = ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: DerivationIndex::Left(0), + }; let alias = alias_request_message( "m-alias".to_string(), "caller.dot".to_string(), + key_handle.clone(), context.clone(), ring_location.clone(), ); let proof = proof_request_message( "m-proof".to_string(), "caller.dot".to_string(), + key_handle, context, ring_location, b"vote".to_vec(), @@ -1175,11 +1451,11 @@ mod tests { assert_host_papp_0_8_11_fixture( alias, - "0x1c6d2d616c69617300032863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f70", + "0x1c6d2d616c69617300032863616c6c65722e646f742470656f706c2e646f74000000000028766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f70", ); assert_host_papp_0_8_11_fixture( proof, - "0x1c6d2d70726f6f66000c2863616c6c65722e646f7428766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", + "0x1c6d2d70726f6f66000c2863616c6c65722e646f742470656f706c2e646f74000000000028766f74696e672e646f7400000000001111111111111111111111111111111111111111111111111111111111111111080043010c706f7010766f7465", ); } @@ -1324,6 +1600,7 @@ mod tests { payload: Ok(vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: sequential_bytes(0), + ring_vrf_domain_entropy: sequential_bytes(64), }, )]), }, @@ -1332,12 +1609,28 @@ mod tests { assert_eq!( hex::encode(message.encode()), format!( - "046d0006047200040003{}", - hex::encode(sequential_bytes::<64>(0)) + "046d0006047200040003{}{}", + hex::encode(sequential_bytes::<64>(0)), + hex::encode(sequential_bytes::<32>(64)) ) ); } + #[test] + fn remote_message_decoder_rejects_trailing_bytes() { + let mut encoded = RemoteMessage { + message_id: "m".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + } + .encode(); + encoded.push(0); + + assert_eq!( + decode_remote_message(&encoded), + Err("invalid SSO remote message: trailing bytes".to_string()) + ); + } + #[test] fn allocated_resource_debug_redacts_private_material_through_all_wrappers() { let allowance = SsoAllocatedResource::StatementStoreAllowance { @@ -1370,6 +1663,7 @@ mod tests { payload: Ok(vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: auto_signing_secret, + ring_vrf_domain_entropy: [0x5A; 32], }, )]), }; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 6b28ca730..68f9c09dc 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -9,10 +9,11 @@ use parity_scale_codec::{Decode, Encode}; use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, - ProductSubtreeRequest, ProductSubtreeResponse, ResourceAllocationRequest, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, + RegisterRingVrfKeyRequest, RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, - RingVrfProofResponse, SignRawLegacyRequest, SignRawLegacyResponse, SignVrfRequest, - SignVrfResponse, SigningRequest, SigningResponse, + RingVrfProofResponse, RingVrfSignRequest, RingVrfSignResponse, SignRawLegacyRequest, + SignRawLegacyResponse, SignVrfRequest, SignVrfResponse, SigningRequest, SigningResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -79,4 +80,28 @@ pub enum RemoteMessage { #[codec(index = 17)] #[display("product_subtree_response")] ProductSubtreeResponse(ProductSubtreeResponse), + /// Register a ring-VRF key with the Account Holder. + #[codec(index = 18)] + #[display("register_ring_vrf_key")] + RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), + /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. + #[codec(index = 19)] + #[display("register_ring_vrf_key_response")] + RegisterRingVrfKeyResponse(RegisterRingVrfKeyResponse), + /// List registered ring-VRF keys. + #[codec(index = 20)] + #[display("list_ring_vrf_keys")] + ListRingVrfKeysRequest(ListRingVrfKeysRequest), + /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. + #[codec(index = 21)] + #[display("list_ring_vrf_keys_response")] + ListRingVrfKeysResponse(ListRingVrfKeysResponse), + /// Sign bytes with a registered ring-VRF key. + #[codec(index = 22)] + #[display("ring_vrf_sign")] + RingVrfSignRequest(RingVrfSignRequest), + /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. + #[codec(index = 23)] + #[display("ring_vrf_sign_response")] + RingVrfSignResponse(RingVrfSignResponse), } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 51f3da5ff..2ce695798 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -24,12 +24,15 @@ use truapi_platform::{ JsonRpcConnection, Navigation, Notifications, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, PlatformInfo, PreimageHost, ProductContext, ProductStorage, RuntimeConfigValidationError, SigningHostConfig, ThemeHost, UserConfirmation, - UserConfirmationReview, async_trait, + UserConfirmationReview, async_trait, normalize_product_identifier, }; pub mod reviews; -pub use reviews::NativeUserConfirmationReview; +pub use reviews::{ + NativeUserConfirmationReview, ProductAccountId as NativeProductAccountId, + RingLocation as NativeRingLocation, +}; use crate::SigningHostRuntime; use crate::host_logic::dotns; @@ -871,6 +874,45 @@ impl NativeTrUApiCore { .map_err(Into::into) } + /// List registered providers for a ring so host UI can present the RFC-0024 + /// personhood-provider setting. + pub fn ring_vrf_providers( + &self, + ring: NativeRingLocation, + ) -> Result, HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + futures::executor::block_on(self.runtime.ring_vrf_providers(&ring)) + .map(|providers| providers.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + /// Return the currently selected provider for a ring. + pub fn selected_ring_vrf_provider( + &self, + ring: NativeRingLocation, + ) -> Result, HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + futures::executor::block_on(self.runtime.selected_ring_vrf_provider(&ring)) + .map(|provider| provider.map(Into::into)) + .map_err(Into::into) + } + + /// Persist a user-selected provider after checking that the handle is + /// registered for the exact ring. + pub fn select_ring_vrf_provider( + &self, + ring: NativeRingLocation, + handle: NativeProductAccountId, + ) -> Result<(), HostRejection> { + let ring = ring.try_into().map_err(native_ring_vrf_input_error)?; + let mut handle: v01::ProductAccountId = + handle.try_into().map_err(native_ring_vrf_input_error)?; + handle.dot_ns_identifier = normalize_product_identifier(&handle.dot_ns_identifier) + .map_err(|error| native_ring_vrf_input_error(error.to_string()))?; + futures::executor::block_on(self.runtime.select_ring_vrf_provider(ring, handle)) + .map_err(Into::into) + } + /// Push a host theme update to active TrUAPI theme subscriptions. pub fn notify_theme_changed(&self, theme: HostTheme) { self.events.notify_theme_changed(theme.into()); @@ -895,6 +937,10 @@ impl NativeTrUApiCore { } } +fn native_ring_vrf_input_error(reason: String) -> HostRejection { + HostRejection::Rejected { reason } +} + /// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`) for /// the `tracing` output, which on native routes to stderr (system logs on /// iOS/Android). Most native diagnostics flow through `on_core_log` instead; diff --git a/rust/crates/truapi-server/src/native/reviews.rs b/rust/crates/truapi-server/src/native/reviews.rs index 432f4977e..d99111221 100644 --- a/rust/crates/truapi-server/src/native/reviews.rs +++ b/rust/crates/truapi-server/src/native/reviews.rs @@ -26,6 +26,19 @@ impl From for DerivationIndex { } } +impl TryFrom for v01::DerivationIndex { + type Error = String; + + fn try_from(index: DerivationIndex) -> Result { + match index { + DerivationIndex::Index(index) => Ok(Self::Left(index)), + DerivationIndex::Raw(raw) => raw.try_into().map(Self::Right).map_err(|raw: Vec| { + format!("raw derivation index must be 32 bytes, got {}", raw.len()) + }), + } + } +} + /// Product account identifier: dotNS domain plus derivation index. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct ProductAccountId { @@ -48,6 +61,17 @@ impl From for ProductAccountId { } } +impl TryFrom for v01::ProductAccountId { + type Error = String; + + fn try_from(account: ProductAccountId) -> Result { + Ok(Self { + dot_ns_identifier: account.dot_ns_identifier, + derivation_index: account.derivation_index.try_into()?, + }) + } +} + /// Raw data to sign — binary bytes or a string message. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] pub enum RawPayload { @@ -333,6 +357,15 @@ impl From for RingLocationJunction { } } +impl From for v01::RingLocationJunction { + fn from(junction: RingLocationJunction) -> Self { + match junction { + RingLocationJunction::PalletInstance(instance) => Self::PalletInstance(instance), + RingLocationJunction::CollectionId(id) => Self::CollectionId(id), + } + } +} + /// Locates a ring for ring VRF operations. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct RingLocation { @@ -351,6 +384,20 @@ impl From for RingLocation { } } +impl TryFrom for v01::RingLocation { + type Error = String; + + fn try_from(location: RingLocation) -> Result { + let chain_id = location.chain_id.try_into().map_err(|chain_id: Vec| { + format!("ring chain id must be 32 bytes, got {}", chain_id.len()) + })?; + Ok(Self { + chain_id, + junctions: location.junctions.into_iter().map(Into::into).collect(), + }) + } +} + /// A product-scoped proof context: a product and a context within it. #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] pub struct ProductProofContext { diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 632cd0086..58ec8f3f7 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -15,6 +15,7 @@ mod authority; pub(crate) mod bulletin_rpc; mod identity; mod pairing_host; +mod ring_vrf_registry; /// Role-neutral runtime services shared by product-facing runtimes. pub(crate) mod services; mod signing_host; @@ -66,8 +67,9 @@ pub(crate) use signing_host::{ use authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, AuthoritySession, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use futures::{FutureExt, StreamExt, pin_mut}; @@ -82,7 +84,11 @@ use truapi::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, - HostAccountGetRequest, HostAccountGetResponse, HostAccountSignVrfError, + HostAccountGetRequest, HostAccountGetResponse, HostAccountListRingVrfKeysError, + HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, + HostAccountRegisterRingVrfKeyError, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignError, + HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, @@ -983,10 +989,17 @@ impl Account for ProductRuntimeHost { request: HostAccountGetAliasRequest, ) -> Result> { let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + key_handle, context, ring_location, }) = request; - + let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetAliasError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetAliasError::V1( v01::HostAccountGetAliasError::Rejected, @@ -1002,6 +1015,7 @@ impl Account for ProductRuntimeHost { &session, AccountAliasAuthorityRequest { calling_product_id, + key_handle, context, ring_location, }, @@ -1019,10 +1033,23 @@ impl Account for ProductRuntimeHost { request: HostAccountCreateProofRequest, ) -> Result> { let HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle, context, ring_location, message, }) = request; + let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { + CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; + if key_handle.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::NotAllowlisted, + ))); + } let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountCreateProofError::V1( @@ -1039,6 +1066,7 @@ impl Account for ProductRuntimeHost { &session, CreateProofAuthorityRequest { calling_product_id, + key_handle, context, ring_location, message, @@ -1052,6 +1080,135 @@ impl Account for ProductRuntimeHost { }) } + #[instrument(skip_all, fields(runtime.method = "account.register_ring_vrf_key"))] + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + request: HostAccountRegisterRingVrfKeyRequest, + ) -> Result> + { + let HostAccountRegisterRingVrfKeyRequest::V1(v01::HostAccountRegisterRingVrfKeyRequest { + index, + ring, + }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountRegisterRingVrfKeyError::V1( + v01::HostAccountRegisterRingVrfKeyError::NotConnected, + ))); + }; + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.register_ring_vrf_key( + &cx, + &session, + RegisterRingVrfKeyAuthorityRequest { + calling_product_id, + index, + ring, + }, + ), + ) + .await + .map(HostAccountRegisterRingVrfKeyResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountRegisterRingVrfKeyError::V1( + ring_vrf_register_error(err), + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.list_ring_vrf_keys"))] + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + request: HostAccountListRingVrfKeysRequest, + ) -> Result> + { + let HostAccountListRingVrfKeysRequest::V1(v01::HostAccountListRingVrfKeysRequest { + owner, + disclosure, + }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountListRingVrfKeysError::V1( + v01::HostAccountListRingVrfKeysError::NotConnected, + ))); + }; + let owner = normalize_product_identifier(&owner).map_err(|err| { + CallError::Domain(HostAccountListRingVrfKeysError::V1( + v01::HostAccountListRingVrfKeysError::Unknown { + reason: err.to_string(), + }, + )) + })?; + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.list_ring_vrf_keys( + &cx, + &session, + ListRingVrfKeysAuthorityRequest { + calling_product_id, + owner, + disclosure, + }, + ), + ) + .await + .map(HostAccountListRingVrfKeysResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountListRingVrfKeysError::V1(ring_vrf_list_error( + err, + ))) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.ring_vrf_sign"))] + async fn ring_vrf_sign( + &self, + cx: &CallContext, + request: HostAccountRingVrfSignRequest, + ) -> Result> { + let HostAccountRingVrfSignRequest::V1(mut request) = request; + request.key_handle = + Self::normalize_product_account_id(request.key_handle).map_err(|()| { + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotConnected, + ))); + }; + if request.key_handle.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotAllowlisted, + ))); + } + let calling_product_id = self.product_id(); + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.ring_vrf_sign( + &cx, + &session, + RingVrfSignAuthorityRequest { + calling_product_id, + key_handle: request.key_handle, + message: request.message, + }, + ), + ) + .await + .map(HostAccountRingVrfSignResponse::V1) + .map_err(|err| CallError::Domain(HostAccountRingVrfSignError::V1(ring_vrf_sign_error(err)))) + } + #[instrument(skip_all, fields(runtime.method = "account.sign_vrf"))] async fn sign_vrf( &self, @@ -1230,6 +1387,9 @@ fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { match err { RingVrfError::RingNotFound => v01::HostAccountGetAliasError::RingNotFound, RingVrfError::NotMember => v01::HostAccountGetAliasError::NotMember, + RingVrfError::KeyNotRegistered => v01::HostAccountGetAliasError::KeyNotRegistered, + RingVrfError::KeyNotInRing => v01::HostAccountGetAliasError::KeyNotInRing, + RingVrfError::NotAllowlisted => v01::HostAccountGetAliasError::Rejected, RingVrfError::Rejected => v01::HostAccountGetAliasError::Rejected, RingVrfError::Unknown { reason } => v01::HostAccountGetAliasError::Unknown { reason }, } @@ -1239,11 +1399,60 @@ fn ring_vrf_proof_error(err: RingVrfError) -> v01::HostAccountCreateProofError { match err { RingVrfError::RingNotFound => v01::HostAccountCreateProofError::RingNotFound, RingVrfError::NotMember => v01::HostAccountCreateProofError::NotMember, + RingVrfError::KeyNotRegistered => v01::HostAccountCreateProofError::KeyNotRegistered, + RingVrfError::KeyNotInRing => v01::HostAccountCreateProofError::KeyNotInRing, + RingVrfError::NotAllowlisted => v01::HostAccountCreateProofError::NotAllowlisted, RingVrfError::Rejected => v01::HostAccountCreateProofError::Rejected, RingVrfError::Unknown { reason } => v01::HostAccountCreateProofError::Unknown { reason }, } } +fn ring_vrf_register_error(err: RingVrfError) -> v01::HostAccountRegisterRingVrfKeyError { + match err { + RingVrfError::RingNotFound => v01::HostAccountRegisterRingVrfKeyError::RingNotFound, + RingVrfError::Rejected => v01::HostAccountRegisterRingVrfKeyError::Rejected, + RingVrfError::NotMember + | RingVrfError::KeyNotRegistered + | RingVrfError::KeyNotInRing + | RingVrfError::NotAllowlisted => v01::HostAccountRegisterRingVrfKeyError::Unknown { + reason: format!("{err:?}"), + }, + RingVrfError::Unknown { reason } => { + v01::HostAccountRegisterRingVrfKeyError::Unknown { reason } + } + } +} + +fn ring_vrf_list_error(err: RingVrfError) -> v01::HostAccountListRingVrfKeysError { + match err { + RingVrfError::Rejected => v01::HostAccountListRingVrfKeysError::Rejected, + RingVrfError::RingNotFound + | RingVrfError::NotMember + | RingVrfError::KeyNotRegistered + | RingVrfError::KeyNotInRing + | RingVrfError::NotAllowlisted => v01::HostAccountListRingVrfKeysError::Unknown { + reason: format!("{err:?}"), + }, + RingVrfError::Unknown { reason } => { + v01::HostAccountListRingVrfKeysError::Unknown { reason } + } + } +} + +fn ring_vrf_sign_error(err: RingVrfError) -> v01::HostAccountRingVrfSignError { + match err { + RingVrfError::KeyNotRegistered => v01::HostAccountRingVrfSignError::KeyNotRegistered, + RingVrfError::NotAllowlisted => v01::HostAccountRingVrfSignError::NotAllowlisted, + RingVrfError::Rejected => v01::HostAccountRingVrfSignError::Rejected, + RingVrfError::RingNotFound | RingVrfError::NotMember | RingVrfError::KeyNotInRing => { + v01::HostAccountRingVrfSignError::Unknown { + reason: format!("{err:?}"), + } + } + RingVrfError::Unknown { reason } => v01::HostAccountRingVrfSignError::Unknown { reason }, + } +} + fn signing_call_error( wrap: fn(v01::HostSignPayloadError) -> E, err: AuthorityError, @@ -4559,6 +4768,12 @@ mod tests { crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( crate::host_logic::sso::messages::SsoAllocatedResource::AutoSigning { product_root_private_key: subtree.secret.to_bytes(), + ring_vrf_domain_entropy: + crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(), }, ), ]), @@ -4615,6 +4830,12 @@ mod tests { crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( crate::host_logic::sso::messages::SsoAllocatedResource::AutoSigning { product_root_private_key, + ring_vrf_domain_entropy: + crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(), }, ), ]), @@ -4685,6 +4906,147 @@ mod tests { .expect("local AutoSigning VRF verifies"); } + #[test] + fn ring_vrf_sign_reports_not_connected_before_foreign_key_policy() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform::default()), + runtime_config("myapp.dot"), + test_spawner(), + ); + let request = HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: account_id("other.dot", 0), + message: b"not connected".to_vec(), + }); + + let error = + futures::executor::block_on(host.ring_vrf_sign(&CallContext::default(), request)) + .unwrap_err(); + + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::NotConnected + )) + )); + } + + #[test] + fn auto_signing_ring_vrf_requires_registration_and_signs_locally() { + use verifiable::GenerateVerifiable; + use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; + + let session = sso_session_info(); + let platform = Arc::new(StubPlatform::default()); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + platform.clone(), + ProductRuntimeHost::compat_host_config(), + ProductContext::new("myapp.dot".to_string()).unwrap(), + test_spawner(), + ); + install_pairing_session(&host, session.clone()); + + let root = + crate::host_logic::product_account::derive_root_keypair_from_entropy(&[0xAB; 16]) + .unwrap(); + let subtree = + crate::host_logic::product_account::derive_product_subtree_keypair(&root, "myapp.dot") + .unwrap(); + let domain = crate::host_logic::product_account::derive_ring_vrf_domain_entropy( + &[0xAB; 16], + "myapp.dot", + ) + .unwrap(); + futures::executor::block_on(pairing_host.remember_auto_signing_key_for_tests( + &session, + pairing_host.current_session_lifecycle_epoch(), + "myapp.dot", + subtree.public.to_bytes(), + subtree.secret.to_bytes(), + domain, + )) + .unwrap(); + + let handle = account_id("myapp.dot", 7); + let request = HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: handle.clone(), + message: b"registered keys only".to_vec(), + }); + let error = + futures::executor::block_on(host.ring_vrf_sign(&CallContext::default(), request)) + .unwrap_err(); + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::KeyNotRegistered + )) + )); + + let ring = ring_location_fixture(); + let entropy = crate::host_logic::product_account::derive_ring_vrf_entropy_from_domain( + &domain, + &handle.derivation_index, + ); + let public_key = + crate::runtime::signing_host::ring_vrf::member_from_entropy(&entropy).unwrap(); + futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + &session, + handle.clone(), + ring, + public_key, + )) + .unwrap(); + + let message = b"registered keys only".to_vec(); + let response = futures::executor::block_on(host.ring_vrf_sign( + &CallContext::default(), + HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: handle, + message: message.clone(), + }), + )) + .unwrap(); + let HostAccountRingVrfSignResponse::V1(signature) = response; + let signature: [u8; 64] = signature + .try_into() + .expect("fixed-width ring-VRF signature"); + assert!(BandersnatchVrfVerifiable::verify_signature( + &signature, + &message, + &public_key + )); + assert!( + platform + .sent_rpc + .lock() + .expect("sent RPC mutex poisoned") + .is_empty(), + "registered AutoSigning ring-VRF use stays local" + ); + + let mismatched_handle = account_id("myapp.dot", 8); + futures::executor::block_on(pairing_host.register_ring_vrf_key_for_tests( + &session, + mismatched_handle.clone(), + ring_location_fixture(), + [0xFF; 32], + )) + .unwrap(); + let error = futures::executor::block_on(host.ring_vrf_sign( + &CallContext::default(), + HostAccountRingVrfSignRequest::V1(v01::HostAccountRingVrfSignRequest { + key_handle: mismatched_handle, + message: b"reject mismatched registry state".to_vec(), + }), + )) + .unwrap_err(); + assert!(matches!( + error, + CallError::Domain(HostAccountRingVrfSignError::V1( + v01::HostAccountRingVrfSignError::Unknown { reason } + )) if reason.contains("does not match the AutoSigning capability") + )); + } + #[test] fn auto_signing_rejects_persisted_key_for_unexpected_product_subtree() { let session = sso_session_info(); @@ -4808,6 +5170,7 @@ mod tests { "myapp.dot", subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .expect_err("the old AutoSigning allocation completion must be rejected"); let statement_store_result = @@ -4893,6 +5256,7 @@ mod tests { product_id, subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .unwrap(); futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( @@ -4974,6 +5338,7 @@ mod tests { "myapp.dot", first.public.to_bytes(), first.secret.to_bytes(), + [0x42; 32], )), Err(AuthorityError::Disconnected) )); @@ -5026,6 +5391,7 @@ mod tests { "myapp.dot", subtree.public.to_bytes(), subtree.secret.to_bytes(), + [0x42; 32], )) .unwrap(); futures::executor::block_on(pairing_host.cache_statement_store_allowance_key( diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index c1080ad1e..cf1cd08fa 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -8,11 +8,12 @@ use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostCreateTransactionResponse, HostRequestResourceAllocationRequest, - HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, - HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, - HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RingLocation, + HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, + HostAccountRingVrfSignResponse, HostCreateTransactionResponse, + HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; @@ -59,21 +60,25 @@ impl BulletinAllowanceKey { pub(crate) struct AutoSigningKey { #[debug("\"\"")] secret: [u8; 64], + #[debug("\"\"")] + ring_vrf_domain_entropy: [u8; 32], } impl AutoSigningKey { - pub(crate) fn from_secret_bytes(secret: Vec) -> Result { - let secret = secret - .try_into() - .map_err(|secret: Vec| AuthorityError::Unavailable { - reason: format!("AutoSigning key must be 64 bytes, got {}", secret.len()), - })?; - Ok(Self { secret }) + pub(crate) fn from_parts(secret: [u8; 64], ring_vrf_domain_entropy: [u8; 32]) -> Self { + Self { + secret, + ring_vrf_domain_entropy, + } } pub(crate) fn as_secret_bytes(&self) -> &[u8; 64] { &self.secret } + + pub(crate) fn ring_vrf_domain_entropy(&self) -> &[u8; 32] { + &self.ring_vrf_domain_entropy + } } /// Snapshot of an account-authority session selected by the authority. /// @@ -219,30 +224,67 @@ pub(crate) enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } -/// Contextual-alias request forwarded to the account authority (RFC 0004). +/// Contextual-alias request forwarded to the account authority. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AccountAliasAuthorityRequest { /// Calling product, so the Account Holder can scope context derivation. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring whose member key the Account Holder selects. + /// Ring the explicit key must be registered for. pub ring_location: RingLocation, } -/// Ring-VRF proof request forwarded to the account authority (RFC 0004). +/// Ring-VRF proof request forwarded to the account authority. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CreateProofAuthorityRequest { /// Calling product, so the Account Holder can scope context derivation. pub calling_product_id: String, + /// Explicit ring-VRF key handle. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring whose member key the Account Holder selects. + /// Ring the explicit key must be registered for. pub ring_location: RingLocation, /// Opaque message bound into the proof. pub message: Vec, } +/// Ring-VRF key registration request forwarded to the account authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RegisterRingVrfKeyAuthorityRequest { + /// Calling product that owns the key. + pub calling_product_id: String, + /// Key derivation index within the caller's ring-VRF domain. + pub index: truapi::v01::DerivationIndex, + /// Declared ring for the key. + pub ring: RingLocation, +} + +/// Ring-VRF key listing request forwarded to the account authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ListRingVrfKeysAuthorityRequest { + /// Calling product requesting the list. + pub calling_product_id: String, + /// Owner product whose entries should be listed. + pub owner: String, + /// Disclosure requested by the caller. + pub disclosure: truapi::v01::RingVrfKeyDisclosure, +} + +/// Direct ring-VRF member-key signing request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RingVrfSignAuthorityRequest { + /// Calling product requesting the signature. + pub calling_product_id: String, + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Message to sign. + pub message: Vec, +} + /// Statement-store allowance signing material held by the authority layer. #[derive(Clone, PartialEq, Eq)] pub(crate) struct StatementStoreAllowanceKey { @@ -356,9 +398,9 @@ pub(crate) trait ProductAuthority: Send + Sync { request: CreateTransactionAuthorityRequest, ) -> Result; - /// Derive a product-scoped contextual alias for a ring (RFC 0004). + /// Derive a product-scoped contextual alias for an explicit registered key. /// - /// The Account Holder selects the member key for `ring_location` and derives + /// The Account Holder resolves `key_handle` from the registry and derives /// the alias bound to `context`; `create_proof` derives the same alias. async fn account_alias( &self, @@ -367,10 +409,10 @@ pub(crate) trait ProductAuthority: Send + Sync { request: AccountAliasAuthorityRequest, ) -> Result; - /// Create a ring-VRF proof bound to a context and message (RFC 0004). + /// Create a ring-VRF proof bound to a context and message. /// - /// Uses the same ring resolution and member-key selection as `account_alias`, - /// so the returned `contextual_alias` matches that method's output. + /// Uses the request's explicit registered key, so the returned + /// `contextual_alias` matches `account_alias` for the same inputs. async fn create_proof( &self, cx: &CallContext, @@ -378,6 +420,30 @@ pub(crate) trait ProductAuthority: Send + Sync { request: CreateProofAuthorityRequest, ) -> Result; + /// Register a ring-VRF key owned by the calling product. + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result; + + /// List registered ring-VRF keys. + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result; + + /// Sign bytes directly with a registered ring-VRF key. + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result; + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index b642f4709..8100db35a 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -20,19 +20,23 @@ use super::auth_state::AuthStateMachine; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; use super::connected_session_ui_info; use super::identity::resolve_session_identity_with_chain; use super::services::RuntimeServices; use super::sso_pairing::{SsoPairingFlow, SsoPairingOutcome}; -use super::sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}; +use super::sso_remote::{ + SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey, sso_message_id, +}; use super::statement_store_rpc::StatementStoreRpc; use crate::chain_runtime::ChainRuntime; use crate::host_logic::entropy::derive_product_entropy_from_source; use crate::host_logic::product_account::{ derivation_index_bytes, derive_product_keypair_from_subtree_secret, + derive_ring_vrf_entropy_from_domain, }; use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; use crate::host_logic::session_store::SessionStoreChangeNotifier; @@ -47,6 +51,13 @@ use truapi_platform::{ CoreStorageKey, PairingHostConfig, Platform, ProductContext, SignVrfReview, UserConfirmationReview, normalize_product_identifier, }; +use zeroize::Zeroizing; + +use super::ring_vrf_registry::{RingVrfRegistryStore, validate_owner_listing}; +use super::signing_host::ring_vrf::{ + ChainRingResolver, MemberCandidate, RingResolver, alias_from_entropy, context_bytes, + create_proof, member_from_entropy, sign_from_entropy, +}; /// Distinguishes all remote authority request entrypoints by wire label. #[derive(Clone, Copy, Debug, derive_more::Display)] @@ -153,11 +164,13 @@ struct PersistedAutoSigningKey { product_id: String, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], } impl Drop for PersistedAutoSigningKey { fn drop(&mut self) { self.secret.zeroize(); + self.ring_vrf_domain_entropy.zeroize(); } } @@ -181,17 +194,24 @@ fn decode_auto_signing_keys(blob: &[u8]) -> Result, fn validate_auto_signing_key( secret: [u8; 64], expected_product_subtree_public_key: [u8; 32], + ring_vrf_domain_entropy: [u8; 32], ) -> Result { - let secret_key = SecretKey::from_bytes(&secret).map_err(|_| AuthorityError::Unavailable { - reason: "AutoSigning capability contains an invalid subtree secret".to_string(), - })?; + let secret = Zeroizing::new(secret); + let ring_vrf_domain_entropy = Zeroizing::new(ring_vrf_domain_entropy); + let secret_key = + SecretKey::from_bytes(&secret[..]).map_err(|_| AuthorityError::Unavailable { + reason: "AutoSigning capability contains an invalid subtree secret".to_string(), + })?; if secret_key.to_public().to_bytes() != expected_product_subtree_public_key { return Err(AuthorityError::Unavailable { reason: "AutoSigning capability does not match the authenticated product subtree" .to_string(), }); } - AutoSigningKey::from_secret_bytes(secret.to_vec()) + Ok(AutoSigningKey::from_parts( + *secret, + *ring_vrf_domain_entropy, + )) } #[derive(Default)] @@ -253,6 +273,8 @@ pub(crate) struct PairingHost { bulletin_allowances: Mutex>, product_subtrees: Mutex>, auto_signing_keys: Mutex>, + ring_resolver: Arc, + ring_vrf_registry: Arc, /// Orders session-secret cache/storage writes against teardown and activation. session_secret_storage: futures::lock::Mutex<()>, session_store_activation: futures::lock::Mutex<()>, @@ -286,6 +308,8 @@ impl PairingHost { bulletin_allowances: Mutex::new(HashMap::new()), product_subtrees: Mutex::new(HashMap::new()), auto_signing_keys: Mutex::new(HashMap::new()), + ring_resolver: ChainRingResolver::new(services.chain.clone()), + ring_vrf_registry: RingVrfRegistryStore::new(services.platform.clone()), session_secret_storage: futures::lock::Mutex::new(()), session_store_activation: futures::lock::Mutex::new(()), session_lifecycle: Mutex::new(SessionLifecycle::default()), @@ -369,6 +393,43 @@ impl PairingHost { self.session_state.current().as_ref().map(authority_session) } + pub(crate) async fn ring_vrf_providers( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .providers(session.public_key, ring) + .await + } + + pub(crate) async fn selected_ring_vrf_provider( + &self, + ring: &v01::RingLocation, + ) -> Result, RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .selected_provider(session.public_key, ring) + .await + } + + pub(crate) async fn select_ring_vrf_provider( + &self, + ring: v01::RingLocation, + handle: v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let session = self.session_state.current().ok_or(RingVrfError::Unknown { + reason: "no active session".to_string(), + })?; + self.ring_vrf_registry + .select_provider(session.public_key, ring, handle) + .await + } + /// Start the disconnect monitor when a session is already active. #[cfg(test)] pub(crate) fn start_remote_monitor_for_current_session(&self) { @@ -897,6 +958,7 @@ impl PairingHost { product_id: &str, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], ) -> Result<(), AuthorityError> { self.remember_auto_signing_key( session, @@ -904,10 +966,24 @@ impl PairingHost { product_id, expected_product_subtree_public_key, secret, + ring_vrf_domain_entropy, ) .await } + #[cfg(test)] + pub(crate) async fn register_ring_vrf_key_for_tests( + &self, + session: &SessionInfo, + handle: v01::ProductAccountId, + ring: v01::RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + self.ring_vrf_registry + .register(session.public_key, handle, ring, public_key) + .await + } + #[cfg(test)] pub(crate) fn capability_cache_sizes_for_tests(&self) -> (usize, usize, usize, usize) { ( @@ -1501,8 +1577,13 @@ impl PairingHost { product_id: &str, expected_product_subtree_public_key: [u8; 32], secret: [u8; 64], + ring_vrf_domain_entropy: [u8; 32], ) -> Result<(), AuthorityError> { - let key = validate_auto_signing_key(secret, expected_product_subtree_public_key)?; + let key = validate_auto_signing_key( + secret, + expected_product_subtree_public_key, + ring_vrf_domain_entropy, + )?; let owner = AutoSigningOwner::from_session(session); let cache_key = (owner.clone(), product_id.to_string()); let _storage_guard = self.session_secret_storage.lock().await; @@ -1530,6 +1611,7 @@ impl PairingHost { product_id: product_id.to_string(), expected_product_subtree_public_key, secret, + ring_vrf_domain_entropy, }); if !self.session_secret_allocation_is_current(session, lifecycle_epoch) { return Err(AuthorityError::Disconnected); @@ -1657,6 +1739,7 @@ impl PairingHost { let key = match validate_auto_signing_key( persisted.secret, persisted.expected_product_subtree_public_key, + persisted.ring_vrf_domain_entropy, ) { Ok(key) => key, Err(err) => { @@ -1694,6 +1777,94 @@ impl PairingHost { subtrees.retain(|(key, _), _| *key != session_key); } + fn require_owned_ring_vrf_key( + calling_product_id: &str, + handle: &v01::ProductAccountId, + ) -> Result<(), RingVrfError> { + let caller = normalize_product_identifier(calling_product_id).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if caller != handle.dot_ns_identifier { + return Err(RingVrfError::NotAllowlisted); + } + Ok(()) + } + + async fn local_ring_vrf_entropy( + &self, + session: &SessionInfo, + handle: &v01::ProductAccountId, + ) -> Result>, RingVrfError> { + let Some(auto_signing) = self + .auto_signing_key(session, &handle.dot_ns_identifier) + .await + .map_err(RingVrfError::from)? + else { + return Ok(None); + }; + let entry = self + .ring_vrf_registry + .entry(session.public_key, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + auto_signing.ring_vrf_domain_entropy(), + &handle.derivation_index, + )); + if entry.public_key != Some(member_from_entropy(&entropy)?) { + return Err(RingVrfError::Unknown { + reason: "registered ring-VRF public key does not match the AutoSigning capability" + .to_string(), + }); + } + Ok(Some(entropy)) + } + + async fn local_ring_vrf_entropy_for_ring( + &self, + session: &SessionInfo, + handle: &v01::ProductAccountId, + ring: &v01::RingLocation, + ) -> Result>, RingVrfError> { + let Some(entropy) = self.local_ring_vrf_entropy(session, handle).await? else { + return Ok(None); + }; + let entry = self + .ring_vrf_registry + .entry(session.public_key, handle) + .await? + .ok_or(RingVrfError::KeyNotRegistered)?; + if !entry.rings.contains(ring) { + return Err(RingVrfError::KeyNotInRing); + } + Ok(Some(entropy)) + } + + fn mirror_ring_vrf_registration( + &self, + session: SessionInfo, + request: RegisterRingVrfKeyAuthorityRequest, + ) { + let weak_self = self.weak_self.clone(); + (self.spawner)(Box::pin(async move { + let Some(host) = weak_self.upgrade() else { + return; + }; + let cx = CallContext::with_request_id(format!( + "ring-vrf-registration-mirror:{}", + sso_message_id() + )); + if let Err(error) = host + .remote_register_ring_vrf_key(&cx, &session, request) + .await + { + warn!(?error, "ring-VRF registration mirror failed"); + } + })); + } + async fn product_subtree_public_key( &self, cx: &CallContext, @@ -1788,8 +1959,27 @@ impl PairingHost { session: &AuthoritySession, request: AccountAliasAuthorityRequest, ) -> Result { - let session = self.current_private_session(session)?; - self.remote_account_alias(cx, &session, request).await + let private_session = self.current_private_session(session)?; + if request.calling_product_id == request.key_handle.dot_ns_identifier + && let Some(entropy) = self + .local_ring_vrf_entropy_for_ring( + &private_session, + &request.key_handle, + &request.ring_location, + ) + .await? + { + self.ring_resolver.validate(&request.ring_location).await?; + self.current_private_session(session)?; + let context = context_bytes(&request.context); + let alias = alias_from_entropy(&entropy, &context)?; + return Ok(v01::ContextualAlias { + context, + alias: alias.to_vec(), + }); + } + self.remote_account_alias(cx, &private_session, request) + .await } async fn create_proof( @@ -1798,8 +1988,146 @@ impl PairingHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { - let session = self.current_private_session(session)?; - self.remote_create_proof(cx, &session, request).await + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let private_session = self.current_private_session(session)?; + if let Some(entropy) = self + .local_ring_vrf_entropy_for_ring( + &private_session, + &request.key_handle, + &request.ring_location, + ) + .await? + { + let member = member_from_entropy(&entropy)?; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &[MemberCandidate { member }]) + .await?; + self.current_private_session(session)?; + let context = context_bytes(&request.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + return Ok(v01::HostAccountCreateProofResponse { + proof, + contextual_alias: v01::ContextualAlias { + context, + alias: alias.to_vec(), + }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, + }); + } + self.remote_create_proof(cx, &private_session, request) + .await + } + + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + let private_session = self.current_private_session(session)?; + let handle = v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&request.calling_product_id).map_err( + |error| RingVrfError::Unknown { + reason: error.to_string(), + }, + )?, + derivation_index: request.index.clone(), + }; + if let Some(auto_signing) = self + .auto_signing_key(&private_session, &request.calling_product_id) + .await + .map_err(RingVrfError::from)? + { + self.ring_resolver.validate(&request.ring).await?; + self.current_private_session(session)?; + let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( + auto_signing.ring_vrf_domain_entropy(), + &request.index, + )); + let public_key = member_from_entropy(&entropy)?; + self.ring_vrf_registry + .register( + private_session.public_key, + handle, + request.ring.clone(), + public_key, + ) + .await?; + self.current_private_session(session)?; + self.mirror_ring_vrf_registration(private_session, request); + return Ok(public_key); + } + let public_key = self + .remote_register_ring_vrf_key(cx, &private_session, request.clone()) + .await?; + self.ring_vrf_registry + .register(private_session.public_key, handle, request.ring, public_key) + .await?; + self.current_private_session(session)?; + Ok(public_key) + } + + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + let private_session = self.current_private_session(session)?; + let owner = normalize_product_identifier(&request.owner).map_err(|error| { + RingVrfError::Unknown { + reason: error.to_string(), + } + })?; + if request.calling_product_id == owner + && let Some(mut entries) = self + .ring_vrf_registry + .complete_owner_entries(private_session.public_key, &owner) + .await? + { + self.current_private_session(session)?; + apply_ring_vrf_disclosure(&mut entries, request.disclosure); + return Ok(entries); + } + let requested_disclosure = request.disclosure; + let mut remote_request = request; + if remote_request.calling_product_id == owner { + remote_request.disclosure = v01::RingVrfKeyDisclosure::PublicKey; + } + let mut entries = self + .remote_list_ring_vrf_keys(cx, &private_session, remote_request) + .await?; + validate_owner_listing(&owner, &entries)?; + if entries.iter().all(|entry| entry.public_key.is_some()) { + entries = self + .ring_vrf_registry + .reconcile_owner(private_session.public_key, &owner, entries) + .await?; + } + self.current_private_session(session)?; + apply_ring_vrf_disclosure(&mut entries, requested_disclosure); + Ok(entries) + } + + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + let private_session = self.current_private_session(session)?; + if let Some(entropy) = self + .local_ring_vrf_entropy(&private_session, &request.key_handle) + .await? + { + self.current_private_session(session)?; + return sign_from_entropy(&entropy, &request.message); + } + self.remote_ring_vrf_sign(cx, &private_session, request) + .await } async fn allocate_resources( @@ -1886,6 +2214,17 @@ impl PairingHost { } } +fn apply_ring_vrf_disclosure( + entries: &mut [v01::RegisteredRingVrfKey], + disclosure: v01::RingVrfKeyDisclosure, +) { + if disclosure == v01::RingVrfKeyDisclosure::Anonymized { + for entry in entries { + entry.public_key = None; + } + } +} + fn login_error_reason(err: &CallError) -> String { match err { CallError::Domain(HostRequestLoginError::V1(v01::HostRequestLoginError::Unknown { @@ -2004,6 +2343,33 @@ impl ProductAuthority for PairingHost { PairingHost::create_proof(self, cx, session, request).await } + async fn register_ring_vrf_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + PairingHost::register_ring_vrf_key(self, cx, session, request).await + } + + async fn list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + PairingHost::list_ring_vrf_keys(self, cx, session, request).await + } + + async fn ring_vrf_sign( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + PairingHost::ring_vrf_sign(self, cx, session, request).await + } + async fn allocate_resources( &self, cx: &CallContext, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index b928eacd9..13494c0cb 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -2,8 +2,10 @@ use super::super::authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, BulletinAllowanceKey, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, StatementStoreAllowanceKey, + CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, + ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, }; use super::super::sso_remote::{ RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, @@ -18,8 +20,9 @@ use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RingVrfError, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, build_outgoing_request_statement, create_transaction_legacy_message, - create_transaction_message, decode_sso_session_statement, product_subtree_request_message, - proof_request_message, resource_allocation_message, sign_payload_message, + create_transaction_message, decode_sso_session_statement, list_ring_vrf_keys_message, + product_subtree_request_message, proof_request_message, register_ring_vrf_key_message, + resource_allocation_message, ring_vrf_sign_message, sign_payload_message, sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -33,6 +36,12 @@ const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signi const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; const UNEXPECTED_SSO_ALIAS_RESPONSE: &str = "Unexpected SSO response for account alias request"; const UNEXPECTED_SSO_PROOF_RESPONSE: &str = "Unexpected SSO response for ring-VRF proof request"; +const UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE: &str = + "Unexpected SSO response for ring-VRF key registration request"; +const UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE: &str = + "Unexpected SSO response for ring-VRF key listing request"; +const UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE: &str = + "Unexpected SSO response for ring-VRF signing request"; fn unexpected_response_reason(context: &str, response_kind: &str) -> String { format!("{context}: {response_kind}") @@ -46,6 +55,12 @@ enum RemoteAction { RingVrfAlias, #[display("ring-vrf-proof")] RingVrfProof, + #[display("register-ring-vrf-key")] + RegisterRingVrfKey, + #[display("list-ring-vrf-keys")] + ListRingVrfKeys, + #[display("ring-vrf-sign")] + RingVrfSign, #[display("sign-vrf")] SignVrf, #[display("resource-allocation")] @@ -482,6 +497,7 @@ impl PairingHost { let message = alias_request_message( message_id, request.calling_product_id, + request.key_handle, request.context, request.ring_location, ); @@ -509,6 +525,7 @@ impl PairingHost { let message = proof_request_message( message_id, request.calling_product_id, + request.key_handle, request.context, request.ring_location, request.message, @@ -526,6 +543,96 @@ impl PairingHost { response.payload } + /// Forward a ring-VRF key registration request to the paired signing host. + pub(super) async fn remote_register_ring_vrf_key( + &self, + cx: &CallContext, + session: &SessionInfo, + request: RegisterRingVrfKeyAuthorityRequest, + ) -> Result { + let message_id = sso_message_id(); + let message = register_ring_vrf_key_message( + message_id, + request.calling_product_id, + request.index, + request.ring, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::RegisterRingVrfKey, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::RegisterRingVrfKey(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + + /// Forward a ring-VRF key listing request to the paired signing host. + pub(super) async fn remote_list_ring_vrf_keys( + &self, + cx: &CallContext, + session: &SessionInfo, + request: ListRingVrfKeysAuthorityRequest, + ) -> Result, RingVrfError> { + let message_id = sso_message_id(); + let message = list_ring_vrf_keys_message( + message_id, + request.calling_product_id, + request.owner, + request.disclosure, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ListRingVrfKeys, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::ListRingVrfKeys(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + + /// Forward a direct ring-VRF signing request to the paired signing host. + pub(super) async fn remote_ring_vrf_sign( + &self, + cx: &CallContext, + session: &SessionInfo, + request: RingVrfSignAuthorityRequest, + ) -> Result, RingVrfError> { + let message_id = sso_message_id(); + let message = ring_vrf_sign_message( + message_id, + request.calling_product_id, + request.key_handle, + request.message, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::RingVrfSign, message) + .await + .map_err(remote_authority_error)?; + let response_kind = response.kind(); + let SsoRemoteResponse::RingVrfSign(response) = response else { + return Err(RingVrfError::Unknown { + reason: unexpected_response_reason( + UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE, + response_kind, + ), + }); + }; + response.payload + } + /// Ask the paired signing host to allocate product resources, caching any /// returned allowance keys. pub(super) async fn remote_allocate_resources( @@ -799,6 +906,7 @@ impl PairingHost { SsoAllocatedResource::SmartContractAllowance => {} SsoAllocatedResource::AutoSigning { product_root_private_key, + ring_vrf_domain_entropy, } => { let expected_product_subtree_public_key = self .remote_product_subtree_public_key(cx, session, product_id.to_string()) @@ -809,6 +917,7 @@ impl PairingHost { product_id, expected_product_subtree_public_key, *product_root_private_key, + *ring_vrf_domain_entropy, ) .await?; } @@ -901,6 +1010,7 @@ mod tests { let private_key = [0xA5; 64]; let resource = SsoAllocatedResource::AutoSigning { product_root_private_key: private_key, + ring_vrf_domain_entropy: [0x5A; 32], }; let resource_reason = unexpected_response_reason( "Unexpected statement-store allowance response resource", diff --git a/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs b/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs new file mode 100644 index 000000000..80be57f06 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/ring_vrf_registry.rs @@ -0,0 +1,665 @@ +//! Durable, wallet-scoped RFC-0024 ring-VRF registry snapshots. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use parity_scale_codec::{Decode, Encode}; +use truapi::v01::{ProductAccountId, RegisteredRingVrfKey, RingLocation}; +use truapi_platform::{CoreStorageKey, Platform, normalize_product_identifier}; + +use crate::host_logic::sso::messages::RingVrfError; + +#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode)] +struct SelectedProvider { + ring: RingLocation, + handle: ProductAccountId, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Encode, Decode)] +struct RegistrySnapshot { + entries: Vec, + /// Owners for which `entries` is a complete Account Holder snapshot. + complete_owners: Vec, + /// First registration wins until the user selects another provider. + selected_providers: Vec, +} + +/// Shared durable repository used by both account-authority roles. +pub(super) struct RingVrfRegistryStore { + platform: Arc, + cache: Mutex>, + storage_guard: futures::lock::Mutex<()>, +} + +impl RingVrfRegistryStore { + pub(super) fn new(platform: Arc) -> Arc { + Arc::new(Self { + platform, + cache: Mutex::new(HashMap::new()), + storage_guard: futures::lock::Mutex::new(()), + }) + } + + pub(super) async fn entry( + &self, + root_public_key: [u8; 32], + handle: &ProductAccountId, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .find(|entry| entry.handle == *handle)) + } + + pub(super) async fn complete_owner_entries( + &self, + root_public_key: [u8; 32], + owner: &str, + ) -> Result>, RingVrfError> { + let snapshot = self.snapshot(root_public_key).await?; + if !snapshot.complete_owners.iter().any(|item| item == owner) { + return Ok(None); + } + Ok(Some( + snapshot + .entries + .into_iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .collect(), + )) + } + + pub(super) async fn owner_entries( + &self, + root_public_key: [u8; 32], + owner: &str, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .collect()) + } + + pub(super) async fn register( + &self, + root_public_key: [u8; 32], + handle: ProductAccountId, + ring: RingLocation, + public_key: [u8; 32], + ) -> Result<(), RingVrfError> { + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + if let Some(entry) = snapshot + .entries + .iter_mut() + .find(|entry| entry.handle == handle) + { + if entry.public_key != Some(public_key) { + return Err(invalid_registry( + "registered key handle has a conflicting public key", + )); + } + if !entry.rings.contains(&ring) { + entry.rings.push(ring.clone()); + } + } else { + snapshot.entries.push(RegisteredRingVrfKey { + handle: handle.clone(), + rings: vec![ring.clone()], + public_key: Some(public_key), + }); + } + if !snapshot + .selected_providers + .iter() + .any(|provider| provider.ring == ring) + { + snapshot + .selected_providers + .push(SelectedProvider { ring, handle }); + } + self.persist_under_guard(root_public_key, snapshot).await + } + + /// Reconcile one owner's complete response with locally registered keys. + /// + /// RFC-0024 has no revocation operation, so a remote response cannot + /// invalidate an entry already accepted by this host. This also prevents a + /// list response created before a fire-and-forget registration mirror from + /// removing that local registration. + pub(super) async fn reconcile_owner( + &self, + root_public_key: [u8; 32], + owner: &str, + entries: Vec, + ) -> Result, RingVrfError> { + validate_authoritative_owner_entries(owner, &entries)?; + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + let mut owner_entries = snapshot + .entries + .iter() + .filter(|entry| entry.handle.dot_ns_identifier == owner) + .cloned() + .collect::>(); + for entry in entries { + if let Some(existing) = owner_entries + .iter_mut() + .find(|existing| existing.handle == entry.handle) + { + if existing.public_key != entry.public_key { + return Err(invalid_registry_listing( + "owner snapshot conflicts with a locally registered public key", + )); + } + for ring in entry.rings { + if !existing.rings.contains(&ring) { + existing.rings.push(ring); + } + } + } else { + owner_entries.push(entry); + } + } + snapshot + .entries + .retain(|entry| entry.handle.dot_ns_identifier != owner); + snapshot.entries.extend(owner_entries.iter().cloned()); + if !snapshot.complete_owners.iter().any(|item| item == owner) { + snapshot.complete_owners.push(owner.to_string()); + } + snapshot.selected_providers.retain(|provider| { + snapshot.entries.iter().any(|entry| { + entry.handle == provider.handle && entry.rings.contains(&provider.ring) + }) + }); + for entry in &snapshot.entries { + for ring in &entry.rings { + if !snapshot + .selected_providers + .iter() + .any(|provider| provider.ring == *ring) + { + snapshot.selected_providers.push(SelectedProvider { + ring: ring.clone(), + handle: entry.handle.clone(), + }); + } + } + } + self.persist_under_guard(root_public_key, snapshot).await?; + Ok(owner_entries) + } + + pub(super) async fn selected_provider( + &self, + root_public_key: [u8; 32], + ring: &RingLocation, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .selected_providers + .into_iter() + .find(|provider| provider.ring == *ring) + .map(|provider| provider.handle)) + } + + pub(super) async fn providers( + &self, + root_public_key: [u8; 32], + ring: &RingLocation, + ) -> Result, RingVrfError> { + Ok(self + .snapshot(root_public_key) + .await? + .entries + .into_iter() + .filter(|entry| entry.rings.contains(ring)) + .map(|entry| entry.handle) + .collect()) + } + + /// Persist a user-selected provider after validating its registration. + pub(super) async fn select_provider( + &self, + root_public_key: [u8; 32], + ring: RingLocation, + handle: ProductAccountId, + ) -> Result<(), RingVrfError> { + let _guard = self.storage_guard.lock().await; + let mut snapshot = self.load_under_guard(root_public_key).await?; + let registered = snapshot + .entries + .iter() + .any(|entry| entry.handle == handle && entry.rings.contains(&ring)); + if !registered { + return Err(RingVrfError::KeyNotInRing); + } + snapshot + .selected_providers + .retain(|provider| provider.ring != ring); + snapshot + .selected_providers + .push(SelectedProvider { ring, handle }); + self.persist_under_guard(root_public_key, snapshot).await + } + + async fn snapshot(&self, root_public_key: [u8; 32]) -> Result { + if let Some(snapshot) = self + .cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .get(&root_public_key) + .cloned() + { + return Ok(snapshot); + } + let _guard = self.storage_guard.lock().await; + self.load_under_guard(root_public_key).await + } + + async fn load_under_guard( + &self, + root_public_key: [u8; 32], + ) -> Result { + if let Some(snapshot) = self + .cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .get(&root_public_key) + .cloned() + { + return Ok(snapshot); + } + let key = CoreStorageKey::RingVrfRegistry { root_public_key }; + let snapshot = match self + .platform + .read_core_storage(key.clone()) + .await + .map_err(storage_error)? + { + Some(blob) => match decode_snapshot(&blob) { + Ok(snapshot) => snapshot, + Err(error) => { + let _ = self.platform.clear_core_storage(key).await; + return Err(error); + } + }, + None => RegistrySnapshot::default(), + }; + self.cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .insert(root_public_key, snapshot.clone()); + Ok(snapshot) + } + + async fn persist_under_guard( + &self, + root_public_key: [u8; 32], + snapshot: RegistrySnapshot, + ) -> Result<(), RingVrfError> { + validate_snapshot(&snapshot)?; + self.platform + .write_core_storage( + CoreStorageKey::RingVrfRegistry { root_public_key }, + snapshot.encode(), + ) + .await + .map_err(storage_error)?; + self.cache + .lock() + .expect("ring-VRF registry cache mutex poisoned") + .insert(root_public_key, snapshot); + Ok(()) + } +} + +fn decode_snapshot(blob: &[u8]) -> Result { + let mut input = blob; + let snapshot = RegistrySnapshot::decode(&mut input).map_err(|error| RingVrfError::Unknown { + reason: format!("invalid persisted ring-VRF registry: {error}"), + })?; + if !input.is_empty() { + return Err(RingVrfError::Unknown { + reason: "invalid persisted ring-VRF registry: trailing bytes".to_string(), + }); + } + validate_snapshot(&snapshot)?; + Ok(snapshot) +} + +fn validate_snapshot(snapshot: &RegistrySnapshot) -> Result<(), RingVrfError> { + let mut handles = HashSet::new(); + for entry in &snapshot.entries { + let owner = normalize_product_identifier(&entry.handle.dot_ns_identifier) + .map_err(|error| invalid_registry(error.to_string()))?; + if owner != entry.handle.dot_ns_identifier { + return Err(invalid_registry("non-canonical key owner")); + } + if entry.public_key.is_none() { + return Err(invalid_registry("stored entry is missing its public key")); + } + if entry.rings.is_empty() { + return Err(invalid_registry("stored entry has no declared rings")); + } + if !handles.insert(entry.handle.encode()) { + return Err(invalid_registry("duplicate key handle")); + } + let mut rings = HashSet::new(); + if entry.rings.iter().any(|ring| !rings.insert(ring.encode())) { + return Err(invalid_registry("duplicate declared ring")); + } + } + let mut owners = HashSet::new(); + for owner in &snapshot.complete_owners { + let canonical = normalize_product_identifier(owner) + .map_err(|error| invalid_registry(error.to_string()))?; + if canonical != *owner || !owners.insert(owner) { + return Err(invalid_registry("invalid complete owner")); + } + } + let mut provider_rings = HashSet::new(); + for provider in &snapshot.selected_providers { + if !provider_rings.insert(provider.ring.encode()) { + return Err(invalid_registry("duplicate selected provider")); + } + if !snapshot + .entries + .iter() + .any(|entry| entry.handle == provider.handle && entry.rings.contains(&provider.ring)) + { + return Err(invalid_registry( + "selected provider is not registered for its ring", + )); + } + } + Ok(()) +} + +pub(super) fn validate_owner_listing( + owner: &str, + entries: &[RegisteredRingVrfKey], +) -> Result<(), RingVrfError> { + let canonical_owner = normalize_product_identifier(owner) + .map_err(|error| invalid_registry_listing(error.to_string()))?; + if canonical_owner != owner { + return Err(invalid_registry_listing("non-canonical listing owner")); + } + let mut handles = HashSet::new(); + for entry in entries { + if entry.handle.dot_ns_identifier != owner { + return Err(invalid_registry_listing( + "owner listing contains a foreign key handle", + )); + } + if entry.rings.is_empty() { + return Err(invalid_registry_listing( + "owner listing contains a key with no rings", + )); + } + if !handles.insert(entry.handle.encode()) { + return Err(invalid_registry_listing( + "owner listing contains a duplicate key handle", + )); + } + let mut rings = HashSet::new(); + if entry.rings.iter().any(|ring| !rings.insert(ring.encode())) { + return Err(invalid_registry_listing( + "owner listing contains a duplicate declared ring", + )); + } + } + Ok(()) +} + +fn validate_authoritative_owner_entries( + owner: &str, + entries: &[RegisteredRingVrfKey], +) -> Result<(), RingVrfError> { + validate_owner_listing(owner, entries)?; + if entries.iter().any(|entry| entry.public_key.is_none()) { + return Err(invalid_registry_listing( + "authoritative owner snapshot contains a key without its public key", + )); + } + Ok(()) +} + +fn storage_error(error: truapi::v01::GenericError) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("ring-VRF registry storage failed: {}", error.reason), + } +} + +fn invalid_registry(reason: impl Into) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("invalid persisted ring-VRF registry: {}", reason.into()), + } +} + +fn invalid_registry_listing(reason: impl Into) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("invalid ring-VRF registry listing: {}", reason.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::StubPlatform; + + fn handle(owner: &str, index: u32) -> ProductAccountId { + ProductAccountId { + dot_ns_identifier: owner.to_string(), + derivation_index: truapi::v01::DerivationIndex::Left(index), + } + } + + fn ring(byte: u8) -> RingLocation { + RingLocation { + chain_id: [byte; 32], + junctions: vec![truapi::v01::RingLocationJunction::PalletInstance(byte)], + } + } + + #[test] + fn registry_is_wallet_scoped_durable_and_registration_is_idempotent() { + let platform = Arc::new(StubPlatform::default()); + let store = RingVrfRegistryStore::new(platform.clone()); + let first_root = [1; 32]; + let second_root = [2; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(first_root, key.clone(), ring(1), [9; 32])) + .unwrap(); + futures::executor::block_on(store.register(first_root, key.clone(), ring(1), [9; 32])) + .unwrap(); + futures::executor::block_on(store.register(first_root, key.clone(), ring(2), [9; 32])) + .unwrap(); + + let entry = futures::executor::block_on(store.entry(first_root, &key)) + .unwrap() + .unwrap(); + assert_eq!(entry.rings, vec![ring(1), ring(2)]); + assert!( + futures::executor::block_on(store.entry(second_root, &key)) + .unwrap() + .is_none() + ); + + let reloaded = RingVrfRegistryStore::new(platform); + assert_eq!( + futures::executor::block_on(reloaded.entry(first_root, &key)).unwrap(), + Some(entry) + ); + } + + #[test] + fn first_registrar_remains_selected_until_explicitly_changed() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let location = ring(1); + let first = handle("first.dot", 0); + let second = handle("second.dot", 0); + futures::executor::block_on(store.register(root, first.clone(), location.clone(), [1; 32])) + .unwrap(); + futures::executor::block_on(store.register( + root, + second.clone(), + location.clone(), + [2; 32], + )) + .unwrap(); + assert_eq!( + futures::executor::block_on(store.selected_provider(root, &location)).unwrap(), + Some(first.clone()) + ); + assert_eq!( + futures::executor::block_on(store.providers(root, &location)).unwrap(), + vec![first.clone(), second.clone()] + ); + futures::executor::block_on(store.select_provider(root, location.clone(), second.clone())) + .unwrap(); + assert_eq!( + futures::executor::block_on(store.selected_provider(root, &location)).unwrap(), + Some(second) + ); + } + + #[test] + fn registration_rejects_a_conflicting_public_key_for_the_same_handle() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(root, key.clone(), ring(1), [1; 32])).unwrap(); + + let error = + futures::executor::block_on(store.register(root, key.clone(), ring(2), [2; 32])) + .unwrap_err(); + + assert!(matches!( + error, + RingVrfError::Unknown { reason } + if reason.contains("conflicting public key") + )); + assert_eq!( + futures::executor::block_on(store.entry(root, &key)) + .unwrap() + .unwrap() + .rings, + vec![ring(1)] + ); + } + + #[test] + fn stale_owner_snapshot_preserves_a_local_registration() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let local = handle("owner.dot", 7); + let remote = RegisteredRingVrfKey { + handle: handle("owner.dot", 8), + rings: vec![ring(2)], + public_key: Some([2; 32]), + }; + futures::executor::block_on(store.register(root, local.clone(), ring(1), [1; 32])).unwrap(); + + let reconciled = futures::executor::block_on(store.reconcile_owner( + root, + "owner.dot", + vec![remote.clone()], + )) + .unwrap(); + + assert_eq!( + reconciled, + vec![ + RegisteredRingVrfKey { + handle: local, + rings: vec![ring(1)], + public_key: Some([1; 32]), + }, + remote, + ] + ); + assert_eq!( + futures::executor::block_on(store.complete_owner_entries(root, "owner.dot")).unwrap(), + Some(reconciled) + ); + } + + #[test] + fn owner_snapshot_rejects_a_conflicting_local_registration() { + let store = RingVrfRegistryStore::new(Arc::new(StubPlatform::default())); + let root = [1; 32]; + let key = handle("owner.dot", 7); + futures::executor::block_on(store.register(root, key.clone(), ring(1), [1; 32])).unwrap(); + + let error = futures::executor::block_on(store.reconcile_owner( + root, + "owner.dot", + vec![RegisteredRingVrfKey { + handle: key.clone(), + rings: vec![ring(1)], + public_key: Some([2; 32]), + }], + )) + .unwrap_err(); + + assert!(matches!( + error, + RingVrfError::Unknown { reason } if reason.contains("conflicts with a locally registered public key") + )); + assert_eq!( + futures::executor::block_on(store.entry(root, &key)) + .unwrap() + .unwrap() + .public_key, + Some([1; 32]) + ); + } + + #[test] + fn persisted_entries_must_declare_at_least_one_ring() { + let snapshot = RegistrySnapshot { + entries: vec![RegisteredRingVrfKey { + handle: handle("owner.dot", 0), + rings: vec![], + public_key: Some([1; 32]), + }], + ..RegistrySnapshot::default() + }; + + assert!(matches!( + decode_snapshot(&snapshot.encode()), + Err(RingVrfError::Unknown { reason }) if reason.contains("no declared rings") + )); + } + + #[test] + fn owner_listing_rejects_foreign_and_duplicate_entries() { + let owner = "owner.dot"; + let entry = RegisteredRingVrfKey { + handle: handle(owner, 0), + rings: vec![ring(1)], + public_key: None, + }; + assert!(validate_owner_listing(owner, std::slice::from_ref(&entry)).is_ok()); + + let mut foreign = entry.clone(); + foreign.handle = handle("foreign.dot", 0); + assert!(matches!( + validate_owner_listing(owner, &[foreign]), + Err(RingVrfError::Unknown { reason }) if reason.contains("foreign key handle") + )); + assert!(matches!( + validate_owner_listing(owner, &[entry.clone(), entry]), + Err(RingVrfError::Unknown { reason }) if reason.contains("duplicate key handle") + )); + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7c6fb3c4d..0cd2efee0 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -32,6 +32,8 @@ pub(crate) struct RuntimeServices { pub(crate) chain: ChainRuntime, /// People-chain statement store RPC client. pub(crate) statement_store: StatementStoreRpc, + /// People-chain genesis hash used for ring locations and statement-store protocols. + pub(crate) people_chain_genesis_hash: [u8; 32], /// In-core Bulletin submission over the configured Bulletin chain. pub(crate) bulletin: BulletinRpc, /// Values from confirmed in-core submissions, served to `lookup_subscribe` @@ -66,6 +68,7 @@ impl RuntimeServices { platform, chain, statement_store, + people_chain_genesis_hash, bulletin, preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 6b85647a2..b575b0ceb 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,10 +28,12 @@ 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::{ @@ -39,22 +41,23 @@ use crate::host_logic::extrinsic::{ }; 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, LITE_PERSON_COLLECTION, 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 +102,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 +115,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 +138,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 +344,168 @@ 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 well_known_ring_location(&self, collection: [u8; 32]) -> v01::RingLocation { + v01::RingLocation { + chain_id: self.services.people_chain_genesis_hash, + junctions: vec![ + v01::RingLocationJunction::PalletInstance(67), + v01::RingLocationJunction::CollectionId(collection.to_vec()), + ], + } } - fn member_candidates( + async fn selected_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 ring = self.well_known_ring_location(LITE_PERSON_COLLECTION); + let handle = self + .ring_vrf_registry + .selected_provider(session.public_key, &ring) + .await + .map_err(|error| AuthorityError::Unknown { + reason: format!("{error:?}"), + })? + .ok_or_else(|| AuthorityError::Unavailable { + reason: "no ring-VRF provider is registered for People Lite".to_string(), + })?; + self.resolve_ring_vrf_key_for_ring(session, &handle, &ring) + .await + .map_err(|error| AuthorityError::Unknown { + reason: format!("{error:?}"), + }) } - 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 +726,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 +741,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 +760,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 +785,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 +1116,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 +1168,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 +1245,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 +1271,101 @@ 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 ring_alias_and_proof_share_the_selected_person_key() { + fn selected_lite_provider_must_match_the_active_wallet() { + 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 ring = authority.well_known_ring_location(super::LITE_PERSON_COLLECTION); + let handle = v01::ProductAccountId { + dot_ns_identifier: "provider.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }; + futures::executor::block_on(authority.ring_vrf_registry.register( + session.public_key, + handle, + ring, + [0xFF; 32], + )) + .expect("tampered registry fixture is structurally valid"); + + let error = futures::executor::block_on(authority.selected_lite_person_entropy(&session)) + .unwrap_err(); + + assert!(matches!( + error, + AuthorityError::Unknown { reason } + if reason.contains("does not match the active wallet") + )); + } + + #[test] + fn internal_provider_selection_requires_the_exact_well_known_ring() { + 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 exact_ring = authority.well_known_ring_location(super::LITE_PERSON_COLLECTION); + let collection_only_ring = v01::RingLocation { + chain_id: exact_ring.chain_id, + junctions: vec![exact_ring.junctions[1].clone()], + }; + let handle = v01::ProductAccountId { + dot_ns_identifier: "provider.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }; + let entropy = authority.ring_vrf_entropy(&session, &handle).unwrap(); + let public_key = member_from_entropy(&entropy).unwrap(); + futures::executor::block_on(authority.ring_vrf_registry.register( + session.public_key, + handle, + collection_only_ring, + public_key, + )) + .expect("registration succeeds"); + + let error = futures::executor::block_on(authority.selected_lite_person_entropy(&session)) + .unwrap_err(); + + assert!(matches!( + error, + AuthorityError::Unavailable { reason } + if reason.contains("no ring-VRF provider is registered") + )); + } + + #[test] + 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 +1377,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 +1395,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 +1411,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 +1492,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 +1512,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 +1543,14 @@ mod tests { let cx = CallContext::default(); let request = AccountAliasAuthorityRequest { calling_product_id: "myapp.dot".to_string(), + key_handle: full_person_key_handle(), context: v01::ProductProofContext { product_id: "other.dot".to_string(), suffix: v01::DerivationIndex::Left(0), }, - ring_location: v01::RingLocation { - chain_id: [0x22; 32], - junctions: vec![v01::RingLocationJunction::CollectionId( - b"pop:polkadot.network/people ".to_vec(), - )], - }, + ring_location: full_person_ring_location(), }; + register_full_person_key(&authority, &session, &request.ring_location); futures::executor::block_on(authority.account_alias(&cx, &session, request.clone())) .expect("first alias succeeds"); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index 7b745bd41..dc85f6eaf 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,29 @@ 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"; +pub(super) 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 +51,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 +213,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 +223,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 +240,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 +258,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 +286,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 +294,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 +423,12 @@ mod tests { } #[test] - fn collection_selects_corresponding_person_key() { - assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); - assert_eq!(key_for_collection(&LITE_PERSON_COLLECTION), PersonKey::Lite); - assert_eq!(key_for_collection(&[0xff; 32]), PersonKey::Full); - } - - #[test] - fn missing_collection_defaults_to_full_personhood() { + fn missing_collection_is_not_a_ring_location() { let location = RingLocation { chain_id: [0; 32], junctions: vec![RingLocationJunction::PalletInstance(42)], }; - assert_eq!(collection_id(&location), Ok(FULL_PERSON_COLLECTION)); + assert_eq!(collection_id(&location), Err(RingVrfError::RingNotFound)); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index f1b822ad8..a9e25a066 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 @@ -33,15 +33,15 @@ use crate::host_logic::product_account::{ }; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::{ - derive_lite_person_ring_vrf_entropy, derive_sr25519_hard_path, + derive_ring_vrf_domain_entropy, derive_sr25519_hard_path, }; 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 +52,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 +477,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 +612,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 +668,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 +766,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 +847,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 +909,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.selected_lite_person_entropy(&session).await?; let rpc = statement_allowance::rpc::RpcClient::new( services .statement_store @@ -959,7 +1007,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.selected_lite_person_entropy(&session).await?; 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 +1252,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 +1274,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 +1283,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 +1445,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 +1607,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 +1632,7 @@ mod tests { vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { product_root_private_key: expected_secret, + ring_vrf_domain_entropy: expected_ring_vrf_domain_entropy, } )] ); diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 37dfe6f02..e1f06ea3e 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -26,6 +26,10 @@ use truapi::{CancellationReason, CancellationToken}; /// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: /// const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// The statement store keeps only the highest-priority statement on a channel. +/// Expiry's lower 32 bits are the tie-breaker for statements expiring in the +/// same second, so every submission from this process must advance them. +static LAST_SSO_STATEMENT_EXPIRY: Mutex = Mutex::new(0); /// Disconnect reason reported when the local session logs out mid-request. pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; /// Disconnect reason reported when the paired signing host announces a disconnect. @@ -424,7 +428,17 @@ pub(super) fn sso_message_id() -> String { /// high 32 bits, seven days from now. pub(super) fn fresh_statement_expiry() -> u64 { let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); - timestamp << 32 + let expiry_floor = timestamp << 32; + let mut last = LAST_SSO_STATEMENT_EXPIRY + .lock() + .expect("SSO statement expiry mutex poisoned"); + let expiry = next_statement_expiry(*last, expiry_floor); + *last = expiry; + expiry +} + +fn next_statement_expiry(last: u64, expiry_floor: u64) -> u64 { + expiry_floor.max(last.saturating_add(1)) } #[cfg(test)] @@ -448,6 +462,17 @@ mod tests { assert!(second.bytes().all(is_nanoid_safe_byte)); } + #[test] + fn fresh_statement_expiry_is_strictly_monotonic() { + let first = fresh_statement_expiry(); + let second = fresh_statement_expiry(); + + assert!(second > first); + let floor = 42_u64 << 32; + assert_eq!(next_statement_expiry(floor, floor), floor + 1); + assert_eq!(next_statement_expiry(floor + 7, floor), floor + 8); + } + fn is_nanoid_safe_byte(value: u8) -> bool { value.is_ascii_alphanumeric() || value == b'_' || value == b'-' } diff --git a/rust/crates/truapi-server/src/runtime/statement_allowance.rs b/rust/crates/truapi-server/src/runtime/statement_allowance.rs index ba9f71e8e..a195810b1 100644 --- a/rust/crates/truapi-server/src/runtime/statement_allowance.rs +++ b/rust/crates/truapi-server/src/runtime/statement_allowance.rs @@ -2,7 +2,7 @@ //! //! Mirrors how an iOS/web client obtains statement-store allowance from the real //! People chain: build the `Resources.set_statement_store_account` call, prove -//! LitePeople membership with the RFC-0022 `peopl.dot` index-1 ring-VRF key, +//! LitePeople membership with the caller's registry-selected ring-VRF key, //! and submit the resulting unsigned General (v5) extrinsic. Native only //! (needs the `verifiable` prover and live chain reads). diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index a9331624d..f36d97a40 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -138,7 +138,7 @@ pub(crate) enum SsoResponseScript { /// Peer acknowledges the request and replies with `response`. Success { session: SessionInfo, - response: RemoteMessage, + response: Box, }, /// Peer acknowledges the request and then sends `Disconnected`. PeerDisconnect { session: SessionInfo }, @@ -371,7 +371,7 @@ pub(crate) fn sso_success_response_script( ) -> SsoResponseScript { SsoResponseScript::Success { session: session.clone(), - response, + response: Box::new(response), } } @@ -619,6 +619,10 @@ pub(crate) fn ring_location_fixture() -> v01::RingLocation { /// Contextual-alias request fixture for `product_id`. pub(crate) fn account_alias_request(product_id: &str) -> HostAccountGetAliasRequest { HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: product_proof_context(product_id), ring_location: ring_location_fixture(), }) @@ -627,6 +631,10 @@ pub(crate) fn account_alias_request(product_id: &str) -> HostAccountGetAliasRequ /// Ring-VRF proof request fixture for `product_id`. pub(crate) fn create_proof_request(product_id: &str) -> HostAccountCreateProofRequest { HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: product_proof_context(product_id), ring_location: ring_location_fixture(), message: vec![4, 5, 6], @@ -952,6 +960,15 @@ fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> Remot RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(response)) => { response.responding_to = message_id.to_string(); } + RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse(response)) => { + response.responding_to = message_id.to_string(); + } RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) => { response.responding_to = message_id.to_string(); } @@ -1007,7 +1024,7 @@ fn sso_scripted_responses( 4 => match script { SsoResponseScript::Success { session, response } => { let (_, request) = submitted_sso_request(&sent, &session); - let response = retarget_sso_response(response, &request.message_id); + let response = retarget_sso_response(*response, &request.message_id); Some(( new_statements_frame( "peer-sub", diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 4ce821934..aeb3eec52 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -180,9 +180,13 @@ fn version_index(version: u8) -> u8 { } #[test] -fn account_proof_declined_confirmation_returns_rejected() { +fn foreign_account_proof_returns_not_allowlisted_without_confirmation() { let core = make_core(); let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + key_handle: v01::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: v01::DerivationIndex::Left(0), + }, context: v01::ProductProofContext { product_id: "myapp.dot".to_string(), suffix: v01::DerivationIndex::Left(0), @@ -207,10 +211,9 @@ fn account_proof_declined_confirmation_returns_rejected() { ); assert_eq!(response.request_id, "p:account-proof"); assert_eq!(response.payload.id, ids.response_id); - // The wire-shape platform declines the confirmation prompt, so the proof - // request maps to a `Rejected` domain error in the standard Result-Err envelope. + // RFC-0024 forbids a prompt fallback for bearer proofs made with a foreign key. let expected = versioned_result_err_payload(account::HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::Rejected, + v01::HostAccountCreateProofError::NotAllowlisted, )); assert_eq!(response.payload.value, expected); } diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index dd8a448c2..5bebd1030 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -4,7 +4,11 @@ use crate::versioned::account::{ HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, - HostAccountGetRequest, HostAccountGetResponse, HostAccountSignVrfError, + HostAccountGetRequest, HostAccountGetResponse, HostAccountListRingVrfKeysError, + HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, + HostAccountRegisterRingVrfKeyError, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignError, + HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, @@ -70,17 +74,27 @@ pub trait Account: Send + Sync { /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; /// /// const PEOPLE_COLLECTION_ID = - /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; + /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465" as const; + /// const keyHandle = { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: { tag: "Left" as const, value: 0 }, + /// }; + /// const ringLocation = { + /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// junctions: [ + /// { tag: "CollectionId" as const, value: PEOPLE_COLLECTION_ID }, + /// ], + /// }; + /// const registration = await truapi.account.registerRingVrfKey({ + /// index: keyHandle.derivationIndex, + /// ring: ringLocation, + /// }); + /// assert(registration.isOk(), "registerRingVrfKey failed:", registration); /// /// const result = await truapi.account.getAccountAlias({ + /// keyHandle, /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, - /// ringLocation: { - /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, - /// junctions: [ - /// { tag: "PalletInstance", value: 67 }, - /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, - /// ], - /// }, + /// ringLocation, /// }); /// assert(result.isOk(), "getAccountAlias failed:", result); /// console.log("account alias:", result.value); @@ -94,7 +108,7 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } - /// Generate a ring VRF proof; the host selects the member key for the ring. + /// Generate a ring VRF proof with an explicitly registered member key. /// /// ```ts /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; @@ -103,18 +117,28 @@ pub trait Account: Send + Sync { /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; /// /// const result = await truapi.account.createAccountProof({ + /// keyHandle: { + /// dotNsIdentifier: "peopl.dot", + /// derivationIndex: { tag: "Left", value: 1 }, + /// }, /// context: { productId: "truapi-playground.dot", suffix: { tag: "Left", value: 0 } }, /// ringLocation: { /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, /// junctions: [ - /// { tag: "PalletInstance", value: 67 }, /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, /// ], /// }, /// message: "0x48656c6c6f", /// }); - /// assert(result.isOk(), "createAccountProof failed:", result); - /// console.log("account proof created:", result.value); + /// assert(result.isErr(), "foreign createAccountProof unexpectedly succeeded:", result); + /// assert( + /// result.error.tag === "Domain" && + /// result.error.value.tag === "V1" && + /// result.error.value.value.tag === "NotAllowlisted", + /// "foreign createAccountProof did not return NotAllowlisted:", + /// result, + /// ); + /// console.log("foreign account proof refused without prompting"); /// ``` #[wire(request_id = 26)] async fn create_account_proof( @@ -156,6 +180,78 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Register a ring-VRF key owned by the calling product. + /// + /// ```ts + /// import { PASEO_NEXT_V2_INDIVIDUALITY } from "@parity/truapi"; + /// + /// const PEOPLE_COLLECTION_ID = + /// "0x706f703a706f6c6b61646f742e6e6574776f726b2f70656f706c652d6c697465"; + /// + /// const result = await truapi.account.registerRingVrfKey({ + /// index: { tag: "Left", value: 0 }, + /// ring: { + /// chainId: PASEO_NEXT_V2_INDIVIDUALITY.genesis, + /// junctions: [ + /// { tag: "CollectionId", value: PEOPLE_COLLECTION_ID }, + /// ], + /// }, + /// }); + /// assert(result.isOk(), "registerRingVrfKey failed:", result); + /// console.log("ring VRF public key:", result.value); + /// ``` + #[wire(request_id = 166)] + async fn register_ring_vrf_key( + &self, + _cx: &CallContext, + _request: HostAccountRegisterRingVrfKeyRequest, + ) -> Result> + { + Err(CallError::unavailable()) + } + + /// List registered ring-VRF keys owned by a product. + /// + /// ```ts + /// const result = await truapi.account.listRingVrfKeys({ + /// owner: "truapi-playground.dot", + /// disclosure: "PublicKey", + /// }); + /// assert(result.isOk(), "listRingVrfKeys failed:", result); + /// console.log("registered ring VRF keys:", result.value); + /// ``` + #[wire(request_id = 168)] + async fn list_ring_vrf_keys( + &self, + _cx: &CallContext, + _request: HostAccountListRingVrfKeysRequest, + ) -> Result> + { + Err(CallError::unavailable()) + } + + /// Sign bytes directly with a registered ring-VRF member key. + /// + /// ```ts + /// const result = await truapi.account.ringVrfSign({ + /// keyHandle: { + /// dotNsIdentifier: "truapi-playground.dot", + /// derivationIndex: { tag: "Left", value: 0 }, + /// }, + /// message: "0x48656c6c6f", + /// }); + /// assert(result.isOk(), "ringVrfSign failed:", result); + /// console.log("ring VRF signature:", result.value); + /// ``` + #[wire(request_id = 170)] + async fn ring_vrf_sign( + &self, + _cx: &CallContext, + _request: HostAccountRingVrfSignRequest, + ) -> Result> { + Err(CallError::unavailable()) + } + /// List non-product accounts the user owns. /// /// Current hosts do not expose non-product accounts, so the list is empty. diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index a138df037..9ed39b741 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -33,12 +33,12 @@ pub mod latest { pub use crate::v01::{ AccountId, AllocatableResource, AllocationOutcome, ContextualAlias, DerivationIndex, GenericError, HostSignPayloadData, NotificationId, OperationStartedResult, - ProductAccountId, ProductProofContext, RawPayload, RemotePermission, + ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, - RemoteStatementStoreSubscribeRequest, RingLocation, RuntimeApi, RuntimeSpec, RuntimeType, - SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeVariant, TxPayloadExtension, + RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, + RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, + StorageQueryItem, StorageQueryType, StorageResultItem, ThemeVariant, TxPayloadExtension, }; /// Latest payload type of a versioned envelope. @@ -50,6 +50,15 @@ pub mod latest { /// Contextual alias derivation result. pub type HostAccountGetAliasResponse = LatestOf; + /// Ring-VRF key registration result. + pub type HostAccountRegisterRingVrfKeyResponse = + LatestOf; + /// Ring-VRF registry listing result. + pub type HostAccountListRingVrfKeysResponse = + LatestOf; + /// Direct ring-VRF key signing result. + pub type HostAccountRingVrfSignResponse = + LatestOf; /// Legacy account listing result. pub type HostGetLegacyAccountsResponse = LatestOf; diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index fe5e3c535..f43afcdf1 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -90,14 +90,66 @@ pub struct ProductProofContext { /// Request to create a ring VRF proof. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountCreateProofRequest { + /// Ring-VRF key handle naming the member key to use. + pub key_handle: ProductAccountId, /// Product-scoped context the derived alias is bound to. pub context: ProductProofContext, - /// Ring to generate the proof against; the host selects the member key. + /// Ring to generate the proof against. pub ring_location: RingLocation, /// Opaque message bound into the proof. pub message: Vec, } +/// Ring-VRF member public key. +pub type RingVrfPublicKey = [u8; 32]; + +/// A registered ring-VRF key entry. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct RegisteredRingVrfKey { + /// Stable public name of the key. + pub handle: ProductAccountId, + /// Rings the owning product declared this key for. + pub rings: Vec, + /// Present when the caller owns the key or requested/granted disclosure. + pub public_key: Option, +} + +/// How much of a registry entry the caller asks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] +pub enum RingVrfKeyDisclosure { + /// Handle and declared rings only. + Anonymized, + /// Include the member public key. + PublicKey, +} + +/// Request to register a ring-VRF key owned by the calling product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountRegisterRingVrfKeyRequest { + /// Key derivation index within the caller's ring-VRF domain. + pub index: DerivationIndex, + /// Ring this key is declared for. + pub ring: RingLocation, +} + +/// Request to list registered ring-VRF keys for an owner product. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountListRingVrfKeysRequest { + /// Product whose registry entries should be listed. + pub owner: String, + /// Disclosure level requested by the caller. + pub disclosure: RingVrfKeyDisclosure, +} + +/// Request to sign bytes with a registered ring-VRF key. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct HostAccountRingVrfSignRequest { + /// Registered key handle. + pub key_handle: ProductAccountId, + /// Opaque message to sign. + pub message: Vec, +} + /// User's authentication state. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub enum HostAccountConnectionStatusSubscribeItem { @@ -156,8 +208,14 @@ pub enum HostAccountGetError { pub enum HostAccountCreateProofError { /// Ring not available at the specified location. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The key handle is not registered. + KeyNotRegistered, + /// The key handle is not registered for the requested ring. + KeyNotInRing, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, /// User or host rejected. Rejected, /// Catch-all. @@ -172,8 +230,12 @@ pub enum HostAccountCreateProofError { pub enum HostAccountGetAliasError { /// Ring not available at the specified location. RingNotFound, - /// The selected member key is not a member of the requested ring. + /// The registered member key is not a member of the requested ring. NotMember, + /// The key handle is not registered. + KeyNotRegistered, + /// The key handle is not registered for the requested ring. + KeyNotInRing, /// User or host rejected. Rejected, /// Catch-all. @@ -221,12 +283,62 @@ pub enum HostGetUserIdError { /// Request to retrieve the contextual alias for a context and ring. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct HostAccountGetAliasRequest { + /// Ring-VRF key handle naming the member key to use. + pub key_handle: ProductAccountId, /// Product-scoped context to derive the alias for. pub context: ProductProofContext, /// Ring whose member key the host should use; matches `create_proof`. pub ring_location: RingLocation, } +/// Error returned when ring-VRF key registration fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountRegisterRingVrfKeyError { + /// User is not logged in. + NotConnected, + /// Ring not available at the specified location. + RingNotFound, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Error returned when listing ring-VRF keys fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountListRingVrfKeysError { + /// User is not logged in. + NotConnected, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + +/// Error returned when direct ring-VRF key signing fails. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostAccountRingVrfSignError { + /// User is not logged in. + NotConnected, + /// The key handle is not registered. + KeyNotRegistered, + /// The foreign key owner has not allowlisted the caller. + NotAllowlisted, + /// User or host rejected. + Rejected, + /// Catch-all. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} + /// Response containing a ring VRF proof and the values needed to verify it /// against a downstream precompile. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index d2579f746..fd0bfd607 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -12,6 +12,15 @@ truapi_macros::versioned_type! { pub enum HostAccountCreateProofRequest { V1 => v01::HostAccountCreateProofRequest } pub enum HostAccountCreateProofResponse { V1 => v01::HostAccountCreateProofResponse } pub enum HostAccountCreateProofError { V1 => v01::HostAccountCreateProofError } + pub enum HostAccountRegisterRingVrfKeyRequest { V1 => v01::HostAccountRegisterRingVrfKeyRequest } + pub enum HostAccountRegisterRingVrfKeyResponse { V1 => v01::RingVrfPublicKey } + pub enum HostAccountRegisterRingVrfKeyError { V1 => v01::HostAccountRegisterRingVrfKeyError } + pub enum HostAccountListRingVrfKeysRequest { V1 => v01::HostAccountListRingVrfKeysRequest } + pub enum HostAccountListRingVrfKeysResponse { V1 => Vec } + pub enum HostAccountListRingVrfKeysError { V1 => v01::HostAccountListRingVrfKeysError } + pub enum HostAccountRingVrfSignRequest { V1 => v01::HostAccountRingVrfSignRequest } + pub enum HostAccountRingVrfSignResponse { V1 => Vec } + pub enum HostAccountRingVrfSignError { V1 => v01::HostAccountRingVrfSignError } pub enum HostAccountSignVrfRequest { V1 => v01::HostAccountSignVrfRequest } pub enum HostAccountSignVrfResponse { V1 => v01::VrfSignature } pub enum HostAccountSignVrfError { V1 => v01::HostAccountSignVrfError } From 36228df30c44070b6e6bba31045b39c97ae62464 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 6 Aug 2026 05:57:04 +0000 Subject: [PATCH 2/4] fix: keep RFC-0024 responder wasm-compatible --- explorer/diagnosis-reports/pairing-host-cli.md | 4 ++-- explorer/diagnosis-reports/signing-host-cli.md | 4 ++-- rust/crates/truapi-server/src/runtime/services.rs | 2 ++ rust/crates/truapi-server/src/runtime/signing_host.rs | 8 ++++++-- .../truapi-server/src/runtime/signing_host/ring_vrf.rs | 1 + .../src/runtime/signing_host/sso_responder.rs | 8 +++----- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index 8475d20f8..fca8f8c7d 100644 --- a/explorer/diagnosis-reports/pairing-host-cli.md +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -54,8 +54,8 @@ | `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | | `Permissions/request_device_permission` | ✅ | | | `Permissions/request_remote_permission` | ✅ | | -| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | -| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "bulletin allowance is not available" } } } } } | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | | `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md index d4d953efd..f4841c9f0 100644 --- a/explorer/diagnosis-reports/signing-host-cli.md +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -54,8 +54,8 @@ | `Payment/status_subscribe` | ❌ | topUp failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "Payments are not supported in dot.li" } } } } } | | `Permissions/request_device_permission` | ✅ | | | `Permissions/request_remote_permission` | ✅ | | -| `Preimage/lookup_subscribe` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } } | -| `Preimage/submit` | ❌ | submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } } | +| `Preimage/lookup_subscribe` | ✅ | | +| `Preimage/submit` | ✅ | | | `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 0cd2efee0..9bbaf2dd0 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -33,6 +33,7 @@ pub(crate) struct RuntimeServices { /// People-chain statement store RPC client. pub(crate) statement_store: StatementStoreRpc, /// People-chain genesis hash used for ring locations and statement-store protocols. + #[cfg(not(target_arch = "wasm32"))] pub(crate) people_chain_genesis_hash: [u8; 32], /// In-core Bulletin submission over the configured Bulletin chain. pub(crate) bulletin: BulletinRpc, @@ -68,6 +69,7 @@ impl RuntimeServices { platform, chain, statement_store, + #[cfg(not(target_arch = "wasm32"))] people_chain_genesis_hash, bulletin, preimage_cache: Mutex::new(PreimageCache::default()), diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b575b0ceb..12190d297 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -48,9 +48,11 @@ 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; +#[cfg(not(target_arch = "wasm32"))] +use ring_vrf::LITE_PERSON_COLLECTION; use ring_vrf::{ - ChainRingResolver, LITE_PERSON_COLLECTION, MemberCandidate, RingResolver, alias_from_entropy, - context_bytes, create_proof, member_from_entropy, sign_from_entropy, + ChainRingResolver, MemberCandidate, RingResolver, alias_from_entropy, context_bytes, + create_proof, member_from_entropy, sign_from_entropy, }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; @@ -358,6 +360,7 @@ impl SigningHost { }) } + #[cfg(not(target_arch = "wasm32"))] fn well_known_ring_location(&self, collection: [u8; 32]) -> v01::RingLocation { v01::RingLocation { chain_id: self.services.people_chain_genesis_hash, @@ -368,6 +371,7 @@ impl SigningHost { } } + #[cfg(not(target_arch = "wasm32"))] async fn selected_lite_person_entropy( &self, session: &AuthoritySession, 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 dc85f6eaf..ecee85c5b 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 @@ -19,6 +19,7 @@ use verifiable::ring::RingDomainSize; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; const MEMBERS_PALLET: &str = "Members"; +#[cfg(not(target_arch = "wasm32"))] pub(super) const LITE_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people-lite"; type RingMember = ::Member; 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 a9e25a066..b1b1caa8b 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,13 +27,11 @@ 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_ring_vrf_domain_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::{ From 7118822cf438caef3c1c53a88df7b0757f748d66 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 6 Aug 2026 06:38:00 +0000 Subject: [PATCH 3/4] fix: preserve reserved LitePeople allowance signing --- .../diagnosis-reports/pairing-host-cli.md | 8 +- .../diagnosis-reports/signing-host-cli.md | 8 +- .../truapi-host-cli/js/diagnosis.test.ts | 99 ++--------------- rust/crates/truapi-host-cli/js/diagnosis.ts | 101 +---------------- .../truapi-server/src/runtime/services.rs | 5 - .../truapi-server/src/runtime/signing_host.rs | 103 +++--------------- .../src/runtime/signing_host/ring_vrf.rs | 2 - .../src/runtime/signing_host/sso_responder.rs | 4 +- 8 files changed, 34 insertions(+), 296 deletions(-) diff --git a/explorer/diagnosis-reports/pairing-host-cli.md b/explorer/diagnosis-reports/pairing-host-cli.md index fca8f8c7d..91a935148 100644 --- a/explorer/diagnosis-reports/pairing-host-cli.md +++ b/explorer/diagnosis-reports/pairing-host-cli.md @@ -56,17 +56,17 @@ | `Permissions/request_remote_permission` | ✅ | | | `Preimage/lookup_subscribe` | ✅ | | | `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | +| `Resource Allocation/request` | ✅ | | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | | `Signing/sign_raw_with_legacy_account` | ✅ | | | `Signing/sign_payload_with_legacy_account` | ✅ | | | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | -| `Statement Store/subscribe` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/subscribe` | ✅ | | | `Statement Store/create_proof` | ✅ | | -| `Statement Store/submit` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | -| `Statement Store/create_proof_authorized` | ❌ | createProof failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | | `System/handshake` | ✅ | | | `System/feature_supported` | ✅ | | | `System/navigate_to` | ✅ | | diff --git a/explorer/diagnosis-reports/signing-host-cli.md b/explorer/diagnosis-reports/signing-host-cli.md index f4841c9f0..eca6b900c 100644 --- a/explorer/diagnosis-reports/signing-host-cli.md +++ b/explorer/diagnosis-reports/signing-host-cli.md @@ -56,17 +56,17 @@ | `Permissions/request_remote_permission` | ✅ | | | `Preimage/lookup_subscribe` | ✅ | | | `Preimage/submit` | ✅ | | -| `Resource Allocation/request` | ❌ | statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] } | +| `Resource Allocation/request` | ✅ | | | `Signing/create_transaction` | ✅ | | | `Signing/create_transaction_with_legacy_account` | ✅ | | | `Signing/sign_raw_with_legacy_account` | ✅ | | | `Signing/sign_payload_with_legacy_account` | ✅ | | | `Signing/sign_raw` | ✅ | | | `Signing/sign_payload` | ✅ | | -| `Statement Store/subscribe` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/subscribe` | ✅ | | | `Statement Store/create_proof` | ✅ | | -| `Statement Store/submit` | ❌ | createProofAuthorized failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | -| `Statement Store/create_proof_authorized` | ❌ | createProof failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } } | +| `Statement Store/submit` | ✅ | | +| `Statement Store/create_proof_authorized` | ✅ | | | `System/handshake` | ✅ | | | `System/feature_supported` | ✅ | | | `System/navigate_to` | ✅ | | diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index a4ad52d60..d2f27f8d5 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -70,107 +70,22 @@ describe("generated-example battery", () => { ).toBe(undefined); }); - test("accepts only the exact RFC-0024 provider-absent battery failures", () => { - const reason = - "RFC-0024 People Lite provider is not installed in the CLI battery"; - expect( - expectedCliBatteryFailureReason( - failedRow( - "Resource Allocation/request", - `statement-store or bulletin allowance was not allocated: ${JSON.stringify( - { - outcomes: [ - "NotAvailable", - "NotAvailable", - "NotAvailable", - "Allocated", - ], - }, - null, - 2, - )}`, - ), - ), - ).toBe(reason); + test("does not mask allowance or People Lite provider failures", () => { for (const id of [ + "Resource Allocation/request", "Statement Store/subscribe", "Statement Store/submit", "Statement Store/create_proof_authorized", + "Preimage/lookup_subscribe", + "Preimage/submit", + "Account/register_ring_vrf_key", ]) { expect( expectedCliBatteryFailureReason( - failedRow( - id, - 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } }', - ), + failedRow(id, "no ring-VRF provider is registered for People Lite"), ), - ).toBe(reason); - } - for (const id of ["Preimage/lookup_subscribe", "Preimage/submit"]) { - for (const providerFailure of [ - "no ring-VRF provider is registered for People Lite", - "bulletin allowance is not available", - ]) { - expect( - expectedCliBatteryFailureReason( - failedRow( - id, - `submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "${providerFailure}" } } } } }`, - ), - ), - ).toBe(reason); - } + ).toBe(undefined); } - expect( - expectedCliBatteryFailureReason( - failedRow("Resource Allocation/request", "request timed out"), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow( - "Resource Allocation/request", - 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "UnableToSign" } } } }', - ), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow( - "Resource Allocation/request", - 'statement-store or bulletin allowance was not allocated: { "outcomes": [ "Allocated", "NotAvailable", "NotAvailable", "Allocated" ] }', - ), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow("Statement Store/submit", "subscription failed"), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow( - "Statement Store/submit", - 'statement-store or bulletin allowance was not allocated: { "outcomes": [ "NotAvailable", "NotAvailable", "NotAvailable", "Allocated" ] }', - ), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow( - "Preimage/submit", - 'submit failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no allowance" } } } } }', - ), - ), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow( - "Account/register_ring_vrf_key", - 'failed: { "error": { "tag": "Domain", "value": { "tag": "V1", "value": { "tag": "Unknown", "value": { "reason": "no ring-VRF provider is registered for People Lite" } } } } }', - ), - ), - ).toBe(undefined); }); test("does not ignore account proof failures", () => { diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index e62496408..cdfe4029e 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -18,15 +18,6 @@ const UNARY_TIMEOUT_MS = 10_000; const REMOTE_RESPONSE_TIMEOUT_MS = 190_000; const LIVE_ALLOCATION_TIMEOUT_MS = 420_000; const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); -const STATEMENT_PROVIDER_DEPENDENT_METHODS = new Set([ - "Statement Store/subscribe", - "Statement Store/submit", - "Statement Store/create_proof_authorized", -]); -const PREIMAGE_PROVIDER_DEPENDENT_METHODS = new Set([ - "Preimage/lookup_subscribe", - "Preimage/submit", -]); const LONG_TIMEOUT_METHODS = new Set([ "Account/get_account", "Account/get_account_alias", @@ -107,104 +98,14 @@ export function knownUnsupportedReason( } export function expectedCliBatteryFailureReason( - row: Pick, + row: Pick, ): string | undefined { if (SKIPPED_SERVICES.has(row.serviceName)) { return `${row.serviceName} service not yet wired up by hosts`; } - const noPeopleProvider = - "RFC-0024 People Lite provider is not installed in the CLI battery"; - const explicitlyReportsNoProvider = row.output.includes( - "no ring-VRF provider is registered for People Lite", - ); - if ( - row.id === "Resource Allocation/request" && - (explicitlyReportsNoProvider || - isProviderAbsentAllocationFailure(row.output)) - ) { - return noPeopleProvider; - } - if ( - STATEMENT_PROVIDER_DEPENDENT_METHODS.has(row.id) && - (explicitlyReportsNoProvider || isUnableToSignFailure(row.output)) - ) { - return noPeopleProvider; - } - if ( - PREIMAGE_PROVIDER_DEPENDENT_METHODS.has(row.id) && - (isDomainUnknownFailure( - row.output, - "no ring-VRF provider is registered for People Lite", - ) || - isDomainUnknownFailure(row.output, "bulletin allowance is not available")) - ) { - return noPeopleProvider; - } return undefined; } -function isProviderAbsentAllocationFailure(output: string): boolean { - if ( - !output.startsWith( - "statement-store or bulletin allowance was not allocated:", - ) - ) { - return false; - } - const payload = parseTrailingJson(output); - if (!isRecord(payload) || Object.keys(payload).length !== 1) return false; - return ( - Array.isArray(payload.outcomes) && - payload.outcomes.length === 4 && - payload.outcomes[0] === "NotAvailable" && - payload.outcomes[1] === "NotAvailable" && - payload.outcomes[2] === "NotAvailable" && - payload.outcomes[3] === "Allocated" - ); -} - -function isUnableToSignFailure(output: string): boolean { - const payload = parseTrailingJson(output); - if (!isRecord(payload) || !isRecord(payload.error)) return false; - const error = payload.error; - return ( - error.tag === "Domain" && - isRecord(error.value) && - error.value.tag === "V1" && - isRecord(error.value.value) && - error.value.value.tag === "UnableToSign" - ); -} - -function isDomainUnknownFailure(output: string, reason: string): boolean { - const payload = parseTrailingJson(output); - if (!isRecord(payload) || !isRecord(payload.error)) return false; - const error = payload.error; - return ( - error.tag === "Domain" && - isRecord(error.value) && - error.value.tag === "V1" && - isRecord(error.value.value) && - error.value.value.tag === "Unknown" && - isRecord(error.value.value.value) && - error.value.value.value.reason === reason - ); -} - -function parseTrailingJson(output: string): unknown { - const jsonStart = output.indexOf("{"); - if (jsonStart < 0) return undefined; - try { - return JSON.parse(output.slice(jsonStart)); - } catch { - return undefined; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - async function runOne( client: TrUApiClient, test: DiagnosisCase, diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 9bbaf2dd0..7c6fb3c4d 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -32,9 +32,6 @@ pub(crate) struct RuntimeServices { pub(crate) chain: ChainRuntime, /// People-chain statement store RPC client. pub(crate) statement_store: StatementStoreRpc, - /// People-chain genesis hash used for ring locations and statement-store protocols. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) people_chain_genesis_hash: [u8; 32], /// In-core Bulletin submission over the configured Bulletin chain. pub(crate) bulletin: BulletinRpc, /// Values from confirmed in-core submissions, served to `lookup_subscribe` @@ -69,8 +66,6 @@ impl RuntimeServices { platform, chain, statement_store, - #[cfg(not(target_arch = "wasm32"))] - people_chain_genesis_hash, bulletin, preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 12190d297..44db144f1 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -39,6 +39,8 @@ 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_ring_vrf_entropy, @@ -48,8 +50,6 @@ 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; -#[cfg(not(target_arch = "wasm32"))] -use ring_vrf::LITE_PERSON_COLLECTION; use ring_vrf::{ ChainRingResolver, MemberCandidate, RingResolver, alias_from_entropy, context_bytes, create_proof, member_from_entropy, sign_from_entropy, @@ -360,39 +360,16 @@ impl SigningHost { }) } + /// 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 well_known_ring_location(&self, collection: [u8; 32]) -> v01::RingLocation { - v01::RingLocation { - chain_id: self.services.people_chain_genesis_hash, - junctions: vec![ - v01::RingLocationJunction::PalletInstance(67), - v01::RingLocationJunction::CollectionId(collection.to_vec()), - ], - } - } - - #[cfg(not(target_arch = "wasm32"))] - async fn selected_lite_person_entropy( + fn reserved_lite_person_entropy( &self, session: &AuthoritySession, ) -> Result, AuthorityError> { self.require_current_session(session)?; - let ring = self.well_known_ring_location(LITE_PERSON_COLLECTION); - let handle = self - .ring_vrf_registry - .selected_provider(session.public_key, &ring) - .await - .map_err(|error| AuthorityError::Unknown { - reason: format!("{error:?}"), - })? - .ok_or_else(|| AuthorityError::Unavailable { - reason: "no ring-VRF provider is registered for People Lite".to_string(), - })?; - self.resolve_ring_vrf_key_for_ring(session, &handle, &ring) - .await - .map_err(|error| AuthorityError::Unknown { - reason: format!("{error:?}"), - }) + let root = self.root_entropy()?; + Ok(Zeroizing::new(derive_lite_person_ring_vrf_entropy(&root))) } async fn registered_ring_vrf_entry( @@ -1305,67 +1282,19 @@ mod tests { } #[test] - fn selected_lite_provider_must_match_the_active_wallet() { - 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 ring = authority.well_known_ring_location(super::LITE_PERSON_COLLECTION); - let handle = v01::ProductAccountId { - dot_ns_identifier: "provider.dot".to_string(), - derivation_index: v01::DerivationIndex::Left(0), - }; - futures::executor::block_on(authority.ring_vrf_registry.register( - session.public_key, - handle, - ring, - [0xFF; 32], - )) - .expect("tampered registry fixture is structurally valid"); - - let error = futures::executor::block_on(authority.selected_lite_person_entropy(&session)) - .unwrap_err(); - - assert!(matches!( - error, - AuthorityError::Unknown { reason } - if reason.contains("does not match the active wallet") - )); - } - - #[test] - fn internal_provider_selection_requires_the_exact_well_known_ring() { + 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 exact_ring = authority.well_known_ring_location(super::LITE_PERSON_COLLECTION); - let collection_only_ring = v01::RingLocation { - chain_id: exact_ring.chain_id, - junctions: vec![exact_ring.junctions[1].clone()], - }; - let handle = v01::ProductAccountId { - dot_ns_identifier: "provider.dot".to_string(), - derivation_index: v01::DerivationIndex::Left(0), - }; - let entropy = authority.ring_vrf_entropy(&session, &handle).unwrap(); - let public_key = member_from_entropy(&entropy).unwrap(); - futures::executor::block_on(authority.ring_vrf_registry.register( - session.public_key, - handle, - collection_only_ring, - public_key, - )) - .expect("registration succeeds"); - - let error = futures::executor::block_on(authority.selected_lite_person_entropy(&session)) - .unwrap_err(); - - assert!(matches!( - error, - AuthorityError::Unavailable { reason } - if reason.contains("no ring-VRF provider is registered") - )); + 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] 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 ecee85c5b..42ab21a55 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -19,8 +19,6 @@ use verifiable::ring::RingDomainSize; use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; const MEMBERS_PALLET: &str = "Members"; -#[cfg(not(target_arch = "wasm32"))] -pub(super) const LITE_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people-lite"; type RingMember = ::Member; 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 b1b1caa8b..23c570ee6 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -910,7 +910,7 @@ pub(super) async fn allocate_statement_store_allowance( let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; - let bandersnatch = *signing_host.selected_lite_person_entropy(&session).await?; + let bandersnatch = *signing_host.reserved_lite_person_entropy(&session)?; let rpc = statement_allowance::rpc::RpcClient::new( services .statement_store @@ -1008,7 +1008,7 @@ pub(super) async fn allocate_bulletin_allowance( let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; - let bandersnatch = *signing_host.selected_lite_person_entropy(&session).await?; + 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? From 20c1d4a62d95746eaec9f77c73661a183c6db04f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Thu, 6 Aug 2026 10:58:03 +0000 Subject: [PATCH 4/4] test: simplify CLI battery failure policy --- .../truapi-host-cli/js/diagnosis.test.ts | 69 +++---------------- 1 file changed, 11 insertions(+), 58 deletions(-) diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index d2f27f8d5..4db0dd335 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -47,58 +47,22 @@ describe("generated-example battery", () => { ); }); - test("classifies only the committed unsupported CLI battery failures as expected", () => { - expect( - expectedCliBatteryFailureReason( - failedRow("Chat/create_room", "unavailable"), - ), - ).toBe("Chat service not yet wired up by hosts"); - expect( - expectedCliBatteryFailureReason( - failedRow("Coin Payment/create_purse", "unavailable"), - ), - ).toBe("Coin Payment service not yet wired up by hosts"); - expect( - expectedCliBatteryFailureReason( - failedRow("Payment/top_up", "unavailable"), - ), - ).toBe("Payment service not yet wired up by hosts"); - expect( - expectedCliBatteryFailureReason( - failedRow("Signing/sign_raw", "Rejected"), - ), - ).toBe(undefined); - }); + test("classifies only the committed unsupported CLI services as expected", () => { + const unsupported = new Set(["Chat", "Coin Payment", "Payment"]); - test("does not mask allowance or People Lite provider failures", () => { - for (const id of [ - "Resource Allocation/request", - "Statement Store/subscribe", - "Statement Store/submit", - "Statement Store/create_proof_authorized", - "Preimage/lookup_subscribe", - "Preimage/submit", - "Account/register_ring_vrf_key", - ]) { + 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( - failedRow(id, "no ring-VRF provider is registered for People Lite"), - ), - ).toBe(undefined); + expectedCliBatteryFailureReason({ serviceName: service.name }), + ).toBe(expected); } }); - test("does not ignore account proof failures", () => { - expect( - knownUnsupportedReason("Account", "Account/create_account_proof"), - ).toBe(undefined); - expect( - expectedCliBatteryFailureReason( - failedRow("Account/create_account_proof", "NotMember"), - ), - ).toBe(undefined); - }); - test("prints failures as concise test-reporter rows", () => { const output: string[] = []; const reporter = new BatteryReporter((line) => output.push(line), false); @@ -221,14 +185,3 @@ function row( durationMs, }; } - -function failedRow( - id: string, - output: string, -): Pick { - return { - id, - serviceName: id.slice(0, id.indexOf("/")), - output, - }; -}