From ef9feffaf94963e879d406237b94eb12528bfed5 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 15:31:47 -0400 Subject: [PATCH 1/7] feat: add Chat v2 account authority operations --- CHANGELOG.md | 2 + README.md | 6 + rust/crates/truapi-client/src/generated.rs | 34 +- .../truapi-codegen/tests/golden/dispatcher.rs | 39 ++ .../truapi-codegen/tests/golden/wire_table.rs | 12 +- rust/crates/truapi-server/Cargo.toml | 2 +- .../src/host_logic/permissions.rs | 47 +- .../src/host_logic/sso/messages.rs | 122 +++++- .../src/host_logic/sso/messages/v1.rs | 11 +- .../src/host_logic/sso/pairing.rs | 4 +- .../src/host_logic/sso/pairing/v2.rs | 8 + rust/crates/truapi-server/src/runtime.rs | 82 ++-- .../truapi-server/src/runtime/authority.rs | 401 +++++++++++++++++- .../src/runtime/capabilities/account.rs | 105 ++++- .../truapi-server/src/runtime/pairing_host.rs | 25 +- .../src/runtime/pairing_host/sso_channel.rs | 100 ++++- .../truapi-server/src/runtime/signing_host.rs | 38 +- .../src/runtime/signing_host/sso_responder.rs | 325 ++++++++++---- .../truapi-server/src/runtime/sso_pairing.rs | 26 +- .../truapi-server/tests/wire_result_shape.rs | 26 ++ rust/crates/truapi/src/api/account.rs | 37 +- rust/crates/truapi/src/lib.rs | 6 + rust/crates/truapi/src/v01/account.rs | 82 ++++ .../truapi/src/v01/resource_allocation.rs | 3 + rust/crates/truapi/src/versioned/account.rs | 3 + 25 files changed, 1394 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 664fd8773..68790019f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Added +- Add `account.productDeviceChat` for host-private Chat v2 identity binding and + identity-route sealing/opening through local or paired account authorities. - Generate a transport-neutral `no_std` Rust client with typed request, subscription, result-subscription, and host-initiated Worker subscription codecs. diff --git a/README.md b/README.md index 6ecc3bf15..38dfe50f0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ const result = await truapi.accountManagement.accountGet({ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full client reference. +`account.productDeviceChat` binds a product-derived account to the connected +wallet's Chat v2 identity and seals or opens identity-route payloads without +exposing the wallet's X25519 private key. Browser pairing hosts forward the +operation over encrypted SSO; signing hosts require the calling product's +identity-disclosure authorization before using local wallet material. + ## Repository layout ``` diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 5aca53c9d..d2aa0a997 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; @@ -239,6 +239,35 @@ impl RequestMethod for AccountRingVrfSign { const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; } +/// `account_product_device_chat` method marker. +pub struct AccountProductDeviceChat; +impl AccountProductDeviceChat { + /// Canonical metadata and frame ids for this method. + pub const DESCRIPTOR: MethodDescriptor = MethodDescriptor { + service: "Account", + method: "product_device_chat", + wire_name: "account_product_device_chat", + request_type: "truapi::versioned::account::HostProductDeviceChatRequest", + response_type: "truapi::versioned::account::HostProductDeviceChatResponse", + error_type: Some("truapi::versioned::account::HostProductDeviceChatError"), + kind: MethodKind::Request, + direction: Direction::ProductToHost, + required_execution: None, + sensitive: true, + wire: MethodWire::Request(RequestFrameIds { + request_id: 174, + response_id: 175, + }), + }; +} +impl RequestMethod for AccountProductDeviceChat { + type Request = truapi::versioned::account::HostProductDeviceChatRequest; + type Response = truapi::versioned::account::HostProductDeviceChatResponse; + type Error = truapi::versioned::account::HostProductDeviceChatError; + const RESPONSE_VERSIONED: bool = true; + const DESCRIPTOR: MethodDescriptor = Self::DESCRIPTOR; +} + /// `account_get_legacy_accounts` method marker. pub struct AccountGetLegacyAccounts; impl AccountGetLegacyAccounts { @@ -2121,6 +2150,7 @@ pub const APP_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2191,6 +2221,7 @@ pub const WIDGET_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, @@ -2261,6 +2292,7 @@ pub const WORKER_METHODS: &[MethodDescriptor] = &[ AccountRegisterRingVrfKey::DESCRIPTOR, AccountListRingVrfKeys::DESCRIPTOR, AccountRingVrfSign::DESCRIPTOR, + AccountProductDeviceChat::DESCRIPTOR, AccountGetLegacyAccounts::DESCRIPTOR, AccountGetUserId::DESCRIPTOR, AccountRequestLogin::DESCRIPTOR, diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index 7b059b295..58c85c5c0 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -370,6 +370,45 @@ where }) }); } + { + let host = host.clone(); + dispatcher.on_request(wire_table::ACCOUNT_PRODUCT_DEVICE_CHAT, move |request_id: String, bytes: Vec| { + let host = host.clone(); + Box::pin(async move { + let request: versioned::account::HostProductDeviceChatRequest = 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::HostProductDeviceChatResponse = match host.product_device_chat(&cx, request).await { + Ok(value) => value, + Err(err) => { + return Ok(encode_versioned_err_payload( + downgrade_call_error(err, target_version), + target_version, + )); + } + }; + // Downgraded to the caller's version: a handler answers in + // latest terms, and a peer that asked in an older version + // cannot decode a newer variant. + Ok(encode_versioned_ok_payload( + ::from_latest( + truapi::versioned::IntoLatest::into_latest(response), + target_version, + ), + )) + }) + }); + } { 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/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 5d59d7f93..e96191a49 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { @@ -496,6 +496,12 @@ pub const ACCOUNT_RING_VRF_SIGN: RequestFrameIds = RequestFrameIds { response_id: 173, }; +/// Wire discriminants for `account_product_device_chat`. +pub const ACCOUNT_PRODUCT_DEVICE_CHAT: RequestFrameIds = RequestFrameIds { + request_id: 174, + response_id: 175, +}; + /// Wire discriminants for `system_get_product_context`. pub const SYSTEM_GET_PRODUCT_CONTEXT: RequestFrameIds = RequestFrameIds { request_id: 190, @@ -795,6 +801,10 @@ pub const WIRE_TABLE: &[WireEntry] = &[ method: "account_ring_vrf_sign", kind: WireKind::Request(ACCOUNT_RING_VRF_SIGN), }, + WireEntry { + method: "account_product_device_chat", + kind: WireKind::Request(ACCOUNT_PRODUCT_DEVICE_CHAT), + }, WireEntry { method: "system_get_product_context", kind: WireKind::Request(SYSTEM_GET_PRODUCT_CONTEXT), diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 390556255..4c0d14293 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -55,7 +55,7 @@ getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] } -x25519-dalek = { version = "2", default-features = false, features = ["static_secrets"] } +x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "zeroize"] } sha2 = "0.10" merlin = "3" parking_lot = "0.12" diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index f8cd35c62..640a6b9de 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,9 +7,9 @@ //! The cache layer is shared but keys are typed so a device grant cannot //! authorize a remote operation by accident. Keys are also scoped by product id //! so one product's authorization never grants another product's request. -//! Identity disclosure is also represented as a product-scoped authorization, -//! but the prompt itself is handled by the account runtime because it uses the -//! richer user-confirmation surface rather than the device/remote callbacks. +//! Identity disclosure is also represented as a product-scoped authorization; +//! its richer user-confirmation prompt is coordinated here so local and remote +//! account-authority paths share one decision state machine. //! //! Domain grants (`RemotePermission::Remote`) are the one request that does not //! occupy a single slot. A product may ask for several domains at once, while @@ -42,9 +42,10 @@ use truapi::latest::{ RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ - CoreStorage, CoreStorageKey, DevicePermissionStatus, PermissionAuthorizationRequest, - PermissionAuthorizationStatus, PermissionStatusHost, Permissions, - has_trusted_remote_permissions, remote_domain_candidates, + CoreStorage, CoreStorageKey, DevicePermissionStatus, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionStatusHost, + Permissions, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, + remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -358,6 +359,40 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a set_authorization_status(self.storage, key, status).await } + /// Resolve the product's identity-disclosure grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_identity_disclosure( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::IdentityDisclosure( + IdentityDisclosureReview { + product_id: self.product_id.to_string(), + }, + )) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, prompting the platform's `device_permission` callback /// and persisting the answer when the question is still open. 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 076703c62..fbdc30aab 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -29,7 +29,7 @@ use truapi::latest::{ HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RawPayload, RingLocation, }; -use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; +use truapi::v01::{self, HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -497,6 +497,8 @@ pub enum SsoAllocatableResource { SmartContractAllowance(DerivationIndex), /// Transfer of the product subtree key so the host can sign locally. AutoSigning, + /// Current UTC-day Statement Store allowance targeting the selected product account. + ProductStatementStoreAllowance(DerivationIndex), } impl From for SsoAllocatableResource { @@ -508,6 +510,9 @@ impl From for SsoAllocatableResource { Self::SmartContractAllowance(index) } AllocatableResource::AutoSigning => Self::AutoSigning, + AllocatableResource::ProductStatementStoreAllowance(index) => { + Self::ProductStatementStoreAllowance(index) + } } } } @@ -563,6 +568,8 @@ pub enum SsoAllocatedResource { /// Entropy of the product's ring-VRF domain. ring_vrf_domain_entropy: [u8; 32], }, + /// Product account was registered as the current Statement Store slot target. + ProductStatementStoreAllowance, } impl SsoAllocatedResource { @@ -573,6 +580,7 @@ impl SsoAllocatedResource { Self::BulletinAllowance { .. } => "bulletin-allowance", Self::SmartContractAllowance => "smart-contract-allowance", Self::AutoSigning { .. } => "auto-signing", + Self::ProductStatementStoreAllowance => "product-statement-store-allowance", } } } @@ -622,6 +630,53 @@ pub struct CreateTransactionLegacyRequest { pub payload: CreateTransactionLegacyPayload, } +/// Product-device Chat v2 request forwarded to the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductDeviceChatRequest { + /// Product originating the operation. + pub calling_product_id: String, + /// Resolved authority operation; no wallet private material is included. + pub operation: SsoProductDeviceChatOperation, +} + +/// Product-device Chat v2 operation carried over encrypted SSO. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum SsoProductDeviceChatOperation { + /// Bind the product device to the wallet identity and derive peer routes. + Bind { + /// Product account index; the Account Holder re-derives the device + /// account instead of trusting a pairing-host supplied public key. + derivation_index: v01::DerivationIndex, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal an identity-route payload for the peer. + Seal { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an authenticated identity-route payload from the peer. + Open { + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, +} + +/// Product-device Chat v2 response returned by the Account Holder. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct ProductDeviceChatResponse { + /// Remote request identifier this response answers. + pub responding_to: String, + /// Authority operation result. + pub payload: Result, +} + /// Versioned legacy transaction-creation payload. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CreateTransactionLegacyPayload { @@ -676,6 +731,8 @@ pub enum SsoRemoteResponse { ListRingVrfKeys(ListRingVrfKeysResponse), /// Direct ring-VRF signing response. RingVrfSign(RingVrfSignResponse), + /// Product-device Chat v2 response. + ProductDeviceChat(Box), } impl SsoRemoteResponse { @@ -693,6 +750,7 @@ impl SsoRemoteResponse { Self::RegisterRingVrfKey(_) => "register-ring-vrf-key", Self::ListRingVrfKeys(_) => "list-ring-vrf-keys", Self::RingVrfSign(_) => "ring-vrf-sign", + Self::ProductDeviceChat(_) => "product-device-chat", } } } @@ -830,10 +888,32 @@ fn remote_response_for_message( { Some(SsoRemoteResponse::RingVrfSign(response)) } + v1::RemoteMessage::ProductDeviceChatResponse(response) + if response.responding_to == expected_remote_message_id => + { + Some(SsoRemoteResponse::ProductDeviceChat(Box::new(response))) + } _ => None, } } +/// Build a product-device Chat v2 request for the Account Holder. +pub fn product_device_chat_message( + message_id: String, + calling_product_id: String, + operation: SsoProductDeviceChatOperation, +) -> RemoteMessage { + RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatRequest( + ProductDeviceChatRequest { + calling_product_id, + operation, + }, + )), + } +} + /// Build an RFC-0024 ring-VRF key registration request for the Account Holder. pub fn register_ring_vrf_key_message( message_id: String, @@ -1563,6 +1643,46 @@ mod tests { ); } + #[test] + fn product_device_chat_messages_pin_mobile_wire_indices() { + let request = product_device_chat_message( + "request".to_string(), + "egui-chat.paseo".to_string(), + SsoProductDeviceChatOperation::Bind { + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [0x66; 32], + }, + ); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 24); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let product_response = ProductDeviceChatResponse { + responding_to: "request".to_string(), + payload: Ok(v01::HostProductDeviceChatResponse::Sealed { + combined_ciphertext: vec![0x77; 28], + }), + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductDeviceChatResponse( + product_response.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 25); + assert_eq!( + remote_response_for_message(response, "request"), + Some(SsoRemoteResponse::ProductDeviceChat(Box::new( + product_response + ))) + ); + } + #[test] fn sign_vrf_messages_match_mobile_wire_contract() { let payload = HostAccountSignVrfRequest { 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 68f9c09dc..dacde0004 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,7 +9,8 @@ use parity_scale_codec::{Decode, Encode}; use super::{ CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, - ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductDeviceChatRequest, + ProductDeviceChatResponse, ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, RingVrfProofResponse, RingVrfSignRequest, RingVrfSignResponse, SignRawLegacyRequest, @@ -104,4 +105,12 @@ pub enum RemoteMessage { #[codec(index = 23)] #[display("ring_vrf_sign_response")] RingVrfSignResponse(RingVrfSignResponse), + /// Forward a product-device Chat v2 operation to the Account Holder. + #[codec(index = 24)] + #[display("product_device_chat")] + ProductDeviceChatRequest(ProductDeviceChatRequest), + /// Account Holder's product-device Chat v2 response. + #[codec(index = 25)] + #[display("product_device_chat_response")] + ProductDeviceChatResponse(ProductDeviceChatResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 037095471..d8a49f86f 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -254,13 +254,15 @@ pub fn establish_sso_session_info( /// The statement keypair signs every session statement (its public key is the /// `identityAccountId` the pairing host binds the session to), and the X25519 /// secret is the persistent `sso` key both sides feed into the session ECDH. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub struct ResponderIdentity { /// Expanded Ed25519 secret used to sign session statements. + #[debug("\"\"")] pub statement_secret: [u8; 64], /// Ed25519 public key advertised as the session identity account. pub statement_public_key: [u8; 32], /// X25519 secret key used to derive the shared session channels. + #[debug("\"\"")] pub encryption_secret_key: [u8; 32], /// Raw X25519 public key advertised during pairing. pub encryption_public_key: [u8; 32], diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index e4ffebfec..aca105596 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -5,6 +5,7 @@ //! use parity_scale_codec::{Decode, Encode}; +use zeroize::Zeroize; /// Handshake proposal sent by the host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -79,3 +80,10 @@ pub struct Success { /// Wallet-derived source for deterministic product entropy, never the raw root secret. pub root_entropy_source: [u8; 32], } + +impl Drop for Success { + fn drop(&mut self) { + self.identity_chat_private_key.zeroize(); + self.root_entropy_source.zeroize(); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..19b26c23c 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -40,7 +40,7 @@ use std::sync::Arc; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; -use authority::{AuthorityCancelError, AuthoritySession}; +use authority::{AuthorityCancelError, AuthoritySession, ProductDeviceChatAuthorityError}; pub(crate) use authority::{AuthorityError, BulletinAllowanceKey, ProductAuthority}; pub(crate) use chat::{ChatConnection, chat_platform_for}; use futures::{FutureExt, StreamExt, pin_mut}; @@ -57,7 +57,9 @@ pub(crate) use signing_host::{ pub use signing_host::{PairedSsoPeer, ResponderExit}; use tracing::{instrument, warn}; use truapi::api::Chat; -use truapi::versioned::account::{HostAccountGetError, HostAccountSignVrfError}; +use truapi::versioned::account::{ + HostAccountGetError, HostAccountSignVrfError, HostProductDeviceChatError, +}; use truapi::versioned::chat::{ HostChatActionSubscribeItem, HostChatCreateRoomError, HostChatCreateRoomRequest, HostChatCreateRoomResponse, HostChatListSubscribeItem, HostChatPostMessageError, @@ -67,7 +69,7 @@ use truapi::versioned::chat::{ use truapi::versioned::preimage::RemotePreimageSubmitError; use truapi::{CallContext, CallError, CancellationReason, Subscription, v01}; use truapi_platform::{ - AccountAccessReview, ChatFieldError, IdentityDisclosureReview, PermissionAuthorizationRequest, + AccountAccessReview, ChatFieldError, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Platform, ProductContext, ProductStorageKey, SessionUiInfo, UserConfirmationReview, normalize_chat_identifier, normalize_product_identifier, validate_chat_icon, validate_chat_message_content, validate_chat_name, @@ -575,41 +577,10 @@ impl ProductRuntimeHost { &self, ) -> Result { let product_id = self.product_id(); - let request = PermissionAuthorizationRequest::IdentityDisclosure; - let service = self.permissions_service(&product_id); - let cached = service - .authorization_status(&request) - .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - if cached != PermissionAuthorizationStatus::NotDetermined { - return Ok(cached); - } - - // A dismissed/unavailable confirmation has no durable user decision. - // Fail the current disclosure request closed but keep authorization in - // the ask/default state so the next request can prompt again. - let confirmed = match self - .platform - .confirm_user_action(UserConfirmationReview::IdentityDisclosure( - IdentityDisclosureReview { - product_id: product_id.clone(), - }, - )) - .await - { - Ok(confirmed) => confirmed, - Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), - }; - let status = if confirmed { - PermissionAuthorizationStatus::Authorized - } else { - PermissionAuthorizationStatus::Denied - }; - service - .set_authorization_status(&request, status) + self.permissions_service(&product_id) + .check_or_prompt_identity_disclosure() .await - .map_err(|err| format!("permission storage failed: {err:?}"))?; - Ok(status) + .map_err(|err| format!("permission storage failed: {err:?}")) } async fn classify_legacy_address_signer( @@ -777,6 +748,43 @@ fn account_get_authority_error(err: AuthorityError) -> CallError CallError { + let error = match error { + AuthorityError::Disconnected => v01::HostProductDeviceChatError::NotConnected, + AuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + AuthorityError::Cancelled(error) => v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + }, + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => v01::HostProductDeviceChatError::Unknown { reason }, + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + +fn product_device_chat_authority_error( + error: ProductDeviceChatAuthorityError, +) -> CallError { + let error = match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }; + CallError::Domain(HostProductDeviceChatError::V1(error)) +} + fn ring_vrf_alias_error(err: RingVrfError) -> v01::HostAccountGetAliasError { match err { RingVrfError::RingNotFound => v01::HostAccountGetAliasError::RingNotFound, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 302473ea3..0dd8a55e3 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -7,7 +7,7 @@ use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ - AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, + AccountId, DerivationIndex, HostAccountCreateProofResponse, HostAccountGetAliasResponse, HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignResponse, HostCreateTransactionResponse, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, @@ -15,7 +15,7 @@ use truapi::latest::{ HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; -use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; +use truapi::v01::{HostAccountSignVrfRequest, HostProductDeviceChatResponse, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -285,10 +285,52 @@ pub(crate) struct RingVrfSignAuthorityRequest { pub message: Vec, } +/// Host-private Chat identity operation after product authorization. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityRequest { + Bind { + calling_product_id: String, + device_account_id: [u8; 32], + derivation_index: DerivationIndex, + peer_identity_account_id: [u8; 32], + peer_chat_public_key: [u8; 32], + }, + Seal { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + plaintext: Vec, + }, + Open { + calling_product_id: String, + peer_chat_public_key: [u8; 32], + combined_ciphertext: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ProductDeviceChatAuthorityError { + Disconnected, + Rejected, + InvalidPeerKey, + InvalidCiphertext, + Unavailable(String), +} + +impl From for ProductDeviceChatAuthorityError { + fn from(error: AuthorityError) -> Self { + match error { + AuthorityError::Disconnected => Self::Disconnected, + AuthorityError::Rejected => Self::Rejected, + other => Self::Unavailable(other.to_string()), + } + } +} + /// Statement-store allowance signing material held by the authority layer. -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, zeroize::Zeroize, zeroize::ZeroizeOnDrop, derive_more::Debug)] pub(crate) struct StatementStoreAllowanceKey { /// sr25519 secret used to sign allowance statements. + #[debug("\"\"")] pub(crate) secret: [u8; 64], /// Public key derived from `secret`. pub(crate) public_key: [u8; 32], @@ -456,6 +498,14 @@ pub(crate) trait ProductAuthority: Send + Sync { request: RingVrfSignAuthorityRequest, ) -> Result; + /// Bind/seal/open using the active wallet's host-private Chat identity key. + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result; + /// Ask the account authority to allocate product-scoped resources. async fn allocate_resources( &self, @@ -511,6 +561,204 @@ pub(crate) trait ProductAuthority: Send + Sync { ) -> Result<[u8; 32], AuthorityError>; } +pub(super) fn execute_product_device_chat( + identity_chat_private_key: &[u8; 32], + identity_account_id: [u8; 32], + request: ProductDeviceChatAuthorityRequest, +) -> Result { + use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::{ChaCha20Poly1305, Nonce}; + use hkdf::Hkdf; + use sha2::Sha256; + use x25519_dalek::{PublicKey, StaticSecret}; + use zeroize::Zeroizing; + + let peer_public_key = match &request { + ProductDeviceChatAuthorityRequest::Bind { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Seal { + peer_chat_public_key, + .. + } + | ProductDeviceChatAuthorityRequest::Open { + peer_chat_public_key, + .. + } => *peer_chat_public_key, + }; + if !is_canonical_x25519_public_key(&peer_public_key) { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + let shared_secret = Zeroizing::new( + StaticSecret::from(*identity_chat_private_key) + .diffie_hellman(&PublicKey::from(peer_public_key)) + .to_bytes(), + ); + if *shared_secret == [0; 32] { + return Err(ProductDeviceChatAuthorityError::InvalidPeerKey); + } + + return match request { + ProductDeviceChatAuthorityRequest::Bind { + device_account_id, + peer_identity_account_id, + .. + } => { + let context = b"mds-chat-request"; + let mut payload = Vec::with_capacity(65 + context.len()); + payload.extend_from_slice(&identity_account_id); + payload.extend_from_slice(&device_account_id); + payload.push((context.len() as u8) << 2); + payload.extend_from_slice(context); + let proof = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret.as_ref()) + .hash(&payload); + let mut proof_bytes = [0; 32]; + proof_bytes.copy_from_slice(proof.as_bytes()); + let wallet_own_session_id = chat_identity_session_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let peer_own_session_id = chat_identity_session_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + let wallet_outgoing_channel_id = chat_request_channel_id( + &shared_secret, + &identity_account_id, + &peer_identity_account_id, + ); + let wallet_incoming_channel_id = chat_request_channel_id( + &shared_secret, + &peer_identity_account_id, + &identity_account_id, + ); + Ok(HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof: proof_bytes, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + }) + } + ProductDeviceChatAuthorityRequest::Seal { plaintext, .. } => { + let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let mut nonce = [0; 12]; + getrandom::getrandom(&mut nonce).map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable(format!( + "failed to generate Chat identity-route nonce: {error}" + )) + })?; + let encrypted = ChaCha20Poly1305::new((&*key).into()) + .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route encryption failed".to_string(), + ) + })?; + let mut combined_ciphertext = Vec::with_capacity(12 + encrypted.len()); + combined_ciphertext.extend_from_slice(&nonce); + combined_ciphertext.extend_from_slice(&encrypted); + Ok(HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + }) + } + ProductDeviceChatAuthorityRequest::Open { + combined_ciphertext, + .. + } => { + if combined_ciphertext.len() < 28 { + return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); + } + let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let plaintext = ChaCha20Poly1305::new((&*key).into()) + .decrypt( + Nonce::from_slice(&combined_ciphertext[..12]), + &combined_ciphertext[12..], + ) + .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; + Ok(HostProductDeviceChatResponse::Opened { plaintext }) + } + }; + + fn product_device_chat_aead_key( + shared_secret: &[u8; 32], + ) -> Result<[u8; 32], ProductDeviceChatAuthorityError> { + let mut key = [0; 32]; + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route HKDF failed".to_string(), + ) + })?; + Ok(key) + } + + fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { + const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0x7f, + ]; + if key[31] & 0x80 != 0 { + return false; + } + for index in (0..32).rev() { + if key[index] < FIELD_MODULUS[index] { + return true; + } + if key[index] > FIELD_MODULUS[index] { + return false; + } + } + false + } + + fn chat_identity_session_id( + shared_secret: &[u8; 32], + first_account_id: &[u8; 32], + second_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(7 + 32 + 32 + 2); + input.extend_from_slice(b"session"); + input.extend_from_slice(first_account_id); + input.extend_from_slice(second_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } + + fn chat_request_channel_id( + shared_secret: &[u8; 32], + requester_account_id: &[u8; 32], + acceptor_account_id: &[u8; 32], + ) -> [u8; 32] { + let mut input = Vec::with_capacity(12 + 32 + 32 + 2); + input.extend_from_slice(b"chat-request"); + input.extend_from_slice(requester_account_id); + input.extend_from_slice(acceptor_account_id); + input.extend_from_slice(b"//"); + let hash = blake2b_simd::Params::new() + .hash_length(32) + .key(shared_secret) + .hash(&input); + let mut output = [0; 32]; + output.copy_from_slice(hash.as_bytes()); + output + } +} + /// Build the neutral authority-session snapshot for `session`. pub(super) fn authority_session(session: &SessionInfo) -> AuthoritySession { AuthoritySession::from_session_info(session, authority_session_validation_id(session)) @@ -549,3 +797,150 @@ pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec } id } + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(value: &str) -> [u8; 32] { + hex::decode(value).unwrap().try_into().unwrap() + } + + #[test] + fn product_device_bind_matches_ios_chat_v2_derivations() { + let peer_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + assert_eq!( + peer_public_key, + hex32("0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20") + ); + + let response = execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: "egui-chat.paseo".to_string(), + device_account_id: [0x44; 32], + derivation_index: DerivationIndex::Index(0), + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: peer_public_key, + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::IdentityBinding { + identity_account_id, + proof, + wallet_own_session_id, + peer_own_session_id, + wallet_outgoing_channel_id, + wallet_incoming_channel_id, + } = response + else { + panic!("Bind must return an identity binding"); + }; + assert_eq!(identity_account_id, [0x33; 32]); + assert_eq!( + proof, + hex32("0263d1995da865e34e06de38b4f4c0c88524e2e591b1ae6714578219bffad333") + ); + assert_eq!( + wallet_own_session_id, + hex32("460db8611d842e65414f9eea4aa74d3fe1ac2e31468d4fbebededd914be28422") + ); + assert_eq!( + peer_own_session_id, + hex32("bfb5eb8c0b959f95b3ab09bd0f8001ab80f100cf5bb617640534372ab777c5c3") + ); + assert_eq!( + wallet_outgoing_channel_id, + hex32("576f71aa7f51aa340f411c20779c35f476361d8008247db367a8ce4d7e087d70") + ); + assert_eq!( + wallet_incoming_channel_id, + hex32("19de8cf16554a8463d0f8af7ad23717f4106463af331ee33f297b7367c8fe9fa") + ); + } + + #[test] + fn product_device_seal_open_round_trip_and_authenticate() { + let identity_chat_private_key = [0x11; 32]; + let peer_chat_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + let plaintext = b"private first-contact payload".to_vec(); + let sealed = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + mut combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let opened = execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + .unwrap(); + assert_eq!(opened, HostProductDeviceChatResponse::Opened { plaintext }); + + let last = combined_ciphertext.len() - 1; + combined_ciphertext[last] ^= 1; + assert_eq!( + execute_product_device_chat( + &identity_chat_private_key, + [0x33; 32], + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key, + combined_ciphertext, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + + #[test] + fn product_device_rejects_invalid_peer_keys() { + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: [0; 32], + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + + let mut noncanonical_peer_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from([0x22; 32])).to_bytes(); + noncanonical_peer_key[31] |= 0x80; + assert_eq!( + execute_product_device_chat( + &[0x11; 32], + [0x33; 32], + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: noncanonical_peer_key, + plaintext: Vec::new(), + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidPeerKey) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 51c12c999..e2509d57f 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -16,8 +16,9 @@ use truapi::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use truapi::{CallContext, CallError, Subscription, latest, v01}; use truapi_platform::{ @@ -27,10 +28,12 @@ use truapi_platform::{ use crate::runtime::authority::{ AccountAliasAuthorityRequest, CreateProofAuthorityRequest, ListRingVrfKeysAuthorityRequest, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + RingVrfSignAuthorityRequest, }; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, + product_device_chat_account_authority_error, product_device_chat_authority_error, remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, ring_vrf_proof_error, ring_vrf_register_error, ring_vrf_sign_error, validate_vrf_transcript, vrf_call_error, @@ -347,6 +350,102 @@ impl Account for ProductRuntimeHost { .map_err(|err| CallError::Domain(HostAccountRingVrfSignError::V1(ring_vrf_sign_error(err)))) } + #[instrument(skip_all, fields(runtime.method = "account.product_device_chat"))] + async fn product_device_chat( + &self, + cx: &CallContext, + request: HostProductDeviceChatRequest, + ) -> Result> { + let HostProductDeviceChatRequest::V1(request) = request; + let product_account_id = match &request { + v01::HostProductDeviceChatRequest::Bind { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Seal { + product_account_id, .. + } + | v01::HostProductDeviceChatRequest::Open { + product_account_id, .. + } => product_account_id.clone(), + }; + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "Invalid product account".to_string(), + }, + )) + })?; + if product_account_id.dot_ns_identifier != self.product_id() { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Unknown { + reason: "product account does not belong to the calling product".to_string(), + }, + ))); + } + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::NotConnected, + ))); + }; + if self + .identity_disclosure_authorization() + .await + .map_err(|reason| CallError::HostFailure { reason })? + != PermissionAuthorizationStatus::Authorized + { + return Err(CallError::Domain(HostProductDeviceChatError::V1( + v01::HostProductDeviceChatError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + let authority_request = match request { + v01::HostProductDeviceChatRequest::Bind { + peer_identity_account_id, + peer_chat_public_key, + .. + } => { + let device_account_id = self + .product_account_public_key(&cx, &session, &product_account_id) + .await + .map_err(product_device_chat_account_authority_error)?; + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id: self.product_id(), + device_account_id, + derivation_index: product_account_id.derivation_index.clone(), + peer_identity_account_id, + peer_chat_public_key, + } + } + v01::HostProductDeviceChatRequest::Seal { + peer_chat_public_key, + plaintext, + .. + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: self.product_id(), + peer_chat_public_key, + plaintext, + }, + v01::HostProductDeviceChatRequest::Open { + peer_chat_public_key, + combined_ciphertext, + .. + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id: self.product_id(), + peer_chat_public_key, + combined_ciphertext, + }, + }; + remote_authority_call( + &cx, + self.authority + .product_device_chat(&cx, &session, authority_request), + ) + .await + .map(HostProductDeviceChatResponse::V1) + .map_err(product_device_chat_authority_error) + } + #[instrument(skip_all, fields(runtime.method = "account.sign_vrf"))] async fn sign_vrf( &self, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index df9453c1a..bfaaca11b 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -22,7 +22,8 @@ use super::auth_state::AuthStateMachine; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, AutoSigningKey, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; @@ -2309,6 +2310,19 @@ impl PairingHost { .await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let private_session = self + .current_private_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + self.remote_product_device_chat(cx, &private_session, request) + .await + } + async fn allocate_resources( &self, cx: &CallContext, @@ -2557,6 +2571,15 @@ impl ProductAuthority for PairingHost { PairingHost::ring_vrf_sign(self, cx, session, request).await } + async fn product_device_chat( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + PairingHost::product_device_chat(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 a19a7084b..d825279f0 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 @@ -3,7 +3,8 @@ use super::super::authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, }; @@ -18,12 +19,13 @@ use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; 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, 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, + SsoAllocatedResource, SsoAllocationOutcome, SsoProductDeviceChatOperation, SsoRemoteResponse, + SsoSessionStatement, alias_request_message, build_outgoing_request_statement, + create_transaction_legacy_message, create_transaction_message, decode_sso_session_statement, + list_ring_vrf_keys_message, product_device_chat_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; @@ -67,6 +69,8 @@ enum RemoteAction { ResourceAllocation, #[display("product-subtree")] ProductSubtree, + #[display("product-device-chat")] + ProductDeviceChat, } /// Active peer-disconnect watcher for one SSO session; aborts on drop. @@ -631,6 +635,87 @@ impl PairingHost { response.payload } + /// Forward a product-device Chat v2 operation without exposing wallet key material. + pub(super) async fn remote_product_device_chat( + &self, + cx: &CallContext, + session: &SessionInfo, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + let (calling_product_id, operation) = match request { + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + .. + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + }, + ), + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + plaintext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + plaintext, + }, + ), + ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + combined_ciphertext, + } => ( + calling_product_id, + SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + combined_ciphertext, + }, + ), + }; + let message_id = sso_message_id(); + let message = product_device_chat_message(message_id, calling_product_id, operation); + let response = self + .submit_remote_message(cx, session, RemoteAction::ProductDeviceChat, message) + .await + .map_err(|error| { + ProductDeviceChatAuthorityError::Unavailable( + remote_authority_error(error).to_string(), + ) + })?; + let response_kind = response.kind(); + let SsoRemoteResponse::ProductDeviceChat(response) = response else { + return Err(ProductDeviceChatAuthorityError::Unavailable( + unexpected_response_reason( + "Unexpected SSO response for product-device Chat request", + response_kind, + ), + )); + }; + response.payload.map_err(|error| match error { + v01::HostProductDeviceChatError::NotConnected => { + ProductDeviceChatAuthorityError::Disconnected + } + v01::HostProductDeviceChatError::Rejected => ProductDeviceChatAuthorityError::Rejected, + v01::HostProductDeviceChatError::InvalidPeerKey => { + ProductDeviceChatAuthorityError::InvalidPeerKey + } + v01::HostProductDeviceChatError::InvalidCiphertext => { + ProductDeviceChatAuthorityError::InvalidCiphertext + } + v01::HostProductDeviceChatError::Unknown { reason } => { + ProductDeviceChatAuthorityError::Unavailable(reason) + } + }) + } + /// Ask the paired signing host to allocate product resources, caching any /// returned allowance keys. pub(super) async fn remote_allocate_resources( @@ -902,6 +987,7 @@ impl PairingHost { .await?; } SsoAllocatedResource::SmartContractAllowance => {} + SsoAllocatedResource::ProductStatementStoreAllowance => {} SsoAllocatedResource::AutoSigning { product_root_private_key, ring_vrf_domain_entropy, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c61543307..dac7f1f7d 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -36,9 +36,10 @@ pub(crate) use sso_responder::{ use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, + ListRingVrfKeysAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, + ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, authority_session_validation_id, + StatementStoreAllowanceKey, authority_session_validation_id, execute_product_device_chat, }; use super::ring_vrf_registry::RingVrfRegistryStore; use super::{RuntimeServices, connected_session_ui_info, validate_vrf_transcript}; @@ -991,6 +992,28 @@ impl ProductAuthority for SigningHost { sign_from_entropy(&entropy, &request.message) } + async fn product_device_chat( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: ProductDeviceChatAuthorityRequest, + ) -> Result { + self.require_current_session(session) + .map_err(|_| ProductDeviceChatAuthorityError::Disconnected)?; + let entropy = self + .root_entropy() + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let (identity, identity_chat_private_key) = + sso_responder::derive_responder_identity(&entropy, self.network_suffix()) + .map_err(|error| ProductDeviceChatAuthorityError::Unavailable(error.to_string()))?; + let identity_chat_private_key = Zeroizing::new(identity_chat_private_key); + execute_product_device_chat( + &identity_chat_private_key, + identity.statement_public_key, + request, + ) + } + async fn allocate_resources( &self, _cx: &CallContext, @@ -1037,6 +1060,17 @@ impl ProductAuthority for SigningHost { .grant_auto_signing(session, &product_id) .map(|_| v01::AllocationOutcome::Allocated) .map_err(sso_responder::AllowanceAllocationError::Authority), + v01::AllocatableResource::ProductStatementStoreAllowance(index) => { + sso_responder::allocate_product_statement_store_allowance( + &self.services, + self, + &product_id, + &index, + OnExistingAllowancePolicy::Increase, + ) + .await + .map(|()| v01::AllocationOutcome::Allocated) + } }; match outcome { Ok(outcome) => outcomes.push(outcome), 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 393232f7a..266e3ec7f 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 @@ -19,15 +19,18 @@ use parity_scale_codec::Encode; use tracing::{debug, instrument, warn}; use truapi::{CallContext, latest as api, v01}; use truapi_platform::{ - CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, + CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, + SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, }; use super::SigningHost; +#[cfg(not(target_arch = "wasm32"))] +use super::allowance_renewal::StatementRenewalTarget; use super::sso_replay::{ReplayExecution, SsoReplayScope, execute_once}; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; +use crate::host_logic::permissions::PermissionsService; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::derive_sr25519_hard_path; use crate::host_logic::product_account::{ @@ -55,6 +58,7 @@ use crate::host_logic::statement_store::{ use crate::runtime::authority::{ AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, ListRingVrfKeysAuthorityRequest, ProductAuthority, + ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; @@ -76,7 +80,7 @@ const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::fr /// Upper bound on undecodable request ids acknowledged within one serve loop. const MAX_DECODE_FAILURE_REQUEST_IDS: usize = 1024; -fn derive_responder_identity( +pub(super) fn derive_responder_identity( entropy: &[u8], network_suffix: &str, ) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { @@ -838,6 +842,14 @@ pub(crate) async fn answer_remote_message( payload: answer.payload, }) } + v1::RemoteMessage::ProductDeviceChatRequest(request) => { + let cx = CallContext::with_request_id(message_id.clone()); + let payload = product_device_chat_response(services, signing_host, &cx, request).await; + v1::RemoteMessage::ProductDeviceChatResponse(messages::ProductDeviceChatResponse { + responding_to: message_id, + payload, + }) + } v1::RemoteMessage::CreateTransactionRequest(request) => { let CreateTransactionPayload::V1(payload) = request.payload; let signed_transaction = create_transaction_response( @@ -908,7 +920,8 @@ pub(crate) async fn answer_remote_message( | v1::RemoteMessage::CreateTransactionResponse(_) | v1::RemoteMessage::SignRawLegacyResponse(_) | v1::RemoteMessage::ProductSubtreeResponse(_) - | v1::RemoteMessage::SignVrfResponse(_) => return None, + | v1::RemoteMessage::SignVrfResponse(_) + | v1::RemoteMessage::ProductDeviceChatResponse(_) => return None, }; Some(AnsweredRemoteMessage { response: RemoteMessage { @@ -918,6 +931,103 @@ pub(crate) async fn answer_remote_message( response_result, }) } +async fn authorize_identity_disclosure( + services: &Arc, + product_id: &str, +) -> Result { + let service = PermissionsService::new( + services.platform.as_ref(), + services.platform.as_ref(), + product_id, + ); + let status = service + .check_or_prompt_identity_disclosure() + .await + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.reason, + })?; + Ok(status == PermissionAuthorizationStatus::Authorized) +} + +async fn product_device_chat_response( + services: &Arc, + signing_host: &Arc, + cx: &CallContext, + request: messages::ProductDeviceChatRequest, +) -> Result { + let calling_product_id = + normalize_product_identifier(&request.calling_product_id).map_err(|_| { + v01::HostProductDeviceChatError::Unknown { + reason: "invalid calling product identifier".to_string(), + } + })?; + if !authorize_identity_disclosure(services, &calling_product_id).await? { + return Err(v01::HostProductDeviceChatError::Rejected); + } + let authority_request = match request.operation { + messages::SsoProductDeviceChatOperation::Bind { + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } => { + let product_account = v01::ProductAccountId { + dot_ns_identifier: calling_product_id.clone(), + derivation_index: derivation_index.clone(), + }; + let device_account_id = signing_host + .product_keypair(&product_account) + .map_err(|error| v01::HostProductDeviceChatError::Unknown { + reason: error.to_string(), + })? + .public + .to_bytes(); + ProductDeviceChatAuthorityRequest::Bind { + calling_product_id, + device_account_id, + derivation_index, + peer_identity_account_id, + peer_chat_public_key, + } + } + messages::SsoProductDeviceChatOperation::Seal { + peer_chat_public_key, + plaintext, + } => ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + peer_chat_public_key, + plaintext, + }, + messages::SsoProductDeviceChatOperation::Open { + peer_chat_public_key, + combined_ciphertext, + } => ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + peer_chat_public_key, + combined_ciphertext, + }, + }; + let session = signing_host + .current_session() + .ok_or(v01::HostProductDeviceChatError::NotConnected)?; + signing_host + .product_device_chat(cx, &session, authority_request) + .await + .map_err(|error| match error { + ProductDeviceChatAuthorityError::Disconnected => { + v01::HostProductDeviceChatError::NotConnected + } + ProductDeviceChatAuthorityError::Rejected => v01::HostProductDeviceChatError::Rejected, + ProductDeviceChatAuthorityError::InvalidPeerKey => { + v01::HostProductDeviceChatError::InvalidPeerKey + } + ProductDeviceChatAuthorityError::InvalidCiphertext => { + v01::HostProductDeviceChatError::InvalidCiphertext + } + ProductDeviceChatAuthorityError::Unavailable(reason) => { + v01::HostProductDeviceChatError::Unknown { reason } + } + }) +} async fn resource_allocation_response( services: &Arc, @@ -1008,6 +1118,21 @@ async fn resource_allocation_response( }, )) })(), + SsoAllocatableResource::ProductStatementStoreAllowance(index) => { + allocate_product_statement_store_allowance( + services, + signing_host, + &request.calling_product_id, + &index, + request.on_existing, + ) + .await + .map(|()| { + SsoAllocationOutcome::Allocated( + SsoAllocatedResource::ProductStatementStoreAllowance, + ) + }) + } }; match outcome { Ok(outcome) => outcomes.push(outcome), @@ -1035,6 +1160,9 @@ fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::Alloca api::AllocatableResource::SmartContractAllowance(index.clone()) } SsoAllocatableResource::AutoSigning => api::AllocatableResource::AutoSigning, + SsoAllocatableResource::ProductStatementStoreAllowance(index) => { + api::AllocatableResource::ProductStatementStoreAllowance(index.clone()) + } } } @@ -1045,16 +1173,67 @@ pub(super) async fn allocate_statement_store_allowance( product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { - use super::allowance_renewal::{self, StatementRenewalTarget}; + let entropy = signing_host.root_entropy()?; + let allowance = + derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; + register_statement_store_target( + services, + signing_host, + product_id, + allowance.public.to_bytes(), + policy, + StatementRenewalTarget::ProductStatementAllowance { + product_id: product_id.to_string(), + }, + ) + .await?; + Ok(allowance.secret.to_bytes().to_vec()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) async fn allocate_product_statement_store_allowance( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + derivation_index: &v01::DerivationIndex, + policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { + let target = signing_host + .product_keypair(&v01::ProductAccountId { + dot_ns_identifier: product_id.to_string(), + derivation_index: derivation_index.clone(), + })? + .public + .to_bytes(); + register_statement_store_target( + services, + signing_host, + product_id, + target, + policy, + StatementRenewalTarget::Account { + account_id: target, + label: format!("product-account:{product_id}"), + }, + ) + .await +} + +#[cfg(not(target_arch = "wasm32"))] +async fn register_statement_store_target( + services: &Arc, + signing_host: &SigningHost, + product_id: &str, + target: [u8; 32], + policy: OnExistingAllowancePolicy, + renewal_target: StatementRenewalTarget, +) -> Result<(), AllowanceAllocationError> { + use super::allowance_renewal; use crate::runtime::statement_allowance::{ self, PooledRegistrationParams, allocated_in, find_including_rings, register_statement_account_pooled, scan_collections, }; - let entropy = signing_host.root_entropy()?; - let allowance = - derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; - let target = allowance.public.to_bytes(); let session = signing_host .current_session() .ok_or(AuthorityError::Disconnected)?; @@ -1069,15 +1248,7 @@ pub(super) async fn allocate_statement_store_allowance( let period = statement_allowance::slot::current_period(current_unix_secs()?); let reuse_existing = matches!(policy, OnExistingAllowancePolicy::Ignore); - // Held from the scan through the submission, not just around the submission: - // the scan is what picks the free slot, so a renewal pass scanning in the gap - // would choose the same one. Released on the early return below, which - // submits nothing. let _registration = signing_host.renewal.registration_lock().lock().await; - - // One read of the period's slot tables, reused below rather than rescanned: - // when an allowance is already recorded on chain neither a proof nor a - // submission is needed, and a ring snapshot pages in every member key. let scans = scan_collections( rpc, &chain.metadata, @@ -1096,72 +1267,59 @@ pub(super) async fn allocate_statement_store_allowance( %collection, "statement-store allowance already allocated" ); - return Ok(allowance.secret.to_bytes().to_vec()); - } - - // Every ring back to index 0, because a membership that stopped being - // re-included still proves against the ring that holds it. - let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; - if memberships.is_empty() { - return Err(AllowanceAllocationError::MissingPersonhoodMembership { - resource: "statement-store", - }); - } - let outcome = register_statement_account_pooled( - rpc, - &chain.metadata, - &chain.state, - &scans, - &memberships, - PooledRegistrationParams { - target: &target, - period, - network_suffix: &network_suffix, - reuse_existing, - // Connecting a product must not revoke another product's allowance. - // A full period is reported as exhaustion; reclaiming space is the - // renewal pass's job, which only ever replaces for its own ledger. - allow_eviction: false, - protected: &[], - }, - ) - .await?; - match outcome { - statement_allowance::RegistrationOutcome::Registered { - block_hash, - seq, - ring_index, - collection, - } => { - debug!( - %product_id, - %block_hash, - seq, - ring_index, - %collection, - "registered statement-store allowance" - ); + } else { + let memberships = find_including_rings(rpc, &chain.metadata, &candidates, u32::MAX).await?; + if memberships.is_empty() { + return Err(AllowanceAllocationError::MissingPersonhoodMembership { + resource: "statement-store", + }); } - statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { - debug!( - %product_id, + let outcome = register_statement_account_pooled( + rpc, + &chain.metadata, + &chain.state, + &scans, + &memberships, + PooledRegistrationParams { + target: &target, + period, + network_suffix: &network_suffix, + reuse_existing, + allow_eviction: false, + protected: &[], + }, + ) + .await?; + match outcome { + statement_allowance::RegistrationOutcome::Registered { + block_hash, seq, - %collection, - "statement-store allowance already allocated" - ); + ring_index, + collection, + } => { + debug!( + %product_id, + %block_hash, + seq, + ring_index, + %collection, + "registered statement-store allowance" + ); + } + statement_allowance::RegistrationOutcome::AlreadyAllocated { seq, collection } => { + debug!( + %product_id, + seq, + %collection, + "statement-store allowance already allocated" + ); + } } } - if let Err(reason) = allowance_renewal::track( - signing_host, - vec![StatementRenewalTarget::ProductStatementAllowance { - product_id: product_id.to_string(), - }], - ) - .await - { + if let Err(reason) = allowance_renewal::track(signing_host, vec![renewal_target]).await { warn!(%product_id, %reason, "failed to record statement-store renewal target"); } - Ok(allowance.secret.to_bytes().to_vec()) + Ok(()) } #[cfg(not(target_arch = "wasm32"))] @@ -1284,6 +1442,19 @@ pub(super) async fn allocate_statement_store_allowance( }) } +#[cfg(target_arch = "wasm32")] +pub(super) async fn allocate_product_statement_store_allowance( + _services: &Arc, + _signing_host: &SigningHost, + _product_id: &str, + _derivation_index: &v01::DerivationIndex, + _policy: OnExistingAllowancePolicy, +) -> Result<(), AllowanceAllocationError> { + Err(AllowanceAllocationError::NativeOnly { + resource: "statement-store", + }) +} + /// Claim an Asset Hub PGAS allowance for the product account `derivation_index` /// selects. /// diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index d5cb2eb64..b2a564ccf 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -107,10 +107,9 @@ impl<'a> SsoPairingFlow<'a> { read_last_processed_pairing_statement(self.host.platform.as_ref()) .await .map_err(|reason| self.fail_before_pairing(reason))?; - // Pairing success statements are retained by statement-store. Reusing a - // previous pairing identity means reusing its topic, where the only - // retained response may be the last processed success. Rotate before - // presenting QR so every explicit login waits on a fresh wallet scan. + // Pairing success statements are retained by statement-store. Persist + // only a one-way fingerprint: the statement ciphertext and the + // session's X25519 secret must never coexist at rest. if reused_identity { debug!("regenerating stored pairing device identity"); pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) @@ -332,7 +331,7 @@ async fn write_last_processed_pairing_statement( if let Err(err) = storage .write_core_storage( CoreStorageKey::LastProcessedPairingStatement, - statement.to_vec(), + pairing_statement_fingerprint(statement), ) .await { @@ -340,6 +339,14 @@ async fn write_last_processed_pairing_statement( } } +fn pairing_statement_fingerprint(statement: &[u8]) -> Vec { + blake2b_simd::Params::new() + .hash_length(32) + .hash(statement) + .as_bytes() + .to_vec() +} + #[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { if let Err(err) = storage @@ -389,7 +396,7 @@ impl PairingProgress { Ok(Self::Success(Box::new(PairingSuccess { statement: statement.to_vec(), peer_statement_account_id: verified.signer, - success: *success, + success: (*success).clone(), }))) } } @@ -526,7 +533,9 @@ fn handle_v2_pairing_result( parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; let mut pending = false; for statement in page.statements { - if last_processed_statement == Some(statement.as_slice()) { + if last_processed_statement + .is_some_and(|fingerprint| fingerprint == pairing_statement_fingerprint(&statement)) + { continue; } match PairingProgress::from_v2_statement(&statement, core_encryption_secret_key)? { @@ -1115,10 +1124,11 @@ mod tests { }, }); + let fingerprint = pairing_statement_fingerprint(&statement); let ignored = handle_v2_pairing_result( &page, bootstrap.encryption_secret_key, - Some(statement.as_slice()), + Some(fingerprint.as_slice()), ) .unwrap(); assert!(ignored.is_none()); diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 5cfb62cbe..fc8636e4f 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -401,6 +401,32 @@ fn malformed_result_subscription_start_interrupts_with_malformed_frame() { } } +#[test] +fn product_device_chat_reaches_the_account_authority() { + let core = make_core(); + let request = + account::HostProductDeviceChatRequest::V1(v01::HostProductDeviceChatRequest::Bind { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "dotli.dot".to_string(), + derivation_index: v01::DerivationIndex::Index(0), + }, + peer_identity_account_id: [0x55; 32], + peer_chat_public_key: [ + 0x0f, 0xaa, 0x68, 0x4e, 0xd2, 0x88, 0x67, 0xb9, 0x7f, 0x4a, 0x6a, 0x2d, 0xee, 0x5d, + 0xf8, 0xce, 0x97, 0x4e, 0x76, 0xb7, 0x01, 0x8e, 0x3f, 0x22, 0xa1, 0xc4, 0xcf, 0x26, + 0x78, 0x57, 0x0f, 0x20, + ], + }); + + assert_request_returns_domain_error( + &core, + "p:product-device-chat", + "account_product_device_chat", + request.encode(), + account::HostProductDeviceChatError::V1(v01::HostProductDeviceChatError::NotConnected), + ); +} + fn make_core() -> TrUApiCore { let (host_config, product) = test_runtime_config(); TrUApiCore::from_platform_with_config( diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index f3e18bcde..132668de0 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -11,8 +11,9 @@ use crate::versioned::account::{ HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, HostAccountSignVrfResponse, HostGetLegacyAccountsError, HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, - HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, - HostRequestLoginResponse, + HostGetUserIdRequest, HostGetUserIdResponse, HostProductDeviceChatError, + HostProductDeviceChatRequest, HostProductDeviceChatResponse, HostRequestLoginError, + HostRequestLoginRequest, HostRequestLoginResponse, }; use crate::wire; use crate::{CallContext, CallError, Subscription}; @@ -272,6 +273,38 @@ pub trait Account: Send + Sync { Err(CallError::unavailable()) } + /// Bind a product account as a Chat v2 device, or seal/open identity-route + /// payloads without exposing the wallet Chat identity secret. + /// + /// ```ts + /// const productContext = await truapi.system.getProductContext(); + /// assert(productContext.isOk(), "getProductContext failed:", productContext); + /// + /// const result = await truapi.account.productDeviceChat({ + /// tag: "Bind", + /// value: { + /// productAccountId: { + /// dotNsIdentifier: productContext.value.productId, + /// derivationIndex: { tag: "Index", value: 0 }, + /// }, + /// peerIdentityAccountId: + /// "0x5555555555555555555555555555555555555555555555555555555555555555", + /// peerChatPublicKey: + /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", + /// }, + /// }); + /// assert(result.isOk(), "productDeviceChat failed:", result); + /// console.log("Chat identity binding:", result.value); + /// ``` + #[wire(request_id = 174, sensitive)] + async fn product_device_chat( + &self, + _cx: &CallContext, + _request: HostProductDeviceChatRequest, + ) -> 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 6b1f43d93..62f776e38 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -168,6 +168,12 @@ pub mod latest { /// Per-resource allocation outcomes. pub type HostRequestResourceAllocationResponse = LatestOf; + /// Product-device Chat v2 identity request. + pub type HostProductDeviceChatRequest = + LatestOf; + /// Product-device Chat v2 identity result. + pub type HostProductDeviceChatResponse = + LatestOf; /// Extrinsic payload signing request for a product account. pub type HostSignPayloadRequest = LatestOf; /// Signing operation result. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index b1eabcf05..91eed9d8a 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -412,3 +412,85 @@ pub enum HostAccountSignVrfError { reason: String, }, } + +/// Product-device Chat v2 identity operation. +/// +/// The wallet Chat identity secret and derived shared key remain host-private. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatRequest { + /// Resolve the product account as a Chat device and bind it to the wallet identity. + Bind { + /// Product account becoming a Chat device. + product_account_id: ProductAccountId, + /// Peer wallet identity account used for directional routing. + peer_identity_account_id: [u8; 32], + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + }, + /// Seal identity-route plaintext for the peer with a host-generated nonce. + Seal { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Identity-route plaintext. + plaintext: Vec, + }, + /// Open an identity-route combined nonce/ciphertext/tag value. + Open { + /// Product account requesting the operation. + product_account_id: ProductAccountId, + /// Peer's X25519 Chat identity public key. + peer_chat_public_key: [u8; 32], + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, +} + +/// Result of a product-device Chat v2 identity operation. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatResponse { + /// Wallet identity binding and deterministic peer routes. + IdentityBinding { + /// Wallet's canonical identity account. + identity_account_id: [u8; 32], + /// Keyed proof binding the wallet identity to the product device. + proof: [u8; 32], + /// Wallet-to-peer session identifier. + wallet_own_session_id: [u8; 32], + /// Peer-to-wallet session identifier. + peer_own_session_id: [u8; 32], + /// Wallet-to-peer contact-request channel. + wallet_outgoing_channel_id: [u8; 32], + /// Peer-to-wallet contact-request channel. + wallet_incoming_channel_id: [u8; 32], + }, + /// Sealed identity-route payload. + Sealed { + /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. + combined_ciphertext: Vec, + }, + /// Opened identity-route payload. + Opened { + /// Authenticated plaintext. + plaintext: Vec, + }, +} + +/// Product-device Chat v2 identity failure. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatError { + /// No account-authority session is connected. + NotConnected, + /// The user or Host rejected the operation. + Rejected, + /// The peer X25519 public key is invalid. + InvalidPeerKey, + /// The ciphertext failed structural or authentication checks. + InvalidCiphertext, + /// The Host could not complete the operation. + Unknown { + /// Human-readable failure reason. + reason: String, + }, +} diff --git a/rust/crates/truapi/src/v01/resource_allocation.rs b/rust/crates/truapi/src/v01/resource_allocation.rs index d9a1ef59a..85fc3f654 100644 --- a/rust/crates/truapi/src/v01/resource_allocation.rs +++ b/rust/crates/truapi/src/v01/resource_allocation.rs @@ -22,6 +22,9 @@ pub enum AllocatableResource { SmartContractAllowance(DerivationIndex), /// Permission to sign on the product's behalf without per-call user prompts. AutoSigning, + /// Current UTC-day Statement Store allowance whose target is the product + /// account selected by this derivation index. + ProductStatementStoreAllowance(DerivationIndex), } /// Outcome of allocating a single resource (RFC 0010). diff --git a/rust/crates/truapi/src/versioned/account.rs b/rust/crates/truapi/src/versioned/account.rs index 2c7c2a1c0..1abbfb524 100644 --- a/rust/crates/truapi/src/versioned/account.rs +++ b/rust/crates/truapi/src/versioned/account.rs @@ -35,4 +35,7 @@ truapi_macros::versioned_type! { pub enum HostGetUserIdRequest { V1 } pub enum HostGetUserIdResponse { V1 => v01::HostGetUserIdResponse } pub enum HostGetUserIdError { V1 => v01::HostGetUserIdError } + pub enum HostProductDeviceChatRequest { V1 => v01::HostProductDeviceChatRequest } + pub enum HostProductDeviceChatResponse { V1 => v01::HostProductDeviceChatResponse } + pub enum HostProductDeviceChatError { V1 => v01::HostProductDeviceChatError } } From da72d306aa1df9f4fa6e9528062dda27a6a3cf30 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 16:31:43 -0400 Subject: [PATCH 2/7] fix: align Chat client examples and catalog test --- rust/crates/truapi-client/src/lib.rs | 19 +++++++++++++++---- rust/crates/truapi/src/api/account.rs | 4 ++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/rust/crates/truapi-client/src/lib.rs b/rust/crates/truapi-client/src/lib.rs index f40b6e57a..af46a5e67 100644 --- a/rust/crates/truapi-client/src/lib.rs +++ b/rust/crates/truapi-client/src/lib.rs @@ -457,10 +457,21 @@ mod tests { #[test] fn catalogs_partition_worker_only_chat() { - assert_eq!(APP_METHODS.len(), 66); - assert_eq!(WIDGET_METHODS.len(), 66); - assert_eq!(WORKER_METHODS.len(), 72); - assert_eq!(WORKER_ONLY_METHODS.len(), 6); + assert_eq!(APP_METHODS, WIDGET_METHODS); + assert_eq!( + WORKER_METHODS.len(), + APP_METHODS.len() + WORKER_ONLY_METHODS.len() + ); + assert!( + APP_METHODS + .iter() + .all(|method| WORKER_METHODS.contains(method)) + ); + assert!( + WORKER_ONLY_METHODS + .iter() + .all(|method| WORKER_METHODS.contains(method) && !APP_METHODS.contains(method)) + ); assert!(WORKER_ONLY_METHODS.iter().all(|method| { method.service == "Chat" && method.required_execution == Some(ExecutionKind::Worker) })); diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 132668de0..61f892ec4 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -280,7 +280,7 @@ pub trait Account: Send + Sync { /// const productContext = await truapi.system.getProductContext(); /// assert(productContext.isOk(), "getProductContext failed:", productContext); /// - /// const result = await truapi.account.productDeviceChat({ + /// const result = await truapi.account.deviceChat({ /// tag: "Bind", /// value: { /// productAccountId: { @@ -293,7 +293,7 @@ pub trait Account: Send + Sync { /// "0x0faa684ed28867b97f4a6a2dee5df8ce974e76b7018e3f22a1c4cf2678570f20", /// }, /// }); - /// assert(result.isOk(), "productDeviceChat failed:", result); + /// assert(result.isOk(), "deviceChat failed:", result); /// console.log("Chat identity binding:", result.value); /// ``` #[wire(request_id = 174, sensitive)] From 36b0dbbd40ff6d30ac1ac36c5e7c02de27d392e6 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 19:43:51 -0400 Subject: [PATCH 3/7] feat(chat): add explicit context-bound cipher suite --- rust/crates/truapi-client/src/generated.rs | 2 +- .../src/host_logic/sso/messages.rs | 4 + .../truapi-server/src/runtime/authority.rs | 192 ++++++++++++++++-- .../src/runtime/capabilities/account.rs | 4 + .../src/runtime/pairing_host/sso_channel.rs | 4 + .../src/runtime/signing_host/sso_responder.rs | 4 + rust/crates/truapi/src/v01/account.rs | 21 ++ 7 files changed, 217 insertions(+), 14 deletions(-) diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index d2aa0a997..4cc6d384c 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; 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 fbdc30aab..b1c5ec6c7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -656,6 +656,8 @@ pub enum SsoProductDeviceChatOperation { Seal { /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, /// Identity-route plaintext. plaintext: Vec, }, @@ -663,6 +665,8 @@ pub enum SsoProductDeviceChatOperation { Open { /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit legacy or context-bound cipher suite. + cipher_suite: v01::HostProductDeviceChatCipherSuite, /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 0dd8a55e3..ee6e5d4d8 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -15,7 +15,10 @@ use truapi::latest::{ HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, }; -use truapi::v01::{HostAccountSignVrfRequest, HostProductDeviceChatResponse, VrfSignature}; +use truapi::v01::{ + HostAccountSignVrfRequest, HostProductDeviceChatCipherSuite, HostProductDeviceChatResponse, + VrfSignature, +}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; @@ -298,11 +301,13 @@ pub(crate) enum ProductDeviceChatAuthorityRequest { Seal { calling_product_id: String, peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, plaintext: Vec, }, Open { calling_product_id: String, peer_chat_public_key: [u8; 32], + cipher_suite: HostProductDeviceChatCipherSuite, combined_ciphertext: Vec, }, } @@ -566,7 +571,7 @@ pub(super) fn execute_product_device_chat( identity_account_id: [u8; 32], request: ProductDeviceChatAuthorityRequest, ) -> Result { - use chacha20poly1305::aead::{Aead, KeyInit}; + use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Nonce}; use hkdf::Hkdf; use sha2::Sha256; @@ -646,8 +651,20 @@ pub(super) fn execute_product_device_chat( wallet_incoming_channel_id, }) } - ProductDeviceChatAuthorityRequest::Seal { plaintext, .. } => { - let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id, + cipher_suite, + plaintext, + .. + } => { + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + true, + )?; + let key = Zeroizing::new(key); let mut nonce = [0; 12]; getrandom::getrandom(&mut nonce).map_err(|error| { ProductDeviceChatAuthorityError::Unavailable(format!( @@ -655,7 +672,13 @@ pub(super) fn execute_product_device_chat( )) })?; let encrypted = ChaCha20Poly1305::new((&*key).into()) - .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: &aad, + }, + ) .map_err(|_| { ProductDeviceChatAuthorityError::Unavailable( "Chat identity-route encryption failed".to_string(), @@ -669,35 +692,85 @@ pub(super) fn execute_product_device_chat( }) } ProductDeviceChatAuthorityRequest::Open { + calling_product_id, + cipher_suite, combined_ciphertext, .. } => { if combined_ciphertext.len() < 28 { return Err(ProductDeviceChatAuthorityError::InvalidCiphertext); } - let key = Zeroizing::new(product_device_chat_aead_key(&shared_secret)?); + let (key, aad) = product_device_chat_aead_material( + &shared_secret, + &calling_product_id, + &identity_account_id, + &cipher_suite, + false, + )?; + let key = Zeroizing::new(key); let plaintext = ChaCha20Poly1305::new((&*key).into()) .decrypt( Nonce::from_slice(&combined_ciphertext[..12]), - &combined_ciphertext[12..], + Payload { + msg: &combined_ciphertext[12..], + aad: &aad, + }, ) .map_err(|_| ProductDeviceChatAuthorityError::InvalidCiphertext)?; Ok(HostProductDeviceChatResponse::Opened { plaintext }) } }; - fn product_device_chat_aead_key( + fn product_device_chat_aead_material( shared_secret: &[u8; 32], - ) -> Result<[u8; 32], ProductDeviceChatAuthorityError> { + calling_product_id: &str, + identity_account_id: &[u8; 32], + cipher_suite: &HostProductDeviceChatCipherSuite, + sealing: bool, + ) -> Result<([u8; 32], Vec), ProductDeviceChatAuthorityError> { let mut key = [0; 32]; - Hkdf::::new(Some(&[]), shared_secret) - .expand(&[], &mut key) + let HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id, + channel_id, + } = cipher_suite + else { + Hkdf::::new(Some(&[]), shared_secret) + .expand(&[], &mut key) + .map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat identity-route HKDF failed".to_string(), + ) + })?; + return Ok((key, Vec::new())); + }; + let product_id_len = u32::try_from(calling_product_id.len()).map_err(|_| { + ProductDeviceChatAuthorityError::Unavailable( + "Chat product identifier is too long".to_string(), + ) + })?; + let (sender_account_id, recipient_account_id) = if sealing { + (identity_account_id, peer_account_id) + } else { + (peer_account_id, identity_account_id) + }; + let domain = b"dotli-chat/context-bound/v1"; + let mut aad = Vec::with_capacity( + domain.len() + 4 + calling_product_id.len() + 32 + 32 + channel_id.len(), + ); + aad.extend_from_slice(domain); + aad.extend_from_slice(&product_id_len.to_le_bytes()); + aad.extend_from_slice(calling_product_id.as_bytes()); + aad.extend_from_slice(sender_account_id); + aad.extend_from_slice(recipient_account_id); + aad.extend_from_slice(channel_id); + Hkdf::::new(Some(domain), shared_secret) + .expand(&aad, &mut key) .map_err(|_| { ProductDeviceChatAuthorityError::Unavailable( - "Chat identity-route HKDF failed".to_string(), + "context-bound Chat identity-route HKDF failed".to_string(), ) })?; - Ok(key) + Ok((key, aad)) } fn is_canonical_x25519_public_key(key: &[u8; 32]) -> bool { @@ -873,6 +946,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: plaintext.clone(), }, ) @@ -890,6 +964,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Open { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, combined_ciphertext: combined_ciphertext.clone(), }, ) @@ -905,6 +980,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Open { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, combined_ciphertext, }, ), @@ -912,6 +988,94 @@ mod tests { ); } + #[test] + fn context_bound_product_device_chat_rejects_downgrade_and_wrong_context() { + let sender_private_key = [0x11; 32]; + let recipient_private_key = [0x22; 32]; + let sender_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(sender_private_key)) + .to_bytes(); + let recipient_public_key = + x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(recipient_private_key)) + .to_bytes(); + let sender_account_id = [0x33; 32]; + let recipient_account_id = [0x44; 32]; + let channel_id = [0x55; 32]; + let plaintext = b"context-bound identity payload".to_vec(); + let sealed = execute_product_device_chat( + &sender_private_key, + sender_account_id, + ProductDeviceChatAuthorityRequest::Seal { + calling_product_id: "egui-chat.paseo".to_string(), + peer_chat_public_key: recipient_public_key, + cipher_suite: HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: recipient_account_id, + channel_id, + }, + plaintext: plaintext.clone(), + }, + ) + .unwrap(); + let HostProductDeviceChatResponse::Sealed { + combined_ciphertext, + } = sealed + else { + panic!("Seal must return ciphertext"); + }; + + let open = |calling_product_id: &str, cipher_suite: HostProductDeviceChatCipherSuite| { + execute_product_device_chat( + &recipient_private_key, + recipient_account_id, + ProductDeviceChatAuthorityRequest::Open { + calling_product_id: calling_product_id.to_string(), + peer_chat_public_key: sender_public_key, + cipher_suite, + combined_ciphertext: combined_ciphertext.clone(), + }, + ) + }; + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Ok(HostProductDeviceChatResponse::Opened { + plaintext: plaintext.clone(), + }) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::LegacyV2 + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.paseo", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id: [0x56; 32], + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + assert_eq!( + open( + "egui-chat.westend", + HostProductDeviceChatCipherSuite::ContextBoundV1 { + peer_account_id: sender_account_id, + channel_id, + }, + ), + Err(ProductDeviceChatAuthorityError::InvalidCiphertext) + ); + } + #[test] fn product_device_rejects_invalid_peer_keys() { assert_eq!( @@ -921,6 +1085,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key: [0; 32], + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: Vec::new(), }, ), @@ -937,6 +1102,7 @@ mod tests { ProductDeviceChatAuthorityRequest::Seal { calling_product_id: "egui-chat.paseo".to_string(), peer_chat_public_key: noncanonical_peer_key, + cipher_suite: HostProductDeviceChatCipherSuite::LegacyV2, plaintext: Vec::new(), }, ), diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index e2509d57f..904e2e6a2 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -419,20 +419,24 @@ impl Account for ProductRuntimeHost { } v01::HostProductDeviceChatRequest::Seal { peer_chat_public_key, + cipher_suite, plaintext, .. } => ProductDeviceChatAuthorityRequest::Seal { calling_product_id: self.product_id(), peer_chat_public_key, + cipher_suite, plaintext, }, v01::HostProductDeviceChatRequest::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, .. } => ProductDeviceChatAuthorityRequest::Open { calling_product_id: self.product_id(), peer_chat_public_key, + cipher_suite, combined_ciphertext, }, }; 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 d825279f0..62b9e8db0 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 @@ -660,22 +660,26 @@ impl PairingHost { ProductDeviceChatAuthorityRequest::Seal { calling_product_id, peer_chat_public_key, + cipher_suite, plaintext, } => ( calling_product_id, SsoProductDeviceChatOperation::Seal { peer_chat_public_key, + cipher_suite, plaintext, }, ), ProductDeviceChatAuthorityRequest::Open { calling_product_id, peer_chat_public_key, + cipher_suite, combined_ciphertext, } => ( calling_product_id, SsoProductDeviceChatOperation::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, }, ), 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 266e3ec7f..d99e23ebc 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 @@ -991,18 +991,22 @@ async fn product_device_chat_response( } messages::SsoProductDeviceChatOperation::Seal { peer_chat_public_key, + cipher_suite, plaintext, } => ProductDeviceChatAuthorityRequest::Seal { calling_product_id, peer_chat_public_key, + cipher_suite, plaintext, }, messages::SsoProductDeviceChatOperation::Open { peer_chat_public_key, + cipher_suite, combined_ciphertext, } => ProductDeviceChatAuthorityRequest::Open { calling_product_id, peer_chat_public_key, + cipher_suite, combined_ciphertext, }, }; diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 91eed9d8a..18ac9c490 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -413,6 +413,23 @@ pub enum HostAccountSignVrfError { }, } +/// Cipher suite used by product-device Chat identity-route operations. +/// +/// Legacy v2 preserves current mobile interoperability. Context-bound v1 +/// authenticates the product/network, both account roles, route, and direction. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub enum HostProductDeviceChatCipherSuite { + /// Existing Chat v2 CryptoKit-compatible empty-context HKDF and AEAD. + LegacyV2, + /// Domain-separated encryption for peers that explicitly support it. + ContextBoundV1 { + /// Peer account corresponding to `peer_chat_public_key`. + peer_account_id: [u8; 32], + /// Statement channel carrying the ciphertext. + channel_id: [u8; 32], + }, +} + /// Product-device Chat v2 identity operation. /// /// The wallet Chat identity secret and derived shared key remain host-private. @@ -433,6 +450,8 @@ pub enum HostProductDeviceChatRequest { product_account_id: ProductAccountId, /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; secure callers must never silently downgrade. + cipher_suite: HostProductDeviceChatCipherSuite, /// Identity-route plaintext. plaintext: Vec, }, @@ -442,6 +461,8 @@ pub enum HostProductDeviceChatRequest { product_account_id: ProductAccountId, /// Peer's X25519 Chat identity public key. peer_chat_public_key: [u8; 32], + /// Explicit cipher suite; must match the sender's selected suite. + cipher_suite: HostProductDeviceChatCipherSuite, /// Nonce-prefixed ChaCha20-Poly1305 ciphertext and tag. combined_ciphertext: Vec, }, From 035f12e180e8bce421a2be21b42ef96c55b20ed2 Mon Sep 17 00:00:00 2001 From: w Date: Wed, 9 Sep 2026 20:10:25 -0400 Subject: [PATCH 4/7] fix(codegen): update Chat wire schema golden --- rust/crates/truapi-codegen/tests/golden/wire_table.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index e96191a49..46500bd97 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "d5113436b7c04f1d"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { From 4ccb1140ea3091fae8863698558e877ed84d3d13 Mon Sep 17 00:00:00 2001 From: w Date: Thu, 10 Sep 2026 13:42:25 -0400 Subject: [PATCH 5/7] feat: add dedicated Chat authority permission --- .../Permissions/Model/ProductPermission.swift | 9 +- .../Localization/Products.xcstrings | 51 ++++++++++ .../ProductPermissionPromptViewFactory.swift | 8 ++ .../TrUAPIReviewPromptMapper.swift | 8 ++ .../TrUAPI/TrUAPIConfirmationPresenter.swift | 4 + .../AppPermissionsViewModelFactory.swift | 5 + .../TrUAPIReviewPromptMapperTests.swift | 12 +++ rust/crates/truapi-host-cli/src/platform.rs | 7 ++ rust/crates/truapi-platform/src/lib.rs | 26 ++++++ .../src/host_logic/permissions.rs | 92 +++++++++++++++++-- .../src/host_logic/sso/messages.rs | 29 +++--- rust/crates/truapi-server/src/native.rs | 1 + .../src/runtime/signing_host/sso_service.rs | 2 +- rust/crates/truapi-server/src/test_support.rs | 12 ++- 14 files changed, 243 insertions(+), 23 deletions(-) diff --git a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift index 59d47e04a..0c38bf7a5 100644 --- a/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift +++ b/hosts/ios/Packages/Products/Sources/Products/Permissions/Model/ProductPermission.swift @@ -13,6 +13,7 @@ public enum ProductPermission: Equatable, Sendable { public static let balanceAccessTypeName = "balance_access" public static let statementSubmitAccessTypeName = "statement_submit" public static let userIdentityAccessTypeName = "user_identity_access" + public static let chatAuthorityTypeName = "chat_authority" case deviceCapability(DeviceCapabilityType) case networkAccess(domain: String) @@ -23,6 +24,7 @@ public enum ProductPermission: Equatable, Sendable { case preimageSubmitAccess case statementSubmitAccess case userIdentityAccess + case chatAuthority public var typeName: String { switch self { @@ -44,6 +46,8 @@ public enum ProductPermission: Equatable, Sendable { Self.statementSubmitAccessTypeName case .userIdentityAccess: Self.userIdentityAccessTypeName + case .chatAuthority: + Self.chatAuthorityTypeName } } @@ -60,7 +64,8 @@ public enum ProductPermission: Equatable, Sendable { .chainSubmitAccess, .preimageSubmitAccess, .statementSubmitAccess, - .userIdentityAccess: + .userIdentityAccess, + .chatAuthority: "" } } @@ -88,6 +93,8 @@ public enum ProductPermission: Equatable, Sendable { return .statementSubmitAccess case userIdentityAccessTypeName: return .userIdentityAccess + case chatAuthorityTypeName: + return .chatAuthority default: return nil } diff --git a/hosts/ios/polkadot-app/Localization/Products.xcstrings b/hosts/ios/polkadot-app/Localization/Products.xcstrings index 5239e4677..0c7baf1f0 100644 --- a/hosts/ios/polkadot-app/Localization/Products.xcstrings +++ b/hosts/ios/polkadot-app/Localization/Products.xcstrings @@ -341,6 +341,23 @@ } } }, + "app.permission.chatAuthority.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Autoridad de identidad de Chat" + } + } + } + }, "app.permission.userIdentity.title": { "extractionState": "manual", "localizations": { @@ -647,6 +664,23 @@ } } }, + "permission.body.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Allows this product to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data." + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "Permite que este producto vincule su cuenta de dispositivo a la identidad de Chat de tu cartera y cifre o descifre los datos de enrutamiento de Chat." + } + } + } + }, "permission.body.userIdentityAccess": { "extractionState": "manual", "localizations": { @@ -1004,6 +1038,23 @@ } } }, + "permission.title.chatAuthority": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ would like to use your Chat identity authority" + } + }, + "es-ES": { + "stringUnit": { + "state": "translated", + "value": "%1$(productId)@ quiere usar tu autoridad de identidad de Chat" + } + } + } + }, "permission.title.deviceCapability": { "extractionState": "manual", "localizations": { diff --git a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift index 78451b066..c066e4a50 100644 --- a/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Products/ProductPermissionPrompt/ProductPermissionPromptViewFactory.swift @@ -146,6 +146,12 @@ private extension ProductPermissionPromptViewFactory { body: String(localized: .Products.permissionBodyUserIdentityAccess), icon: makeIcon(systemName: "person.text.rectangle") ) + case .chatAuthority: + PromptContent( + title: String(localized: .Products.permissionTitleChatAuthority(productId: productId)), + body: String(localized: .Products.permissionBodyChatAuthority), + icon: makeIcon(systemName: "message.badge.shield") + ) } } @@ -188,6 +194,8 @@ private extension ProductPermissionPromptViewFactory { ) case .userIdentityAccess: "- " + String(localized: .Products.permissionBodyUserIdentityAccess) + case .chatAuthority: + "- " + String(localized: .Products.permissionBodyChatAuthority) } } diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift index 7f021a447..8541df792 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/Confirmation/TrUAPIReviewPromptMapper.swift @@ -20,6 +20,7 @@ struct TrUAPIAllowanceRequest: Equatable { /// and the statement-sign prompt. protocol TrUAPIReviewPromptMapping: Sendable { func makePermissionRequest(from review: IdentityDisclosureReview) -> TrUAPIPermissionRequest + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: PreimageSubmitReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: AccountAccessReview) -> TrUAPIPermissionRequest func makePermissionRequest(from review: ProductSubtreeReview) -> TrUAPIPermissionRequest @@ -40,6 +41,13 @@ struct TrUAPIReviewPromptMapper: TrUAPIReviewPromptMapping { ) } + func makePermissionRequest(from review: ChatAuthorityReview) -> TrUAPIPermissionRequest { + TrUAPIPermissionRequest( + productId: review.productId, + permissions: [.chatAuthority] + ) + } + /// `PreimageSubmitReview` carries no product identity: the submit is /// host-mediated, so the prompt is raised without a product scope. func makePermissionRequest(from _: PreimageSubmitReview) -> TrUAPIPermissionRequest { diff --git a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift index 40d3fc8a9..a242ace80 100644 --- a/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift +++ b/hosts/ios/polkadot-app/Modules/Products/TrUAPI/TrUAPIConfirmationPresenter.swift @@ -56,6 +56,10 @@ private extension TrUAPIConfirmationPresenter { await confirmPermission( promptMapper.makePermissionRequest(from: identityReview) ) + case let .chatAuthority(chatReview): + await confirmPermission( + promptMapper.makePermissionRequest(from: chatReview) + ) case let .preimageSubmit(preimageReview): await confirmPermission( promptMapper.makePermissionRequest(from: preimageReview) diff --git a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift index f0f736b4c..8eee2167c 100644 --- a/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift +++ b/hosts/ios/polkadot-app/Modules/Settings/Apps/Permissions/Helpers/AppPermissionsViewModelFactory.swift @@ -82,6 +82,11 @@ private extension AppPermissionsViewModelFactory { String(localized: .Products.appPermissionUserIdentityTitle), String(localized: .Products.permissionBodyUserIdentityAccess) ) + case .chatAuthority: + ( + String(localized: .Products.appPermissionChatAuthorityTitle), + String(localized: .Products.permissionBodyChatAuthority) + ) } } diff --git a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift index 554ec617a..773ba2b94 100644 --- a/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift +++ b/hosts/ios/polkadot-appTests/TrUAPI/TrUAPIReviewPromptMapperTests.swift @@ -21,6 +21,18 @@ struct TrUAPIReviewPromptMapperTests { )) } + @Test + func mapsChatAuthorityToDedicatedPermission() { + let request = mapper.makePermissionRequest( + from: ChatAuthorityReview(productId: "chat.dot") + ) + + #expect(request == TrUAPIPermissionRequest( + productId: "chat.dot", + permissions: [.chatAuthority] + )) + } + @Test func mapsPreimageSubmitToHostProductPermission() { let request = mapper.makePermissionRequest(from: PreimageSubmitReview(size: 1_024)) diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index facad2333..ed9435bd1 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -867,6 +867,13 @@ fn approval_summary(review: &UserConfirmationReview) -> (&'static str, String) { review.product_id ), ), + UserConfirmationReview::ChatAuthority(review) => ( + "use Chat identity authority", + format!( + "Product {} requested permission to bind its device account to your wallet Chat identity and encrypt or decrypt Chat routing data.", + review.product_id + ), + ), } } diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 170cbaf4e..9f618043e 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -1034,6 +1034,9 @@ pub enum PermissionAuthorizationRequest { /// Product whose account context may be accessed. target_product_id: String, }, + /// Product-scoped permission to bind and use wallet-held Chat identity authority. + #[codec(index = 4)] + ChatAuthority, } /// Authorization status for a permission request. @@ -1413,6 +1416,14 @@ impl CoreStorageKey { }, } } + + /// Persisted authorization key for wallet-held Chat identity authority. + pub fn chat_authority_authorization(product_id: &str) -> Self { + Self::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::ChatAuthority, + } + } } /// Canonical storage form for one remote-access domain pattern. @@ -2329,6 +2340,8 @@ mod tests { let account_access = CoreStorageKey::account_access_authorization("product.dot", "target.dot"); let other_target = CoreStorageKey::account_access_authorization("product.dot", "other.dot"); + let chat_authority = CoreStorageKey::chat_authority_authorization("product.dot"); + let other_product_chat = CoreStorageKey::chat_authority_authorization("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); @@ -2337,6 +2350,9 @@ mod tests { assert_ne!(identity, other_product_identity); assert_ne!(account_access, other_target); assert_ne!(account_access, camera); + assert_ne!(chat_authority, identity); + assert_ne!(chat_authority, account_access); + assert_ne!(chat_authority, other_product_chat); } #[test] @@ -2734,6 +2750,14 @@ pub struct IdentityDisclosureReview { pub product_id: String, } +/// Review shown before a product binds or uses wallet-held Chat identity authority. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChatAuthorityReview { + /// Product requesting the Chat identity operation. + pub product_id: String, +} + /// Review shown before a product resolves its own account subtree over SSO, /// when the value is not cached and the core must ask the Account Holder. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -2780,6 +2804,8 @@ pub enum UserConfirmationReview { SignVrf(SignVrfReview), /// Resolve a product's own account subtree over SSO. ProductSubtree(ProductSubtreeReview), + /// Allow a product to bind and use wallet-held Chat identity authority. + ChatAuthority(ChatAuthorityReview), } /// Local user confirmation UI for sensitive core-owned operations. diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index 640a6b9de..ff5174d28 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -33,7 +33,7 @@ //! authorized for every remote permission while nothing is stored, and never //! reaches the prompt callback. A stored decision still wins, so a denial //! written through the admin surface revokes the grant. Device permissions, -//! identity disclosure and account access are never covered. +//! identity disclosure, account access, and Chat authority are never covered. use parity_scale_codec::{Decode, Encode}; @@ -42,10 +42,10 @@ use truapi::latest::{ RemotePermissionRequest, RemotePermissionResponse, }; use truapi_platform::{ - CoreStorage, CoreStorageKey, DevicePermissionStatus, IdentityDisclosureReview, - PermissionAuthorizationRequest, PermissionAuthorizationStatus, PermissionStatusHost, - Permissions, UserConfirmation, UserConfirmationReview, has_trusted_remote_permissions, - remote_domain_candidates, + ChatAuthorityReview, CoreStorage, CoreStorageKey, DevicePermissionStatus, + IdentityDisclosureReview, PermissionAuthorizationRequest, PermissionAuthorizationStatus, + PermissionStatusHost, Permissions, UserConfirmation, UserConfirmationReview, + has_trusted_remote_permissions, remote_domain_candidates, }; /// Persisted answer for a single permission request. Keep `Authorized` at @@ -298,6 +298,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a ) .await } + PermissionAuthorizationRequest::ChatAuthority => { + authorization_status( + self.storage, + CoreStorageKey::chat_authority_authorization(self.product_id), + ) + .await + } } } @@ -355,6 +362,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::AccountAccess { target_product_id } => { CoreStorageKey::account_access_authorization(self.product_id, target_product_id) } + PermissionAuthorizationRequest::ChatAuthority => { + CoreStorageKey::chat_authority_authorization(self.product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -393,6 +403,38 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a Ok(status) } + /// Resolve the product's Chat authority grant, prompting once when no + /// durable user decision exists. + pub async fn check_or_prompt_chat_authority( + &self, + ) -> Result + where + P: UserConfirmation, + { + let request = PermissionAuthorizationRequest::ChatAuthority; + let cached = self.authorization_status(&request).await?; + if cached != PermissionAuthorizationStatus::NotDetermined { + return Ok(cached); + } + let confirmed = match self + .prompt + .confirm_user_action(UserConfirmationReview::ChatAuthority(ChatAuthorityReview { + product_id: self.product_id.to_string(), + })) + .await + { + Ok(confirmed) => confirmed, + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), + }; + let status = if confirmed { + PermissionAuthorizationStatus::Authorized + } else { + PermissionAuthorizationStatus::Denied + }; + self.set_authorization_status(&request, status).await?; + Ok(status) + } + /// Resolves a device capability against both the OS state and the stored /// product decision, prompting the platform's `device_permission` callback /// and persisting the answer when the question is still open. @@ -553,6 +595,7 @@ fn status_into_stored(status: PermissionAuthorizationStatus) -> Option::response_from_message(data), + Some(response_envelope) ); } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 79b503084..a9412e012 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -2321,6 +2321,7 @@ mod tests { cases.push(PermissionAuthorizationRequest::AccountAccess { target_product_id: "other.dot".to_string(), }); + cases.push(PermissionAuthorizationRequest::ChatAuthority); for case in cases { let native = case.clone(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 89b094c6f..2b3c76319 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -511,7 +511,7 @@ impl SigningHostSsoService { &calling_product_id, ); if permissions - .check_or_prompt_identity_disclosure() + .check_or_prompt_chat_authority() .await .map_err(|error| v01::HostProductDeviceChatError::Unknown { reason: error.reason, diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 85c4a2adf..44f9e8300 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -25,7 +25,7 @@ use truapi::v01; use truapi::versioned::account::{HostAccountCreateProofRequest, HostAccountGetAliasRequest}; use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; use truapi_platform::{ - AccountAccessReview, AuthPresenter, AuthState, ChainProvider, + AccountAccessReview, AuthPresenter, AuthState, ChainProvider, ChatAuthorityReview, CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, JsonRpcConnection, LocaleHost, Navigation as PlatformNavigation, Notifications as PlatformNotifications, PairingHostConfig, Permissions as PlatformPermissions, @@ -95,6 +95,9 @@ pub(crate) struct StubPlatform { pub(crate) identity_disclosure_confirmed: bool, pub(crate) identity_disclosure_error: Option<&'static str>, pub(crate) identity_disclosure_calls: Arc, + pub(crate) chat_authority_confirmed: bool, + pub(crate) chat_authority_error: Option<&'static str>, + pub(crate) chat_authority_reviews: Arc>>, pub(crate) sign_payload_confirmed: bool, pub(crate) sign_payload_error: Option<&'static str>, pub(crate) sign_raw_confirmed: bool, @@ -1556,6 +1559,13 @@ impl UserConfirmation for StubPlatform { self.identity_disclosure_confirmed, ) } + UserConfirmationReview::ChatAuthority(review) => { + self.chat_authority_reviews + .lock() + .expect("Chat authority review list mutex poisoned") + .push(review); + (self.chat_authority_error, self.chat_authority_confirmed) + } UserConfirmationReview::ResourceAllocation(review) => { self.resource_allocation_reviews .lock() From a0681e199bb9ed62fde953be768913fd813d4a7a Mon Sep 17 00:00:00 2001 From: w Date: Thu, 10 Sep 2026 13:53:10 -0400 Subject: [PATCH 6/7] chore: regenerate Chat authority artifacts --- CHANGELOG.md | 3 +- README.md | 2 +- rust/crates/truapi-client/src/generated.rs | 2 +- .../truapi-codegen/tests/golden/dispatcher.rs | 2 +- .../tests/golden/host-callbacks.ts | 32 +++++++++++++++++-- .../truapi-codegen/tests/golden/wire_table.rs | 2 +- 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b89018ca1..73545d2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ generated from [Conventional Commits](https://www.conventionalcommits.org/). ### Added - Add `account.productDeviceChat` for host-private Chat v2 identity binding and - identity-route sealing/opening through local or paired account authorities. + identity-route sealing/opening through local or paired account authorities, + guarded by a dedicated, product-scoped Chat-authority permission. - Generate a transport-neutral `no_std` Rust client with typed request, subscription, result-subscription, and host-initiated Worker subscription codecs. diff --git a/README.md b/README.md index d24670b19..346b2f402 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ See [`js/packages/truapi/README.md`](js/packages/truapi/README.md) for the full wallet's Chat v2 identity and seals or opens identity-route payloads without exposing the wallet's X25519 private key. Browser pairing hosts forward the operation over encrypted SSO; signing hosts require the calling product's -identity-disclosure authorization before using local wallet material. +dedicated Chat-authority permission before using local wallet material. ## Repository layout diff --git a/rust/crates/truapi-client/src/generated.rs b/rust/crates/truapi-client/src/generated.rs index 4cc6d384c..57967a527 100644 --- a/rust/crates/truapi-client/src/generated.rs +++ b/rust/crates/truapi-client/src/generated.rs @@ -5,7 +5,7 @@ use super::*; /// Fingerprint of the generated wire contract. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "f1682972c34c8609"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0e49a2f7d93138a3"; /// `account_connection_status_subscribe` method marker. pub struct AccountConnectionStatusSubscribe; diff --git a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs index f3fa5e94f..792cc84a3 100644 --- a/rust/crates/truapi-codegen/tests/golden/dispatcher.rs +++ b/rust/crates/truapi-codegen/tests/golden/dispatcher.rs @@ -387,7 +387,7 @@ where } }; let target_version = request.version(); - let cx = CallContext::with_request_id(request_id.clone()); + let cx = CallContext::with_request_id(request_id); let response: versioned::account::HostProductDeviceChatResponse = match host.product_device_chat(&cx, request).await { Ok(value) => value, Err(err) => { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index b9ccb6ee7..1e62c3b23 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -111,6 +111,16 @@ export type AuthState = */ | { tag: "Authenticating"; value?: undefined }; +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export interface ChatAuthorityReview { + /** + * Product requesting the Chat identity operation. + */ + productId: string; +} + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -314,7 +324,11 @@ export type PermissionAuthorizationRequest = /** * Product-scoped permission to access another product's account context. */ - | { tag: "AccountAccess"; value: { targetProductId: string } }; + | { tag: "AccountAccess"; value: { targetProductId: string } } + /** + * Product-scoped permission to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value?: undefined }; /** * Authorization status for a permission request. @@ -554,7 +568,11 @@ export type UserConfirmationReview = /** * Resolve a product's own account subtree over SSO. */ - | { tag: "ProductSubtree"; value: ProductSubtreeReview }; + | { tag: "ProductSubtree"; value: ProductSubtreeReview } + /** + * Allow a product to bind and use wallet-held Chat identity authority. + */ + | { tag: "ChatAuthority"; value: ChatAuthorityReview }; /** * Review shown before a product asks to access another product account. @@ -598,6 +616,14 @@ export const AuthState: S.Codec = S.lazy( }), ); +/** + * Review shown before a product binds or uses wallet-held Chat identity authority. + */ +export const ChatAuthorityReview: S.Codec = S.lazy( + (): S.Codec => + S.Struct({ productId: S.str }) as S.Codec, +); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. @@ -737,6 +763,7 @@ export const PermissionAuthorizationRequest: S.Codec, + ChatAuthority: S._void, }), ); @@ -893,6 +920,7 @@ export const UserConfirmationReview: S.Codec = S.lazy( AccountAccess: AccountAccessReview, SignVrf: SignVrfReview, ProductSubtree: ProductSubtreeReview, + ChatAuthority: ChatAuthorityReview, }), ); diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 85a835bc4..4d8180aad 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "43581e5572c0315a"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0e49a2f7d93138a3"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { From 43888503775e77f4f1e7dac0aae29efcbba1de79 Mon Sep 17 00:00:00 2001 From: w Date: Fri, 11 Sep 2026 07:50:47 -0400 Subject: [PATCH 7/7] fix(chat): forward product statement proofs over SSO --- .../src/host_logic/sso/messages.rs | 58 +++++++++++++++++++ .../src/host_logic/sso/messages/v1.rs | 7 +++ .../truapi-server/src/runtime/pairing_host.rs | 15 ++--- .../src/runtime/pairing_host/sso_channel.rs | 25 +++++++- .../src/runtime/signing_host/sso_service.rs | 37 +++++++++++- 5 files changed, 130 insertions(+), 12 deletions(-) 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 b21e2f4a5..1ae7693a6 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -295,6 +295,22 @@ pub struct ProductSubtreeRequest { /// Account Holder response carrying a product subtree public key. pub type ProductSubtreeResponse = Result<[u8; 32], String>; +/// Exact unsigned Statement Store payload to sign with a product-derived account. +/// +/// The signing host validates the payload as canonical unsigned statement +/// fields before signing, so this cannot become a generic signing oracle. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct StatementStoreProductSignRequest { + /// Product making the request. + pub calling_product_id: String, + /// Product account that signs the statement payload. + pub account: v01::ProductAccountId, + /// Exact unsigned statement fields, without their SCALE vector prefix. + pub payload: Vec, +} + +/// Account Holder response carrying the product-account sr25519 signature. +pub type StatementStoreProductSignResponse = Result<[u8; 64], String>; /// Request sent when a product asks the signing host to create a transaction /// for a product-derived account. @@ -621,6 +637,7 @@ mod tests { use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{ StatementField, build_signed_statement, decode_statement_data, + unsigned_statement_signing_payload, }; use crate::test_support::sso_host_and_responder_sessions; use schnorrkel::{ExpansionMode, MiniSecretKey}; @@ -1030,6 +1047,47 @@ mod tests { Some(response_envelope) ); } + #[test] + fn statement_store_product_sign_messages_pin_extension_wire_indices() { + let payload = unsigned_statement_signing_payload(vec![ + StatementField::Data(vec![1, 2, 3]), + StatementField::Channel([0x44; 32]), + ]) + .unwrap(); + let request_payload = StatementStoreProductSignRequest { + calling_product_id: "egui-chat.paseo".to_string(), + account: ProductAccountId { + dot_ns_identifier: "egui-chat.paseo".to_string(), + derivation_index: DerivationIndex::Index(0), + }, + payload, + }; + let request = RemoteMessage::request("request".to_string(), request_payload); + let encoded_request = request.encode(); + assert_eq!(encoded_request[9], 26); + assert_eq!( + RemoteMessage::decode(&mut encoded_request.as_slice()).unwrap(), + request + ); + + let response_envelope = Response { + responding_to: "request".to_string(), + payload: Ok([0xAB; 64]), + }; + let response = RemoteMessage { + message_id: "response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::StatementStoreProductSignResponse( + response_envelope.clone(), + )), + }; + let encoded_response = response.encode(); + assert_eq!(encoded_response[10], 27); + let RemoteMessageData::V1(data) = response.data; + assert_eq!( + StatementStoreProductSignRequest::response_from_message(data), + Some(response_envelope) + ); + } #[test] fn sign_vrf_messages_match_mobile_wire_contract() { 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 1df0a69e1..e316cbc90 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 @@ -18,6 +18,7 @@ use super::{ RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, Response, RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfResponse, SsoProductDeviceChatOperation, + StatementStoreProductSignRequest, StatementStoreProductSignResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. @@ -90,4 +91,10 @@ pub enum RemoteMessage { /// Account Holder's product-device Chat v2 response. #[codec(index = 25)] ProductDeviceChatResponse(Response), + /// Ask the Account Holder to sign an exact Statement Store product payload. + #[codec(index = 26)] + StatementStoreProductSignRequest(StatementStoreProductSignRequest), + /// Account Holder's product-account Statement Store signature. + #[codec(index = 27)] + StatementStoreProductSignResponse(Response), } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index 4bfa9015c..cf7b4d45c 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -2330,17 +2330,14 @@ impl PairingHost { async fn sign_statement_store_product_payload( &self, - _cx: &CallContext, + cx: &CallContext, session: &AuthoritySession, - _account: v01::ProductAccountId, - _payload: Vec, + account: v01::ProductAccountId, + payload: Vec, ) -> Result<[u8; 64], AuthorityError> { - self.current_private_session(session)?; - Err(AuthorityError::Unavailable { - reason: "pairing host: exact statement proof signing is not supported over the \ - current SSO raw-signing protocol" - .to_string(), - }) + let session = self.current_private_session(session)?; + self.remote_sign_statement_store_product_payload(cx, &session, account, payload) + .await } fn derive_entropy( 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 5f40a3999..8a6b7ee8b 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 @@ -19,7 +19,8 @@ use crate::host_logic::sso::messages::{ ProductSubtreeRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, RingVrfError, SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, SsoAllocationOutcome, SsoProductDeviceChatOperation, SsoSessionStatement, - build_outgoing_request_statement, decode_sso_session_statement, v1, + StatementStoreProductSignRequest, build_outgoing_request_statement, + decode_sso_session_statement, v1, }; use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::parse_new_statements_result; @@ -471,6 +472,28 @@ impl PairingHost { .await .map_err(ring_vrf_transport_error)? } + /// Forward exact Statement Store product-account signing to the Account Holder. + pub(super) async fn remote_sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + let calling_product_id = account.dot_ns_identifier.clone(); + self.call( + cx, + session, + StatementStoreProductSignRequest { + calling_product_id, + account, + payload, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error) + } /// Forward a product-device Chat v2 operation without exposing wallet key material. pub(super) async fn remote_product_device_chat( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 2b3c76319..3e392f17f 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -7,7 +7,8 @@ use tracing::warn; use truapi::{latest as api, v01}; use truapi_platform::{ CreateTransactionReview, PermissionAuthorizationStatus, ResourceAllocationReview, - SignPayloadReview, SignRawReview, UserConfirmationReview, normalize_product_identifier, + SignPayloadReview, SignRawReview, StatementStoreProductSignReview, UserConfirmationReview, + normalize_product_identifier, }; use super::SigningHost; @@ -28,9 +29,11 @@ use crate::host_logic::sso::messages::{ RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, - SsoProductDeviceChatOperation, + SsoProductDeviceChatOperation, StatementStoreProductSignRequest, + StatementStoreProductSignResponse, }; use crate::host_logic::sso::wire::ResponseOutcome; +use crate::host_logic::statement_store::validate_unsigned_statement_signing_payload; use crate::runtime::authority::{ AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, ProductDeviceChatAuthorityError, ProductDeviceChatAuthorityRequest, @@ -495,6 +498,36 @@ impl SigningHostSsoService { .ring_vrf_sign(&cx.call, &cx.session, request) .await } + /// Sign a canonical unsigned Statement Store payload with a product account. + async fn statement_store_product_sign( + &self, + cx: &SsoRequestContext, + request: StatementStoreProductSignRequest, + ) -> StatementStoreProductSignResponse { + let calling_product_id = normalize_product_identifier(&request.calling_product_id) + .map_err(|_| "invalid calling product identifier".to_string())?; + let mut account = request.account; + let account_product_id = normalize_product_identifier(&account.dot_ns_identifier) + .map_err(|_| "invalid product account identifier".to_string())?; + if account_product_id != calling_product_id { + return Err("product account does not belong to the calling product".to_string()); + } + account.dot_ns_identifier = calling_product_id; + validate_unsigned_statement_signing_payload(&request.payload) + .map_err(|error| error.to_string())?; + self.confirm(UserConfirmationReview::StatementStoreProductSign( + StatementStoreProductSignReview { + account: account.clone(), + payload: request.payload.clone(), + }, + )) + .await?; + self.signing_host + .sign_statement_store_product_payload(&cx.call, &cx.session, account, request.payload) + .await + .map_err(|error| error.to_string()) + } + /// Perform a Chat identity operation without exposing wallet key material. async fn product_device_chat( &self,