diff --git a/Cargo.lock b/Cargo.lock index 4b514a60f..84faa7319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1856,6 +1856,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3283,6 +3292,7 @@ dependencies = [ "hex", "hkdf", "js-sys", + "nanoid", "p256", "parity-scale-codec", "pin-project", @@ -3307,6 +3317,7 @@ dependencies = [ "wasm-bindgen-test", "web-sys", "web-time", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d657d267e..042aff824 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] resolver = "2" members = ["rust/crates/*"] -exclude = ["rust/crates/truapi-server"] [workspace.package] edition = "2024" diff --git a/rust/crates/truapi-codegen/src/rustdoc.rs b/rust/crates/truapi-codegen/src/rustdoc.rs index 8901fc0ad..10dac17ca 100644 --- a/rust/crates/truapi-codegen/src/rustdoc.rs +++ b/rust/crates/truapi-codegen/src/rustdoc.rs @@ -414,6 +414,8 @@ fn should_skip_type_name(name: &str) -> bool { "Subscription" | "CallContext" | "CallError" + | "CancellationFuture" + | "CancellationReason" | "CancellationToken" | "FrameworkOnlyError" | "Infallible" diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index 3edef0ecb..d87dfd88c 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -20,6 +20,7 @@ import type { NotificationId, } from "@parity/truapi"; import { + BulletinAllowanceSigner, CoreStorageKey, UserConfirmationReview, } from "./host-callbacks.js"; @@ -41,7 +42,7 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array): Promise; + submitPreimage(value: Uint8Array, bulletinAllowanceSigner: Uint8Array): Promise; lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; @@ -67,7 +68,7 @@ export function createWasmRawCallbacks( cancelNotification: async (id) => await host.cancelNotification(id), devicePermission: async (request) => HostDevicePermissionResponse.enc(await host.devicePermission(HostDevicePermissionRequest.dec(request))), remotePermission: async (request) => RemotePermissionResponse.enc(await host.remotePermission(RemotePermissionRequest.dec(request))), - submitPreimage: async (value) => await host.submitPreimage(value), + submitPreimage: async (value, bulletinAllowanceSigner) => await host.submitPreimage(value, BulletinAllowanceSigner.dec(bulletinAllowanceSigner)), lookupPreimage: (key, sendItem) => driveResultStream(host.lookupPreimage(key), sendItem), read: async (key) => await host.read(key), write: async (key, value) => await host.write(key, value), diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 407e2050c..43b3a7b43 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -31,6 +31,21 @@ import type { ThemeVariant, } from "@parity/truapi"; +/** + * Review shown before a product asks to access another product account. + */ +export interface AccountAccessReview { + /** + * Product currently handling the request. + */ + requestingProductId: string; + + /** + * Product whose account is being requested. + */ + targetProductId: string; +} + /** * Review shown before a product asks to alias another product account. */ @@ -71,9 +86,19 @@ export type AuthState = */ | { tag: "LoginFailed"; value: { reason: string } }; +/** + * Host-facing signer for Bulletin preimage submission. + */ +export interface BulletinAllowanceSigner { + +} + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ export type CoreStorageKey = /** @@ -87,7 +112,15 @@ export type CoreStorageKey = /** * Persisted authorization for one product-scoped permission request. */ - | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } }; + | { tag: "PermissionAuthorization"; value: { productId: string; request: PermissionAuthorizationRequest } } + /** + * Persisted allowance-slot keys for one paired SSO session. + */ + | { tag: "AllowanceKeys"; value: { sessionId: string } } + /** + * Last processed SSO pairing response statement for the pairing device. + */ + | { tag: "LastProcessedPairingStatement"; value?: undefined }; /** * Review shown before a transaction-creation request is sent to the paired wallet. @@ -102,6 +135,16 @@ export type CreateTransactionReview = */ | { tag: "LegacyAccount"; value: LegacyAccountTxPayload }; +/** + * Review shown before a product learns the user's primary identity. + */ +export interface IdentityDisclosureReview { + /** + * Product currently handling the request. + */ + productId: string; +} + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. @@ -114,7 +157,11 @@ export type PermissionAuthorizationRequest = /** * Remote/product-scoped permission such as chain submit or HTTP access. */ - | { tag: "Remote"; value: RemotePermissionRequest }; + | { tag: "Remote"; value: RemotePermissionRequest } + /** + * Product-scoped permission to disclose the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value?: undefined }; /** * Authorization status for a permission request. @@ -206,6 +253,10 @@ export type UserConfirmationReview = * Allow a product to request another product account alias. */ | { tag: "AccountAlias"; value: AccountAliasReview } + /** + * Allow a product to learn the user's primary identity. + */ + | { tag: "IdentityDisclosure"; value: IdentityDisclosureReview } /** * Allocate resources for the requesting product. */ @@ -213,29 +264,51 @@ export type UserConfirmationReview = /** * Submit a preimage to the host-selected backend. */ - | { tag: "PreimageSubmit"; value: PreimageSubmitReview }; + | { tag: "PreimageSubmit"; value: PreimageSubmitReview } + /** + * Allow a product to access another product account. + */ + | { tag: "AccountAccess"; value: AccountAccessReview }; + +/** + * Review shown before a product asks to access another product account. + */ +export const AccountAccessReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); /** * Review shown before a product asks to alias another product account. */ export const AccountAliasReview: S.Codec = S.lazy((): S.Codec => S.Struct({requestingProductId: S.str, targetProductId: S.str}) as S.Codec); +/** + * Host-facing signer for Bulletin preimage submission. + */ +export const BulletinAllowanceSigner: S.Codec = S.lazy((): S.Codec => S.Struct({}) as S.Codec); + /** * Core-owned host-private storage slots. Products never address these slots; * the host chooses the backing store for each slot. + * + * Storage is host-local; `storage.md` records the current status quo: + * */ -export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>})); +export const CoreStorageKey: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({AuthSession: S._void, PairingDeviceIdentity: S._void, PermissionAuthorization: S.Struct({productId: S.str, request: PermissionAuthorizationRequest}) as S.Codec<{ productId: string; request: PermissionAuthorizationRequest }>, AllowanceKeys: S.Struct({sessionId: S.str}) as S.Codec<{ sessionId: string }>, LastProcessedPairingStatement: S._void})); /** * Review shown before a transaction-creation request is sent to the paired wallet. */ export const CreateTransactionReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Product: ProductAccountTxPayload, LegacyAccount: LegacyAccountTxPayload})); +/** + * Review shown before a product learns the user's primary identity. + */ +export const IdentityDisclosureReview: S.Codec = S.lazy((): S.Codec => S.Struct({productId: S.str}) as S.Codec); + /** * Permission request whose authorization status can be inspected or updated * by host administration UI. */ -export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest})); +export const PermissionAuthorizationRequest: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({Device: HostDevicePermissionRequest, Remote: RemotePermissionRequest, IdentityDisclosure: S._void})); /** * Authorization status for a permission request. @@ -263,7 +336,7 @@ export const SignRawReview: S.Codec = S.lazy((): S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview})); +export const UserConfirmationReview: S.Codec = S.lazy((): S.Codec => S.TaggedUnion({SignPayload: SignPayloadReview, SignRaw: SignRawReview, CreateTransaction: CreateTransactionReview, AccountAlias: AccountAliasReview, IdentityDisclosure: IdentityDisclosureReview, ResourceAllocation: HostRequestResourceAllocationRequest, PreimageSubmit: PreimageSubmitReview, AccountAccess: AccountAccessReview})); /** * Host auth UI driven by core-owned `AuthState` transitions. @@ -282,6 +355,9 @@ export interface AuthPresenter { * * The platform provides a way to get a JSON-RPC connection for a given chain. * The server runtime manages the chainHead v1 state machine on top of this. + * Host-spec N.6 requires products to access chains through host-mediated + * providers: + * */ export interface ChainProvider { /** @@ -449,7 +525,7 @@ export interface PreimageHost { /** * Submit the preimage and return its key. */ - submitPreimage?(value: Uint8Array): Promise; + submitPreimage?(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; /** * Emits current value/miss immediately, then future updates. @@ -491,11 +567,11 @@ export interface ThemeHost { } /** - * Local user confirmation UI for session-channel operations. + * Local user confirmation UI for sensitive core-owned operations. */ export interface UserConfirmation { /** - * Confirm a reviewed action before the core asks the SSO peer. + * Confirm a reviewed action before the core continues. */ confirmUserAction(review: UserConfirmationReview): Promise; } diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 34275dbf9..fadb79383 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -65,8 +65,8 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required, remotePermission: (request) => bridge.callbackRequest("remotePermission", [request]) as ReturnType, - submitPreimage: (value) => - bridge.callbackRequest("submitPreimage", [value]) as ReturnType, + submitPreimage: (value, bulletinAllowanceSigner) => + bridge.callbackRequest("submitPreimage", [value, bulletinAllowanceSigner]) as ReturnType, read: (key) => bridge.callbackRequest("read", [key]) as ReturnType, write: (key, value) => diff --git a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs index 98f7a095a..8fc74fde5 100644 --- a/rust/crates/truapi-codegen/tests/golden_rust_emit.rs +++ b/rust/crates/truapi-codegen/tests/golden_rust_emit.rs @@ -10,51 +10,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -fn quoted_strings_in_const_array(src: &str, const_name: &str) -> Vec { - let marker = format!("export const {const_name} = ["); - let start = src - .find(&marker) - .unwrap_or_else(|| panic!("missing {const_name}")); - let rest = &src[start + marker.len()..]; - let end = rest - .find("] as const") - .unwrap_or_else(|| panic!("unterminated {const_name}")); - rest[..end] - .lines() - .filter_map(|line| { - let trimmed = line.trim().trim_end_matches(','); - trimmed - .strip_prefix('"') - .and_then(|s| s.strip_suffix('"')) - .map(str::to_string) - }) - .collect() -} - -fn wasm_optional_callback_names(workspace: &Path) -> Vec { - let wasm_rs = workspace.join("rust/crates/truapi-server/src/wasm.rs"); - if !wasm_rs.exists() { - return Vec::new(); - } - let src = fs::read_to_string(wasm_rs).expect("read wasm.rs"); - let mut names = src - .lines() - .filter_map(|line| { - let line = line.trim(); - let start = line.find("get_optional_function(callbacks, \"")?; - let quoted = &line[start + "get_optional_function(callbacks, \"".len()..]; - let end = quoted.find('"')?; - let name = "ed[..end]; - match name { - "chainConnect" | "dispose" => None, - _ => Some(name.to_string()), - } - }) - .collect::>(); - names.sort(); - names -} - /// Run `cargo +nightly rustdoc -p truapi --output-format json` into the /// given `target_dir` and return the path to the produced JSON file. /// Panics with a clear message if nightly is unavailable so CI cannot @@ -271,16 +226,4 @@ fn golden_host_callbacks_ts() { !worker_actual.contains("OPTIONAL_CALLBACK_NAMES"), "worker callback generation should not expose an optional callback manifest" ); - let mut generated_names = quoted_strings_in_const_array(&worker_actual, "CALLBACK_NAMES"); - generated_names.extend(quoted_strings_in_const_array( - &worker_actual, - "SUBSCRIPTION_NAMES", - )); - let wasm_optional = wasm_optional_callback_names(&workspace); - for name in wasm_optional { - assert!( - generated_names.contains(&name), - "generated worker names must include JsBridge optional callback `{name}`" - ); - } } diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index ee4f101cf..cfbd0fc4d 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -8,7 +8,7 @@ license = "MIT" [dependencies] truapi = { path = "../truapi" } async-trait = "0.1" -derive_more = { version = "2", features = ["display"] } +derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" parity-scale-codec = { version = "3", features = ["derive"] } unicode-normalization = "0.1" diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 9b8ccac5e..c03512680 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -9,6 +9,8 @@ //! Async capability traits use `async_trait` so the combined [`Platform`] //! surface can be used as a trait object by the runtime. +use std::sync::Arc; + use futures::stream::BoxStream; use parity_scale_codec::{Decode, Encode}; use unicode_normalization::UnicodeNormalization; @@ -42,10 +44,17 @@ pub struct HostRuntimeConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairingHostConfig { /// Host identity shown to the signing host during pairing. + /// + /// Host-spec B.1.3 defines the host metadata consumed by the signing host: + /// pub host: HostRuntimeConfig, /// People-chain genesis hash used for statement-store SSO. pub people_chain_genesis_hash: [u8; 32], /// Deeplink URI scheme used in pairing QR payloads, without `://`. + /// + /// Host-spec L.2-L.3 define the `polkadotapp://pair` route and construction + /// rules: + /// pub pairing_deeplink_scheme: String, } @@ -70,6 +79,9 @@ pub struct SigningHostConfig { pub struct ProductContext { /// Product identifier used for account derivation and product-scoped /// storage/permission namespaces. + /// + /// Host-spec C.7 defines accepted product id forms: + /// pub product_id: String, } @@ -200,7 +212,7 @@ fn require_non_empty(field: &'static str, value: &str) -> Result<(), RuntimeConf } /// Runtime config validation error. -#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] pub enum RuntimeConfigValidationError { /// Required string field was empty or whitespace-only. #[display("{field} must not be empty")] @@ -234,8 +246,6 @@ pub enum RuntimeConfigValidationError { }, } -impl core::error::Error for RuntimeConfigValidationError {} - /// Product-scoped key-value storage. /// /// The core namespaces product keys before calling this trait. Host @@ -305,6 +315,8 @@ pub enum PermissionAuthorizationRequest { Device(HostDevicePermissionRequest), /// Remote/product-scoped permission such as chain submit or HTTP access. Remote(RemotePermissionRequest), + /// Product-scoped permission to disclose the user's primary identity. + IdentityDisclosure, } /// Authorization status for a permission request. @@ -382,6 +394,9 @@ pub trait Features: Send + Sync { /// /// The platform provides a way to get a JSON-RPC connection for a given chain. /// The server runtime manages the chainHead v1 state machine on top of this. +/// Host-spec N.6 requires products to access chains through host-mediated +/// providers: +/// #[async_trait] pub trait ChainProvider: Send + Sync { /// Open a JSON-RPC connection for the chain identified by `genesis_hash`. @@ -409,6 +424,9 @@ pub trait JsonRpcConnection: Send + Sync { /// Core-owned host-private storage slots. Products never address these slots; /// the host chooses the backing store for each slot. +/// +/// Storage is host-local; `storage.md` records the current status quo: +/// #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum CoreStorageKey { /// Opaque SSO/auth session blob. @@ -422,6 +440,13 @@ pub enum CoreStorageKey { /// Permission request whose authorization is being stored. request: PermissionAuthorizationRequest, }, + /// Persisted allowance-slot keys for one paired SSO session. + AllowanceKeys { + /// Stable host-derived SSO session id. + session_id: String, + }, + /// Last processed SSO pairing response statement for the pairing device. + LastProcessedPairingStatement, } /// Host-private persistence for core-owned state. @@ -444,7 +469,7 @@ pub trait CoreStorage: Send + Sync { /// Decoded session fields a host shell needs to render account UI without /// parsing the opaque session blob the core persists through [`CoreStorage`]. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub struct SessionUiInfo { /// 32-byte sr25519 root public key of the active session. pub public_key: [u8; 32], @@ -459,7 +484,7 @@ pub struct SessionUiInfo { /// Auth/session lifecycle state the core projects for host UI. The core owns /// every transition and emits states in order; hosts render the current state /// and never derive auth UI from any other signal. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Encode, Decode)] pub enum AuthState { /// No active session and no login in progress. #[default] @@ -526,6 +551,22 @@ pub struct AccountAliasReview { pub target_product_id: String, } +/// Review shown before a product asks to access another product account. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct AccountAccessReview { + /// Product currently handling the request. + pub requesting_product_id: String, + /// Product whose account is being requested. + pub target_product_id: String, +} + +/// Review shown before a product learns the user's primary identity. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct IdentityDisclosureReview { + /// Product currently handling the request. + pub product_id: String, +} + /// Review shown before a preimage is submitted. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct PreimageSubmitReview { @@ -545,16 +586,20 @@ pub enum UserConfirmationReview { CreateTransaction(CreateTransactionReview), /// Allow a product to request another product account alias. AccountAlias(AccountAliasReview), + /// Allow a product to learn the user's primary identity. + IdentityDisclosure(IdentityDisclosureReview), /// Allocate resources for the requesting product. ResourceAllocation(HostRequestResourceAllocationRequest), /// Submit a preimage to the host-selected backend. PreimageSubmit(PreimageSubmitReview), + /// Allow a product to access another product account. + AccountAccess(AccountAccessReview), } -/// Local user confirmation UI for session-channel operations. +/// Local user confirmation UI for sensitive core-owned operations. #[async_trait] pub trait UserConfirmation: Send + Sync { - /// Confirm a reviewed action before the core asks the SSO peer. + /// Confirm a reviewed action before the core continues. async fn confirm_user_action( &self, review: UserConfirmationReview, @@ -567,13 +612,106 @@ pub trait ThemeHost: Send + Sync { fn subscribe_theme(&self) -> BoxStream<'static, Result>; } +/// Secret key allocated for Bulletin preimage submission. +#[derive(Clone, derive_more::Debug, PartialEq, Eq)] +pub struct BulletinAllowanceKey { + #[debug("{:?}", "")] + secret: [u8; 64], +} + +impl BulletinAllowanceKey { + /// Build a Bulletin allowance key from raw secret bytes. + pub fn from_secret_bytes(secret: Vec) -> Result { + let secret: [u8; 64] = secret.try_into().map_err(|secret: Vec| { + BulletinAllowanceKeyError::InvalidLength { + actual: secret.len(), + } + })?; + Ok(Self { secret }) + } + + /// Raw secret bytes for bridge and storage adapters. + pub fn as_secret_bytes(&self) -> &[u8] { + &self.secret + } + + /// Consume the wrapper and return raw secret bytes. + pub fn into_secret_bytes(self) -> [u8; 64] { + self.secret + } +} + +/// Invalid Bulletin allowance key material. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub enum BulletinAllowanceKeyError { + /// Secret material was not a 64-byte sr25519 secret key. + #[display("bulletin allowance key must be 64 bytes, got {actual}")] + InvalidLength { + /// Actual secret byte length. + actual: usize, + }, +} + +/// Bulletin allowance signing capability exposed across the platform boundary. +/// +/// Rust owns the allowance key format and secret material. Host code only gets +/// `public_key + sign(payload)`, enough for PAPI to build and submit the +/// Bulletin transaction without reintroducing allowance key parsing in host code. +type BulletinAllowanceSignFn = + dyn Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync; + +/// Host-facing signer for Bulletin preimage submission. +#[derive(Clone, derive_more::Debug)] +pub struct BulletinAllowanceSigner { + public_key: [u8; 32], + /// Rust-owned signing capability passed to host code without exposing the + /// raw allowance secret. + #[debug("{:?}", "")] + sign: Arc, +} + +impl BulletinAllowanceSigner { + /// Build a signer from a public key and signing function. + pub fn new( + public_key: [u8; 32], + sign: impl Fn(&[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> + Send + Sync + 'static, + ) -> Self { + Self { + public_key, + sign: Arc::new(sign), + } + } + + /// Public key of the allowance account. + pub fn public_key(&self) -> [u8; 32] { + self.public_key + } + + /// Sign a SCALE transaction payload with the allowance account. + pub fn sign(&self, payload: &[u8]) -> Result<[u8; 64], BulletinAllowanceSignError> { + (self.sign)(payload) + } +} + +/// Bulletin allowance signing failed. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +#[display("{reason}")] +pub struct BulletinAllowanceSignError { + /// Human-readable failure reason. + pub reason: String, +} + /// Host preimage backend. The core owns wire mapping and subscription /// lifecycle; the host owns the selected backend. #[async_trait] pub trait PreimageHost: Send + Sync { /// Submit the preimage and return its key. - async fn submit_preimage(&self, value: Vec) -> Result, PreimageSubmitError> { - let _ = value; + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, PreimageSubmitError> { + let _ = (value, bulletin_allowance_signer); Err(PreimageSubmitError::Unknown { reason: "submitPreimage callback not provided by host".to_string(), }) diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index a4a86a0c8..93ce86b54 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -11,11 +11,14 @@ crate-type = ["rlib", "cdylib"] [features] default = [] +[lints.rust] +unsafe_code = "forbid" + [dependencies] truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } async-trait = "0.1" -derive_more = { version = "2", features = ["display"] } +derive_more = { version = "2", features = ["display", "error"] } futures = "0.3" futures-timer = { version = "3", features = ["wasm-bindgen"] } parity-scale-codec = { version = "3", features = ["derive"] } @@ -26,11 +29,13 @@ thiserror = "1" unicode-normalization = "0.1" url = "2" hex = "0.4" +nanoid = "0.4" blake2-rfc = { version = "0.2", default-features = false } sp-crypto-hashing = { version = "0.1", default-features = false } bs58 = { version = "0.5", default-features = false, features = ["alloc"] } schnorrkel = { version = "0.11.5", default-features = false, features = ["alloc", "getrandom"] } substrate-bip39 = { version = "0.6", default-features = false } +zeroize = { version = "1", default-features = false, features = ["alloc"] } getrandom = { version = "0.2", features = ["js"] } p256 = { version = "0.13", default-features = false, features = ["ecdh"] } hkdf = "0.12" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index c4a918563..a69dca866 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -13,6 +13,185 @@ _Runtime core for TrUAPI: dispatcher, protocol frames, SCALE-coded wire envelope - the [`Transport`] trait that platform-specific IPC backends implement - the auto-generated dispatcher/wire-table tables shipped under [`crate::generated`] +- the host embedding surface: one long-lived role handle + (`PairingHostRuntime` or `SigningHostRuntime`) per host application, exposing + shared [`RuntimeServices`] plus one [`ProductRuntime`] per product connection + +## Architecture + +Two ownership bands. A **per-product-connection** band (byte frames → +dispatcher → role-neutral product runtime) is minted once per host↔product +connection by the role handle and lives for that product's whole session; a +**shared per-host** band owns role-neutral infrastructure (`RuntimeServices`) +and the role object (`PairingHost` or `SigningHost`), which is itself the +`ProductAuthority`. Pure `host_logic` is a no-I/O library both bands call, not a +stage in the frame path; the host's `Platform` impl is the syscall floor. + +```text + ┌───────────────────────────────────────────────────────┐ + │ product sandboxed iframe · native WebView │ + └───────────────────────────────────────────────────────┘ + │ ▲ + SCALE frames │ │ MessageChannel · loopback + both directions ▼ │ WS + ┌───────────────────────────────────────────────────────┐ + │ binding layer : host shell / transport adapter │ + │ thin byte bridge · no protocol logic │ + └───────────────────────────────────────────────────────┘ + + ══ per host→product connection ( one per connected product ) ══ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntime frame endpoint │ + │ decode each SCALE frame → dispatch one typed call │ + └───────────────────────────────────────────────────────┘ + │ typed method call + ▼ + ┌───────────────────────────────────────────────────────┐ + │ ProductRuntimeHost role-neutral │ + │ validate · permission-gate · confirm │ + └───────────────────────────────────────────────────────┘ + │ wallet-authority tail : + │ sign · alias · entropy · alloc + │ via Arc + ▼ + + ══ shared per host app ( one per host, all connections ) ══════ + the PairingHostRuntime | SigningHostRuntime handle owns both: + ┌─────────────────────────────┐ ┌────────────────────────┐ + │ role = ProductAuthority │ │ RuntimeServices │ + │ PairingHost | SigningHost │ │ platform · chain · RPC │ + └─────────────────────────────┘ └────────────────────────┘ + │ + │ PairingHost only : encrypted SSO channel + ▼ + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ remote signing host ( external wallet ) ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + both bands call host_logic for pure work, never traverse it : + ┌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐ + ╎ host_logic pure library ( no I/O ) ╎ + ╎ crypto · codecs · derivation · policy ╎ + └╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘ + + ══ host-owned floor · where every I/O above bottoms out ═══════ + ┌───────────────────────────────────────────────────────┐ + │ Platform impl ( TS / Swift / Kotlin ) │ + │ storage · prompts · chain RPC · navigation │ + └───────────────────────────────────────────────────────┘ +``` + +`ProductRuntimeHost` handles everything role-neutral (id normalization, +permission gating, confirmation, soft product-key derivation), then delegates +the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, +`allocate_resources`, `derive_entropy`) through an `Arc` +handle with an `AuthoritySession` snapshot the role revalidates before touching +key material. + +### Permission flow + +Permission grants are scoped by product id and typed request, so a grant for +one product never authorizes another product or another permission class. + +```text +Product app +(product_id = "my-product") + | + | host API call + | e.g. getUserId(), chain submit, camera, remote fetch + v +Generated product client / host callback bridge + | + v +ProductRuntime + | + | attaches current ProductContext.product_id + v +PermissionsService(storage, platform, product_id) + | + | builds storage key: + | + | CoreStorageKey::PermissionAuthorization { + | product_id: "my-product", + | request: PermissionAuthorizationRequest::... + | } + v +CoreStorage lookup + | + +-- Authorized ---------------> allow protected host/backend call + | + +-- Denied -------------------> return PermissionDenied / deny call + | + +-- NotDetermined / missing ---+ + | + v + Platform prompt callback + | + +-------------------+-------------------+ + | | | + v v v + device_permission() remote_permission() confirm_user_action() + camera/mic/etc chain/preimage/etc identity disclosure + | | | + +-------------------+-------------------+ + | + v + user chooses Allow / Deny + | + v + write Authorized / Denied to CoreStorage + under the same product-scoped key + | + +-------------+-------------+ + | | + v v + Authorized Denied + allow call deny call +``` + +Permission administration uses the same key without prompting: + +```text +Product UI + | + | permission_authorization_status(request) + | set_permission_authorization_status(request, status) + v +HostAdmin / ProductRuntime + | + v +PermissionsService + | + v +CoreStorageKey::PermissionAuthorization { product_id, request } +``` + +The embedder builds a role handle, `PairingHostRuntime::new(...)` or +`SigningHostRuntime::new(...)`, then calls `product_runtime(product, sink)` for +each product connection. Role-specific operations live only on the matching handle: +`cancel_pairing` and `notify_session_store_changed` on the pairing handle, +`activate_local_session` on the signing handle. Calling the wrong operation is +a compile error, not a runtime `Unavailable`. + +### The two roles + +Both implement the role-neutral **`ProductAuthority`** trait; each owns its +role-specific lifecycle, so no method exists on a role that can't mean it: + +- **`PairingHost`** (seedless): the user's keys live in an external wallet, so + signing/aliases/entropy relay over an encrypted SSO channel (statement store + on the People chain; the channel lives in `pairing_host/sso_channel.rs`). It + owns pairing/login state, persisted auth-session reload, and remote + signing-host liveness monitoring. +- **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, + no pairing flow. `signing_host/local_activation.rs` establishes a session + from host-held secret material. Extrinsic signing / transaction construction / + ring-VRF aliases / resource allocation currently return `Unavailable` pending + chain-metadata and on-chain support. + +`host_logic` stays pure: the orchestrators above call into it for codecs, +session/SSO crypto, key derivation, and permission policy, while all I/O +(statement-store RPC, storage, prompts, chain RPC) stays in the layers above. ## Wire envelope diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index e77afbddf..853fc1338 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -12,21 +12,23 @@ //! (`Unsupported`, `HostFailure`, ...). This avoids leaking json-rpc plumbing //! into the public API. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - use core::pin::Pin; use core::task::{Context, Poll}; use std::collections::HashMap; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; use futures::FutureExt; use futures::channel::mpsc; use futures::future::{AbortHandle, Abortable}; use futures::future::{BoxFuture, Shared}; use futures::stream::BoxStream; -use futures::{Stream, StreamExt}; +use futures::{Stream, StreamExt, pin_mut}; use parity_scale_codec::{Decode, Error as ScaleError, Input}; use primitive_types::H256; use serde::de::{Deserializer, Error as DeError}; @@ -215,10 +217,18 @@ impl ChainRuntime { let cleanup_runtime = self.clone(); let cleanup_genesis_hash = request.genesis_hash.clone(); let cleanup_follow_id = follow_subscription_id.clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let setup_cancelled = cancelled.clone(); + let cleanup_cancelled = cancelled.clone(); let fut = async move { if runtime - .start_follow(follow_subscription_id, request, Some(tx.clone())) + .start_follow( + follow_subscription_id, + request, + Some(tx.clone()), + setup_cancelled, + ) .await .is_err() { @@ -230,6 +240,7 @@ impl ChainRuntime { ManagedSubscription::new( rx.boxed(), Some(Box::new(move || { + cleanup_cancelled.store(true, Ordering::SeqCst); cleanup_runtime.cleanup_follow(&cleanup_genesis_hash, &cleanup_follow_id); })), ) @@ -540,14 +551,30 @@ impl ChainRuntime { local_follow_id: String, request: RemoteChainHeadFollowRequest, sender: Option>, + cancelled: Arc, ) -> Result<(), RuntimeFailure> { + if cancelled.load(Ordering::SeqCst) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let connection = self .connection_for(FOLLOW_METHOD, &request.genesis_hash) .await?; + if cancelled.load(Ordering::SeqCst) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } // Record this subscriber's sender before kicking off (or joining) the // single-flight setup so events route to it regardless of which caller // wins the setup. - connection.register_follow_intent(&local_follow_id, request.with_runtime, sender); + connection.register_follow_intent( + &local_follow_id, + request.with_runtime, + sender, + cancelled, + ); + if connection.follow_cancelled_or_missing(&local_follow_id) { + connection.unfollow(&local_follow_id); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } connection .ensure_remote_follow(local_follow_id, request.with_runtime) .await?; @@ -641,12 +668,14 @@ impl ChainConnection { local_follow_id: &str, with_runtime: bool, sender: Option>, + cancelled: Arc, ) { let mut follows = self.follows.lock().unwrap(); match follows.get_mut(local_follow_id) { Some(follow) => { if sender.is_some() { follow.sender = sender; + follow.cancelled = cancelled; } } None => { @@ -657,12 +686,20 @@ impl ChainConnection { remote_subscription_id: None, abort: None, sender, + cancelled, }, ); } } } + fn follow_cancelled_or_missing(&self, local_follow_id: &str) -> bool { + match self.follows.lock().unwrap().get(local_follow_id) { + Some(follow) => follow.cancelled.load(Ordering::SeqCst), + None => true, + } + } + /// Issue `chainHead_v1_follow` exactly once per local follow id and return /// the remote subscription id. Concurrent callers for the same id share /// one in-flight setup instead of each opening a duplicate remote @@ -673,6 +710,9 @@ impl ChainConnection { local_follow_id: String, with_runtime: bool, ) -> Result { + if self.follow_cancelled_or_missing(&local_follow_id) { + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } if let Some(remote_follow_id) = self.remote_follow_id(&local_follow_id) { return Ok(remote_follow_id); } @@ -750,17 +790,10 @@ impl ChainConnection { local_follow_id: String, with_runtime: bool, ) -> Result { - self.follows - .lock() - .unwrap() - .entry(local_follow_id.clone()) - .or_insert_with(|| FollowState { - with_runtime, - remote_subscription_id: None, - abort: None, - sender: None, - }); - + if self.follow_cancelled_or_missing(&local_follow_id) { + self.remove_follow(&local_follow_id); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let mut follow = self .methods .chainhead_v1_follow(with_runtime) @@ -775,6 +808,11 @@ impl ChainConnection { RuntimeFailure::host_failure(FOLLOW_METHOD, "missing follow subscription id") })? .to_string(); + if self.follow_cancelled_or_missing(&local_follow_id) { + self.remove_follow(&local_follow_id); + drop(follow); + return Err(RuntimeFailure::unavailable(FOLLOW_METHOD)); + } let (abort, abort_registration) = AbortHandle::new_pair(); let connection = self.clone(); @@ -893,6 +931,7 @@ struct FollowState { remote_subscription_id: Option, abort: Option, sender: Option>, + cancelled: Arc, } /// Subscription wrapper that runs an `on_drop` cleanup when the stream is @@ -1110,6 +1149,127 @@ pub(crate) fn encode_hex(value: &[u8]) -> String { format!("0x{}", hex::encode(value)) } +/// Wait for a usable best block hash from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_best_hash( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + initialization_timeout: Duration, + best_hash_timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(initialization_timeout).fuse(); + pin_mut!(timeout); + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::Initialized { finalized_block_hashes, .. }) => { + let fallback = finalized_block_hashes.last().cloned(); + return wait_for_chain_head_best_hash_after_initialization( + follow, + label, + fallback, + best_hash_timeout, + ) + .await; + } + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before initialization")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} follow initialization timed out")), + } + } +} + +async fn wait_for_chain_head_best_hash_after_initialization( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + label: &'static str, + fallback: Option>, + timeout: Duration, +) -> Result, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut candidate = fallback; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::BestBlockChanged { best_block_hash }) => { + return Ok(best_block_hash); + } + Some(RemoteChainHeadFollowItem::NewBlock { block_hash, .. }) => { + candidate = Some(block_hash); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped before best block")); + } + _ => {} + }, + () = timeout => { + return candidate.ok_or_else(|| { + format!("{label} follow best block timed out") + }); + }, + } + } +} + +/// Wait for one storage operation's value from a `chainHead_v1_follow` stream. +pub(crate) async fn wait_for_chain_head_storage_value( + follow: &mut BoxStream<'static, RemoteChainHeadFollowItem>, + operation_id: &str, + key: &[u8], + label: &'static str, + timeout: Duration, +) -> Result>, String> { + let timeout = futures_timer::Delay::new(timeout).fuse(); + pin_mut!(timeout); + let mut value = None; + loop { + let next = follow.next().fuse(); + pin_mut!(next); + futures::select! { + item = next => match item { + Some(RemoteChainHeadFollowItem::OperationStorageItems { operation_id: item_operation_id, items }) + if item_operation_id == operation_id => + { + for item in items { + if item.key == key { + value = item.value; + } + } + } + Some(RemoteChainHeadFollowItem::OperationStorageDone { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(value); + } + Some(RemoteChainHeadFollowItem::OperationInaccessible { operation_id: item_operation_id }) + if item_operation_id == operation_id => + { + return Ok(None); + } + Some(RemoteChainHeadFollowItem::OperationError { operation_id: item_operation_id, error }) + if item_operation_id == operation_id => + { + return Err(error); + } + Some(RemoteChainHeadFollowItem::Stop) | None => { + return Err(format!("{label} follow stopped during storage lookup")); + } + _ => {} + }, + () = timeout => return Err(format!("{label} storage lookup timed out")), + } + } +} + #[cfg(test)] fn decode_hex(value: &str) -> Result, String> { hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| "invalid hex".to_string()) @@ -1120,7 +1280,7 @@ mod tests { use super::*; use async_trait::async_trait; use futures::channel::mpsc as fut_mpsc; - use futures::stream::BoxStream; + use futures::stream::{self, BoxStream}; use std::sync::atomic::{AtomicUsize, Ordering}; fn spawner_for_tests() -> Spawner { @@ -1134,6 +1294,57 @@ mod tests { } } + #[test] + fn chain_head_best_hash_prefers_best_block_after_initialization() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::BestBlockChanged { + best_block_hash: vec![0x02], + }, + ]) + .boxed(); + + let hash = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect("best hash should resolve"); + + assert_eq!(hash, vec![0x02]); + } + + #[test] + fn chain_head_best_hash_errors_on_stop_before_best_block() { + let mut follow = stream::iter(vec![ + RemoteChainHeadFollowItem::Initialized { + finalized_block_hashes: vec![vec![0x01]], + finalized_block_runtime: None, + }, + RemoteChainHeadFollowItem::NewBlock { + block_hash: vec![0x03], + parent_block_hash: vec![0x01], + new_runtime: None, + }, + RemoteChainHeadFollowItem::Stop, + ]) + .boxed(); + + let err = futures::executor::block_on(wait_for_chain_head_best_hash( + &mut follow, + "test chain", + Duration::from_secs(10), + Duration::from_secs(2), + )) + .expect_err("follow stop should be terminal before best block"); + + assert_eq!(err, "test chain follow stopped before best block"); + } + #[derive(Default)] struct UnavailableChainProvider; diff --git a/rust/crates/truapi-server/src/core.rs b/rust/crates/truapi-server/src/core.rs new file mode 100644 index 000000000..a9fbd337d --- /dev/null +++ b/rust/crates/truapi-server/src/core.rs @@ -0,0 +1,369 @@ +//! Internal dispatcher/runtime core. +//! +//! Public host adapters should wrap this through [`crate::ProductRuntime`], which +//! owns the stable byte-frame ingress/egress and lifecycle API. + +use std::sync::{Arc, Mutex}; + +use parity_scale_codec::{Decode, Encode}; +use tracing::instrument; +use truapi::api::TrUApi; +use truapi_platform::{PairingHostConfig, Platform, ProductContext}; + +use crate::dispatcher::Dispatcher; +use crate::frame::ProtocolMessage; +use crate::generated::dispatcher; +use crate::host_logic::session::SessionState; +use crate::runtime::{PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices}; +use crate::subscription::Spawner; +use crate::transport::Transport; + +/// Top-level core. Owns the generated dispatcher. +pub struct TrUApiCore { + dispatcher: Dispatcher, + session_state: Arc, +} + +impl TrUApiCore { + /// Build a core around a direct `TrUApi` implementation. The session + /// state holder is unused on this path (no platform pushes updates), + /// but is created anyway so the public API surface stays consistent. + /// Subscription work runs on `spawner`. + #[instrument(skip_all, fields(runtime.method = "core.new"))] + pub fn new

(host: Arc

, spawner: Spawner) -> Self + where + P: TrUApi + 'static, + { + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, host); + Self { + dispatcher, + session_state: SessionState::new(), + } + } + + /// Build a product-facing core around a [`Platform`] implementation, + /// explicit host runtime config, and product context. + #[instrument(skip_all, fields(runtime.method = "core.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + host_config: PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new( + platform, + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), host_config); + pairing_host.clone().start_session_store_sync(spawner); + Self::from_runtime_parts(services, pairing_host, product) + } + + /// Build a product-facing core from shared services and authority. + #[instrument(skip_all, fields(runtime.method = "core.from_runtime_parts"))] + pub(crate) fn from_runtime_parts( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let runtime = Arc::new(ProductRuntimeHost::from_services( + services.clone(), + authority.clone(), + product, + )); + Self::from_product_runtime(runtime, services.spawner.clone(), authority.session_state()) + } + + /// Build a dispatcher core around an already-created product runtime. + #[instrument(skip_all, fields(runtime.method = "core.from_product_runtime"))] + pub(crate) fn from_product_runtime( + runtime: Arc, + spawner: Spawner, + session_state: Arc, + ) -> Self { + let mut dispatcher = Dispatcher::new(spawner); + dispatcher::register(&mut dispatcher, runtime); + Self { + dispatcher, + session_state, + } + } + + /// Handle to the shared session-state holder used by subscriptions and + /// tests. Real host lifecycle flows through CoreStorage session sync and + /// `disconnect`. + pub fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Decode an incoming product frame, run it through the dispatcher, and + /// return the SCALE-encoded response frame when the method has one. + /// Subscription starts should use [`Self::dispatch`] with a long-lived + /// transport; changing this byte-frame helper to reject them or return a + /// richer response shape is a separate API decision. + #[instrument(skip_all, fields(runtime.method = "core.receive_from_product"))] + pub async fn receive_from_product(&self, frame: &[u8]) -> Option> { + let message = ProtocolMessage::decode(&mut &*frame).ok()?; + let transport = Arc::new(ResponseTransport::default()); + self.dispatcher + .dispatch(message, transport.clone() as Arc) + .await; + transport.take().map(|response| response.encode()) + } + + /// Dispatch an already-decoded protocol message through the underlying + /// dispatcher. Bridges that own a long-lived transport (e.g. WebSocket, + /// JS callback) call this directly so subscription items flow back + /// through the bridge transport instead of the single-slot capture used + /// by [`Self::receive_from_product`]. + #[instrument(skip_all, fields(runtime.method = "core.dispatch"))] + pub async fn dispatch(&self, message: ProtocolMessage, transport: Arc) { + self.dispatcher.dispatch(message, transport).await; + } + + /// Cancel all active and pending subscriptions owned by this core. + pub fn cancel_subscriptions(&self) { + self.dispatcher.cancel_subscriptions(); + } +} + +/// Single-slot transport that captures the next response the dispatcher +/// emits. Used by [`TrUApiCore::receive_from_product`] to bridge between the +/// dispatcher's push model and the one-response frame API exposed to embedders. +#[derive(Default)] +struct ResponseTransport { + response: Mutex>, +} + +impl ResponseTransport { + fn take(&self) -> Option { + self.response.lock().unwrap().take() + } +} + +impl Transport for ResponseTransport { + fn send(&self, message: ProtocolMessage) { + *self.response.lock().unwrap() = Some(message); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use parity_scale_codec::Encode; + use truapi::v01; + use truapi::versioned::local_storage::{ + HostLocalStorageClearRequest, HostLocalStorageReadRequest, HostLocalStorageWriteRequest, + }; + use truapi::versioned::notifications::HostPushNotificationRequest; + use truapi::versioned::permissions::RemotePermissionRequest; + use truapi::versioned::system::HostFeatureSupportedRequest; + + use crate::frame::{Payload, request_ids, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + + #[test] + fn from_platform_dispatches_feature_supported() { + let (host_config, product) = runtime_config("dotli.dot"); + let core = TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + ); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let encoded = frame.encode(); + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + // Wire payload is `Result`-shaped: + // [Ok disc=0x00][V1 variant 0x00][supported=1] + assert_eq!(response.payload.value, vec![0x00, 0x00, 0x01]); + } + + /// Drive a request frame through `TrUApiCore::receive_from_product`, + /// decode the response envelope, and return its payload bytes (without + /// the wrapping ProtocolMessage). Shared by the runtime-delegation + /// tests below. + fn run_request(core: &TrUApiCore, method: &str, request_bytes: Vec) -> Vec { + let ids = request_ids(method).expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request_bytes, + }, + }; + let response_bytes = + futures::executor::block_on(core.receive_from_product(&frame.encode())) + .expect("dispatcher should emit a response"); + let response = ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response"); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + response.payload.value + } + + fn make_core() -> TrUApiCore { + let (host_config, product) = runtime_config("dotli.dot"); + TrUApiCore::from_platform_with_config( + Arc::new(StubPlatform::default()), + host_config, + product, + test_spawner(), + ) + } + + #[test] + fn local_storage_read_round_trips_none() { + let core = make_core(); + let request = HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { + key: "missing".into(), + }); + let payload = run_request(&core, "local_storage_read", request.encode()); + // Ok disc 0x00, V1 variant 0x00, Option::None = 0x00. + assert_eq!(payload, vec![0x00, 0x00, 0x00]); + } + + #[test] + fn local_storage_write_round_trips_unit_ok() { + let core = make_core(); + let request = HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { + key: "k".into(), + value: vec![1, 2, 3], + }); + let payload = run_request(&core, "local_storage_write", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn local_storage_clear_round_trips_unit_ok() { + let core = make_core(); + let request = + HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key: "k".into() }); + let payload = run_request(&core, "local_storage_clear", request.encode()); + // Ok disc 0x00, V1 variant 0x00. + assert_eq!(payload, vec![0x00, 0x00]); + } + + #[test] + fn send_push_notification_delegates_to_platform() { + let core = make_core(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "hi".into(), + deeplink: None, + scheduled_at: None, + }); + let payload = run_request( + &core, + "notifications_send_push_notification", + request.encode(), + ); + // Ok disc 0x00, V1 variant 0x00, notification id 0. + let mut expected = vec![0x00u8]; + truapi::versioned::notifications::HostPushNotificationResponse::V1( + v01::HostPushNotificationResponse { id: 0 }, + ) + .encode_to(&mut expected); + assert_eq!(payload, expected); + } + + #[test] + fn request_remote_permission_round_trips_granted() { + let core = make_core(); + let request = RemotePermissionRequest::V1(v01::RemotePermissionRequest { + permission: v01::RemotePermission::ChainSubmit, + }); + let payload = run_request( + &core, + "permissions_request_remote_permission", + request.encode(), + ); + // Stub permissions grants every request. Wire is Ok disc 0x00, V1 + // variant 0x00, granted=1. + assert_eq!(payload, vec![0x00, 0x00, 0x01]); + } + + /// `connection_status_subscribe` produces a stream whose first item is + /// the current session state. Drive it through the dispatcher with a + /// recording transport and assert exactly one `_receive` frame appears. + #[test] + fn connection_status_subscribe_yields_initial_disconnected() { + use std::sync::Mutex; + + #[derive(Default)] + struct RecordingTransport { + sent: Mutex>, + } + impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } + } + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let sub_ids = + subscription_ids("account_connection_status_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: sub_ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(frame, dyn_transport)); + + // Wait briefly for the spawned thread to emit the initial item. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if !transport.sent.lock().unwrap().is_empty() { + break; + } + if std::time::Instant::now() > deadline { + panic!("subscription did not yield an item in time"); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + let sent = transport.sent.lock().unwrap().clone(); + assert!(!sent.is_empty(), "expected at least one _receive frame"); + let first = &sent[0]; + assert_eq!(first.payload.id, sub_ids.receive_id); + // V1(Disconnected): V1 variant 0x00, Disconnected discriminant 0x00. + assert_eq!(first.payload.value, vec![0x00, 0x00]); + } +} diff --git a/rust/crates/truapi-server/src/dispatcher.rs b/rust/crates/truapi-server/src/dispatcher.rs index da27c8c0a..e0c3db605 100644 --- a/rust/crates/truapi-server/src/dispatcher.rs +++ b/rust/crates/truapi-server/src/dispatcher.rs @@ -162,6 +162,11 @@ impl Dispatcher { // Unknown discriminant: drop. Response / receive / interrupt frames are // handled by the client side and never registered here. } + + /// Cancel every subscription currently owned by this dispatcher. + pub fn cancel_subscriptions(&self) { + self.subscriptions.cancel_all(); + } } #[cfg(test)] diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs new file mode 100644 index 000000000..8478467cb --- /dev/null +++ b/rust/crates/truapi-server/src/host_core.rs @@ -0,0 +1,589 @@ +//! Stable host-embedding API for the TrUAPI server runtime. +//! +//! `ProductRuntime` is the target-neutral boundary embedders should use. +//! Platform adapters provide: +//! - a [`truapi_platform::Platform`] implementation for host callbacks, +//! - a task [`Spawner`] for runtime-owned async work, +//! - a [`FrameSink`] for outgoing protocol frames. +//! +//! Target-specific shells such as wasm-bindgen, iOS FFI, or desktop IPC should +//! keep their conversion code outside this module. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::future::{AbortHandle, Abortable}; +use parity_scale_codec::{Decode, Encode}; +use thiserror::Error; +use tracing::instrument; +use truapi::v01; +use truapi_platform::{ + CoreAdmin, PairingHostAdmin, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, +}; + +use crate::core::TrUApiCore; +use crate::frame::ProtocolMessage; +use crate::runtime::{ + LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, RuntimeServices, + SigningHostRole, +}; +use crate::subscription::Spawner; +use crate::transport::Transport; + +/// Outgoing frame sink owned by a host adapter. +/// +/// Implementations bridge encoded TrUAPI protocol frames to their target +/// transport: JS callbacks, native callbacks, IPC, channels, or another +/// host-specific mechanism. +pub trait FrameSink: Send + Sync { + /// Emit one SCALE-encoded [`ProtocolMessage`] frame. + fn emit_frame(&self, frame: Vec); +} + +/// Errors returned by [`ProductRuntime::receive_frame`]. +#[derive(Debug, Error)] +pub enum ProductRuntimeError { + /// Incoming bytes did not decode as a protocol frame. + #[error("invalid frame: {reason}")] + InvalidFrame { + /// Decode failure reason. + reason: String, + }, +} + +fn product_context(product_id: &str) -> Result { + ProductContext::new(product_id.to_string()).map_err(|err| v01::GenericError { + reason: err.to_string(), + }) +} + +/// A seedless pairing host: the user's keys live in an external wallet reached +/// over the SSO pairing channel. +/// +/// Owns the shared services plus pairing-host state. Local-session activation +/// is a signing-host operation and is not present here. +pub struct PairingHostRuntime { + services: Arc, + pairing_host: Arc, +} + +impl PairingHostRuntime { + /// Build a long-lived pairing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: PairingHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHostRole::new(services.clone(), config); + pairing_host.clone().start_session_store_sync(spawner); + Self { + services, + pairing_host, + } + } + + /// Build a product-facing runtime from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.pairing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this pairing host. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.pairing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.pairing_host.disconnect().await; + } + + /// Cancel an in-flight SSO pairing request. A no-op when no pairing is + /// active. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.cancel_pairing"))] + pub fn cancel_pairing(&self) { + self.pairing_host.cancel_login(); + } + + /// Notify the pairing runtime that the persisted auth-session blob may + /// have changed and should be re-read. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.notify_session_store_changed"))] + pub fn notify_session_store_changed(&self) { + self.pairing_host.notify_session_store_changed(); + } + + /// Read a stored permission authorization status for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_status", product_id = %product_id))] + pub async fn permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_admin(product_context(product_id)?) + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses for a product without prompting. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.permission_authorization_statuses", product_id = %product_id))] + pub async fn permission_authorization_statuses( + &self, + product_id: &str, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_admin(product_context(product_id)?) + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status for a product. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.set_permission_authorization_status", product_id = %product_id))] + pub async fn set_permission_authorization_status( + &self, + product_id: &str, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_admin(product_context(product_id)?) + .set_permission_authorization_status(request, status) + .await + } +} + +impl PairingHostAdmin for PairingHostRuntime { + fn cancel_pairing(&self) { + PairingHostRuntime::cancel_pairing(self); + } + + fn notify_session_store_changed(&self) { + PairingHostRuntime::notify_session_store_changed(self); + } +} + +/// A wallet-local signing host: the user's keys are held on this device. +/// +/// Owns the shared services plus signing-host state. There is no pairing flow, +/// so pairing cancellation is not present here. +/// +/// Raw-bytes signing and product entropy are implemented; extrinsic-payload +/// signing, transaction construction, ring-VRF aliases, and resource allocation +/// return an `Unavailable` error pending chain-metadata and on-chain support. +pub struct SigningHostRuntime { + services: Arc, + signing_host: Arc, +} + +impl SigningHostRuntime { + /// Build a long-lived signing-host runtime around a platform implementation. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.new"))] + pub fn new

(platform: Arc

, config: SigningHostConfig, spawner: Spawner) -> Self + where + P: Platform + 'static, + { + let platform: Arc = platform; + let services = + RuntimeServices::new(platform.clone(), config.people_chain_genesis_hash, spawner); + let signing_host = SigningHostRole::new(platform); + Self { + services, + signing_host, + } + } + + /// Build a product-facing runtime from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_runtime"))] + pub fn product_runtime( + &self, + product: ProductContext, + sink: Arc, + ) -> ProductRuntime { + ProductRuntime::new( + self.services.clone(), + self.signing_host.clone(), + product, + sink, + ) + } + + /// Build a product-scoped administration handle from this signing host. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.product_admin"))] + pub fn product_admin(&self, product: ProductContext) -> HostAdmin { + HostAdmin::new(self.services.clone(), self.signing_host.clone(), product) + } + + /// Disconnect the active account-authority session. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.signing_host.disconnect().await; + } + + /// Activate a wallet-local session from host-held secret material (raw + /// BIP-39 entropy). + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.activate_local_session"))] + pub async fn activate_local_session(&self, secret: Vec) -> Result<(), v01::GenericError> { + self.signing_host + .activate_local_session(secret) + .await + .map_err(|err| v01::GenericError { + reason: err.reason(), + }) + } +} + +/// Product-scoped administration handle for host UI. +/// +/// Host UI should use this when it needs to inspect or update core-owned state +/// without owning a product frame endpoint. +pub struct HostAdmin { + authority: Arc, + product_runtime: Arc, +} + +impl HostAdmin { + /// Build an admin handle from a long-lived host runtime. + #[instrument(skip_all, fields(runtime.method = "host_admin.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let product_runtime = Arc::new(ProductRuntimeHost::from_services( + services, + authority.clone(), + product, + )); + Self { + authority, + product_runtime, + } + } + + /// Core-owned logout/disconnect. + #[instrument(skip_all, fields(runtime.method = "host_admin.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.authority.disconnect().await; + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.product_runtime + .permission_authorization_status(request) + .await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "host_admin.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.product_runtime + .permission_authorization_statuses(requests) + .await + } + + /// Update a stored permission authorization status. + #[instrument(skip_all, fields(runtime.method = "host_admin.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.product_runtime + .set_permission_authorization_status(request, status) + .await + } +} + +#[truapi_platform::async_trait] +impl CoreAdmin for HostAdmin { + async fn disconnect_session(&self) -> Result<(), v01::GenericError> { + HostAdmin::disconnect_session(self).await; + Ok(()) + } + + async fn get_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.permission_authorization_status(request).await + } + + async fn get_permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.permission_authorization_statuses(requests).await + } + + async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + HostAdmin::set_permission_authorization_status(self, request, status).await + } +} + +/// Target-neutral host runtime wrapper. +/// +/// `ProductRuntime` is product-scoped. It owns the dispatcher core for one product +/// connection and handles byte-frame ingress, response/subscription egress, and +/// in-flight dispatch cancellation on dispose. +pub struct ProductRuntime { + core: TrUApiCore, + admin: HostAdmin, + transport: Arc, + disposed: Arc, + in_flight: Mutex>, + next_dispatch_id: AtomicU64, +} + +impl ProductRuntime { + /// Build a product-facing host core around a platform implementation and + /// outgoing frame sink. + #[instrument(skip_all, fields(runtime.method = "product_runtime.from_platform_with_config"))] + pub fn from_platform_with_config

( + platform: Arc

, + host_config: PairingHostConfig, + product: ProductContext, + spawner: Spawner, + sink: Arc, + ) -> Self + where + P: Platform + 'static, + { + let pairing = PairingHostRuntime::new(platform, host_config, spawner); + pairing.product_runtime(product, sink) + } + + /// Build a product-facing runtime from shared services and an authority. + #[instrument(skip_all, fields(runtime.method = "product_runtime.new"))] + pub(crate) fn new( + services: Arc, + authority: Arc, + product: ProductContext, + sink: Arc, + ) -> Self { + let disposed = Arc::new(AtomicBool::new(false)); + let transport = Arc::new(SinkTransport { + sink, + disposed: disposed.clone(), + }); + let admin = HostAdmin::new(services.clone(), authority.clone(), product); + Self { + core: TrUApiCore::from_product_runtime( + admin.product_runtime.clone(), + services.spawner.clone(), + authority.session_state(), + ), + admin, + transport, + disposed, + in_flight: Mutex::new(HashMap::new()), + next_dispatch_id: AtomicU64::new(0), + } + } + + /// Push one SCALE-encoded protocol frame into the dispatcher. + /// + /// Calls after [`Self::dispose`] are ignored and return `Ok(())` without + /// decoding. If dispose happens while a dispatch is in flight, the dispatch + /// is aborted and this method still returns `Ok(())`. + #[instrument(skip_all, fields(runtime.method = "product_runtime.receive_frame"))] + pub async fn receive_frame(&self, frame: Vec) -> Result<(), ProductRuntimeError> { + if self.disposed.load(Ordering::Acquire) { + return Ok(()); + } + + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { + ProductRuntimeError::InvalidFrame { + reason: err.to_string(), + } + })?; + let dispatch_id = self.next_dispatch_id.fetch_add(1, Ordering::Relaxed); + let (abort_handle, abort_registration) = AbortHandle::new_pair(); + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .insert(dispatch_id, abort_handle); + + let transport: Arc = self.transport.clone(); + let _ = Abortable::new(self.core.dispatch(message, transport), abort_registration).await; + + self.in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .remove(&dispatch_id); + if self.disposed.load(Ordering::Acquire) { + self.core.cancel_subscriptions(); + } + Ok(()) + } + + /// Core-owned logout/disconnect. Best-effort notifies the SSO peer when + /// the session has channel material, then clears in-memory and persisted + /// session state. + #[instrument(skip_all, fields(runtime.method = "product_runtime.disconnect_session"))] + pub async fn disconnect_session(&self) { + self.admin.disconnect_session().await; + } + + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_status"))] + pub async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + self.admin.permission_authorization_status(request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "product_runtime.permission_authorization_statuses"))] + pub async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + self.admin.permission_authorization_statuses(requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "product_runtime.set_permission_authorization_status"))] + pub async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + self.admin + .set_permission_authorization_status(request, status) + .await + } + + /// Dispose this host core. Idempotent. + /// + /// Disposal suppresses future outgoing frames, aborts in-flight dispatch + /// futures, and cancels active subscriptions. + #[instrument(skip_all, fields(runtime.method = "product_runtime.dispose"))] + pub fn dispose(&self) { + if self.disposed.swap(true, Ordering::AcqRel) { + return; + } + for (_, handle) in self + .in_flight + .lock() + .expect("host core in-flight dispatch mutex poisoned") + .drain() + { + handle.abort(); + } + self.core.cancel_subscriptions(); + } +} + +struct SinkTransport { + sink: Arc, + disposed: Arc, +} + +impl Transport for SinkTransport { + fn send(&self, message: ProtocolMessage) { + if self.disposed.load(Ordering::Acquire) { + return; + } + self.sink.emit_frame(message.encode()); + } + + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frame::{Payload, ProtocolMessage, subscription_ids}; + use crate::test_support::{StubPlatform, runtime_config, test_spawner}; + use parity_scale_codec::Encode; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct RecordingSink { + frames: Mutex>>, + } + + impl FrameSink for RecordingSink { + fn emit_frame(&self, frame: Vec) { + self.frames + .lock() + .expect("recording sink mutex poisoned") + .push(frame); + } + } + + #[test] + fn dispose_cancels_active_subscriptions() { + let theme_stream_dropped = Arc::new(AtomicBool::new(false)); + let platform = Arc::new(StubPlatform { + theme_stream_pending: true, + theme_stream_dropped: theme_stream_dropped.clone(), + ..Default::default() + }); + let sink = Arc::new(RecordingSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink, + ); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(runtime.receive_frame(frame.encode())).unwrap(); + + runtime.dispose(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !theme_stream_dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "dispose did not drop the active theme subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } +} diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index 183688fa0..0ed7572ff 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -4,6 +4,7 @@ //! storage, URL handler, notification center). Everything else lives here so //! iOS, Android, and web hosts share one canonical implementation. +pub mod allowance_signer; pub mod dotns; pub mod entropy; pub mod features; diff --git a/rust/crates/truapi-server/src/host_logic/allowance_signer.rs b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs new file mode 100644 index 000000000..9e414beac --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/allowance_signer.rs @@ -0,0 +1,92 @@ +//! Bulletin allowance signing helpers shared by platform backends. + +use schnorrkel::SecretKey; +use truapi_platform::{BulletinAllowanceKey, BulletinAllowanceSignError, BulletinAllowanceSigner}; + +use crate::host_logic::product_account::SR25519_SIGNING_CONTEXT; + +/// Build a host-facing Bulletin signer from cached allowance key material. +pub(crate) fn bulletin_allowance_signer_from_key( + key: BulletinAllowanceKey, +) -> Result { + let secret = key.into_secret_bytes(); + let public_key = public_key_from_allowance_secret(secret)?; + // The host receives only the allowance public key plus this Rust-backed + // signing capability while constructing the `TransactionStorage.store` + // extrinsic; the allowance secret stays in Rust. + Ok(BulletinAllowanceSigner::new(public_key, move |payload| { + let secret = secret_key_from_allowance_secret(secret) + .map_err(|reason| BulletinAllowanceSignError { reason })?; + let public = secret.to_public(); + Ok(secret + .sign_simple(SR25519_SIGNING_CONTEXT, payload, &public) + .to_bytes()) + })) +} + +/// Derive the public key for a mobile slot-account allowance secret. +pub(crate) fn public_key_from_allowance_secret(secret: [u8; 64]) -> Result<[u8; 32], String> { + Ok(secret_key_from_allowance_secret(secret)? + .to_public() + .to_bytes()) +} + +fn secret_key_from_allowance_secret(secret: [u8; 64]) -> Result { + // Mobile allowance keys are `SlotAccountKey` values (`privateKey || nonce`) + // and must use schnorrkel's canonical `SecretKey::from_bytes` path. Older + // JS-derived keys used ed25519-expanded bytes, so keep the fallback for + // compatibility with persisted allocations. + match SecretKey::from_bytes(&secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(&secret).map_err(|ed_error| { + format!( + "invalid bulletin allowance key: canonical={canonical_error}; ed25519={ed_error}" + ) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use schnorrkel::{PublicKey, Signature}; + + fn slot_secret_fixture() -> [u8; 64] { + hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + .try_into() + .unwrap() + } + + #[test] + fn derives_mobile_slot_account_public_key() { + let public_key = public_key_from_allowance_secret(slot_secret_fixture()).unwrap(); + + assert_eq!( + hex::encode(public_key), + "10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d" + ); + } + + #[test] + fn signs_with_mobile_slot_account_secret() { + let secret = slot_secret_fixture(); + let signer = bulletin_allowance_signer_from_key( + BulletinAllowanceKey::from_secret_bytes(secret.to_vec()).unwrap(), + ) + .unwrap(); + let payload = b"hello-slot"; + let signature = signer.sign(payload).unwrap(); + let public_key = PublicKey::from_bytes(&signer.public_key()).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + + assert!( + public_key + .verify_simple(SR25519_SIGNING_CONTEXT, payload, &signature) + .is_ok() + ); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/dotns.rs b/rust/crates/truapi-server/src/host_logic/dotns.rs index 67f933518..ab29a4d39 100644 --- a/rust/crates/truapi-server/src/host_logic/dotns.rs +++ b/rust/crates/truapi-server/src/host_logic/dotns.rs @@ -43,8 +43,8 @@ pub enum NavigateDecision { impl NavigateDecision { /// Canonical URL string for the three `Open*` variants; `None` for /// `Reject`. `DotName` and `Localhost` keep the dotns/localhost identity - /// visible so env-aware hosts (e.g. dotli rewriting `.dot` to `.dot.li`) - /// can re-parse and do their own assembly without losing information. + /// visible so env-aware hosts can rewrite `.dot` names for their active + /// environment and re-parse without losing information. pub fn canonical_url(&self) -> Option { match self { Self::DotName { identifier, path } => Some(join_url("https://", identifier, path)), @@ -63,7 +63,7 @@ fn join_url(scheme: &str, host: &str, path: &str) -> String { } } -/// Classify a URL the way dotli's `handleNavigateTo` does: try `.dot` first, +/// Classify a URL the way the host navigation handler does: try `.dot` first, /// then `localhost`, then normalize as external. pub fn parse_navigate(input: &str) -> NavigateDecision { let trimmed = input.trim(); diff --git a/rust/crates/truapi-server/src/host_logic/entropy.rs b/rust/crates/truapi-server/src/host_logic/entropy.rs index cbdd3fe20..1c9323d9b 100644 --- a/rust/crates/truapi-server/src/host_logic/entropy.rs +++ b/rust/crates/truapi-server/src/host_logic/entropy.rs @@ -1,6 +1,6 @@ //! Product-scoped deterministic entropy derivation. //! -//! Matches dotli's product entropy contract: three keyed BLAKE2b-256 layers +//! Matches the host product entropy contract: three keyed BLAKE2b-256 layers //! over the session secret, product id, and caller key. //! Host-spec C.8 defines the RFC-0007 product entropy algorithm: //! diff --git a/rust/crates/truapi-server/src/host_logic/identity.rs b/rust/crates/truapi-server/src/host_logic/identity.rs index 4f798825f..f175edb50 100644 --- a/rust/crates/truapi-server/src/host_logic/identity.rs +++ b/rust/crates/truapi-server/src/host_logic/identity.rs @@ -1,6 +1,6 @@ //! People-chain identity lookup for paired SSO sessions. //! -//! dotli's previous host-papp path read `Resources.Consumers[account]` from +//! The previous host-papp path read `Resources.Consumers[account]` from //! the People chain and used only the username fields. Keep this module narrow: //! it builds that storage key and decodes the leading username fields from the //! SCALE value. The record begins with a fixed identifier public key; credibility diff --git a/rust/crates/truapi-server/src/host_logic/permissions.rs b/rust/crates/truapi-server/src/host_logic/permissions.rs index b1189689c..d44ec1daa 100644 --- a/rust/crates/truapi-server/src/host_logic/permissions.rs +++ b/rust/crates/truapi-server/src/host_logic/permissions.rs @@ -7,6 +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. use parity_scale_codec::{Decode, Encode}; @@ -104,6 +107,13 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a self.peek_device(permission).await } PermissionAuthorizationRequest::Remote(request) => self.peek_remote(request).await, + PermissionAuthorizationRequest::IdentityDisclosure => { + authorization_status( + self.storage, + identity_disclosure_core_storage_key(self.product_id), + ) + .await + } } } @@ -136,6 +146,9 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a PermissionAuthorizationRequest::Remote(request) => { remote_core_storage_key(self.product_id, request) } + PermissionAuthorizationRequest::IdentityDisclosure => { + identity_disclosure_core_storage_key(self.product_id) + } }; set_authorization_status(self.storage, key, status).await } @@ -150,13 +163,12 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } - // Only a genuine user authorization is persisted. A prompt-callback error is - // transient (UI unavailable, IPC timeout), not a denial, so fail closed - // for this call but do not cache it — the next request re-prompts rather - // than locking the capability out permanently with no revoke path. + // Only a genuine user authorization is persisted. A prompt-callback + // error is transient (dismissed UI, unavailable UI, IPC timeout), not + // a denial, so leave the authorization ask/default. let authorization = match self.prompt.device_permission(permission).await { Ok(HostDevicePermissionResponse { granted }) => granted.into(), - Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), }; self.persist_decision(key, authorization).await } @@ -171,11 +183,11 @@ impl<'a, S: CoreStorage + ?Sized, P: Permissions + ?Sized> PermissionsService<'a if let Some(cached) = peek_stored(self.storage, key.clone()).await? { return Ok(cached.into()); } - // See `check_or_prompt_device`: persist only a genuine user decision; a - // transient callback error fails closed for this call without caching. + // See `check_or_prompt_device`: persist only a genuine user decision; + // transient callback errors leave the authorization ask/default. let authorization = match self.prompt.remote_permission(request).await { Ok(RemotePermissionResponse { granted }) => granted.into(), - Err(_) => return Ok(PermissionAuthorizationStatus::Denied), + Err(_) => return Ok(PermissionAuthorizationStatus::NotDetermined), }; self.persist_decision(key, authorization).await } @@ -249,6 +261,13 @@ fn remote_core_storage_key(product_id: &str, request: &RemotePermissionRequest) } } +fn identity_disclosure_core_storage_key(product_id: &str) -> CoreStorageKey { + CoreStorageKey::PermissionAuthorization { + product_id: product_id.to_string(), + request: PermissionAuthorizationRequest::IdentityDisclosure, + } +} + fn canonical_remote_request(request: &RemotePermissionRequest) -> RemotePermissionRequest { let permission = match &request.permission { RemotePermission::Remote { domains } => { @@ -366,9 +385,14 @@ mod tests { permission: RemotePermission::ChainSubmit, }, ); + let identity = identity_disclosure_core_storage_key("product.dot"); + let other_product_identity = identity_disclosure_core_storage_key("other.dot"); assert_ne!(camera, other_product); assert_ne!(camera, remote); + assert_ne!(camera, identity); + assert_ne!(remote, identity); + assert_ne!(identity, other_product_identity); } #[test] @@ -585,6 +609,35 @@ mod tests { ); } + #[test] + fn identity_disclosure_authorization_round_trips() { + let storage = MemStorage::default(); + let prompt = ScriptedPrompt::new(vec![], vec![]); + let service = PermissionsService::new(&storage, &prompt, "product.dot"); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + + futures::executor::block_on( + service.set_authorization_status(&request, PermissionAuthorizationStatus::Authorized), + ) + .unwrap(); + assert_eq!( + futures::executor::block_on(service.authorization_status(&request)).unwrap(), + PermissionAuthorizationStatus::Authorized + ); + + let other_product_service = PermissionsService::new(&storage, &prompt, "other.dot"); + assert_eq!( + futures::executor::block_on(other_product_service.authorization_status(&request)) + .unwrap(), + PermissionAuthorizationStatus::NotDetermined + ); + } + /// Prompt callback that always errors, to exercise the transient-failure /// path (fail closed for the current call, but do not persist the error). struct FailingPrompt; @@ -611,25 +664,46 @@ mod tests { } #[test] - fn prompt_failure_denies_without_persisting() { + fn prompt_failure_stays_not_determined_without_persisting() { let storage = MemStorage::default(); let prompt = FailingPrompt; let service = PermissionsService::new(&storage, &prompt, "product.dot"); - let decision = futures::executor::block_on( + let device_decision = futures::executor::block_on( service.check_or_prompt_device(HostDevicePermissionRequest::Camera), ) .unwrap(); - assert_eq!(decision, PermissionAuthorizationStatus::Denied); + assert_eq!( + device_decision, + PermissionAuthorizationStatus::NotDetermined + ); - // A transient callback error is fail-closed for this call but NOT - // cached, so peek still sees no authorization and the next request - // re-prompts rather than permanently locking out the capability. - let cached = + let remote_request = RemotePermissionRequest { + permission: RemotePermission::ChainSubmit, + }; + let remote_decision = + futures::executor::block_on(service.check_or_prompt_remote(remote_request.clone())) + .unwrap(); + assert_eq!( + remote_decision, + PermissionAuthorizationStatus::NotDetermined + ); + + // A transient callback error is not cached, so peek still sees no + // authorization and the next request re-prompts rather than + // permanently locking out the capability. + let cached_device = futures::executor::block_on(service.peek_device(&HostDevicePermissionRequest::Camera)) .unwrap(); assert_eq!( - cached, + cached_device, + PermissionAuthorizationStatus::NotDetermined, + "a transient prompt error must not be persisted" + ); + let cached_remote = + futures::executor::block_on(service.peek_remote(&remote_request)).unwrap(); + assert_eq!( + cached_remote, PermissionAuthorizationStatus::NotDetermined, "a transient prompt error must not be persisted" ); diff --git a/rust/crates/truapi-server/src/host_logic/product_account.rs b/rust/crates/truapi-server/src/host_logic/product_account.rs index 04298daad..cfc329aad 100644 --- a/rust/crates/truapi-server/src/host_logic/product_account.rs +++ b/rust/crates/truapi-server/src/host_logic/product_account.rs @@ -1,6 +1,6 @@ //! Product account derivation shared by all hosts. //! -//! Mirrors dotli's `packages/auth/src/account.ts`: derive an sr25519 public +//! Mirrors host product-account derivation: derive an sr25519 public //! key through soft HDKD junctions `["product", product_id, derivation_index]`. //! Host-spec C.5-C.7 define the product-account derivation, SS58 address, and //! `ProductAccountId` shape: diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index 3eb51ff88..b19f47046 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -38,6 +38,32 @@ pub struct SessionInfo { pub full_username: Option, } +impl SessionInfo { + /// Whether the session already carries a usable username. + pub(crate) fn has_username(&self) -> bool { + non_empty_username(&self.full_username) || non_empty_username(&self.lite_username) + } + + /// Apply resolved username fields without replacing populated values with + /// empty strings. + pub(crate) fn apply_usernames( + &mut self, + lite_username: Option, + full_username: Option, + ) { + if non_empty_username(&full_username) { + self.full_username = full_username; + } + if non_empty_username(&lite_username) { + self.lite_username = lite_username; + } + } +} + +fn non_empty_username(value: &Option) -> bool { + value.as_ref().is_some_and(|value| !value.is_empty()) +} + /// SSO session material negotiated by the pairing host with the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct SsoSessionInfo { @@ -63,37 +89,21 @@ pub struct SsoSessionInfo { pub peer_request_channel: [u8; 32], } -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -enum PersistedSessionBlob { - #[codec(index = 3)] - V3(SessionInfo), -} - /// Encode the active-session fields the core currently understands into an -/// opaque host-global session blob. Later SSO channel state should bump -/// the enum variant instead of extending this layout silently. +/// opaque host-global session blob. pub fn encode_persisted_session(info: &SessionInfo) -> Vec { - PersistedSessionBlob::V3(info.clone()).encode() + info.encode() } /// Decode a core-owned persisted session blob. pub fn decode_persisted_session(blob: &[u8]) -> Result { - let Some(version) = blob.first() else { - return Err("invalid session blob: missing version".to_string()); - }; - if *version != 3 { - return Err(format!("unsupported session blob version {version}")); - } - let mut input = blob; - let decoded = PersistedSessionBlob::decode(&mut input) - .map_err(|err| format!("invalid session blob: {err}"))?; + let decoded = + SessionInfo::decode(&mut input).map_err(|err| format!("invalid session blob: {err}"))?; if !input.is_empty() { return Err("invalid session blob: trailing bytes".to_string()); } - match decoded { - PersistedSessionBlob::V3(info) => Ok(info), - } + Ok(decoded) } /// Holds the currently-active session and broadcasts connection-status @@ -197,6 +207,24 @@ mod tests { } } + #[test] + fn session_username_helpers_check_and_apply_non_empty_values() { + let mut session = info(0x42); + session.lite_username = None; + session.full_username = None; + + assert!(!session.has_username()); + + session.apply_usernames(Some(String::new()), Some("Alice Smith".to_string())); + assert!(session.has_username()); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username, None); + + session.apply_usernames(Some("alice".to_string()), Some(String::new())); + assert_eq!(session.full_username.as_deref(), Some("Alice Smith")); + assert_eq!(session.lite_username.as_deref(), Some("alice")); + } + #[test] fn current_starts_empty() { let state = SessionState::new(); @@ -246,25 +274,6 @@ mod tests { assert_eq!(decoded, session); } - #[test] - fn persisted_session_rejects_unknown_version() { - let mut blob = encode_persisted_session(&info(0x42)); - blob[0] = 0xff; - - let err = decode_persisted_session(&blob).unwrap_err(); - - assert_eq!(err, "unsupported session blob version 255"); - } - - #[test] - fn persisted_session_rejects_legacy_v2() { - let blob = vec![2]; - - let err = decode_persisted_session(&blob).unwrap_err(); - - assert_eq!(err, "unsupported session blob version 2"); - } - #[test] fn persisted_session_rejects_trailing_bytes() { let mut blob = encode_persisted_session(&info(0x42)); 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 f801abaed..5af8e3b89 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -112,8 +112,8 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -impl From for SigningPayloadRequest { - fn from(value: truapi::v01::HostSignPayloadRequest) -> Self { +impl SigningPayloadRequest { + fn from_host_request(value: HostSignPayloadRequest) -> Self { let payload = value.payload; Self { product_account_id: value.account, @@ -148,8 +148,8 @@ pub struct SigningRawRequest { pub data: SigningRawPayload, } -impl From for SigningRawRequest { - fn from(value: truapi::v01::HostSignRawRequest) -> Self { +impl SigningRawRequest { + fn from_host_request(value: HostSignRawRequest) -> Self { Self { product_account_id: value.account, data: value.payload.into(), @@ -236,8 +236,9 @@ pub struct RingVrfAliasResponse { /// Request sent when a product asks the signing host to allocate SSO-backed /// resources. /// -/// Used by `ResourceAllocation::request` for capabilities such as statement -/// store allowance and auto-signing material. +/// Used by `ResourceAllocation::request` for capabilities from +/// `docs/rfcs/0010-allowance.md`, such as statement-store allowance and +/// auto-signing material. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct ResourceAllocationRequest { pub calling_product_id: String, @@ -249,7 +250,7 @@ pub struct ResourceAllocationRequest { #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatableResource { StatementStoreAllowance, - BulletInAllowance, + BulletinAllowance, SmartContractAllowance(u32), AutoSigning, } @@ -258,7 +259,7 @@ impl From for SsoAllocatableResource { fn from(value: AllocatableResource) -> Self { match value { AllocatableResource::StatementStoreAllowance => Self::StatementStoreAllowance, - AllocatableResource::BulletinAllowance => Self::BulletInAllowance, + AllocatableResource::BulletinAllowance => Self::BulletinAllowance, AllocatableResource::SmartContractAllowance(index) => { Self::SmartContractAllowance(index) } @@ -295,7 +296,7 @@ pub enum SsoAllocatedResource { StatementStoreAllowance { slot_account_key: Vec, }, - BulletInAllowance { + BulletinAllowance { slot_account_key: Vec, }, SmartContractAllowance, @@ -470,7 +471,7 @@ pub fn sign_payload_message(message_id: String, request: HostSignPayloadRequest) RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Payload(Box::new(request.into())), + SigningRequest::Payload(Box::new(SigningPayloadRequest::from_host_request(request))), ))), } } @@ -480,7 +481,7 @@ pub fn sign_raw_message(message_id: String, request: HostSignRawRequest) -> Remo RemoteMessage { message_id, data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Raw(request.into()), + SigningRequest::Raw(SigningRawRequest::from_host_request(request)), ))), } } @@ -639,8 +640,7 @@ mod tests { use p256::SecretKey as P256SecretKey; use p256::elliptic_curve::sec1::ToEncodedPoint; use schnorrkel::{ExpansionMode, MiniSecretKey}; - use truapi::latest::HostSignPayloadData; - use truapi::v01; + use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; fn account() -> ProductAccountId { ProductAccountId { @@ -714,7 +714,7 @@ mod tests { fn late_remote_message_variants_match_host_papp_order() { let legacy_tx = create_transaction_legacy_message( String::new(), - v01::LegacyAccountTxPayload { + LegacyAccountTxPayload { signer: [1; 32], genesis_hash: [2; 32], call_data: Vec::new(), @@ -766,14 +766,14 @@ mod tests { fn create_transaction_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_message( "m-product-tx".to_string(), - v01::ProductAccountTxPayload { - signer: v01::ProductAccountId { + ProductAccountTxPayload { + signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, genesis_hash: sequential_bytes(32), call_data: vec![0, 0], - extensions: vec![v01::TxPayloadExtension { + extensions: vec![TxPayloadExtension { id: "CheckNonce".to_string(), extra: vec![1], additional_signed: vec![2, 3], @@ -792,8 +792,8 @@ mod tests { fn playground_create_transaction_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_message( "create-transaction-1".to_string(), - v01::ProductAccountTxPayload { - signer: v01::ProductAccountId { + ProductAccountTxPayload { + signer: ProductAccountId { dot_ns_identifier: "truapi-playground.dot".to_string(), derivation_index: 0, }, @@ -818,11 +818,11 @@ mod tests { fn create_transaction_legacy_message_matches_host_papp_0_8_8_fixture() { let message = create_transaction_legacy_message( "m-legacy-tx".to_string(), - v01::LegacyAccountTxPayload { + LegacyAccountTxPayload { signer: sequential_bytes(0), genesis_hash: sequential_bytes(32), call_data: vec![0, 0], - extensions: vec![v01::TxPayloadExtension { + extensions: vec![TxPayloadExtension { id: "CheckNonce".to_string(), extra: vec![1], additional_signed: vec![2, 3], @@ -883,11 +883,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let true_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); + let false_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from(request).encode(); + let none_encoded = SigningPayloadRequest::from_host_request(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); @@ -917,7 +917,7 @@ mod tests { request.resources, vec![ SsoAllocatableResource::StatementStoreAllowance, - SsoAllocatableResource::BulletInAllowance, + SsoAllocatableResource::BulletinAllowance, SsoAllocatableResource::SmartContractAllowance(9), SsoAllocatableResource::AutoSigning, ] diff --git a/rust/crates/truapi-server/src/host_logic/statement_store.rs b/rust/crates/truapi-server/src/host_logic/statement_store.rs index 3068285b6..7195b5d6a 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store.rs @@ -23,7 +23,8 @@ pub use statement::{ build_signed_statement, decode_signed_statement, decode_statement_data, decode_verified_statement_data, hex_topic, sign_statement_fields, signed_statement_to_scale, statement_expiry_elapsed, statement_fields_from_v01, statement_proof_to_v01, - statement_signing_payload, + statement_public_key_from_secret, statement_signing_payload, + unsigned_statement_signing_payload, }; /// Error while parsing statement-store JSON-RPC or SCALE statement payloads. diff --git a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs index c90bb848c..1340cd3a0 100644 --- a/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs +++ b/rust/crates/truapi-server/src/host_logic/statement_store/statement.rs @@ -175,8 +175,7 @@ pub fn sign_statement_fields( } fields.sort_by_key(statement_field_sort_index); - let secret = - SecretKey::from_bytes(&ss_secret).map_err(|err| format!("invalid ss_secret: {err}"))?; + let secret = statement_secret_key_from_bytes(ss_secret)?; let public = secret.to_public(); if public.to_bytes() != expected_public_key { return Err("ss_secret does not match session statement public key".to_string()); @@ -196,6 +195,37 @@ pub fn sign_statement_fields( Ok(signed) } +/// Derive the sr25519 public key for a 64-byte statement-store secret. +pub fn statement_public_key_from_secret(ss_secret: [u8; 64]) -> Result<[u8; 32], String> { + let secret = statement_secret_key_from_bytes(ss_secret)?; + Ok(secret.to_public().to_bytes()) +} + +fn statement_secret_key_from_bytes(ss_secret: [u8; 64]) -> Result { + // Rust-generated session keys use schnorrkel's canonical scalar bytes. + // Legacy JS signers may send scure/ed25519-style scalar bytes instead. + match SecretKey::from_bytes(&ss_secret) { + Ok(secret) => Ok(secret), + Err(canonical_error) => SecretKey::from_ed25519_bytes(&ss_secret).map_err(|ed_error| { + format!("invalid ss_secret: canonical={canonical_error}; ed25519={ed_error}") + }), + } +} + +/// Build the statement proof payload for unsigned fields. +pub fn unsigned_statement_signing_payload( + mut fields: Vec, +) -> Result, String> { + if fields + .iter() + .any(|field| matches!(field, StatementField::Proof(_))) + { + return Err("statement is already signed".to_string()); + } + fields.sort_by_key(statement_field_sort_index); + statement_signing_payload(&fields) +} + /// Build the statement signing payload from sorted fields. pub fn statement_signing_payload(fields: &[StatementField]) -> Result, String> { let encoded = fields.to_vec().encode(); @@ -515,6 +545,32 @@ mod tests { } } + #[test] + fn scure_statement_store_secret_fixture_signs_statement() { + // Fixture from @novasamatech/statement-store 0.7.5 createSr25519Secret. + let secret: [u8; 64] = hex::decode( + "8848ab59e934e06a54835252b8abdf946b893055cd34a4433a32b37470f90745\ + 62bb6d70fa3ea98b6ff05ea3f3cac76b62affd2f44c66624873fc6a58e3cd776", + ) + .unwrap() + .try_into() + .unwrap(); + let public_key = statement_public_key_from_secret(secret).unwrap(); + assert_eq!( + hex::encode(public_key), + "c0c18f0e3bbb9c0cf31c6c4db37d7d34efb42035daac647a4a14c93320d33857" + ); + + let signed = sign_statement_fields( + secret, + public_key, + vec![StatementField::Data(vec![1, 2, 3])], + ) + .unwrap(); + let verified = decode_verified_statement_data(&signed.encode(), Some(public_key)).unwrap(); + assert_eq!(verified.data, vec![1, 2, 3]); + } + #[test] fn decodes_statement_data_field() { let statement = vec![ diff --git a/rust/crates/truapi-server/src/host_rpc_client.rs b/rust/crates/truapi-server/src/host_rpc_client.rs index ec959b141..b487107f7 100644 --- a/rust/crates/truapi-server/src/host_rpc_client.rs +++ b/rust/crates/truapi-server/src/host_rpc_client.rs @@ -5,10 +5,6 @@ //! [`subxt_rpcs::RpcClientT`]: request correlation, subscription routing, and //! best-effort unsubscribe on subscription drop. -// Temporary for this stack layer: runtime wiring lands in the next child PR. -#![allow(dead_code)] - -use core::fmt; use core::mem; use core::pin::Pin; use core::task::{Context, Poll}; @@ -60,16 +56,9 @@ struct SubscriptionSink { tx: mpsc::UnboundedSender, RpcError>>, } -#[derive(Debug)] -struct HostRpcClientError(String); - -impl fmt::Display for HostRpcClientError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for HostRpcClientError {} +#[derive(Debug, derive_more::Display, derive_more::Error)] +#[display("{}", _0)] +struct HostRpcClientError(#[error(not(source))] String); #[derive(Serialize)] struct JsonRpcRequest<'a> { diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index 9708f5597..8fa63e239 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -1,17 +1,32 @@ -//! TrUAPI server runtime support. +//! TrUAPI server runtime: dispatcher, frames, SCALE encoding, stream management. //! -//! This layer contains host-agnostic logic, wire-frame dispatch, and chain -//! JSON-RPC mechanics shared by the runtime and target adapters. Platform -//! runtime wiring is added by a later stack layer. - -#![forbid(unsafe_code)] +//! Hosts instantiate a role runtime around a [`truapi_platform::Platform`] +//! implementation, then create product-scoped [`ProductRuntime`] endpoints that +//! expose the stable byte-frame API used from WASM, native mobile, or desktop +//! shells. pub(crate) mod chain_runtime; +pub mod core; pub(crate) mod dispatcher; pub mod frame; +pub(crate) mod host_core; pub mod host_logic; pub(crate) mod host_rpc_client; +pub mod logging; +pub(crate) mod runtime; pub mod subscription; pub mod transport; +#[cfg(test)] +pub(crate) mod test_support; + pub mod generated; + +pub use host_core::{ + FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, + SigningHostRuntime, +}; +pub use truapi_platform::{ + HostRuntimeConfig, PairingHostConfig, PermissionAuthorizationRequest, + PermissionAuthorizationStatus, Platform, ProductContext, SigningHostConfig, +}; diff --git a/rust/crates/truapi-server/src/logging.rs b/rust/crates/truapi-server/src/logging.rs new file mode 100644 index 000000000..6141aad90 --- /dev/null +++ b/rust/crates/truapi-server/src/logging.rs @@ -0,0 +1,210 @@ +//! Level-controlled `tracing` output, routed to the host console. +//! +//! Events emitted via the `tracing` macros (`info!`, `debug!`, …) and +//! `#[instrument]` spans flow through a single subscriber installed once by +//! [`init`]. A reloadable [`LevelFilter`] decides what reaches the console, so +//! the verbosity is tunable at runtime via [`set_level`] (exposed to JS as +//! `setLogLevel`). Disabled by default ([`LevelFilter::OFF`]). +//! +//! On wasm each level maps to the matching `console` method +//! (`error`/`warn`/`info`/`debug`); on native everything goes to stderr. +//! In Chrome, `debug`/`trace` land on `console.debug`, which the DevTools +//! console hides unless its level dropdown includes "Verbose". +//! +//! Output is plaintext, so never log secret material (key bytes, session +//! tokens, signatures). + +use core::fmt::{self, Write as _}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use tracing::field::{Field, Visit}; +use tracing::span::{Attributes, Record}; +use tracing::{Event, Id, Level, Subscriber}; +use tracing_subscriber::Registry; +use tracing_subscriber::filter::LevelFilter; +use tracing_subscriber::layer::{Context, Layer, SubscriberExt as _}; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::reload; + +static RELOAD_HANDLE: OnceLock> = OnceLock::new(); +static TRACE_SPANS: AtomicBool = AtomicBool::new(false); + +/// Install the global subscriber. Idempotent: the first call wins, later +/// calls (and a foreign subscriber already being set) are no-ops. +pub fn init() { + if RELOAD_HANDLE.get().is_some() { + return; + } + let (filter, handle) = reload::Layer::::new(LevelFilter::OFF); + let subscriber = Registry::default().with(ConsoleLayer.with_filter(filter)); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + let _ = RELOAD_HANDLE.set(handle); + } +} + +/// Set the live verbosity threshold. No-op until [`init`] has run. +pub fn set_level(level: LevelFilter) { + TRACE_SPANS.store(level == LevelFilter::TRACE, Ordering::Relaxed); + if let Some(handle) = RELOAD_HANDLE.get() { + let _ = handle.reload(level); + } +} + +/// Apply a host-supplied level string, installing the subscriber first so the +/// call works regardless of whether the core has been constructed yet, then +/// emitting a confirmation event so hosts can verify the logging pipeline end +/// to end. The confirmation is logged at `INFO` (mapping to `console.info`, +/// visible without DevTools "Verbose") rather than at the level just set, so it +/// surfaces even when `debug`/`trace` events land on the hidden `console.debug`. +pub fn set_level_from_str(level: &str) { + init(); + set_level(parse_level(level)); + tracing::info!(level, "log level set"); +} + +/// Parse a host-supplied level string. Unknown values disable logging. +pub fn parse_level(level: &str) -> LevelFilter { + match level.to_ascii_lowercase().as_str() { + "error" => LevelFilter::ERROR, + "warn" | "warning" => LevelFilter::WARN, + "info" => LevelFilter::INFO, + "debug" => LevelFilter::DEBUG, + "trace" => LevelFilter::TRACE, + _ => LevelFilter::OFF, + } +} + +/// Routes each event to the console method matching its level. +struct ConsoleLayer; + +impl Layer for ConsoleLayer +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + attrs.record(&mut visitor); + span.extensions_mut().insert(SpanFields { + fields: visitor.fields, + }); + if trace_spans_enabled() { + emit_span("new", &span); + } + } + + fn on_record(&self, id: &Id, values: &Record<'_>, ctx: Context<'_, S>) { + let Some(span) = ctx.span(id) else { + return; + }; + let mut visitor = EventVisitor::default(); + values.record(&mut visitor); + if visitor.fields.is_empty() { + return; + } + let mut extensions = span.extensions_mut(); + if let Some(fields) = extensions.get_mut::() { + if !fields.fields.is_empty() { + fields.fields.push_str(", "); + } + fields.fields.push_str(&visitor.fields); + } else { + extensions.insert(SpanFields { + fields: visitor.fields, + }); + } + } + + fn on_close(&self, id: Id, ctx: Context<'_, S>) { + if !trace_spans_enabled() { + return; + } + let Some(span) = ctx.span(&id) else { + return; + }; + emit_span("close", &span); + } + + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let meta = event.metadata(); + let mut visitor = EventVisitor::default(); + event.record(&mut visitor); + + let mut line = format!("[truapi] {} {}", meta.level(), meta.target()); + if !visitor.message.is_empty() { + let _ = write!(line, ": {}", visitor.message); + } + if !visitor.fields.is_empty() { + let _ = write!(line, " {{{}}}", visitor.fields); + } + emit(*meta.level(), &line); + } +} + +#[derive(Default)] +struct SpanFields { + fields: String, +} + +fn trace_spans_enabled() -> bool { + TRACE_SPANS.load(Ordering::Relaxed) +} + +fn emit_span(kind: &str, span: &tracing_subscriber::registry::SpanRef<'_, S>) +where + S: Subscriber, + S: for<'a> LookupSpan<'a>, +{ + let meta = span.metadata(); + let mut line = format!("[truapi] TRACE {}: span {}", meta.target(), kind); + let extensions = span.extensions(); + let fields = extensions.get::(); + let _ = write!(line, " {{span={:?}", meta.name()); + if let Some(fields) = fields + && !fields.fields.is_empty() + { + let _ = write!(line, ", {}", fields.fields); + } + line.push('}'); + emit(Level::TRACE, &line); +} + +/// Collects the implicit `message` field separately from explicit key-values. +#[derive(Default)] +struct EventVisitor { + message: String, + fields: String, +} + +impl Visit for EventVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.message, "{value:?}"); + } else { + if !self.fields.is_empty() { + self.fields.push_str(", "); + } + let _ = write!(self.fields, "{}={value:?}", field.name()); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit(_level: Level, line: &str) { + eprintln!("{line}"); +} + +#[cfg(target_arch = "wasm32")] +fn emit(level: Level, line: &str) { + let js = wasm_bindgen::JsValue::from_str(line); + match level { + Level::ERROR => web_sys::console::error_1(&js), + Level::WARN => web_sys::console::warn_1(&js), + Level::INFO => web_sys::console::info_1(&js), + Level::DEBUG | Level::TRACE => web_sys::console::debug_1(&js), + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs new file mode 100644 index 000000000..7004a228a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime.rs @@ -0,0 +1,4159 @@ +//! `ProductRuntimeHost` adapts one product connection into the +//! typed `truapi::api::*` host traits the generated dispatcher routes to. +//! +//! Most methods are straight delegations to the platform; the rest carry +//! host-agnostic logic owned by the core (the chainHead-v1 runtime behind +//! the Chain surface, `dotns` URL parsing for `navigate_to`, and the +//! permission cache layer). Methods with no platform backing return +//! `CallError::unavailable()`. + +mod allowances; +pub(crate) mod auth_state; +mod authority; +mod identity; +mod pairing_host; +pub(crate) mod services; +mod signing_host; +pub(crate) mod sso_pairing; +pub(crate) mod sso_remote; +pub(crate) mod statement_store; +mod statement_store_rpc; + +use core::future::Future; +use core::time::Duration; +use std::sync::Arc; + +use crate::chain_runtime::RuntimeFailure; +use crate::host_logic::allowance_signer::bulletin_allowance_signer_from_key; +use crate::host_logic::dotns::{NavigateDecision, parse_navigate}; +use crate::host_logic::features::feature_supported; +use crate::host_logic::permissions::PermissionsService; +use crate::host_logic::product_account::{ + derive_product_public_key, product_public_key_to_address, +}; +use crate::host_logic::session::SessionInfo; +#[cfg(test)] +use crate::host_logic::session::SessionState; +#[cfg(test)] +use crate::subscription::Spawner; +pub(crate) use authority::ProductAuthority; +#[cfg(test)] +use pairing_host::PairingHost; +pub(crate) use pairing_host::PairingHost as PairingHostRole; +pub(crate) use services::RuntimeServices; +pub(crate) use signing_host::{LocalActivation, SigningHost as SigningHostRole}; + +use authority::{ + AuthorityCancelError, AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; + +use futures::{FutureExt, StreamExt, pin_mut}; +#[cfg(test)] +use parity_scale_codec::Encode; +use tracing::{info, instrument}; +use truapi::api::{ + Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, + Preimage, ResourceAllocation, Signing, System, Theme, +}; +use truapi::v01; +use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostAccountCreateProofError, + HostAccountCreateProofRequest, HostAccountCreateProofResponse, HostAccountGetAliasError, + HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountGetError, + HostAccountGetRequest, HostAccountGetResponse, HostGetLegacyAccountsError, + HostGetLegacyAccountsRequest, HostGetLegacyAccountsResponse, HostGetUserIdError, + HostGetUserIdRequest, HostGetUserIdResponse, HostRequestLoginError, HostRequestLoginRequest, + HostRequestLoginResponse, +}; +use truapi::versioned::chain::{ + RemoteChainHeadBodyError, RemoteChainHeadBodyRequest, RemoteChainHeadBodyResponse, + RemoteChainHeadCallError, RemoteChainHeadCallRequest, RemoteChainHeadCallResponse, + RemoteChainHeadContinueError, RemoteChainHeadContinueRequest, RemoteChainHeadContinueResponse, + RemoteChainHeadFollowItem, RemoteChainHeadFollowRequest, RemoteChainHeadHeaderError, + RemoteChainHeadHeaderRequest, RemoteChainHeadHeaderResponse, RemoteChainHeadStopOperationError, + RemoteChainHeadStopOperationRequest, RemoteChainHeadStopOperationResponse, + RemoteChainHeadStorageError, RemoteChainHeadStorageRequest, RemoteChainHeadStorageResponse, + RemoteChainHeadUnpinError, RemoteChainHeadUnpinRequest, RemoteChainHeadUnpinResponse, + RemoteChainSpecChainNameError, RemoteChainSpecChainNameRequest, + RemoteChainSpecChainNameResponse, RemoteChainSpecGenesisHashError, + RemoteChainSpecGenesisHashRequest, RemoteChainSpecGenesisHashResponse, + RemoteChainSpecPropertiesError, RemoteChainSpecPropertiesRequest, + RemoteChainSpecPropertiesResponse, RemoteChainTransactionBroadcastError, + RemoteChainTransactionBroadcastRequest, RemoteChainTransactionBroadcastResponse, + RemoteChainTransactionStopError, RemoteChainTransactionStopRequest, + RemoteChainTransactionStopResponse, +}; +use truapi::versioned::entropy::{ + HostDeriveEntropyError, HostDeriveEntropyRequest, HostDeriveEntropyResponse, +}; +use truapi::versioned::local_storage::{ + HostLocalStorageClearError, HostLocalStorageClearRequest, HostLocalStorageClearResponse, + HostLocalStorageReadError, HostLocalStorageReadRequest, HostLocalStorageReadResponse, + HostLocalStorageWriteError, HostLocalStorageWriteRequest, HostLocalStorageWriteResponse, +}; +use truapi::versioned::notifications::{ + HostPushNotificationCancelError, HostPushNotificationCancelRequest, + HostPushNotificationCancelResponse, HostPushNotificationError, HostPushNotificationRequest, + HostPushNotificationResponse, +}; +use truapi::versioned::payment::{ + HostPaymentBalanceSubscribeError, HostPaymentBalanceSubscribeItem, + HostPaymentBalanceSubscribeRequest, HostPaymentError, HostPaymentRequest, HostPaymentResponse, + HostPaymentStatusSubscribeError, HostPaymentStatusSubscribeItem, + HostPaymentStatusSubscribeRequest, HostPaymentTopUpError, HostPaymentTopUpRequest, + HostPaymentTopUpResponse, +}; +use truapi::versioned::permissions::{ + HostDevicePermissionError, HostDevicePermissionRequest, HostDevicePermissionResponse, + RemotePermissionError, RemotePermissionRequest, RemotePermissionResponse, +}; +use truapi::versioned::preimage::{ + RemotePreimageLookupSubscribeItem, RemotePreimageLookupSubscribeRequest, + RemotePreimageSubmitError, RemotePreimageSubmitRequest, RemotePreimageSubmitResponse, +}; +use truapi::versioned::resource_allocation::{ + HostRequestResourceAllocationError, HostRequestResourceAllocationRequest, + HostRequestResourceAllocationResponse, +}; +use truapi::versioned::signing::{ + HostCreateTransactionError, HostCreateTransactionRequest, HostCreateTransactionResponse, + HostCreateTransactionWithLegacyAccountError, HostCreateTransactionWithLegacyAccountRequest, + HostCreateTransactionWithLegacyAccountResponse, HostSignPayloadError, HostSignPayloadRequest, + HostSignPayloadResponse, HostSignPayloadWithLegacyAccountError, + HostSignPayloadWithLegacyAccountRequest, HostSignPayloadWithLegacyAccountResponse, + HostSignRawError, HostSignRawRequest, HostSignRawResponse, HostSignRawWithLegacyAccountError, + HostSignRawWithLegacyAccountRequest, HostSignRawWithLegacyAccountResponse, +}; +use truapi::versioned::system::{ + HostFeatureSupportedError, HostFeatureSupportedRequest, HostFeatureSupportedResponse, + HostNavigateToError, HostNavigateToRequest, HostNavigateToResponse, +}; +use truapi::versioned::theme::HostThemeSubscribeItem; +use truapi::{CallContext, CallError, CancellationReason, Subscription}; +#[cfg(test)] +use truapi_platform::Platform; +use truapi_platform::{ + AccountAccessReview, AccountAliasReview, CreateTransactionReview, IdentityDisclosureReview, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, PreimageSubmitReview, + ProductContext, SessionUiInfo, SignPayloadReview, SignRawReview, UserConfirmationReview, + normalize_product_identifier, +}; + +pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; +/// Host-spec B.6.2 recommends timing out unanswered SSO application requests +/// after 180 seconds: +/// +const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); + +fn remote_authority_context(cx: &CallContext) -> CallContext { + let mut cx = cx.clone(); + if cx.timeout().is_none() { + cx.set_timeout(DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + } + cx +} + +async fn remote_authority_call(cx: &CallContext, call: F) -> Result +where + F: Future>, +{ + let call = call.fuse(); + let cancelled = cx.cancel().cancelled().fuse(); + pin_mut!(call, cancelled); + + if let Some(timeout_duration) = cx.timeout() { + let timeout = futures_timer::Delay::new(timeout_duration).fuse(); + pin_mut!(timeout); + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + () = timeout => { + let reason = CancellationReason::TimedOut { + timeout: timeout_duration, + }; + cx.cancel().cancel_with_reason(reason.clone()); + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + } + } + } else { + futures::select! { + result = call => result, + reason = cancelled => { + let error = authority_cancellation_error(cx, reason); + let _ = call.await; + Err(error) + }, + } + } +} + +fn authority_cancellation_error(cx: &CallContext, reason: CancellationReason) -> AuthorityError { + AuthorityError::Cancelled(AuthorityCancelError::new(cx.request_id(), reason)) +} + +/// Product-scoped adapter that exposes a long-lived host runtime through the +/// `truapi::api::*` trait set the generated dispatcher routes to. +pub struct ProductRuntimeHost { + services: Arc, + authority: Arc, + product: ProductContext, + /// Stable per-product-runtime id used to scope long-lived chain follow + /// operation ids within one shared host runtime. + core_instance: u64, +} + +impl ProductRuntimeHost { + /// Build a product-scoped dispatcher target from a long-lived host runtime. + pub(crate) fn from_services( + services: Arc, + authority: Arc, + product: ProductContext, + ) -> Self { + let core_instance = services.next_core_instance(); + Self { + services, + authority, + product, + core_instance, + } + } + + #[cfg(test)] + pub fn new

( + platform: Arc

, + config: (truapi_platform::PairingHostConfig, ProductContext), + spawner: Spawner, + ) -> Self + where + P: Platform + 'static, + { + let (host_config, product) = config; + let platform: Arc = platform; + Self::new_pairing_for_tests(platform, host_config, product, spawner).0 + } + + /// Compatibility constructor used only by tests that do not exercise + /// product-scoped behavior. + #[cfg(test)] + fn new_compat(platform: Arc, spawner: Spawner) -> Self { + Self::new_compat_with_pairing(platform, spawner).0 + } + + #[cfg(test)] + fn new_compat_with_pairing( + platform: Arc, + spawner: Spawner, + ) -> (Self, Arc) { + let host_config = truapi_platform::PairingHostConfig::new( + truapi_platform::HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + truapi_platform::PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("compat runtime config is valid"); + Self::new_pairing_for_tests( + platform, + host_config, + ProductContext::new("unknown.dot".to_string()) + .expect("compat product context is valid"), + spawner, + ) + } + + #[cfg(test)] + fn new_pairing_for_tests( + platform: Arc, + host_config: truapi_platform::PairingHostConfig, + product: ProductContext, + spawner: Spawner, + ) -> (Self, Arc) { + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let core_instance = services.next_core_instance(); + let host = Self { + services, + authority: pairing_host.clone(), + product, + core_instance, + }; + (host, pairing_host) + } + + /// Test-only access to the shared session-state holder. + #[cfg(test)] + pub(crate) fn test_session_state(&self) -> Arc { + self.authority.session_state() + } + + /// Disconnect this runtime from its paired signing host. + #[cfg(test)] + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + pub(crate) async fn disconnect(&self) { + self.authority.disconnect().await; + } + + fn is_product_account_valid_for_caller(&self, dot_ns_identifier: &str) -> bool { + let Ok(dot_ns_identifier) = normalize_product_identifier(dot_ns_identifier) else { + return false; + }; + let product_id = self.product_id(); + // Localhost products are development-only wildcards once a host admits + // them. Production hosts must reject localhost products before creating + // the product runtime. + product_id == "localhost" + || product_id.starts_with("localhost:") + || dot_ns_identifier == product_id + } + + fn normalize_product_account_id( + product_account_id: v01::ProductAccountId, + ) -> Result { + Ok(v01::ProductAccountId { + dot_ns_identifier: normalize_product_identifier(&product_account_id.dot_ns_identifier) + .map_err(|_| ())?, + derivation_index: product_account_id.derivation_index, + }) + } + + fn product_id(&self) -> String { + self.product.product_id.as_str().to_string() + } + + fn legacy_slot_zero_public_key(&self, session: &AuthoritySession) -> Result<[u8; 32], String> { + derive_product_public_key(session.public_key, &self.product_id(), 0) + .map_err(|err| err.to_string()) + } + + fn product_storage_key(&self, key: String) -> String { + product_storage_key(self.product.product_id.as_str(), &key) + } + + fn follow_id(&self, id: &str) -> String { + format!("c{}:{id}", self.core_instance) + } +} + +impl ProductRuntimeHost { + /// Read a stored permission authorization status without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_status"))] + pub(crate) async fn permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + ) -> Result { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.authorization_status(&request).await + } + + /// Read stored permission authorization statuses without prompting. + #[instrument(skip_all, fields(runtime.method = "permissions.authorization_statuses"))] + pub(crate) async fn permission_authorization_statuses( + &self, + requests: Vec, + ) -> Result, v01::GenericError> { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.authorization_statuses(&requests).await + } + + /// Update a stored permission authorization status. `NotDetermined` + /// clears the stored value so the next product request prompts again. + #[instrument(skip_all, fields(runtime.method = "permissions.set_authorization_status"))] + pub(crate) async fn set_permission_authorization_status( + &self, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ) -> Result<(), v01::GenericError> { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service.set_authorization_status(&request, status).await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.remote_authorization"))] + async fn remote_permission_authorization( + &self, + permission: v01::RemotePermission, + ) -> Result { + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + service + .check_or_prompt_remote(v01::RemotePermissionRequest { permission }) + .await + .map_err(|err| format!("permission storage failed: {err:?}")) + } + + pub(super) async fn require_remote_permission( + &self, + permission: v01::RemotePermission, + denied_error: E, + ) -> Result<(), CallError> { + match self.remote_permission_authorization(permission).await { + Ok(PermissionAuthorizationStatus::Authorized) => Ok(()), + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => Err(CallError::Domain(denied_error)), + Err(reason) => Err(CallError::HostFailure { reason }), + } + } + + async fn require_chain_submit(&self, denied_error: E) -> Result<(), CallError> { + self.require_remote_permission(v01::RemotePermission::ChainSubmit, denied_error) + .await + } + + #[instrument(skip_all, fields(runtime.method = "permissions.identity_disclosure_authorization"))] + async fn identity_disclosure_authorization( + &self, + ) -> Result { + let product_id = self.product_id(); + let request = PermissionAuthorizationRequest::IdentityDisclosure; + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &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 + .services + .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) + .await + .map_err(|err| format!("permission storage failed: {err:?}"))?; + Ok(status) + } + + fn validate_legacy_address_signer( + &self, + session: &AuthoritySession, + signer: &str, + ) -> Result<[u8; 32], v01::HostSignPayloadError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostSignPayloadError::Unknown { reason })?; + let expected = product_public_key_to_address(public_key); + if expected == signer + || parse_legacy_signer_hex(signer).is_some_and(|key| key == public_key) + { + Ok(public_key) + } else { + Err(v01::HostSignPayloadError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } + + fn validate_legacy_public_key_signer( + &self, + session: &AuthoritySession, + signer: [u8; 32], + ) -> Result<(), v01::HostCreateTransactionError> { + let public_key = self + .legacy_slot_zero_public_key(session) + .map_err(|reason| v01::HostCreateTransactionError::Unknown { reason })?; + if public_key == signer { + Ok(()) + } else { + Err(v01::HostCreateTransactionError::Unknown { + reason: "Account can't be derived from product account id".to_string(), + }) + } + } +} + +fn parse_legacy_signer_hex(signer: &str) -> Option<[u8; 32]> { + let raw = signer + .strip_prefix("0x") + .or_else(|| signer.strip_prefix("0X")) + .unwrap_or(signer); + if raw.len() != 64 { + return None; + } + hex::decode(raw).ok()?.try_into().ok() +} + +fn product_storage_key(product_id: &str, key: &str) -> String { + format!( + "truapi:product-storage:v1:{}:{}:{}", + product_id.len(), + product_id, + key + ) +} + +fn runtime_failure_to_call_error(failure: RuntimeFailure) -> CallError { + CallError::HostFailure { + reason: failure.reason(), + } +} + +// --------------------------------------------------------------------------- +// System +// --------------------------------------------------------------------------- + +impl System for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "system.feature_supported"))] + async fn feature_supported( + &self, + _cx: &CallContext, + request: HostFeatureSupportedRequest, + ) -> Result> { + let HostFeatureSupportedRequest::V1(inner) = request; + feature_supported(self.services.platform.as_ref(), inner) + .await + .map(HostFeatureSupportedResponse::V1) + .map_err(|err| CallError::Domain(HostFeatureSupportedError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "system.navigate_to"))] + async fn navigate_to( + &self, + _cx: &CallContext, + request: HostNavigateToRequest, + ) -> Result> { + let HostNavigateToRequest::V1(v01::HostNavigateToRequest { url }) = request; + let resolved = match parse_navigate(&url) { + NavigateDecision::Reject { reason } => { + return Err(CallError::Domain(HostNavigateToError::V1( + v01::HostNavigateToError::Unknown { reason }, + ))); + } + decision => match decision.canonical_url() { + Some(url) => url, + None => { + return Err(CallError::HostFailure { + reason: "navigate decision produced no canonical URL".to_string(), + }); + } + }, + }; + self.services + .platform + .navigate_to(resolved) + .await + .map(|()| HostNavigateToResponse::V1) + .map_err(|err| CallError::Domain(HostNavigateToError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Permissions +// --------------------------------------------------------------------------- + +impl Permissions for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "permissions.request_device_permission"))] + async fn request_device_permission( + &self, + _cx: &CallContext, + request: HostDevicePermissionRequest, + ) -> Result> { + let HostDevicePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + match service.check_or_prompt_device(inner).await { + Ok(decision) => Ok(HostDevicePermissionResponse::V1( + v01::HostDevicePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } + + #[instrument(skip_all, fields(runtime.method = "permissions.request_remote_permission"))] + async fn request_remote_permission( + &self, + _cx: &CallContext, + request: RemotePermissionRequest, + ) -> Result> { + let RemotePermissionRequest::V1(inner) = request; + let product_id = self.product_id(); + let service = PermissionsService::new( + self.services.platform.as_ref(), + self.services.platform.as_ref(), + &product_id, + ); + match service.check_or_prompt_remote(inner).await { + Ok(decision) => Ok(RemotePermissionResponse::V1( + v01::RemotePermissionResponse { + granted: decision == PermissionAuthorizationStatus::Authorized, + }, + )), + Err(err) => Err(CallError::HostFailure { + reason: format!("permission storage failed: {err:?}"), + }), + } + } +} + +// --------------------------------------------------------------------------- +// LocalStorage +// --------------------------------------------------------------------------- + +impl LocalStorage for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "local_storage.read"))] + async fn read( + &self, + _cx: &CallContext, + request: HostLocalStorageReadRequest, + ) -> Result> { + let HostLocalStorageReadRequest::V1(v01::HostLocalStorageReadRequest { key }) = request; + self.services + .platform + .read(self.product_storage_key(key)) + .await + .map(|value| { + HostLocalStorageReadResponse::V1(v01::HostLocalStorageReadResponse { value }) + }) + .map_err(|err| CallError::Domain(HostLocalStorageReadError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.write"))] + async fn write( + &self, + _cx: &CallContext, + request: HostLocalStorageWriteRequest, + ) -> Result> { + let HostLocalStorageWriteRequest::V1(v01::HostLocalStorageWriteRequest { key, value }) = + request; + self.services + .platform + .write(self.product_storage_key(key), value) + .await + .map(|()| HostLocalStorageWriteResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageWriteError::V1(err))) + } + + #[instrument(skip_all, fields(runtime.method = "local_storage.clear"))] + async fn clear( + &self, + _cx: &CallContext, + request: HostLocalStorageClearRequest, + ) -> Result> { + let HostLocalStorageClearRequest::V1(v01::HostLocalStorageClearRequest { key }) = request; + self.services + .platform + .clear(self.product_storage_key(key)) + .await + .map(|()| HostLocalStorageClearResponse::V1) + .map_err(|err| CallError::Domain(HostLocalStorageClearError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Account +// --------------------------------------------------------------------------- +// +// Account-management flows live in the Rust core itself, backed by the shared +// session state and, for alias/proof/login success paths, the SSO service. + +impl Account for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "account.get_account"))] + async fn get_account( + &self, + _cx: &CallContext, + request: HostAccountGetRequest, + ) -> Result> { + let HostAccountGetRequest::V1(v01::HostAccountGetRequest { product_account_id }) = request; + let product_account_id = + Self::normalize_product_account_id(product_account_id).map_err(|()| { + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid, + )) + })?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + let product_id = self.product_id(); + if product_account_id.dot_ns_identifier != product_id { + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::AccountAccess(AccountAccessReview { + requesting_product_id: product_id, + target_product_id: product_account_id.dot_ns_identifier.clone(), + })) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("account access confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + } + + let public_key = derive_product_public_key( + session.public_key, + &product_account_id.dot_ns_identifier, + product_account_id.derivation_index, + ) + .map_err(|err| { + CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Unknown { + reason: err.to_string(), + })) + })?; + + Ok(HostAccountGetResponse::V1(v01::HostAccountGetResponse { + account: v01::ProductAccount { + public_key: public_key.to_vec(), + }, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_account_alias"))] + async fn get_account_alias( + &self, + cx: &CallContext, + request: HostAccountGetAliasRequest, + ) -> Result> { + let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { product_account_id }) = + request; + let product_account_id = Self::normalize_product_account_id(product_account_id); + + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected, + ))); + }; + + let product_account_id = product_account_id.map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid, + )) + })?; + + let product_id = self.product_id(); + if product_account_id.dot_ns_identifier != product_id { + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::AccountAlias(AccountAliasReview { + requesting_product_id: product_id.clone(), + target_product_id: product_account_id.dot_ns_identifier.clone(), + })) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("account alias confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected, + ))); + } + } + + self.authority + .account_alias(cx, &session, product_account_id, product_id) + .await + .map(HostAccountGetAliasResponse::V1) + .map_err(|err| { + CallError::Domain(HostAccountGetAliasError::V1( + account_get_error_from_authority(err), + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "account.create_account_proof"))] + async fn create_account_proof( + &self, + _cx: &CallContext, + _request: HostAccountCreateProofRequest, + ) -> Result> { + Err(CallError::Unsupported) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_legacy_accounts"))] + async fn get_legacy_accounts( + &self, + _cx: &CallContext, + _request: HostGetLegacyAccountsRequest, + ) -> Result> { + let Some(session) = self.authority.current_session() else { + return Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { accounts: vec![] }, + )); + }; + + let product_id = self.product_id(); + + let public_key = + derive_product_public_key(session.public_key, &product_id, 0).map_err(|err| { + CallError::Domain(HostGetLegacyAccountsError::V1( + v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, + )) + })?; + + Ok(HostGetLegacyAccountsResponse::V1( + v01::HostGetLegacyAccountsResponse { + accounts: vec![v01::LegacyAccount { + public_key: public_key.to_vec(), + // TODO(#266): gate this legacy display name on + // IdentityDisclosure while keeping the public key for + // compatibility. + name: session.lite_username.clone(), + }], + }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "account.get_user_id"))] + async fn get_user_id( + &self, + _cx: &CallContext, + _request: HostGetUserIdRequest, + ) -> Result> { + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::NotConnected, + ))); + }; + + match self.identity_disclosure_authorization().await { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => { + return Err(CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied, + ))); + } + Err(reason) => return Err(CallError::HostFailure { reason }), + } + + let primary_username = session + .full_username + .clone() + .filter(|value| !value.is_empty()) + .or_else(|| { + session + .lite_username + .clone() + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| { + CallError::Domain(HostGetUserIdError::V1(v01::HostGetUserIdError::Unknown { + reason: "No primary username for this session".to_string(), + })) + })?; + + Ok(HostGetUserIdResponse::V1(v01::HostGetUserIdResponse { + primary_username, + })) + } + + #[instrument(skip_all, fields(runtime.method = "account.connection_status_subscribe"))] + async fn connection_status_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription { + Subscription::new(self.authority.session_state().subscribe()) + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %self.product.product_id))] + async fn request_login( + &self, + _cx: &CallContext, + _request: HostRequestLoginRequest, + ) -> Result> { + self.authority.request_login(&self.product).await + } +} + +/// Host-UI projection of an active session for `AuthState::Connected`. +fn connected_session_ui_info(session: &SessionInfo) -> SessionUiInfo { + SessionUiInfo { + public_key: session.public_key, + identity_account_id: session.identity_account_id, + lite_username: session.lite_username.clone(), + full_username: session.full_username.clone(), + } +} + +fn account_get_error_from_authority(err: AuthorityError) -> v01::HostAccountGetError { + match err { + AuthorityError::Rejected => v01::HostAccountGetError::Rejected, + AuthorityError::Disconnected => v01::HostAccountGetError::NotConnected, + AuthorityError::Cancelled(err) => v01::HostAccountGetError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostAccountGetError::Unknown { reason } + } + } +} + +fn signing_call_error( + wrap: fn(v01::HostSignPayloadError) -> E, + err: AuthorityError, +) -> CallError { + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostSignPayloadError::Rejected + } + AuthorityError::Cancelled(err) => v01::HostSignPayloadError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostSignPayloadError::Unknown { reason } + } + })) +} + +fn transaction_call_error( + wrap: fn(v01::HostCreateTransactionError) -> E, + err: AuthorityError, +) -> CallError { + CallError::Domain(wrap(match err { + AuthorityError::Rejected | AuthorityError::Disconnected => { + v01::HostCreateTransactionError::Rejected + } + AuthorityError::Cancelled(err) => v01::HostCreateTransactionError::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } | AuthorityError::Unknown { reason } => { + v01::HostCreateTransactionError::Unknown { reason } + } + })) +} + +impl Signing for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload"))] + async fn sign_payload( + &self, + cx: &CallContext, + request: HostSignPayloadRequest, + ) -> Result> { + info!("sign_payload: requesting signing-host signature"); + let HostSignPayloadRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignPayloadResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw"))] + async fn sign_raw( + &self, + cx: &CallContext, + request: HostSignRawRequest, + ) -> Result> { + info!("sign_raw: requesting signing-host signature"); + let HostSignRawRequest::V1(mut inner) = request; + inner.account = Self::normalize_product_account_id(inner.account).map_err(|()| { + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.account.dot_ns_identifier) { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + ))); + } + self.require_chain_submit(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignRaw(SignRawReview::Product( + inner.clone(), + ))) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(inner)), + ) + .await + .map(HostSignRawResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction"))] + async fn create_transaction( + &self, + cx: &CallContext, + request: HostCreateTransactionRequest, + ) -> Result> { + info!("create_transaction: requesting signing-host signature"); + let HostCreateTransactionRequest::V1(mut inner) = request; + inner.signer = Self::normalize_product_account_id(inner.signer).map_err(|()| { + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.signer.dot_ns_identifier) { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + ))); + } + self.require_chain_submit(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + }; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::Product(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::Product(inner), + ), + ) + .await + .map(HostCreateTransactionResponse::V1) + .map_err(|reason| transaction_call_error(HostCreateTransactionError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_payload_with_legacy_account"))] + async fn sign_payload_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignPayloadWithLegacyAccountRequest, + ) -> Result< + HostSignPayloadWithLegacyAccountResponse, + CallError, + > { + let HostSignPayloadWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignPayloadWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignPayloadWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignPayload( + SignPayloadReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign payload confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostSignPayloadWithLegacyAccountError::V1(v01::HostSignPayloadError::Rejected), + )); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_payload( + &cx, + &session, + SignPayloadAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(HostSignPayloadWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignPayloadWithLegacyAccountError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.sign_raw_with_legacy_account"))] + async fn sign_raw_with_legacy_account( + &self, + cx: &CallContext, + request: HostSignRawWithLegacyAccountRequest, + ) -> Result> + { + let HostSignRawWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + }; + self.validate_legacy_address_signer(&session, &inner.signer) + .map_err(|err| CallError::Domain(HostSignRawWithLegacyAccountError::V1(err)))?; + self.require_chain_submit(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::SignRaw( + SignRawReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("sign raw confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Rejected, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.sign_raw( + &cx, + &session, + SignRawAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(HostSignRawWithLegacyAccountResponse::V1) + .map_err(|reason| signing_call_error(HostSignRawWithLegacyAccountError::V1, reason)) + } + + #[instrument(skip_all, fields(runtime.method = "signing.create_transaction_with_legacy_account"))] + async fn create_transaction_with_legacy_account( + &self, + cx: &CallContext, + request: HostCreateTransactionWithLegacyAccountRequest, + ) -> Result< + HostCreateTransactionWithLegacyAccountResponse, + CallError, + > { + let HostCreateTransactionWithLegacyAccountRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + }; + self.validate_legacy_public_key_signer(&session, inner.signer) + .map_err(|err| { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1(err)) + })?; + self.require_chain_submit(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::PermissionDenied, + )) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::CreateTransaction( + CreateTransactionReview::LegacyAccount(inner.clone()), + )) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("create transaction confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain( + HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Rejected, + ), + )); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority.create_transaction( + &cx, + &session, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account: v01::ProductAccountId { + dot_ns_identifier: self.product_id(), + derivation_index: 0, + }, + request: inner, + }, + ), + ) + .await + .map(|response| { + HostCreateTransactionWithLegacyAccountResponse::V1( + v01::HostCreateTransactionWithLegacyAccountResponse { + transaction: response.transaction, + }, + ) + }) + .map_err(|reason| { + transaction_call_error(HostCreateTransactionWithLegacyAccountError::V1, reason) + }) + } +} + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- +// +// The chain surface is backed by `ChainRuntime`, which keeps one +// `chainHead_v1` connection per genesis hash on top of the platform-supplied +// `ChainProvider::connect`. Requests go through `request_value` and parse +// json-rpc responses into typed v01 results; follow notifications are +// translated into `RemoteChainHeadFollowItem` items on the subscription +// stream. + +impl Chain for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "chain.follow_head_subscribe"))] + async fn follow_head_subscribe( + &self, + cx: &CallContext, + request: RemoteChainHeadFollowRequest, + ) -> Subscription { + let RemoteChainHeadFollowRequest::V1(inner) = request; + let follow_subscription_id = self.follow_id(cx.request_id()); + let stream = self + .services + .chain + .remote_chain_head_follow(follow_subscription_id, inner) + .map(RemoteChainHeadFollowItem::V1); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_header"))] + async fn get_head_header( + &self, + _cx: &CallContext, + request: RemoteChainHeadHeaderRequest, + ) -> Result> { + let RemoteChainHeadHeaderRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_header(inner) + .await + .map(RemoteChainHeadHeaderResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_body"))] + async fn get_head_body( + &self, + _cx: &CallContext, + request: RemoteChainHeadBodyRequest, + ) -> Result> { + let RemoteChainHeadBodyRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_body(inner) + .await + .map(RemoteChainHeadBodyResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_head_storage"))] + async fn get_head_storage( + &self, + _cx: &CallContext, + request: RemoteChainHeadStorageRequest, + ) -> Result> { + let RemoteChainHeadStorageRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_storage(inner) + .await + .map(RemoteChainHeadStorageResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.call_head"))] + async fn call_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadCallRequest, + ) -> Result> { + let RemoteChainHeadCallRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_call(inner) + .await + .map(RemoteChainHeadCallResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.unpin_head"))] + async fn unpin_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadUnpinRequest, + ) -> Result> { + let RemoteChainHeadUnpinRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_unpin(inner) + .await + .map(|()| RemoteChainHeadUnpinResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.continue_head"))] + async fn continue_head( + &self, + _cx: &CallContext, + request: RemoteChainHeadContinueRequest, + ) -> Result> { + let RemoteChainHeadContinueRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_continue(inner) + .await + .map(|()| RemoteChainHeadContinueResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_head_operation"))] + async fn stop_head_operation( + &self, + _cx: &CallContext, + request: RemoteChainHeadStopOperationRequest, + ) -> Result> + { + let RemoteChainHeadStopOperationRequest::V1(mut inner) = request; + inner.follow_subscription_id = self.follow_id(&inner.follow_subscription_id); + self.services + .chain + .remote_chain_head_stop_operation(inner) + .await + .map(|()| RemoteChainHeadStopOperationResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_genesis_hash"))] + async fn get_spec_genesis_hash( + &self, + _cx: &CallContext, + request: RemoteChainSpecGenesisHashRequest, + ) -> Result> + { + let RemoteChainSpecGenesisHashRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_genesis_hash(inner.genesis_hash) + .await + .map(RemoteChainSpecGenesisHashResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_chain_name"))] + async fn get_spec_chain_name( + &self, + _cx: &CallContext, + request: RemoteChainSpecChainNameRequest, + ) -> Result> { + let RemoteChainSpecChainNameRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_chain_name(inner.genesis_hash) + .await + .map(RemoteChainSpecChainNameResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.get_spec_properties"))] + async fn get_spec_properties( + &self, + _cx: &CallContext, + request: RemoteChainSpecPropertiesRequest, + ) -> Result> { + let RemoteChainSpecPropertiesRequest::V1(inner) = request; + self.services + .chain + .remote_chain_spec_properties(inner.genesis_hash) + .await + .map(RemoteChainSpecPropertiesResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.broadcast_transaction"))] + async fn broadcast_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionBroadcastRequest, + ) -> Result< + RemoteChainTransactionBroadcastResponse, + CallError, + > { + let RemoteChainTransactionBroadcastRequest::V1(inner) = request; + self.require_chain_submit(RemoteChainTransactionBroadcastError::V1( + v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }, + )) + .await?; + self.services + .chain + .remote_chain_transaction_broadcast(inner) + .await + .map(RemoteChainTransactionBroadcastResponse::V1) + .map_err(runtime_failure_to_call_error) + } + + #[instrument(skip_all, fields(runtime.method = "chain.stop_transaction"))] + async fn stop_transaction( + &self, + _cx: &CallContext, + request: RemoteChainTransactionStopRequest, + ) -> Result> + { + let RemoteChainTransactionStopRequest::V1(inner) = request; + // We intentionally forward the provider operation id here. Transaction + // operation ids are node-assigned and short-lived, so cross-product + // collision or guessing is not worth local id indirection yet. + self.services + .chain + .remote_chain_transaction_stop(inner) + .await + .map(|()| RemoteChainTransactionStopResponse::V1) + .map_err(runtime_failure_to_call_error) + } +} + +// --------------------------------------------------------------------------- +// Deferred product surfaces. +// +// Payment and full account proof are explicitly out of current host parity, +// but products should still observe the host's typed "not implemented" errors +// rather than a generic transport failure. +// Chat and CoinPayment remain outside this milestone and keep their generated +// trait defaults until another host/product needs real implementations. + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +impl Chat for ProductRuntimeHost {} +impl CoinPayment for ProductRuntimeHost {} +impl Payment for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "payment.balance_subscribe"))] + async fn balance_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentBalanceSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.request"))] + async fn request( + &self, + _cx: &CallContext, + _request: HostPaymentRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentError::V1( + v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.status_subscribe"))] + async fn status_subscribe( + &self, + _cx: &CallContext, + _request: HostPaymentStatusSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + Err(CallError::Domain(HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } + + #[instrument(skip_all, fields(runtime.method = "payment.top_up"))] + async fn top_up( + &self, + _cx: &CallContext, + _request: HostPaymentTopUpRequest, + ) -> Result> { + Err(CallError::Domain(HostPaymentTopUpError::V1( + v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ))) + } +} + +impl ResourceAllocation for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "resource_allocation.request"))] + async fn request( + &self, + cx: &CallContext, + request: HostRequestResourceAllocationRequest, + ) -> Result> + { + let HostRequestResourceAllocationRequest::V1(inner) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "No active session".to_string(), + }, + ))); + }; + + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::ResourceAllocation(inner.clone())) + .await + .map_err(|err| CallError::HostFailure { + reason: format!("resource allocation confirmation failed: {err:?}"), + })?; + if !confirmed { + return Err(CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: "User rejected resource allocation".to_string(), + }, + ))); + } + let cx = remote_authority_context(cx); + remote_authority_call( + &cx, + self.authority + .allocate_resources(&cx, &session, self.product_id(), inner), + ) + .await + .map(HostRequestResourceAllocationResponse::V1) + .map_err(|err| { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { + reason: err.reason(), + }, + )) + }) + } +} +// --------------------------------------------------------------------------- +// Entropy +// --------------------------------------------------------------------------- + +impl Entropy for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "entropy.derive"))] + async fn derive( + &self, + _cx: &CallContext, + request: HostDeriveEntropyRequest, + ) -> Result> { + let HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context }) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: "Not connected".to_string(), + }, + ))); + }; + let entropy = self + .authority + .derive_entropy(&session, &self.product_id(), &context) + .map_err(|err| { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { + reason: err.reason(), + }, + )) + })?; + + Ok(HostDeriveEntropyResponse::V1( + v01::HostDeriveEntropyResponse { entropy }, + )) + } +} + +// --------------------------------------------------------------------------- +// Preimage +// --------------------------------------------------------------------------- + +impl Preimage for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "preimage.lookup_subscribe"))] + async fn lookup_subscribe( + &self, + _cx: &CallContext, + request: RemotePreimageLookupSubscribeRequest, + ) -> Subscription { + let RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key, + }) = request; + let stream = self + .services + .platform + .lookup_preimage(key) + .filter_map(|item| async move { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. + item.ok().map(|value| { + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } + + #[instrument(skip_all, fields(runtime.method = "preimage.submit"))] + async fn submit( + &self, + cx: &CallContext, + request: RemotePreimageSubmitRequest, + ) -> Result> { + let RemotePreimageSubmitRequest::V1(value) = request; + let Some(session) = self.authority.current_session() else { + return Err(CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: "No active session".to_string(), + }, + ))); + }; + self.require_remote_permission( + v01::RemotePermission::PreimageSubmit, + RemotePreimageSubmitError::V1(v01::PreimageSubmitError::Unknown { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; + let confirmed = self + .services + .platform + .confirm_user_action(UserConfirmationReview::PreimageSubmit( + PreimageSubmitReview { + size: value.len() as u64, + }, + )) + .await + .map_err(|err| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason: err.reason }, + )) + })?; + if !confirmed { + return Err(CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: "User rejected preimage submission".to_string(), + }, + ))); + } + let cx = remote_authority_context(cx); + let allowance = remote_authority_call( + &cx, + self.authority + .bulletin_allowance_key(&cx, &session, self.product_id()), + ) + .await + .map_err(|err| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { + reason: err.reason(), + }, + )) + })?; + let signer = bulletin_allowance_signer_from_key(allowance).map_err(|reason| { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) + })?; + self.services + .platform + .submit_preimage(value, signer) + .await + .map(RemotePreimageSubmitResponse::V1) + .map_err(|err| CallError::Domain(RemotePreimageSubmitError::V1(err))) + } +} + +// --------------------------------------------------------------------------- +// Theme +// --------------------------------------------------------------------------- + +impl Theme for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "theme.subscribe"))] + async fn subscribe(&self, _cx: &CallContext) -> Subscription { + let stream = self + .services + .platform + .subscribe_theme() + .filter_map(|item| async { + // TODO: preserve platform stream errors as terminal + // subscription interrupts once subscription items can carry + // in-stream failures. + item.ok().map(|variant| { + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant, + }) + }) + }); + Subscription::new(Box::pin(stream)) + } +} + +// `Notifications` delegates to the platform so hosts can own scheduling and +// cancellation while the core preserves the typed TrUAPI wire shape. +impl Notifications for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "notifications.send_push_notification"))] + async fn send_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationRequest, + ) -> Result> { + let HostPushNotificationRequest::V1(inner) = request; + self.services + .platform + .push_notification(inner) + .await + .map(HostPushNotificationResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationError::V1( + v01::HostPushNotificationError::Unknown { reason: err.reason }, + )) + }) + } + + #[instrument(skip_all, fields(runtime.method = "notifications.cancel_push_notification"))] + async fn cancel_push_notification( + &self, + _cx: &CallContext, + request: HostPushNotificationCancelRequest, + ) -> Result> + { + let HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { id }) = + request; + self.services + .platform + .cancel_notification(id) + .await + .map(|()| HostPushNotificationCancelResponse::V1) + .map_err(|err| { + CallError::Domain(HostPushNotificationCancelError::V1(v01::GenericError { + reason: err.reason, + })) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, ResourceAllocationResponse, + SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, v1, + }; + use crate::test_support::*; + use std::sync::Mutex; + use std::sync::atomic::Ordering; + use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest}; + + fn wait_until(mut condition: impl FnMut() -> bool, message: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !condition() { + assert!(std::time::Instant::now() < deadline, "{message}"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + fn recorded_rpc_methods(sent_rpc: &Mutex>) -> Vec { + sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect() + } + + fn recorded_rpc_method_count(sent_rpc: &Mutex>, method: &str) -> usize { + recorded_rpc_methods(sent_rpc) + .iter() + .filter(|candidate| candidate.as_str() == method) + .count() + } + + #[test] + fn feature_supported_round_trips_through_runtime() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + let HostFeatureSupportedResponse::V1(inner) = response; + assert!(inner.supported); + } + + #[test] + fn chain_follow_ids_are_scoped_per_product_core() { + let (host_config, product) = runtime_config("same.dot"); + let spawner = test_spawner(); + let platform: Arc = stub_platform(); + let services = RuntimeServices::new( + platform.clone(), + host_config.people_chain_genesis_hash, + spawner.clone(), + ); + let pairing_host = PairingHost::new(services.clone(), host_config); + let first = ProductRuntimeHost::from_services( + services.clone(), + pairing_host.clone(), + product.clone(), + ); + let second = ProductRuntimeHost::from_services(services, pairing_host, product); + + assert_eq!(first.follow_id("request-1"), "c1:request-1"); + assert_eq!(second.follow_id("request-1"), "c2:request-1"); + } + + #[test] + fn bare_localhost_product_allows_dev_product_accounts() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("localhost"), test_spawner()); + + assert!(host.is_product_account_valid_for_caller("myapp.dot")); + } + + #[test] + fn navigate_to_uses_dotns_decision_and_then_platform() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "mytestapp.dot".to_string(), + }); + let response = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap(); + assert_eq!(response, HostNavigateToResponse::V1); + } + + #[test] + fn navigate_to_rejects_empty_input_without_calling_platform() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostNavigateToRequest::V1(v01::HostNavigateToRequest { + url: "".to_string(), + }); + let err = futures::executor::block_on(host.navigate_to(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostNavigateToError::V1(v01::HostNavigateToError::Unknown { + .. + })) => {} + other => panic!("expected Unknown navigate error, got {other:?}"), + } + } + + #[test] + fn push_notification_delegates_payload_and_returns_host_id() { + let pushed_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + notification_id: 42, + pushed_notifications: pushed_notifications.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = HostPushNotificationRequest::V1(v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }); + + let response = + futures::executor::block_on(host.send_push_notification(&cx, request)).unwrap(); + + assert_eq!( + response, + HostPushNotificationResponse::V1(v01::HostPushNotificationResponse { id: 42 }) + ); + assert_eq!( + pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .as_slice(), + &[v01::HostPushNotificationRequest { + text: "Hello".to_string(), + deeplink: Some("https://example.invalid/launch".to_string()), + scheduled_at: Some(1_776_144_000_000), + }] + ); + } + + #[test] + fn cancel_notification_delegates_host_id() { + let cancelled_notifications = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + cancelled_notifications: cancelled_notifications.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform, test_spawner()); + let cx = CallContext::new(); + let request = + HostPushNotificationCancelRequest::V1(v01::HostPushNotificationCancelRequest { + id: 42, + }); + + let response = + futures::executor::block_on(host.cancel_push_notification(&cx, request)).unwrap(); + + assert_eq!(response, HostPushNotificationCancelResponse::V1); + assert_eq!( + cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .as_slice(), + &[42] + ); + } + + #[test] + fn get_account_requires_session() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_rejects_invalid_product_identifier() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "example.com".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_other_product_rejects_when_user_declines() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1(v01::HostAccountGetError::Rejected)) + )); + assert_eq!( + platform + .account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .as_slice(), + &[AccountAccessReview { + requesting_product_id: "myapp.dot".to_string(), + target_product_id: "other.dot".to_string(), + }] + ); + } + + #[test] + fn get_account_other_product_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(host.get_account(&cx, request)).unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn get_account_other_product_accepts_confirmation_then_derives_key() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_confirmed: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + let session = session_info(); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + inner.account.public_key, + derive_product_public_key(session.public_key, "other.dot", 0) + .unwrap() + .to_vec() + ); + } + + #[test] + fn get_account_derives_dotli_product_key() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_normalizes_product_identifier_before_deriving() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("MyApp.DOT"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "MyApp.DOT".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_localhost_product_prompts_for_other_product_identifier() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_access_confirmed: true, + ..Default::default() + }), + runtime_config("localhost:3000"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let response = futures::executor::block_on(host.get_account(&cx, request)).unwrap(); + let HostAccountGetResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.account.public_key), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + } + + #[test] + fn get_account_alias_requires_session() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn get_account_alias_rejects_invalid_product_identifier() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("example.com")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::DomainNotValid + )) + )); + } + + #[test] + fn get_account_alias_same_domain_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("myapp.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [9; 32]); + assert_eq!(inner.alias, vec![1, 2, 3]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_normalizes_remote_request_identifier() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-1".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [9; 32], + alias: vec![1, 2, 3], + }), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("MyApp.DOT"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-1".to_string()); + futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("MyApp.DOT")), + ) + .unwrap(); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(request), + ) = message.data + else { + panic!("expected ring VRF alias request"); + }; + assert_eq!(request.product_account_id.dot_ns_identifier, "myapp.dot"); + assert_eq!(request.product_id, "myapp.dot"); + } + + #[test] + fn get_account_alias_cross_domain_rejects_when_user_declines() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetError::Rejected + )) + )); + } + + #[test] + fn get_account_alias_cross_domain_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + account_alias_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn get_account_alias_cross_domain_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + account_alias_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alias-2".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasResponse( + crate::host_logic::sso::messages::RingVrfAliasResponse { + responding_to: "alias-2".to_string(), + payload: Ok(v01::HostAccountGetAliasResponse { + context: [8; 32], + alias: vec![4, 5, 6], + }), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alias-2".to_string()); + let response = futures::executor::block_on( + host.get_account_alias(&cx, account_alias_request("other.dot")), + ) + .unwrap(); + let HostAccountGetAliasResponse::V1(inner) = response; + assert_eq!(inner.context, [8; 32]); + assert_eq!(inner.alias, vec![4, 5, 6]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::RingVrfAliasRequest(_) + ) + )); + } + + #[test] + fn get_legacy_accounts_returns_derived_slot_zero_when_connected() { + let host = ProductRuntimeHost::new( + stub_platform(), + runtime_config("localhost:3000"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert_eq!(inner.accounts.len(), 1); + assert_eq!(inner.accounts[0].name.as_deref(), Some("alice")); + assert_eq!( + hex::encode(&inner.accounts[0].public_key), + "1c822b488297fde8c60d9cbc5585839f70a69fb2c5c69daa66b6043c75184467" + ); + } + + #[test] + fn get_legacy_accounts_returns_empty_when_disconnected() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let response = futures::executor::block_on( + host.get_legacy_accounts(&cx, HostGetLegacyAccountsRequest::V1), + ) + .unwrap(); + let HostGetLegacyAccountsResponse::V1(inner) = response; + assert!(inner.accounts.is_empty()); + } + + #[test] + fn get_user_id_returns_primary_username() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_caches_identity_disclosure_grant() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Authorized); + } + + #[test] + fn get_user_id_caches_identity_disclosure_denial() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + let first = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + assert!(matches!( + first, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert!(matches!( + second, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::Denied); + } + + #[test] + fn get_user_id_dismissed_identity_disclosure_stays_not_determined() { + let platform = Arc::new(StubPlatform { + identity_disclosure_error: Some("dismissed"), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + + let first = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + let second = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 2); + assert!(matches!( + first, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert!(matches!( + second, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + let status = + futures::executor::block_on(host.permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + )) + .unwrap(); + assert_eq!(status, PermissionAuthorizationStatus::NotDetermined); + } + + #[test] + fn get_user_id_checks_identity_disclosure_before_username() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let mut session = session_info(); + session.full_username = None; + session.lite_username = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + + let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::PermissionDenied + )) + )); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_reports_missing_username_after_identity_disclosure() { + let platform = Arc::new(StubPlatform { + identity_disclosure_confirmed: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let mut session = session_info(); + session.full_username = None; + session.lite_username = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + + let err = futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostGetUserIdError::V1( + v01::HostGetUserIdError::Unknown { ref reason } + )) if reason == "No primary username for this session" + )); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn get_user_id_respects_pre_authorized_identity_disclosure() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + futures::executor::block_on(host.set_permission_authorization_status( + PermissionAuthorizationRequest::IdentityDisclosure, + PermissionAuthorizationStatus::Authorized, + )) + .unwrap(); + + let response = + futures::executor::block_on(host.get_user_id(&cx, HostGetUserIdRequest::V1)).unwrap(); + let HostGetUserIdResponse::V1(inner) = response; + assert_eq!(inner.primary_username, "Alice Smith"); + assert_eq!(platform.identity_disclosure_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn derive_entropy_matches_dotli_vector() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let response = futures::executor::block_on(host.derive(&cx, request)).unwrap(); + let HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); + } + + #[test] + fn derive_entropy_requires_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Not connected"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_requires_secret() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = None; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"product-key".to_vec(), + }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "Session secret missing"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + #[test] + fn derive_entropy_rejects_empty_context_like_dotli_key() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let mut session = sso_session_info(); + session.root_entropy_source = session_info().root_entropy_source; + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = + HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { context: vec![] }); + let err = futures::executor::block_on(host.derive(&cx, request)).unwrap_err(); + match err { + CallError::Domain(HostDeriveEntropyError::V1( + v01::HostDeriveEntropyError::Unknown { reason }, + )) => assert_eq!(reason, "\"key\" must be between 1 and 32 bytes, got 0"), + other => panic!("expected Unknown entropy error, got {other:?}"), + } + } + + fn bulletin_slot_account_key_fixture() -> Vec { + hex::decode( + "0eef5183411d40c32446bb1cbaabd70004a17af6012a577c735d054f04059208\ + 573dfc9b6ffeb1c786a16349e70f9836876a743c31c0a7a2a70727a852eec372", + ) + .unwrap() + } + + fn expected_bulletin_slot_account_public_key() -> Vec { + hex::decode("10c68432943c68a6e1be650818b5e08db79e57823de9f34df7ba36d404d91e1d").unwrap() + } + + #[test] + fn preimage_submit_confirms_and_delegates_to_platform() { + let session = sso_session_info(); + let slot_account_key = bulletin_slot_account_key_fixture(); + let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); + let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), + preimage_submit_signatures: preimage_submit_signatures.clone(), + sso_response_script: Some(sso_success_response_script( + &session, + RemoteMessage { + message_id: "wallet-preimage-allowance".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( + ResourceAllocationResponse { + responding_to: "preimage-submit".to_string(), + payload: Ok(vec![SsoAllocationOutcome::Allocated( + SsoAllocatedResource::BulletinAllowance { + slot_account_key: slot_account_key.clone(), + }, + )]), + }, + )), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); + assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + assert_eq!( + preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .as_slice(), + &[expected_bulletin_slot_account_public_key()] + ); + assert_eq!( + preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned")[0] + .len(), + 64 + ); + let message = submitted_remote_message(&platform, &session); + match message.data { + RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) => { + assert_eq!( + request.resources, + vec![SsoAllocatableResource::BulletinAllowance] + ); + assert_eq!(request.on_existing, OnExistingAllowancePolicy::Ignore); + } + other => panic!("expected bulletin allowance request, got {other:?}"), + } + } + + #[test] + fn preimage_submit_uses_persisted_bulletin_allowance_key() { + let session = sso_session_info(); + let slot_account_key = bulletin_slot_account_key_fixture(); + let preimage_submit_allowance_public_keys = Arc::new(Mutex::new(Vec::new())); + let preimage_submit_signatures = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + preimage_submit_allowance_public_keys: preimage_submit_allowance_public_keys.clone(), + preimage_submit_signatures: preimage_submit_signatures.clone(), + ..Default::default() + }); + futures::executor::block_on(allowances::write_allowance_key( + &*platform, + &session, + "unknown.dot", + allowances::AllowanceResource::Bulletin, + slot_account_key.clone(), + )) + .unwrap(); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let response = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap(); + assert_eq!(response, RemotePreimageSubmitResponse::V1(vec![1, 2, 3])); + assert_eq!( + preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .as_slice(), + &[expected_bulletin_slot_account_public_key()] + ); + assert_eq!( + preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned")[0] + .len(), + 64 + ); + assert!( + platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .is_empty(), + "persisted allowance should not send an SSO resource-allocation request" + ); + } + + #[test] + fn preimage_submit_requires_session_before_backend_call() { + let preimage_submits = Arc::new(Mutex::new(Vec::new())); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + preimage_submits: preimage_submits.clone(), + ..Default::default() + }), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected preimage session error, got {other:?}"), + } + assert!( + preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn preimage_submit_requires_remote_permission_before_backend_call() { + let preimage_submits = Arc::new(Mutex::new(Vec::new())); + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + remote_permission_denied: true, + preimage_submits: preimage_submits.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = RemotePreimageSubmitRequest::V1(vec![1, 2, 3]); + let err = futures::executor::block_on(Preimage::submit(&host, &cx, request)).unwrap_err(); + match err { + CallError::Domain(RemotePreimageSubmitError::V1( + v01::PreimageSubmitError::Unknown { reason }, + )) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected preimage permission denial, got {other:?}"), + } + assert!( + preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn chain_broadcast_requires_remote_permission_before_backend_call() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let request = RemoteChainTransactionBroadcastRequest::V1( + v01::RemoteChainTransactionBroadcastRequest { + genesis_hash: vec![0; 32], + transaction: vec![1, 2, 3], + }, + ); + let err = futures::executor::block_on(Chain::broadcast_transaction(&host, &cx, request)) + .unwrap_err(); + match err { + CallError::Domain(RemoteChainTransactionBroadcastError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected chain broadcast permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn preimage_lookup_subscribe_maps_platform_values() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = + RemotePreimageLookupSubscribeRequest::V1(v01::RemotePreimageLookupSubscribeRequest { + key: vec![0; 32], + }); + let mut subscription = futures::executor::block_on(host.lookup_subscribe(&cx, request)); + let item = futures::executor::block_on(subscription.next()).expect("preimage item"); + assert_eq!( + item, + RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { + value: Some(vec![9, 8, 7]) + }) + ); + } + + #[test] + fn theme_subscribe_maps_platform_values() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let mut subscription = futures::executor::block_on(Theme::subscribe(&host, &cx)); + let item = futures::executor::block_on(subscription.next()).expect("theme item"); + assert_eq!( + item, + HostThemeSubscribeItem::V1(v01::HostThemeSubscribeItem { + name: v01::ThemeName::Default, + variant: v01::ThemeVariant::Dark, + }) + ); + } + + #[test] + fn sign_raw_rejects_invalid_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("other.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_without_session_after_valid_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_raw_rejects_when_user_declines_confirmation() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + } + + #[test] + fn sign_raw_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + sign_response_message("sign-raw-1", vec![7, 7], None), + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-raw-1".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let response = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap(); + let HostSignRawResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![7, 7]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Raw(_) + ) + )); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let methods = sent + .iter() + .map(|request| { + serde_json::from_str::(request).unwrap()["method"] + .as_str() + .unwrap() + .to_string() + }) + .collect::>(); + assert_eq!( + methods, + vec![ + "statement_subscribeStatement", + "statement_subscribeStatement", + "statement_submit", + "statement_unsubscribeStatement", + "statement_unsubscribeStatement", + ] + ); + let mut unsubscribe_ids = sent[3..] + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["params"][0].as_str().unwrap().to_string()) + .collect::>(); + unsubscribe_ids.sort(); + assert_eq!(unsubscribe_ids, vec!["own-sub", "peer-sub"]); + } + + #[test] + fn sign_raw_uses_call_context_timeout_for_sso_response_wait() { + let session = sso_session_info(); + let message_id = "sign-raw-timeout"; + let mut rpc_responses = sso_success_responses( + &session, + message_id, + sign_response_message(message_id, vec![], None), + ); + rpc_responses.truncate(3); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let mut cx = CallContext::with_request_id(message_id.to_string()); + cx.set_timeout(std::time::Duration::from_millis(1)); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => assert_eq!( + reason, + "Account authority request timed out after 1ms for sign-raw-timeout" + ), + other => panic!("expected SSO response timeout, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "timed-out SSO request did not unsubscribe statement streams", + ); + } + + #[test] + fn sign_raw_cancellation_unsubscribes_sso_subscriptions() { + let session = sso_session_info(); + let message_id = "sign-raw-cancel"; + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "own-sub-sign-raw-cancel"), + subscribe_ack_frame("truapi:2", "peer-sub-sign-raw-cancel"), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let cancel = truapi::CancellationToken::new(); + let cx = CallContext::with_parts(message_id.to_string(), cancel.clone()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let handle = std::thread::spawn(move || { + futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err() + }); + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_subscribeStatement") == 2, + "SSO subscriptions were not established before cancellation", + ); + cancel.cancel(); + + let err = handle + .join() + .expect("sign_raw cancellation thread panicked"); + match err { + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Unknown { + reason, + })) => { + assert!( + reason.contains("cancelled"), + "expected cancellation, got {reason}" + ); + assert!( + reason.contains(message_id), + "expected request id in cancellation reason, got {reason}" + ); + } + other => panic!("expected SSO cancellation, got {other:?}"), + } + + wait_until( + || recorded_rpc_method_count(&platform.sent_rpc, "statement_unsubscribeStatement") == 2, + "cancelled SSO request did not unsubscribe statement streams", + ); + } + + #[test] + fn sign_raw_peer_disconnect_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + sso_response_script: Some(sso_peer_disconnect_response_script(&session)), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let cx = CallContext::with_request_id("sign-raw-disconnect".to_string()); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: account_id("myapp.dot", 0), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw(&cx, request)).unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(HostSignRawError::V1(v01::HostSignPayloadError::Rejected)) + )); + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn idle_peer_disconnect_monitor_clears_session_store_and_broadcasts() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + rpc_responses: sso_peer_disconnect_monitor_responses(&session), + ..Default::default() + }); + let (host_config, product) = runtime_config("myapp.dot"); + let (host, pairing_host) = ProductRuntimeHost::new_pairing_for_tests( + platform.clone(), + host_config, + product, + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + pairing_host.start_session_supervision_for_current_session(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let disconnected = loop { + if let Some(item) = statuses.next().now_or_never() { + break item.expect("status stream ended"); + } + assert!( + std::time::Instant::now() < deadline, + "peer disconnect monitor did not emit Disconnected" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + }; + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert_eq!( + disconnected, + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + } + + #[test] + fn sign_payload_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignPayloadError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn sign_payload_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + sign_payload_error: Some("modal failed"), + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + let err = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn sign_payload_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_payload_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + sign_response_message("sign-payload-1", vec![8, 8], Some(vec![0xab, 0xcd])), + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("sign-payload-1".to_string()); + let request = HostSignPayloadRequest::V1(v01::HostSignPayloadRequest { + account: account_id("myapp.dot", 0), + payload: sign_payload_data(), + }); + + let response = futures::executor::block_on(host.sign_payload(&cx, request)).unwrap(); + + let HostSignPayloadResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + assert_eq!(inner.signed_transaction, Some(vec![0xab, 0xcd])); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + &message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) + ) if matches!( + request.as_ref(), + crate::host_logic::sso::messages::SigningRequest::Payload(_) + ) + )); + } + + #[test] + fn create_transaction_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("create-tx-1".to_string()); + let request = HostCreateTransactionRequest::V1(product_tx_payload("myapp.dot")); + let response = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap(); + let HostCreateTransactionResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(_) + ) + )); + } + + #[test] + fn legacy_sign_raw_rejects_signer_mismatch() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5Ci5sCERp3MFEDpF2jVkQDJoBevpRosB7toYRqKWShewhdhq".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn legacy_sign_raw_denies_when_chain_submit_denied() { + let host = ProductRuntimeHost::new( + Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let err = futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)) + .unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostSignRawWithLegacyAccountError::V1( + v01::HostSignPayloadError::PermissionDenied + )) + )); + } + + #[test] + fn legacy_sign_raw_accepts_derived_ss58_then_returns_sso_response() { + let session = sso_session_info(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + sign_response_message("legacy-sign-raw-1", vec![9, 9], None), + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1".to_string(), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![9, 9]); + assert_eq!(inner.signed_transaction, None); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), + ) = message.data + else { + panic!("expected product raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing payload"); + }; + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + assert!(matches!( + &request.data, + crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) + if bytes == b"hello" + )); + } + + #[test] + fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + sign_raw_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + sign_response_message("legacy-sign-raw-hex-1", vec![8, 8], None), + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-sign-raw-hex-1".to_string()); + let request = + HostSignRawWithLegacyAccountRequest::V1(v01::HostSignRawWithLegacyAccountRequest { + signer: format!("0x{}", hex::encode(signer)), + payload: raw_payload(), + }); + let response = + futures::executor::block_on(host.sign_raw_with_legacy_account(&cx, request)).unwrap(); + let HostSignRawWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.signature, vec![8, 8]); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request), + ) = message.data + else { + panic!("expected product raw signing request"); + }; + let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + panic!("expected raw signing payload"); + }; + assert_eq!( + request.product_account_id, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + } + + #[test] + fn legacy_create_transaction_rejects_raw_key_mismatch() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer: [0; 32], + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + let err = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap_err(); + match err { + CallError::Domain(HostCreateTransactionWithLegacyAccountError::V1( + v01::HostCreateTransactionError::Unknown { reason }, + )) => assert_eq!(reason, "Account can't be derived from product account id"), + other => panic!("expected legacy signer mismatch, got {other:?}"), + } + } + + #[test] + fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { + let session = sso_session_info(); + let signer = derive_product_public_key(session.public_key, "myapp.dot", 0).unwrap(); + let platform = Arc::new(StubPlatform { + create_transaction_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-legacy-create-tx-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionResponse( + crate::host_logic::sso::messages::CreateTransactionResponse { + responding_to: "legacy-create-tx-1".to_string(), + signed_transaction: Ok(vec![0xca, 0xfe]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("legacy-create-tx-1".to_string()); + let request = + HostCreateTransactionWithLegacyAccountRequest::V1(v01::LegacyAccountTxPayload { + signer, + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + }); + + let response = + futures::executor::block_on(host.create_transaction_with_legacy_account(&cx, request)) + .unwrap(); + + let HostCreateTransactionWithLegacyAccountResponse::V1(inner) = response; + assert_eq!(inner.transaction, vec![0xca, 0xfe]); + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionRequest(request), + ) = message.data + else { + panic!("expected product transaction request"); + }; + let crate::host_logic::sso::messages::CreateTransactionPayload::V1(payload) = + request.payload; + assert_eq!( + payload.signer, + v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + } + ); + } + + #[test] + fn create_transaction_rejects_invalid_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostCreateTransactionRequest::V1(product_tx_payload("other.dot")); + let err = futures::executor::block_on(host.create_transaction(&cx, request)).unwrap_err(); + assert!(matches!( + err, + CallError::Domain(HostCreateTransactionError::V1( + v01::HostCreateTransactionError::PermissionDenied + )) + )); + } + + #[test] + fn resource_allocation_rejects_without_session() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "No active session"), + other => panic!("expected no-session resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_rejects_when_user_declines() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + match err { + CallError::Domain(HostRequestResourceAllocationError::V1( + v01::ResourceAllocationError::Unknown { reason }, + )) => assert_eq!(reason, "User rejected resource allocation"), + other => panic!("expected user-rejected resource allocation error, got {other:?}"), + } + } + + #[test] + fn resource_allocation_maps_confirmation_failure_to_host_failure() { + let host = ProductRuntimeHost::new_compat( + Arc::new(StubPlatform { + resource_allocation_error: Some("modal failed"), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let err = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap_err(); + assert!( + matches!(err, CallError::HostFailure { reason } if reason.contains("modal failed")) + ); + } + + #[test] + fn resource_allocation_accepts_confirmation_then_returns_sso_response() { + let session = sso_session_info(); + let slot_account_key = { + let mini_secret = schnorrkel::MiniSecretKey::from_bytes(&[12; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(schnorrkel::ExpansionMode::Ed25519); + keypair.secret.to_bytes().to_vec() + }; + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-alloc-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( + crate::host_logic::sso::messages::ResourceAllocationResponse { + responding_to: "alloc-1".to_string(), + payload: Ok(vec![ + crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( + crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }, + ), + crate::host_logic::sso::messages::SsoAllocationOutcome::Rejected, + crate::host_logic::sso::messages::SsoAllocationOutcome::NotAvailable, + ]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("alloc-1".to_string()); + let response = futures::executor::block_on(ResourceAllocation::request( + &host, + &cx, + resource_allocation_request(), + )) + .unwrap(); + let HostRequestResourceAllocationResponse::V1(inner) = response; + assert_eq!( + inner.outcomes, + vec![ + v01::AllocationOutcome::Allocated, + v01::AllocationOutcome::Rejected, + v01::AllocationOutcome::NotAvailable, + ] + ); + let message = submitted_remote_message(&platform, &session); + assert!(matches!( + message.data, + crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationRequest(_) + ) + )); + } + + #[test] + fn session_store_sync_restores_valid_blob_from_tick() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || host.test_session_state().current() == Some(stored.clone()), + "session store sync did not restore valid blob", + ); + + assert_eq!(host.test_session_state().current(), Some(stored.clone())); + let expected_auth_states = vec![AuthState::Connected(connected_session_ui_info(&stored))]; + wait_until( + || { + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + == expected_auth_states + }, + "session store sync did not broadcast connected auth state", + ); + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + expected_auth_states + ); + } + + #[test] + fn session_store_sync_replaces_valid_blob_and_broadcasts_connected() { + let mut replacement = sso_session_info(); + replacement.public_key = [0x44; 32]; + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &replacement, + )), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + let mut statuses = host.test_session_state().subscribe(); + let _ = futures::executor::block_on(statuses.next()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + assert_eq!(host.test_session_state().current(), Some(replacement)); + } + + #[test] + fn session_store_sync_clears_invalid_blob() { + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || host.test_session_state().current().is_none(), + "session store sync did not clear invalid blob", + ); + + assert!(host.test_session_state().current().is_none()); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and clearing the invalid blob emits nothing. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn session_store_sync_clears_unreadable_blob() { + let session_clears = Arc::new(Mutex::new(0)); + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || *session_clears.lock().unwrap() == 1, + "session store sync did not clear unreadable blob", + ); + + assert!(host.test_session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 1); + } + + /// A persistently failing read clears the backing store once for the + /// initial sync tick. Further clears require explicit host notifications. + #[test] + fn session_store_sync_clears_once_on_initial_persistent_read_error() { + let session_clears = Arc::new(Mutex::new(0)); + let (host, pairing_host) = ProductRuntimeHost::new_compat_with_pairing( + Arc::new(StubPlatform { + session_error: Some("storage unavailable"), + session_clears: session_clears.clone(), + ..Default::default() + }), + test_spawner(), + ); + host.test_session_state().set_session(sso_session_info()); + + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + + wait_until( + || *session_clears.lock().unwrap() == 1, + "clear_stored_session was never called", + ); + assert_eq!(*session_clears.lock().unwrap(), 1); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn disconnect_submits_disconnected_message_best_effort() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let session = sso_session_info(); + host.test_session_state().set_session(session.clone()); + + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + let message = submitted_remote_message(&platform, &session); + assert_eq!(message.message_id, "truapi:sso:disconnect"); + assert!(matches!( + message.data, + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) + )); + } + + #[test] + fn disconnect_clears_session_store_and_broadcasts_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + vec![1, 2, 3], + ); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + assert!( + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "logout may leave the old pairing identity in storage; the next login rotates it before presenting QR" + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + // `set_session` bypasses the auth state cell, so the cell never left + // `Disconnected` and the logout emits nothing new. + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .is_empty() + ); + } + + #[test] + fn disconnect_emits_disconnected_auth_state_after_store_sync_connected() { + let stored = sso_session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + pairing_host + .clone() + .start_session_store_sync_for_tests(test_spawner()); + wait_until( + || { + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .len() + == 1 + }, + "session store sync did not emit connected auth state", + ); + + futures::executor::block_on(host.disconnect()); + + assert_eq!( + *platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"), + vec![ + AuthState::Connected(connected_session_ui_info(&stored)), + AuthState::Disconnected, + ] + ); + } + + #[test] + fn disconnect_tolerates_repeated_logout_when_already_disconnected() { + let platform = Arc::new(StubPlatform::default()); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + + futures::executor::block_on(host.disconnect()); + futures::executor::block_on(host.disconnect()); + + assert!(host.test_session_state().current().is_none()); + assert_eq!( + *platform + .session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 2 + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn permissions_grants_and_caches() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostDevicePermissionRequest::V1(v01::HostDevicePermissionRequest::Camera); + let response = + futures::executor::block_on(host.request_device_permission(&cx, request)).unwrap(); + let HostDevicePermissionResponse::V1(inner) = response; + assert!(inner.granted); + } + + #[test] + fn feature_supported_encodes_response_to_known_bytes() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + let cx = CallContext::new(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let response = futures::executor::block_on(host.feature_supported(&cx, request)).unwrap(); + // [V1 variant=0][supported=1] + assert_eq!(response.encode(), vec![0x00, 0x01]); + } +} diff --git a/rust/crates/truapi-server/src/runtime/allowances.rs b/rust/crates/truapi-server/src/runtime/allowances.rs new file mode 100644 index 000000000..aa5e2ad1f --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/allowances.rs @@ -0,0 +1,296 @@ +//! Persistent allowance-key repository for pairing-host SSO sessions. +//! +//! Implements the host-side allowance cache described in +//! `docs/rfcs/0010-allowance.md`. +//! +//! This mirrors host-papp's allowance repository shape: keys are grouped by +//! SSO session and then indexed by `(product_id, resource)`. The runtime keeps +//! a short-lived memory cache in `PairingHost`; this module owns the durable +//! CoreStorage encoding. + +use parity_scale_codec::{Decode, Encode}; +use truapi::latest::GenericError; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use super::authority::AuthorityError; +use super::sso_remote::SsoSessionKey; +use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Encode, Decode)] +pub(super) enum AllowanceResource { + Bulletin, + StatementStore, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) struct AllowanceCacheKey { + session: SsoSessionKey, + product_id: String, + resource: AllowanceResource, +} + +impl AllowanceCacheKey { + pub(super) fn new( + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, + ) -> Result { + Ok(Self { + session: sso_cache_key(session)?, + product_id: product_id.to_string(), + resource, + }) + } + + pub(super) fn is_for_session(&self, session: SsoSessionKey) -> bool { + self.session == session + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +struct StoredAllowanceEntry { + product_id: String, + resource: AllowanceResource, + slot_account_key: Vec, +} + +pub(super) async fn read_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, +) -> Result>, AuthorityError> { + let entries = read_entries(storage, session).await?; + Ok(entries + .into_iter() + .find(|entry| entry.product_id == product_id && entry.resource == resource) + .map(|entry| entry.slot_account_key)) +} + +pub(super) async fn write_allowance_key( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, + product_id: &str, + resource: AllowanceResource, + slot_account_key: Vec, +) -> Result<(), AuthorityError> { + let mut entries = read_entries(storage, session).await?; + entries.retain(|entry| !(entry.product_id == product_id && entry.resource == resource)); + entries.push(StoredAllowanceEntry { + product_id: product_id.to_string(), + resource, + slot_account_key, + }); + storage + .write_core_storage(storage_key(session)?, encode_entries(entries)) + .await + .map_err(storage_error) +} + +pub(super) async fn clear_session_allowance_keys( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, +) -> Result<(), AuthorityError> { + storage + .clear_core_storage(storage_key(session)?) + .await + .map_err(storage_error) +} + +async fn read_entries( + storage: &(impl CoreStorage + ?Sized), + session: &SessionInfo, +) -> Result, AuthorityError> { + let Some(blob) = storage + .read_core_storage(storage_key(session)?) + .await + .map_err(storage_error)? + else { + return Ok(Vec::new()); + }; + decode_entries(&blob) +} + +fn encode_entries(entries: Vec) -> Vec { + entries.encode() +} + +fn decode_entries(blob: &[u8]) -> Result, AuthorityError> { + let mut input = blob; + let entries = + Vec::::decode(&mut input).map_err(|err| AuthorityError::Unknown { + reason: format!("invalid persisted allowance keys: {err}"), + })?; + if !input.is_empty() { + return Err(AuthorityError::Unknown { + reason: "invalid persisted allowance keys: trailing bytes".to_string(), + }); + } + Ok(entries) +} + +fn storage_key(session: &SessionInfo) -> Result { + Ok(CoreStorageKey::AllowanceKeys { + session_id: session_storage_id(session.sso.as_ref().ok_or(AuthorityError::Disconnected)?), + }) +} + +fn sso_cache_key(session: &SessionInfo) -> Result { + let sso = session.sso.as_ref().ok_or(AuthorityError::Disconnected)?; + Ok(SsoSessionKey::from_session(sso)) +} + +fn session_storage_id(session: &SsoSessionInfo) -> String { + let mut bytes = Vec::with_capacity(64); + bytes.extend_from_slice(&session.session_id_own); + bytes.extend_from_slice(&session.session_id_peer); + hex::encode(bytes) +} + +fn storage_error(err: GenericError) -> AuthorityError { + AuthorityError::Unknown { + reason: format!("allowance storage failed: {}", err.reason), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use std::sync::Mutex; + + use crate::test_support::sso_session_info; + + #[derive(Default)] + struct MemStorage { + inner: Mutex, Vec>>, + } + + #[truapi_platform::async_trait] + impl CoreStorage for MemStorage { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, GenericError> { + Ok(self + .inner + .lock() + .expect("storage mutex poisoned") + .get(&key.encode()) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .insert(key.encode(), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), GenericError> { + self.inner + .lock() + .expect("storage mutex poisoned") + .remove(&key.encode()); + Ok(()) + } + } + + #[test] + fn stores_allowance_keys_by_product_and_resource() { + let storage = MemStorage::default(); + let session = sso_session_info(); + + futures::executor::block_on(async { + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin, + vec![1; 64], + ) + .await + .unwrap(); + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::StatementStore, + vec![2; 64], + ) + .await + .unwrap(); + + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + Some(vec![1; 64]) + ); + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::StatementStore + ) + .await + .unwrap(), + Some(vec![2; 64]) + ); + assert_eq!( + read_allowance_key( + &storage, + &session, + "other.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + None + ); + }); + } + + #[test] + fn clears_session_allowance_keys() { + let storage = MemStorage::default(); + let session = sso_session_info(); + + futures::executor::block_on(async { + write_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin, + vec![1; 64], + ) + .await + .unwrap(); + clear_session_allowance_keys(&storage, &session) + .await + .unwrap(); + assert_eq!( + read_allowance_key( + &storage, + &session, + "dotli.localhost", + AllowanceResource::Bulletin + ) + .await + .unwrap(), + None + ); + }); + } +} diff --git a/rust/crates/truapi-server/src/runtime/auth_state.rs b/rust/crates/truapi-server/src/runtime/auth_state.rs new file mode 100644 index 000000000..1b0b85410 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/auth_state.rs @@ -0,0 +1,168 @@ +//! Core-owned auth/session UI state machine. Every [`AuthState`] emission to +//! the host funnels through [`AuthStateMachine`], so transitions stay ordered +//! and a stale session-store tick can never tear down an in-flight pairing. + +use std::sync::{Arc, Mutex}; + +use futures::channel::oneshot; +use truapi_platform::{AuthPresenter, AuthState, Platform, SessionUiInfo}; + +/// Serialized auth-state machine bound to the platform's `auth_state_changed` +/// sink. Each transition mutates under the lock, releases it, then emits the +/// new state (when it actually changed), so `auth_state_changed` handlers may +/// safely re-enter the runtime (e.g. a host cancelling the login it just +/// observed). The cancel channel for an in-flight login lives inside the +/// `Pairing` state, making its registration atomic with the transition. +pub(crate) struct AuthStateMachine { + platform: Arc, + inner: Arc>, +} + +impl Clone for AuthStateMachine { + fn clone(&self) -> Self { + Self { + platform: self.platform.clone(), + inner: self.inner.clone(), + } + } +} + +#[derive(Default)] +struct AuthStateInner { + state: AuthState, + /// Increments on every `pairing_started`; lets an abandoned flow's reset + /// guard distinguish its own `Pairing` from a newer flow's. + pairing_epoch: u64, + /// Resolves the in-flight login's cancel receiver. Present exactly while + /// the state is `Pairing`. + cancel_tx: Option>, +} + +impl AuthStateMachine { + /// Create an auth state machine that reports transitions to `platform`. + pub(super) fn new(platform: Arc) -> Self { + Self { + platform, + inner: Arc::new(Mutex::new(AuthStateInner::default())), + } + } + + /// Enter `Pairing`. Returns the cancel receiver and the pairing epoch, or + /// `None` when a pairing is already in flight (single-flight guard). + pub(super) fn pairing_started(&self, deeplink: String) -> Option<(oneshot::Receiver<()>, u64)> { + let (cancel_tx, cancel_rx) = oneshot::channel(); + let epoch = self.transition(|inner| { + if matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.state = AuthState::Pairing { deeplink }; + inner.pairing_epoch = inner.pairing_epoch.wrapping_add(1); + inner.cancel_tx = Some(cancel_tx); + Some(inner.pairing_epoch) + })?; + Some((cancel_rx, epoch)) + } + + /// `Pairing` -> `LoginFailed`: the in-flight login reported a failure. + pub(super) fn login_failed(&self, reason: String) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Disconnected`/`LoginFailed` -> `LoginFailed`: a login failed before + /// it reached `Pairing` (device identity or bootstrap errors). A no-op + /// while `Pairing`, so a concurrent second login attempt failing early + /// cannot tear down the first one's presentation. + pub(super) fn login_failed_before_pairing(&self, reason: String) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Connected(_) + ) { + return None; + } + inner.state = AuthState::LoginFailed { reason }; + Some(()) + }); + } + + /// `Pairing`/`LoginFailed` -> `Disconnected` (host cancelled or + /// dismissed). Wakes the in-flight login, which resolves as `Rejected`. + pub(super) fn login_cancelled(&self) { + self.transition(|inner| { + if !matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::LoginFailed { .. } + ) { + return None; + } + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Any state -> `Connected`. A login in flight is cancelled: another + /// runtime won the race, and the waking flow resolves as + /// `AlreadyConnected`. Emits only when the connected info changed. + pub(super) fn connected(&self, info: &SessionUiInfo) { + self.transition(|inner| { + if let Some(cancel_tx) = inner.cancel_tx.take() { + let _ = cancel_tx.send(()); + } + if matches!(&inner.state, AuthState::Connected(current) if current == info) { + return None; + } + inner.state = AuthState::Connected(info.clone()); + Some(()) + }); + } + + /// Session store reports no session. A no-op while `Pairing`: the login + /// flow owns its own terminal transition, and a boot-time store tick must + /// not tear down the pairing UI. + pub(super) fn store_disconnected(&self) { + self.transition(|inner| { + if matches!( + inner.state, + AuthState::Pairing { .. } | AuthState::Disconnected + ) { + return None; + } + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Reset a `Pairing` left behind by a dropped login future, but only when + /// it still belongs to `epoch` (a newer flow's pairing is left alone). + pub(super) fn reset_abandoned_pairing(&self, epoch: u64) { + self.transition(|inner| { + if !matches!(inner.state, AuthState::Pairing { .. }) || inner.pairing_epoch != epoch { + return None; + } + inner.cancel_tx = None; + inner.state = AuthState::Disconnected; + Some(()) + }); + } + + /// Run `apply` under the lock; when it changed the state (returned + /// `Some`), emit the new state to the host after releasing the lock. + fn transition(&self, apply: impl FnOnce(&mut AuthStateInner) -> Option) -> Option { + let mut inner = self.inner.lock().expect("auth state mutex poisoned"); + let applied = apply(&mut inner)?; + let state = inner.state.clone(); + drop(inner); + AuthPresenter::auth_state_changed(self.platform.as_ref(), state); + Some(applied) + } +} diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs new file mode 100644 index 000000000..7b5ce02d0 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -0,0 +1,340 @@ +//! Role-neutral account authority contracts used by product runtimes. +//! +//! Pairing and signing hosts implement these traits differently, but +//! `ProductRuntimeHost` can use this module's shared request/session types +//! without knowing where the key material lives. + +use async_trait::async_trait; +use core::fmt; +use core::time::Duration; +use std::sync::Arc; +use truapi::latest::{ + HostAccountGetAliasResponse, HostCreateTransactionResponse, + HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, + HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, +}; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, CancellationReason}; +use truapi_platform::{BulletinAllowanceKeyError, ProductContext}; + +pub(crate) use truapi_platform::BulletinAllowanceKey; + +use crate::host_logic::session::{SessionInfo, SessionState}; +use crate::host_logic::statement_store::statement_public_key_from_secret; + +/// Snapshot of an account-authority session selected by the authority. +/// +/// This is the neutral session projection product runtimes can use while +/// preserving authority-private material inside the concrete authority +/// implementation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AuthoritySession { + /// Root account public key for the active authority session. + pub public_key: [u8; 32], + /// Identity account resolved from the signing host, when available. + pub identity_account_id: Option<[u8; 32]>, + /// Lightweight username resolved from People-chain identity, when available. + pub lite_username: Option, + /// Fully qualified username resolved from People-chain identity, when available. + pub full_username: Option, + /// Opaque session token used to reject stale pre-confirmation snapshots. + pub validation_id: Vec, +} + +impl AuthoritySession { + pub(crate) fn from_session_info(info: &SessionInfo, validation_id: Vec) -> Self { + Self { + public_key: info.public_key, + identity_account_id: info.identity_account_id, + lite_username: info.lite_username.clone(), + full_username: info.full_username.clone(), + validation_id, + } + } +} + +/// Typed account-authority failure before it is mapped to an API-specific error. +#[derive(Debug, Clone, PartialEq, Eq, derive_more::Display)] +pub(crate) enum AuthorityError { + /// User or authority rejected the request. + #[display("Rejected")] + Rejected, + /// The selected authority session is no longer active. + #[display("Disconnected")] + Disconnected, + /// The authority call was cancelled before completion. + #[display("{_0}")] + Cancelled(AuthorityCancelError), + /// The authority cannot service the request. + #[display("{reason}")] + Unavailable { reason: String }, + /// Catch-all authority failure. + #[display("{reason}")] + Unknown { reason: String }, +} + +impl AuthorityError { + pub(crate) fn reason(self) -> String { + self.to_string() + } +} + +impl From for AuthorityError { + fn from(err: BulletinAllowanceKeyError) -> Self { + AuthorityError::Unavailable { + reason: err.to_string(), + } + } +} + +/// Cancellation cause for an account-authority call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthorityCancelError { + request_id: String, + reason: CancellationReason, +} + +impl AuthorityCancelError { + pub(crate) fn new(request_id: &str, reason: CancellationReason) -> Self { + Self { + request_id: request_id.to_string(), + reason, + } + } +} + +impl fmt::Display for AuthorityCancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let request = if self.request_id.is_empty() { + String::new() + } else { + format!(" for {}", self.request_id) + }; + match &self.reason { + CancellationReason::Cancelled => { + write!(f, "Account authority request cancelled{request}") + } + CancellationReason::TimedOut { timeout } => write!( + f, + "Account authority request timed out after {}{request}", + format_timeout_duration(*timeout) + ), + } + } +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + +/// Payload-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignPayloadAuthorityRequest { + /// Sign a payload with a product-derived account. + Product(HostSignPayloadRequest), + /// Sign a payload through the legacy-account API. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignPayloadWithLegacyAccountRequest, + }, +} + +/// Raw-signing request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SignRawAuthorityRequest { + /// Sign raw data with a product-derived account. + Product(HostSignRawRequest), + /// Sign raw data through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account request. + request: HostSignRawWithLegacyAccountRequest, + }, +} + +/// Transaction-creation request selected by the product API entrypoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CreateTransactionAuthorityRequest { + /// Create a transaction with a product-derived account. + Product(ProductAccountTxPayload), + /// Create a transaction through the legacy-account API using the product slot-zero account. + LegacyAccount { + /// Product slot-zero account that backs the validated legacy signer. + product_account: ProductAccountId, + /// Original legacy-account transaction request. + request: LegacyAccountTxPayload, + }, +} + +/// Statement-store allowance signing material held by the authority layer. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct StatementStoreAllowanceKey { + pub(crate) secret: [u8; 64], + pub(crate) public_key: [u8; 32], +} + +impl StatementStoreAllowanceKey { + pub(crate) fn from_secret_bytes(secret: Vec) -> Result { + let secret: [u8; 64] = + secret + .try_into() + .map_err(|secret: Vec| AuthorityError::Unavailable { + reason: format!( + "statement-store allowance key must be 64 bytes, got {}", + secret.len() + ), + })?; + let public_key = statement_public_key_from_secret(secret) + .map_err(|reason| AuthorityError::Unavailable { reason })?; + Ok(Self { secret, public_key }) + } +} + +/// Host-level account authority used by product runtimes. +/// +/// Pairing hosts implement this by forwarding authority requests to a paired +/// signing host. A signing-host implementation can later provide the same +/// surface from local keys without changing product runtime code. +#[async_trait] +pub(crate) trait ProductAuthority: Send + Sync { + /// Current account-authority session, if connected. + fn current_session(&self) -> Option; + + /// Shared session holder owned by this authority. + /// + /// Product runtimes use it for connection-status subscriptions. The + /// concrete authority keeps ownership of the actual session material. + fn session_state(&self) -> Arc; + + /// Request account connection for the calling product. + async fn request_login( + &self, + product: &ProductContext, + ) -> Result>; + + /// Disconnect the current account-authority session. + async fn disconnect(&self); + + /// Sign a SCALE transaction payload for a product account. + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result; + + /// Sign arbitrary bytes for a product account. + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result; + + /// Build and sign a transaction for a product account. + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result; + + /// Request an alias proof for a product account in another product context. + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: ProductAccountId, + requesting_product_id: String, + ) -> Result; + + /// Ask the account authority to allocate product-scoped resources. + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: HostRequestResourceAllocationRequest, + ) -> Result; + + /// Return statement-store allowance key material for the calling product. + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + + /// Return Bulletin allowance key material for the calling product. + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result; + + /// Sign exact statement-store proof bytes with a product-derived account. + async fn sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + account: ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError>; + + /// Derive product-scoped entropy for a connected session. + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError>; +} + +/// 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)) +} + +/// Revalidate a pre-confirmation snapshot against the live session, returning +/// the current [`SessionInfo`] when it still matches. +/// +/// Both roles use this before touching key material: a snapshot taken before +/// user confirmation must still be the current authority session when the +/// signature or derivation happens, otherwise the request is rejected. +pub(super) fn require_current_session( + session_state: &SessionState, + session: &AuthoritySession, +) -> Result { + let current = session_state + .current() + .ok_or(AuthorityError::Disconnected)?; + if authority_session_validation_id(¤t) == session.validation_id { + Ok(current) + } else { + Err(AuthorityError::Disconnected) + } +} + +/// Opaque token identifying which concrete session a snapshot was taken from. +pub(super) fn authority_session_validation_id(session: &SessionInfo) -> Vec { + let mut id = Vec::with_capacity(67); + if let Some(sso) = &session.sso { + id.extend_from_slice(b"sso"); + id.extend_from_slice(&sso.session_id_own); + id.extend_from_slice(&sso.session_id_peer); + } else { + id.extend_from_slice(b"local"); + id.extend_from_slice(&session.public_key); + } + id +} diff --git a/rust/crates/truapi-server/src/runtime/identity.rs b/rust/crates/truapi-server/src/runtime/identity.rs new file mode 100644 index 000000000..0c36a8972 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/identity.rs @@ -0,0 +1,163 @@ +//! People-chain identity lookup used to resolve usernames for a paired session. + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use crate::chain_runtime::{ + ChainRuntime, wait_for_chain_head_best_hash, wait_for_chain_head_storage_value, +}; +use crate::host_logic::identity::{ + PeopleIdentity, decode_people_identity, resources_consumers_storage_key, +}; +use crate::host_logic::session::SessionInfo; + +use tracing::{debug, instrument, warn}; +use truapi::latest::{ + OperationStartedResult, RemoteChainHeadFollowRequest, RemoteChainHeadStorageRequest, + StorageQueryItem, StorageQueryType, +}; + +/// Fill in missing usernames by querying the people chain; returns the +/// session unchanged when it already carries a username or no people chain +/// is configured. +#[instrument(skip_all, fields(runtime.method = "session.identity.resolve_with_chain"))] +pub(super) async fn resolve_session_identity_with_chain( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + mut session: SessionInfo, +) -> SessionInfo { + if session.has_username() || people_chain_genesis_hash == [0; 32] { + return session; + } + + let preferred_account = session.identity_account_id.unwrap_or(session.public_key); + if !lookup_and_apply( + chain, + people_chain_genesis_hash, + preferred_account, + &mut session, + "identity", + ) + .await + && preferred_account != session.public_key + { + let public_key = session.public_key; + lookup_and_apply( + chain, + people_chain_genesis_hash, + public_key, + &mut session, + "root identity", + ) + .await; + } + + session +} + +/// Look up `account`'s people-chain identity and apply any usernames to +/// `session`; returns whether a username record was found and applied. +async fn lookup_and_apply( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account: [u8; 32], + session: &mut SessionInfo, + label: &str, +) -> bool { + match lookup_people_identity(chain, people_chain_genesis_hash, account).await { + Ok(Some(identity)) => { + debug!( + account = %hex::encode(account), + lite_username = identity.lite_username.as_deref().unwrap_or(""), + full_username = identity.full_username.as_deref().unwrap_or(""), + "People-chain {label} lookup found username" + ); + session.apply_usernames(identity.lite_username, identity.full_username); + true + } + Ok(None) => { + debug!( + account = %hex::encode(account), + "People-chain {label} lookup found no consumer record" + ); + false + } + Err(reason) => { + warn!( + account = %hex::encode(account), + %reason, + "People-chain {label} lookup failed" + ); + false + } + } +} + +#[instrument(skip_all, fields(runtime.method = "session.identity.lookup"))] +async fn lookup_people_identity( + chain: &ChainRuntime, + people_chain_genesis_hash: [u8; 32], + account_id: [u8; 32], +) -> Result, String> { + let genesis_hash = people_chain_genesis_hash.to_vec(); + let key = resources_consumers_storage_key(&account_id); + let lookup_id = { + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Monotonic salt for local identity lookup follow ids, avoiding + /// collisions between concurrent People-chain identity lookups. + static IDENTITY_LOOKUP_COUNTER: AtomicU64 = AtomicU64::new(1); + + IDENTITY_LOOKUP_COUNTER.fetch_add(1, Ordering::Relaxed) + }; + let follow_id = format!("truapi:identity:{}:{}", lookup_id, hex::encode(account_id),); + let mut follow = chain.remote_chain_head_follow( + follow_id.clone(), + RemoteChainHeadFollowRequest { + genesis_hash: genesis_hash.clone(), + with_runtime: false, + }, + ); + + let hash = wait_for_chain_head_best_hash( + &mut follow, + "People-chain", + Duration::from_secs(10), + Duration::from_secs(2), + ) + .await?; + let response = chain + .remote_chain_head_storage(RemoteChainHeadStorageRequest { + genesis_hash, + follow_subscription_id: follow_id, + hash, + items: vec![StorageQueryItem { + key: key.clone(), + query_type: StorageQueryType::Value, + }], + child_trie: None, + }) + .await + .map_err(|failure| failure.reason())?; + + let operation_id = match response.operation { + OperationStartedResult::Started { operation_id } => operation_id, + OperationStartedResult::LimitReached => { + return Err("People-chain storage lookup limit reached".to_string()); + } + }; + let Some(value) = wait_for_chain_head_storage_value( + &mut follow, + &operation_id, + &key, + "People-chain", + Duration::from_secs(10), + ) + .await? + else { + return Ok(None); + }; + decode_people_identity(&value).map(Some) +} diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs new file mode 100644 index 000000000..4969a42c6 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -0,0 +1,856 @@ +//! Pairing-host role for inter-host account authority. +//! +//! A pairing host does not own the user's signing keys. It pairs with a signing +//! host, keeps the active inter-host session, and sends authority requests to +//! that signing host over the SSO channel in [`sso_channel`]. + +mod sso_channel; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, Weak}; + +use futures::channel::oneshot; +use sso_channel::SsoDisconnectMonitor; + +use super::allowances::{self, AllowanceCacheKey, AllowanceResource}; +use super::auth_state::AuthStateMachine; +use super::authority::{ + AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session, require_current_session, +}; +use super::connected_session_ui_info; +use super::identity::resolve_session_identity_with_chain; +use super::services::RuntimeServices; +use super::sso_pairing::{SsoPairingFlow, SsoPairingOutcome}; +use super::sso_remote::{SSO_PEER_DISCONNECT_REASON, SessionDisconnects, SsoSessionKey}; +use super::statement_store_rpc::StatementStoreRpc; +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::entropy::derive_product_entropy_from_source; +use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; +use crate::host_logic::session_store::SessionStoreChangeNotifier; +use crate::subscription::Spawner; + +use futures::StreamExt; +use tracing::instrument; +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{CoreStorageKey, PairingHostConfig, Platform, ProductContext}; + +/// Distinguishes all remote authority request entrypoints by wire label. +#[derive(Clone, Copy, Debug, derive_more::Display)] +pub(super) enum AuthorityRequestKind { + #[display("sign-payload")] + SignPayload, + #[display("sign-raw")] + SignRaw, + #[display("create-transaction")] + CreateTransaction, + #[display("legacy-sign-payload")] + LegacySignPayload, + #[display("legacy-sign-raw")] + LegacySignRaw, + #[display("legacy-create-transaction")] + LegacyCreateTransaction, +} + +impl From<&SignPayloadAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignPayloadAuthorityRequest) -> Self { + match request { + SignPayloadAuthorityRequest::Product(_) => Self::SignPayload, + SignPayloadAuthorityRequest::LegacyAccount { .. } => Self::LegacySignPayload, + } + } +} + +impl From<&SignRawAuthorityRequest> for AuthorityRequestKind { + fn from(request: &SignRawAuthorityRequest) -> Self { + match request { + SignRawAuthorityRequest::Product(_) => Self::SignRaw, + SignRawAuthorityRequest::LegacyAccount { .. } => Self::LegacySignRaw, + } + } +} + +impl From<&CreateTransactionAuthorityRequest> for AuthorityRequestKind { + fn from(request: &CreateTransactionAuthorityRequest) -> Self { + match request { + CreateTransactionAuthorityRequest::Product(_) => Self::CreateTransaction, + CreateTransactionAuthorityRequest::LegacyAccount { .. } => { + Self::LegacyCreateTransaction + } + } + } +} + +struct LoginInFlight { + waiters: Vec>>, +} + +struct LoginInFlightOwner<'a> { + host: &'a PairingHost, + active: bool, +} + +impl<'a> LoginInFlightOwner<'a> { + fn new(host: &'a PairingHost) -> Self { + Self { host, active: true } + } + + fn finish(&mut self, result: Result<(), String>) { + if self.active { + self.active = false; + self.host.finish_login_in_flight(result); + } + } +} + +impl Drop for LoginInFlightOwner<'_> { + fn drop(&mut self) { + if self.active { + self.host + .finish_login_in_flight(Err("login request aborted".to_string())); + } + } +} + +/// Remote account authority for a pairing host. +pub(crate) struct PairingHost { + pub(super) platform: Arc, + pub(super) host_config: PairingHostConfig, + pub(super) chain: ChainRuntime, + /// Active inter-host session with a signing host. + session_state: Arc, + session_store_changes: Arc, + pub(super) auth_state: AuthStateMachine, + pub(super) statement_store: StatementStoreRpc, + session_disconnects: Arc, + disconnect_monitor: Mutex>, + login_in_flight: Mutex>, + login_generation: Mutex, + statement_store_allowances: Mutex>, + bulletin_allowances: Mutex>, + /// Self-reference captured by the spawned disconnect-monitor task. + weak_self: Weak, + pub(super) spawner: Spawner, +} + +impl PairingHost { + pub(crate) fn new(services: Arc, host_config: PairingHostConfig) -> Arc { + let platform = services.platform.clone(); + let auth_state = AuthStateMachine::new(platform.clone()); + Arc::new_cyclic(|weak_self| Self { + platform, + host_config, + chain: services.chain.clone(), + session_state: SessionState::new(), + session_store_changes: SessionStoreChangeNotifier::new(), + auth_state, + statement_store: services.statement_store.clone(), + session_disconnects: Arc::new(SessionDisconnects::default()), + disconnect_monitor: Mutex::new(None), + login_in_flight: Mutex::new(None), + login_generation: Mutex::new(0), + statement_store_allowances: Mutex::new(HashMap::new()), + bulletin_allowances: Mutex::new(HashMap::new()), + weak_self: weak_self.clone(), + spawner: services.spawner.clone(), + }) + } + + pub(crate) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + pub(crate) fn notify_session_store_changed(&self) { + self.session_store_changes.notify(); + } + + #[cfg(test)] + pub(crate) fn start_session_store_sync_for_tests(self: Arc, spawner: Spawner) { + self.start_session_store_sync(spawner); + } + + #[cfg(test)] + pub(crate) fn start_session_supervision_for_current_session(&self) { + self.start_remote_monitor_for_current_session(); + } + + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + #[cfg(test)] + pub(crate) fn start_remote_monitor_for_current_session(&self) { + if let Some(session) = self.session_state.current() { + self.start_disconnect_monitor(&session); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.sync"))] + pub(crate) fn start_session_store_sync(self: Arc, spawner: Spawner) { + let pairing_host = Arc::downgrade(&self); + spawner(Box::pin(async move { + let Some(current) = pairing_host.upgrade() else { + return; + }; + let mut ticks = current.session_store_changes.subscribe(); + drop(current); + // Clearing the store can itself notify this subscription; clear at + // most once per read-error streak so a persistently failing read + // cannot spin the loop through its own clear notifications. + let mut cleared_after_read_error = false; + while ticks.next().await.is_some() { + let Some(pairing_host) = pairing_host.upgrade() else { + break; + }; + match pairing_host + .platform + .read_core_storage(CoreStorageKey::AuthSession) + .await + { + Ok(Some(blob)) => { + cleared_after_read_error = false; + match crate::host_logic::session::decode_persisted_session(&blob) { + Ok(session) => { + let resolved = resolve_session_identity_with_chain( + &pairing_host.chain, + pairing_host.host_config.people_chain_genesis_hash, + session, + ) + .await; + if encode_persisted_session(&resolved) != blob { + let _ = pairing_host + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&resolved), + ) + .await; + } + pairing_host.set_connected_session(resolved); + } + Err(_) => { + pairing_host.clear_disconnected_session(true).await; + } + } + } + Ok(None) => { + cleared_after_read_error = false; + pairing_host.clear_disconnected_session(false).await; + } + Err(_) => { + pairing_host.clear_disconnected_session(false).await; + if !cleared_after_read_error { + cleared_after_read_error = true; + let _ = pairing_host + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + } + } + } + })); + } + + #[instrument(skip_all, fields(runtime.method = "account.request_login", product = %product.product_id))] + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + let _ = product; + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )); + } + + if let Some(waiter) = self.login_waiter() { + match waiter.await { + Ok(Ok(())) => { + return Ok(HostRequestLoginResponse::V1( + if self.session_state.current().is_some() { + v01::HostRequestLoginResponse::AlreadyConnected + } else { + v01::HostRequestLoginResponse::Rejected + }, + )); + } + Ok(Err(reason)) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + ))); + } + Err(_) => { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login waiter dropped".to_string(), + }, + ))); + } + } + } + + let mut login_owner = LoginInFlightOwner::new(self); + let login_generation = self.begin_login_attempt(); + let outcome = match SsoPairingFlow::new(self).request_session().await { + Ok(outcome) => outcome, + Err(err) => { + login_owner.finish(Err(login_error_reason(&err))); + return Err(err); + } + }; + match outcome { + SsoPairingOutcome::Cancelled => { + login_owner.finish(Ok(())); + if self.session_state.current().is_some() { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + SsoPairingOutcome::Success(session) => { + if !self.is_current_login_attempt(login_generation) { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + login_owner.finish(Ok(())); + return Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )); + } + self.set_connected_session(*session); + login_owner.finish(Ok(())); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Success, + )) + } + } + } + + #[instrument(skip_all, fields(runtime.method = "account.disconnect"))] + async fn disconnect(&self) { + self.cancel_login(); + let session = self.session_state.current(); + self.clear_disconnected_session(true).await; + if let Some(session) = session.as_ref() { + let _ = self.submit_disconnected_message(session).await; + } + } + + #[instrument(skip_all, fields(runtime.method = "account.cancel_login"))] + pub(crate) fn cancel_login(&self) { + self.invalidate_login_attempts(); + self.auth_state.login_cancelled(); + } + + fn begin_login_attempt(&self) -> u64 { + let mut generation = self + .login_generation + .lock() + .expect("login generation mutex poisoned"); + *generation = generation.wrapping_add(1); + *generation + } + + fn invalidate_login_attempts(&self) { + let mut generation = self + .login_generation + .lock() + .expect("login generation mutex poisoned"); + *generation = generation.wrapping_add(1); + } + + fn is_current_login_attempt(&self, generation: u64) -> bool { + *self + .login_generation + .lock() + .expect("login generation mutex poisoned") + == generation + } + + fn login_waiter(&self) -> Option>> { + let mut in_flight = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned"); + if let Some(in_flight) = in_flight.as_mut() { + let (tx, rx) = oneshot::channel(); + in_flight.waiters.push(tx); + Some(rx) + } else { + *in_flight = Some(LoginInFlight { + waiters: Vec::new(), + }); + None + } + } + + fn finish_login_in_flight(&self, result: Result<(), String>) { + let waiters = self + .login_in_flight + .lock() + .expect("login in-flight mutex poisoned") + .take() + .map(|in_flight| in_flight.waiters) + .unwrap_or_default(); + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + + #[instrument(skip_all, fields(runtime.method = "session_store.clear_disconnected"))] + async fn clear_disconnected_session(&self, clear_auth_session: bool) { + let previous = self.session_state.current(); + self.session_state.clear_session(); + self.stop_session_channel(previous.as_ref()); + if clear_auth_session { + let _ = self + .platform + .clear_core_storage(CoreStorageKey::AuthSession) + .await; + } + if let Some(session) = previous.as_ref() { + let _ = allowances::clear_session_allowance_keys(&*self.platform, session).await; + } + self.auth_state.store_disconnected(); + } + + fn set_connected_session(&self, session: SessionInfo) { + let previous = self.session_state.current(); + self.session_state.set_session(session.clone()); + if previous.as_ref() != Some(&session) { + self.stop_session_channel(previous.as_ref()); + } + self.start_disconnect_monitor(&session); + self.auth_state + .connected(&connected_session_ui_info(&session)); + } + + /// Single funnel for peer-initiated disconnects. Every detection source + /// (monitor task, request-path error) must route here: it wakes in-flight + /// waiters for `key`, then clears the session when `key` is still current, + /// so stale notifications for replaced sessions only wake their own + /// waiters. + async fn handle_signing_host_disconnected(&self, key: SsoSessionKey) { + self.session_disconnects + .notify_key(key, SSO_PEER_DISCONNECT_REASON); + if !self.current_sso_session_matches(key) { + return; + } + + self.clear_disconnected_session(true).await; + } + + fn current_sso_session_matches(&self, key: SsoSessionKey) -> bool { + sso_channel::session_matches_key(&self.session_state, key) + } + + fn current_private_session( + &self, + session: &AuthoritySession, + ) -> Result { + require_current_session(&self.session_state, session) + } + + pub(super) async fn cache_statement_store_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + slot_account_key: Vec, + ) -> Result { + let allowance = StatementStoreAllowanceKey::from_secret_bytes(slot_account_key)?; + allowances::write_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::StatementStore, + allowance.secret.to_vec(), + ) + .await?; + self.remember_statement_store_allowance_key(session, product_id, allowance.clone())?; + Ok(allowance) + } + + fn remember_statement_store_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + allowance: StatementStoreAllowanceKey, + ) -> Result<(), AuthorityError> { + let cache_key = + AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; + self.statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .insert(cache_key, allowance); + Ok(()) + } + + pub(super) async fn cached_statement_store_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result, AuthorityError> { + let cache_key = + AllowanceCacheKey::new(session, product_id, AllowanceResource::StatementStore)?; + if let Some(allowance) = self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned") + .get(&cache_key) + .cloned() + { + return Ok(Some(allowance)); + } + let Some(secret) = allowances::read_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::StatementStore, + ) + .await? + else { + return Ok(None); + }; + let allowance = StatementStoreAllowanceKey::from_secret_bytes(secret)?; + self.remember_statement_store_allowance_key(session, product_id, allowance.clone())?; + Ok(Some(allowance)) + } + + pub(super) async fn cache_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + slot_account_key: Vec, + ) -> Result { + let allowance = BulletinAllowanceKey::from_secret_bytes(slot_account_key)?; + allowances::write_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::Bulletin, + allowance.as_secret_bytes().to_vec(), + ) + .await?; + self.remember_bulletin_allowance_key(session, product_id, allowance.clone())?; + Ok(allowance) + } + + fn remember_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + allowance: BulletinAllowanceKey, + ) -> Result<(), AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; + self.bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .insert(cache_key, allowance); + Ok(()) + } + + pub(super) async fn cached_bulletin_allowance_key( + &self, + session: &SessionInfo, + product_id: &str, + ) -> Result, AuthorityError> { + let cache_key = AllowanceCacheKey::new(session, product_id, AllowanceResource::Bulletin)?; + if let Some(allowance) = self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned") + .get(&cache_key) + .cloned() + { + return Ok(Some(allowance)); + } + let Some(secret) = allowances::read_allowance_key( + &*self.platform, + session, + product_id, + AllowanceResource::Bulletin, + ) + .await? + else { + return Ok(None); + }; + let allowance = BulletinAllowanceKey::from_secret_bytes(secret)?; + self.remember_bulletin_allowance_key(session, product_id, allowance.clone())?; + Ok(Some(allowance)) + } + + pub(super) fn clear_statement_store_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .statement_store_allowances + .lock() + .expect("statement-store allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let Some(sso) = session.sso.as_ref() else { + return; + }; + let session_key = SsoSessionKey::from_session(sso); + allowances.retain(|key, _| !key.is_for_session(session_key)); + } + + pub(super) fn clear_bulletin_allowance_keys(&self, session: Option<&SessionInfo>) { + let mut allowances = self + .bulletin_allowances + .lock() + .expect("bulletin allowance cache mutex poisoned"); + let Some(session) = session else { + allowances.clear(); + return; + }; + let Some(sso) = session.sso.as_ref() else { + return; + }; + let session_key = SsoSessionKey::from_session(sso); + allowances.retain(|key, _| !key.is_for_session(session_key)); + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_payload(cx, &session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_sign_raw(cx, &session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_create_transaction(cx, &session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_account_alias(cx, &session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_allocate_resources(cx, &session, product_id, request) + .await + } + + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_statement_store_allowance_key(cx, &session, product_id) + .await + } + + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + let session = self.current_private_session(session)?; + self.remote_bulletin_allowance_key(cx, &session, product_id) + .await + } + + async fn sign_statement_store_product_payload( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _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(), + }) + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + let session = self.current_private_session(session)?; + if session.sso.is_none() { + return Err(AuthorityError::Disconnected); + } + let root_entropy_source = + session + .root_entropy_source + .ok_or_else(|| AuthorityError::Unavailable { + reason: "Session secret missing".to_string(), + })?; + derive_product_entropy_from_source(&root_entropy_source, product_id, context).map_err( + |err| AuthorityError::Unknown { + reason: err.to_string(), + }, + ) + } +} + +fn login_error_reason(err: &CallError) -> String { + match err { + CallError::Domain(HostRequestLoginError::V1(v01::HostRequestLoginError::Unknown { + reason, + })) + | CallError::HostFailure { reason } => reason.clone(), + CallError::Unsupported => "login unsupported".to_string(), + CallError::Denied => "login denied".to_string(), + CallError::MalformedFrame { reason } => reason.clone(), + } +} + +#[async_trait::async_trait] +impl ProductAuthority for PairingHost { + fn current_session(&self) -> Option { + PairingHost::current_session(self) + } + + fn session_state(&self) -> Arc { + PairingHost::session_state(self) + } + + async fn request_login( + &self, + product: &ProductContext, + ) -> Result> { + PairingHost::request_login(self, product).await + } + + async fn disconnect(&self) { + PairingHost::disconnect(self).await; + } + + async fn sign_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignPayloadAuthorityRequest, + ) -> Result { + PairingHost::sign_payload(self, cx, session, request).await + } + + async fn sign_raw( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + PairingHost::sign_raw(self, cx, session, request).await + } + + async fn create_transaction( + &self, + cx: &CallContext, + session: &AuthoritySession, + request: CreateTransactionAuthorityRequest, + ) -> Result { + PairingHost::create_transaction(self, cx, session, request).await + } + + async fn account_alias( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_account_id: v01::ProductAccountId, + requesting_product_id: String, + ) -> Result { + PairingHost::account_alias(self, cx, session, product_account_id, requesting_product_id) + .await + } + + async fn allocate_resources( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + PairingHost::allocate_resources(self, cx, session, product_id, request).await + } + + async fn statement_store_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + PairingHost::statement_store_allowance_key(self, cx, session, product_id).await + } + + async fn bulletin_allowance_key( + &self, + cx: &CallContext, + session: &AuthoritySession, + product_id: String, + ) -> Result { + PairingHost::bulletin_allowance_key(self, cx, session, product_id).await + } + + async fn sign_statement_store_product_payload( + &self, + cx: &CallContext, + session: &AuthoritySession, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + PairingHost::sign_statement_store_product_payload(self, cx, session, account, payload).await + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + PairingHost::derive_entropy(self, session, product_id, context) + } +} 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 new file mode 100644 index 000000000..77668cba1 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -0,0 +1,620 @@ +//! SSO statement-store channel to the paired remote signing host. + +use super::super::authority::{ + AuthorityCancelError, AuthorityError, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, +}; +use super::super::sso_remote::{ + RemoteResponseWait, SSO_LOCAL_DISCONNECT_REASON, SSO_PEER_DISCONNECT_REASON, + SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, sso_message_id, + statement_subscription_stream, subscribe_statement_topic, wait_for_sso_remote_response, +}; +use super::super::statement_store_rpc::{self, StatementStoreRpc}; +use super::AuthorityRequestKind; +use super::PairingHost; +use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; +use crate::host_logic::sso::messages::{ + OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, SsoAllocatedResource, + SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, alias_request_message, + build_outgoing_request_statement, create_transaction_message, decode_sso_session_statement, + resource_allocation_message, sign_payload_message, sign_raw_message, v1, +}; +use crate::host_logic::statement_store::parse_new_statements_result; + +use futures::FutureExt; +use futures::future::{AbortHandle, Abortable}; +use tracing::{debug, instrument, warn}; +use truapi::{CallContext, latest}; + +const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; +const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; + +#[derive(Clone, Copy, Debug, derive_more::Display)] +enum RemoteAction { + #[display("{_0}")] + Signing(AuthorityRequestKind), + #[display("account-alias")] + AccountAlias, + #[display("resource-allocation")] + ResourceAllocation, +} + +/// Active peer-disconnect watcher for one SSO session; aborts on drop. +pub(super) struct SsoDisconnectMonitor { + key: SsoSessionKey, + abort: AbortHandle, +} + +impl Drop for SsoDisconnectMonitor { + fn drop(&mut self) { + self.abort.abort(); + } +} + +impl PairingHost { + async fn submit_sign_request( + &self, + cx: &CallContext, + session: &SessionInfo, + action: AuthorityRequestKind, + message: RemoteMessage, + ) -> Result { + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(SsoRemoteResponseError::Failure( + UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + )); + }; + response + .payload + .map(|payload| latest::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + .map_err(SsoRemoteResponseError::Failure) + } + + fn stop_disconnect_monitor(&self) { + self.disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned") + .take(); + } + + pub(super) fn start_disconnect_monitor(&self, session: &SessionInfo) { + let Some(sso) = session.sso.clone() else { + self.stop_disconnect_monitor(); + return; + }; + let key = SsoSessionKey::from_session(&sso); + + let (registration, spawner) = { + let mut current = self + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if current.as_ref().is_some_and(|active| active.key == key) { + return; + } + let (abort, registration) = AbortHandle::new_pair(); + *current = Some(SsoDisconnectMonitor { key, abort }); + (registration, self.spawner.clone()) + }; + + let statement_store = self.statement_store.clone(); + let pairing_host = self.weak_self.clone(); + let future = async move { + let result = wait_for_sso_peer_disconnect(statement_store, sso).await; + let Some(pairing_host) = pairing_host.upgrade() else { + return; + }; + { + let mut active = pairing_host + .disconnect_monitor + .lock() + .expect("SSO disconnect monitor mutex poisoned"); + if active.as_ref().is_some_and(|active| active.key == key) { + *active = None; + } + } + match result { + Ok(()) => { + pairing_host.handle_signing_host_disconnected(key).await; + } + Err(reason) => { + warn!(%reason, "SSO peer disconnect monitor stopped"); + } + } + }; + spawner(Box::pin(Abortable::new(future, registration).map(|_| ()))); + } + + /// Stop channel work for a cleared session: wake its in-flight waiters + /// with a local disconnect, then drop the peer-disconnect monitor. + pub(super) fn stop_session_channel(&self, session: Option<&SessionInfo>) { + if let Some(sso) = session.and_then(|session| session.sso.as_ref()) { + self.session_disconnects + .notify(sso, SSO_LOCAL_DISCONNECT_REASON); + } + self.clear_statement_store_allowance_keys(session); + self.clear_bulletin_allowance_keys(session); + self.stop_disconnect_monitor(); + } + + /// Best-effort `Disconnected` notification to the SSO peer. + #[instrument(skip_all, fields(runtime.method = "sso.disconnect.submit"))] + pub(super) async fn submit_disconnected_message( + &self, + session: &SessionInfo, + ) -> Result<(), String> { + let sso = session + .sso + .as_ref() + .ok_or_else(|| "No SSO session state".to_string())?; + let message_id = "truapi:sso:disconnect".to_string(); + let message = RemoteMessage { + message_id: message_id.clone(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + sso, + message_id, + vec![message], + fresh_statement_expiry(), + )?; + self.statement_store + .submit_fire_and_forget(statement, "SSO statement-store") + .await + .map_err(|err| format!("SSO statement submit failed: {err}"))?; + Ok(()) + } + + /// Submit an SSO remote message and wait for the signing-host response. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = %action))] + async fn submit_remote_message( + &self, + cx: &CallContext, + session: &SessionInfo, + action: RemoteAction, + message: RemoteMessage, + ) -> Result { + let sso = session + .sso + .as_ref() + .ok_or_else(|| SsoRemoteResponseError::Failure("No SSO session state".to_string()))?; + let key = SsoSessionKey::from_session(sso); + let (_disconnect_guard, disconnect) = self.session_disconnects.subscribe(sso); + if !session_matches_key(&self.session_state, key) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + let message_id = message.message_id.clone(); + let statement = build_outgoing_request_statement( + sso, + message_id.clone(), + vec![message], + fresh_statement_expiry(), + ) + .map_err(SsoRemoteResponseError::Failure)?; + let rpc_client = self.statement_store.client("SSO statement-store").await?; + let own_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_own) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO own statement-store subscribe failed: {err}" + )) + })?; + let peer_subscription = subscribe_statement_topic(&rpc_client, sso.session_id_peer) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!( + "SSO peer statement-store subscribe failed: {err}" + )) + })?; + let submit_client = rpc_client.clone(); + let session_state = self.session_state.clone(); + let submit = async move { + if !session_matches_key(&session_state, key) { + return Err(SsoRemoteResponseError::LocalDisconnected); + } + statement_store_rpc::submit(&submit_client, statement) + .await + .map_err(|err| { + SsoRemoteResponseError::Failure(format!("SSO statement submit failed: {err}")) + }) + } + .boxed(); + let action = action.to_string(); + debug!(action, %message_id, "submitted SSO remote message, awaiting response"); + let result = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: statement_subscription_stream(own_subscription, "own"), + peer_statements: statement_subscription_stream(peer_subscription, "peer"), + submit, + session: sso, + statement_request_id: &message_id, + remote_message_id: &message_id, + cancel: cx.cancel(), + disconnect: Some(disconnect), + }) + .await; + let result = result.map_err(|reason| match reason { + SsoRemoteResponseError::Cancelled(err) if !cx.request_id().is_empty() => { + SsoRemoteResponseError::Cancelled(err.with_remote_message_id(cx.request_id())) + } + reason => reason, + }); + match &result { + Ok(_) => debug!(action, %message_id, "SSO remote response received"), + Err(reason) => warn!(action, %message_id, %reason, "SSO remote message failed"), + } + if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { + self.handle_signing_host_disconnected(key).await; + } + result + } + + pub(super) async fn remote_sign_payload( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignPayloadAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(); + let request = match request { + SignPayloadAuthorityRequest::Product(request) => request, + SignPayloadAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::HostSignPayloadRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_payload_message(message_id, request); + self.submit_sign_request(cx, session, action, message) + .await + .map_err(remote_authority_error) + } + + pub(super) async fn remote_sign_raw( + &self, + cx: &CallContext, + session: &SessionInfo, + request: SignRawAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(); + let request = match request { + SignRawAuthorityRequest::Product(request) => request, + SignRawAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::HostSignRawRequest { + account: product_account, + payload: request.payload, + }, + }; + let message = sign_raw_message(message_id, request); + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::Sign(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_SIGNING_RESPONSE.to_string(), + }); + }; + response + .payload + .map(|payload| latest::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_create_transaction( + &self, + cx: &CallContext, + session: &SessionInfo, + request: CreateTransactionAuthorityRequest, + ) -> Result { + let action = AuthorityRequestKind::from(&request); + let message_id = sso_message_id(); + let request = match request { + CreateTransactionAuthorityRequest::Product(request) => request, + CreateTransactionAuthorityRequest::LegacyAccount { + product_account, + request, + } => latest::ProductAccountTxPayload { + signer: product_account, + genesis_hash: request.genesis_hash, + call_data: request.call_data, + extensions: request.extensions, + tx_ext_version: request.tx_ext_version, + }, + }; + let message = create_transaction_message(message_id, request); + let response = self + .submit_remote_message(cx, session, RemoteAction::Signing(action), message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::CreateTransaction(response) = response else { + return Err(AuthorityError::Unknown { + reason: UNEXPECTED_SSO_TRANSACTION_RESPONSE.to_string(), + }); + }; + response + .signed_transaction + .map(|transaction| latest::HostCreateTransactionResponse { transaction }) + .map_err(remote_authority_error) + } + + pub(super) async fn remote_account_alias( + &self, + cx: &CallContext, + session: &SessionInfo, + product_account_id: latest::ProductAccountId, + requesting_product_id: String, + ) -> Result { + let message_id = sso_message_id(); + let message = alias_request_message( + message_id.clone(), + product_account_id, + requesting_product_id, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::AccountAlias, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::RingVrfAlias(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for account alias request".to_string(), + }); + }; + response.payload.map_err(remote_authority_error) + } + + pub(super) async fn remote_allocate_resources( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + request: latest::HostRequestResourceAllocationRequest, + ) -> Result { + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + request.resources, + OnExistingAllowancePolicy::Increase, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for resource allocation request".to_string(), + }); + }; + let outcomes = response.payload.map_err(remote_authority_error)?; + self.cache_allowance_outcomes(session, &product_id, &outcomes) + .await?; + Ok(latest::HostRequestResourceAllocationResponse { + outcomes: outcomes.into_iter().map(Into::into).collect(), + }) + } + + pub(super) async fn remote_statement_store_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + if let Some(cached) = self + .cached_statement_store_allowance_key(session, &product_id) + .await? + { + return Ok(cached); + } + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::StatementStoreAllowance], + OnExistingAllowancePolicy::Ignore, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for statement-store allowance request".to_string(), + }); + }; + let mut outcomes = response + .payload + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty statement-store allowance response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) => { + self.cache_statement_store_allowance_key(session, &product_id, slot_account_key) + .await + } + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!( + "Unexpected statement-store allowance response resource: {other:?}" + ), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "statement-store allowance is not available".to_string(), + }), + } + } + + pub(super) async fn remote_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: String, + ) -> Result { + if let Some(cached) = self + .cached_bulletin_allowance_key(session, &product_id) + .await? + { + return Ok(cached); + } + + let message_id = sso_message_id(); + let message = resource_allocation_message( + message_id, + product_id.clone(), + vec![latest::AllocatableResource::BulletinAllowance], + OnExistingAllowancePolicy::Ignore, + ); + let response = self + .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) + .await + .map_err(remote_authority_error)?; + let SsoRemoteResponse::ResourceAllocation(response) = response else { + return Err(AuthorityError::Unknown { + reason: "Unexpected SSO response for bulletin allowance request".to_string(), + }); + }; + let mut outcomes = response + .payload + .map_err(remote_authority_error)? + .into_iter(); + let outcome = outcomes.next().ok_or_else(|| AuthorityError::Unknown { + reason: "Empty bulletin allowance response".to_string(), + })?; + match outcome { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) => { + self.cache_bulletin_allowance_key(session, &product_id, slot_account_key) + .await + } + SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { + reason: format!("Unexpected bulletin allowance response resource: {other:?}"), + }), + SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), + SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { + reason: "bulletin allowance is not available".to_string(), + }), + } + } + + async fn cache_allowance_outcomes( + &self, + session: &SessionInfo, + product_id: &str, + outcomes: &[SsoAllocationOutcome], + ) -> Result<(), AuthorityError> { + for outcome in outcomes { + if let SsoAllocationOutcome::Allocated(resource) = outcome { + match resource { + SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { + self.cache_statement_store_allowance_key( + session, + product_id, + slot_account_key.clone(), + ) + .await?; + } + SsoAllocatedResource::BulletinAllowance { slot_account_key } => { + self.cache_bulletin_allowance_key( + session, + product_id, + slot_account_key.clone(), + ) + .await?; + } + SsoAllocatedResource::SmartContractAllowance + | SsoAllocatedResource::AutoSigning { .. } => {} + } + } + } + Ok(()) + } +} + +/// True when the current session's SSO channel matches `key`. +pub(super) fn session_matches_key(session_state: &SessionState, key: SsoSessionKey) -> bool { + session_state.current().as_ref().is_some_and(|current| { + current + .sso + .as_ref() + .is_some_and(|sso| SsoSessionKey::from_session(sso) == key) + }) +} + +fn remote_authority_error(reason: impl Into) -> AuthorityError { + match reason.into() { + SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( + AuthorityCancelError::new(err.remote_message_id(), err.reason()), + ), + SsoRemoteResponseError::LocalDisconnected | SsoRemoteResponseError::PeerDisconnected => { + AuthorityError::Disconnected + } + SsoRemoteResponseError::Failure(reason) => match reason.as_str() { + "Rejected" | "User rejected" => AuthorityError::Rejected, + SSO_LOCAL_DISCONNECT_REASON | SSO_PEER_DISCONNECT_REASON => { + AuthorityError::Disconnected + } + _ => AuthorityError::Unknown { reason }, + }, + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.peer_disconnect.monitor"))] +async fn wait_for_sso_peer_disconnect( + statement_store: StatementStoreRpc, + session: SsoSessionInfo, +) -> Result<(), String> { + let rpc_client = statement_store.client("SSO disconnect monitor").await?; + let mut subscription = + statement_store_rpc::subscribe_match_all(&rpc_client, &[session.session_id_peer]) + .await + .map_err(|err| format!("SSO disconnect monitor subscribe failed: {err}"))?; + while let Some(item) = subscription.next().await { + let value = item.map_err(|err| format!("SSO disconnect monitor item failed: {err}"))?; + let page = parse_new_statements_result("sso-peer-disconnect-monitor".to_string(), &value) + .map_err(|err| err.to_string())?; + for statement in page.statements { + if matches!( + decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + "truapi:sso-peer-disconnect-monitor", + )?, + Some(SsoSessionStatement::Disconnected) + ) { + return Ok(()); + } + } + } + Err("SSO disconnect monitor response stream ended".to_string()) +} + +impl From for latest::AllocationOutcome { + fn from(outcome: SsoAllocationOutcome) -> Self { + match outcome { + SsoAllocationOutcome::Allocated(_) => Self::Allocated, + SsoAllocationOutcome::Rejected => Self::Rejected, + SsoAllocationOutcome::NotAvailable => Self::NotAvailable, + } + } +} diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs new file mode 100644 index 000000000..97511af5d --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -0,0 +1,77 @@ +//! Role-neutral runtime services shared by product-facing runtimes. +//! +//! This module owns only infrastructure that is valid for both pairing hosts +//! and signing hosts. Pairing state, signing state, active sessions, and role +//! controls live on the concrete role objects. + +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::chain_runtime::{ChainRuntime, RuntimeChainProvider, RuntimeFailure}; +use crate::runtime::statement_store_rpc::StatementStoreRpc; +use crate::subscription::Spawner; +use async_trait::async_trait; +use truapi_platform::{JsonRpcConnection, Platform}; + +/// Infrastructure shared by all product runtimes created from one host role. +pub(crate) struct RuntimeServices { + pub(crate) platform: Arc, + pub(crate) chain: ChainRuntime, + pub(crate) statement_store: StatementStoreRpc, + pub(crate) spawner: Spawner, + next_core_instance: AtomicU64, +} + +impl RuntimeServices { + /// Build role-neutral runtime services from the platform and People-chain + /// genesis hash used by statement-store backed protocols. + pub(crate) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Arc { + let chain_provider = Arc::new(HostChainProvider { + platform: platform.clone(), + }); + let chain = ChainRuntime::new(chain_provider, spawner.clone()); + let statement_store = + StatementStoreRpc::new(platform.clone(), people_chain_genesis_hash, spawner.clone()); + Arc::new(Self { + platform, + chain, + statement_store, + spawner, + next_core_instance: AtomicU64::new(1), + }) + } + + pub(crate) fn next_core_instance(&self) -> u64 { + self.next_core_instance.fetch_add(1, Ordering::Relaxed) + } +} + +/// Adapter from `truapi_platform::ChainProvider` into the +/// [`RuntimeChainProvider`] surface the chain runtime expects. +struct HostChainProvider { + platform: Arc, +} + +#[async_trait] +impl RuntimeChainProvider for HostChainProvider { + async fn connect( + &self, + genesis_hash: Vec, + ) -> Result, RuntimeFailure> { + let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|genesis_hash: Vec| { + RuntimeFailure::host_failure( + "remote_chain_connect", + format!("genesis_hash must be 32 bytes, got {}", genesis_hash.len()), + ) + })?; + self.platform + .connect(genesis_hash) + .await + .map(Arc::from) + .map_err(|_| RuntimeFailure::unavailable("remote_chain_connect")) + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs new file mode 100644 index 000000000..4ec98c674 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -0,0 +1,650 @@ +//! Signing-host role for wallet-local account authority. +//! +//! A signing host owns the user's keys and serves authority requests locally, +//! with no pairing flow and no SSO channel. Secret material is provided by the +//! embedding host at unlock through [`LocalActivation::activate_local_session`] +//! (the host owns its persistence, e.g. the OS keychain) and kept in memory +//! for the session, zeroized on disconnect. + +mod local_activation; + +use std::sync::{Arc, Mutex}; + +pub(crate) use local_activation::LocalActivation; + +use super::authority::{ + AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + StatementStoreAllowanceKey, authority_session, require_current_session, +}; +use super::connected_session_ui_info; +use crate::host_logic::entropy::derive_product_entropy; +use crate::host_logic::product_account::{ + ProductAccountError, SR25519_SIGNING_CONTEXT, derive_product_keypair, + derive_root_keypair_from_entropy, +}; +use crate::host_logic::session::SessionState; +use crate::runtime::auth_state::AuthStateMachine; + +use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; +use truapi::{CallContext, CallError, v01}; +use truapi_platform::{Platform, ProductContext, normalize_product_identifier}; +use zeroize::Zeroizing; + +const BYTES_WRAP_PREFIX: &[u8] = b""; +const BYTES_WRAP_SUFFIX: &[u8] = b""; + +/// Wallet-local account authority for a signing host. +pub(crate) struct SigningHost { + session_state: Arc, + auth_state: AuthStateMachine, + /// Root BIP-39 entropy held only while a session is active. + root_entropy: Mutex>>>, +} + +impl SigningHost { + pub(crate) fn new(platform: Arc) -> Arc { + Arc::new(Self { + session_state: SessionState::new(), + auth_state: AuthStateMachine::new(platform), + root_entropy: Mutex::new(None), + }) + } + + pub(super) fn session_state(&self) -> Arc { + self.session_state.clone() + } + + /// Current root entropy, or [`AuthorityError::Disconnected`] when no local + /// session is active. + fn root_entropy(&self) -> Result>, AuthorityError> { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .clone() + .ok_or(AuthorityError::Disconnected) + } + + /// Derive the product-account keypair for `account` from the root entropy. + /// + /// The root keypair is recomputed per call (PBKDF2, 2048 rounds, via + /// `substrate-bip39`) rather than cached: the signing host holds only the + /// raw, zeroizable entropy, never an expanded secret key. + fn product_keypair( + &self, + account: &v01::ProductAccountId, + ) -> Result { + let entropy = self.root_entropy()?; + let root = derive_root_keypair_from_entropy(&entropy).map_err(product_authority_error)?; + let product_id = + normalize_product_identifier(&account.dot_ns_identifier).map_err(|err| { + AuthorityError::Unavailable { + reason: err.to_string(), + } + })?; + derive_product_keypair(&root, &product_id, account.derivation_index) + .map_err(product_authority_error) + } +} + +#[async_trait::async_trait] +impl ProductAuthority for SigningHost { + fn current_session(&self) -> Option { + self.session_state.current().as_ref().map(authority_session) + } + + fn session_state(&self) -> Arc { + SigningHost::session_state(self) + } + + async fn request_login( + &self, + _product: &ProductContext, + ) -> Result> { + if let Some(session) = self.session_state.current() { + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::AlreadyConnected, + )) + } else { + // The host activates a local session out of band once the wallet + // is unlocked; there is no in-core login prompt to drive. + Ok(HostRequestLoginResponse::V1( + v01::HostRequestLoginResponse::Rejected, + )) + } + } + + async fn disconnect(&self) { + self.root_entropy + .lock() + .expect("signing host entropy mutex poisoned") + .take(); + self.session_state.clear_session(); + self.auth_state.store_disconnected(); + } + + async fn sign_payload( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: SignPayloadAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: extrinsic-payload signing needs chain-metadata payload \ + assembly (not yet implemented)" + .to_string(), + }) + } + + async fn sign_raw( + &self, + _cx: &CallContext, + session: &AuthoritySession, + request: SignRawAuthorityRequest, + ) -> Result { + let SignRawAuthorityRequest::Product(request) = request else { + return Err(AuthorityError::Unavailable { + reason: "signing host: legacy-account raw signing is not yet implemented" + .to_string(), + }); + }; + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&request.account)?; + let message = raw_payload_bytes(request.payload)?; + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, + }) + } + + async fn create_transaction( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _request: CreateTransactionAuthorityRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: transaction construction needs chain metadata (not yet \ + implemented)" + .to_string(), + }) + } + + async fn account_alias( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _product_account_id: v01::ProductAccountId, + _requesting_product_id: String, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), + }) + } + + async fn allocate_resources( + &self, + _cx: &CallContext, + _session: &AuthoritySession, + _product_id: String, + _request: v01::HostRequestResourceAllocationRequest, + ) -> Result { + Err(AuthorityError::Unavailable { + reason: "signing host: on-chain resource allocation not yet implemented".to_string(), + }) + } + + async fn statement_store_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _product_id: String, + ) -> Result { + require_current_session(&self.session_state, session)?; + Err(AuthorityError::Unavailable { + reason: "signing host: statement-store allowance allocation not yet implemented" + .to_string(), + }) + } + + async fn bulletin_allowance_key( + &self, + _cx: &CallContext, + session: &AuthoritySession, + _product_id: String, + ) -> Result { + require_current_session(&self.session_state, session)?; + Err(AuthorityError::Unavailable { + reason: "signing host: bulletin allowance allocation not yet implemented".to_string(), + }) + } + + async fn sign_statement_store_product_payload( + &self, + _cx: &CallContext, + session: &AuthoritySession, + account: v01::ProductAccountId, + payload: Vec, + ) -> Result<[u8; 64], AuthorityError> { + require_current_session(&self.session_state, session)?; + let keypair = self.product_keypair(&account)?; + Ok(keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &payload, &keypair.public) + .to_bytes()) + } + + fn derive_entropy( + &self, + session: &AuthoritySession, + product_id: &str, + context: &[u8], + ) -> Result<[u8; 32], AuthorityError> { + require_current_session(&self.session_state, session)?; + let entropy = self.root_entropy()?; + derive_product_entropy(&entropy, product_id, context).map_err(|err| { + AuthorityError::Unknown { + reason: err.to_string(), + } + }) + } +} + +fn product_authority_error(err: ProductAccountError) -> AuthorityError { + AuthorityError::Unavailable { + reason: err.to_string(), + } +} + +/// Wrap raw sign-message bytes in the `` envelope unless +/// already wrapped, matching the polkadot-app raw-signing convention. +/// +/// String payloads follow the polkadot-app `isHex` rule: a `0x`-prefixed, +/// even-length string is decoded from hex, and a corrupt hex body is a hard +/// error (never silently signed as UTF-8); any other string is signed as its +/// UTF-8 bytes. +fn raw_payload_bytes(payload: v01::RawPayload) -> Result, AuthorityError> { + let raw = match payload { + v01::RawPayload::Bytes { bytes } => bytes, + v01::RawPayload::Payload { payload } => decode_payload_string(payload)?, + }; + if raw.starts_with(BYTES_WRAP_PREFIX) && raw.ends_with(BYTES_WRAP_SUFFIX) { + return Ok(raw); + } + let mut wrapped = + Vec::with_capacity(BYTES_WRAP_PREFIX.len() + raw.len() + BYTES_WRAP_SUFFIX.len()); + wrapped.extend_from_slice(BYTES_WRAP_PREFIX); + wrapped.extend_from_slice(&raw); + wrapped.extend_from_slice(BYTES_WRAP_SUFFIX); + Ok(wrapped) +} + +fn decode_payload_string(payload: String) -> Result, AuthorityError> { + // `isHex`: `0x` prefix and even total length. Odd length is not hex and is + // signed as UTF-8, matching polkadot-app. + if let Some(body) = payload + .strip_prefix("0x") + .filter(|_| payload.len().is_multiple_of(2)) + { + return hex::decode(body).map_err(|_| AuthorityError::Unknown { + reason: "raw sign payload is 0x-prefixed but not valid hex".to_string(), + }); + } + Ok(payload.into_bytes()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::super::authority::{AuthorityError, SignRawAuthorityRequest}; + use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; + use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use crate::host_logic::product_account::{ + derive_product_keypair, derive_root_keypair_from_entropy, + }; + use crate::test_support::{StubPlatform, test_spawner}; + use truapi::api::{Account, Entropy, Signing}; + use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; + use truapi::versioned::entropy::HostDeriveEntropyRequest; + use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; + use truapi::{CallContext, CallError, v01}; + use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + fn signing_runtime() -> (Arc, Arc) { + // Auto-confirm raw signing so the role-neutral confirmation gate does + // not reject before reaching the signing authority. + let platform: Arc = Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + }); + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + ) + .expect("signing host config is valid"); + let services = RuntimeServices::new( + platform.clone(), + config.people_chain_genesis_hash, + test_spawner(), + ); + let signing_host = SigningHostRole::new(platform); + (services, signing_host) + } + + fn product_runtime( + services: Arc, + authority: Arc, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new("myapp.dot".to_string()).expect("valid product id"), + ) + } + + fn product_runtime_for( + services: Arc, + authority: Arc, + product_id: &str, + ) -> ProductRuntimeHost { + ProductRuntimeHost::from_services( + services, + authority, + ProductContext::new(product_id.to_string()).expect("valid product id"), + ) + } + + #[test] + fn activate_then_sign_raw_verifies_against_derived_product_key() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hello world".to_vec(), + }, + }); + let HostSignRawResponse::V1(response) = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); + assert!(response.signed_transaction.is_none()); + + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hello world", &signature) + .is_ok(), + "signature verifies over the -wrapped message", + ); + } + + #[test] + fn sign_raw_requires_active_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }); + let err = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect_err("no session"); + assert!(matches!(err, CallError::Domain(HostSignRawError::V1(_)))); + } + + #[test] + fn derive_entropy_matches_ios_vector_over_local_session() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime_for(services, activation, "test.product.dot"); + let cx = CallContext::new(); + let request = HostDeriveEntropyRequest::V1(v01::HostDeriveEntropyRequest { + context: b"my-key".to_vec(), + }); + let response = + futures::executor::block_on(runtime.derive(&cx, request)).expect("derive ok"); + let truapi::versioned::entropy::HostDeriveEntropyResponse::V1(inner) = response; + assert_eq!( + hex::encode(inner.entropy), + "479d5b9ecce19615397c9f160ee95e2f00c579837a5afb111132dd0da5fd472a", + ); + } + + #[test] + fn get_account_gates_on_local_session() { + let (services, authority) = signing_runtime(); + let runtime = product_runtime(services, authority); + let cx = CallContext::new(); + let request = HostAccountGetRequest::V1(v01::HostAccountGetRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + }); + let err = futures::executor::block_on(runtime.get_account(&cx, request)) + .expect_err("no session yet"); + assert!(matches!( + err, + CallError::Domain(HostAccountGetError::V1( + v01::HostAccountGetError::NotConnected + )) + )); + } + + #[test] + fn raw_payload_bytes_wraps_and_decodes() { + let ok = |p| raw_payload_bytes(p).expect("payload ok"); + // Bytes are -wrapped. + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec() + }), + b"hi".to_vec(), + ); + // A 0x-hex string payload decodes to bytes before wrapping. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xdeadbeef".to_string(), + }), + [ + BYTES_WRAP_PREFIX, + &[0xde, 0xad, 0xbe, 0xef], + BYTES_WRAP_SUFFIX + ] + .concat(), + ); + // A non-hex string payload is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "hello".to_string(), + }), + b"hello".to_vec(), + ); + // An odd-length 0x string is not `isHex`, so it is signed as UTF-8. + assert_eq!( + ok(v01::RawPayload::Payload { + payload: "0xabc".to_string(), + }), + b"0xabc".to_vec(), + ); + // Already-wrapped input is left untouched (no double wrapping). + assert_eq!( + ok(v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }), + b"hi".to_vec(), + ); + // An even-length 0x string that is not valid hex is a hard error, + // never silently signed as UTF-8 (matches polkadot-app abort). + assert!(matches!( + raw_payload_bytes(v01::RawPayload::Payload { + payload: "0xZZ".to_string(), + }), + Err(AuthorityError::Unknown { .. }), + )); + } + + #[test] + fn sign_raw_leaves_already_wrapped_payload_untouched() { + let (services, activation) = signing_runtime(); + futures::executor::block_on(activation.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let runtime = product_runtime(services, activation); + let cx = CallContext::new(); + let request = HostSignRawRequest::V1(v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: b"hi".to_vec(), + }, + }); + let HostSignRawResponse::V1(response) = + futures::executor::block_on(runtime.sign_raw(&cx, request)).expect("sign_raw ok"); + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let signature = + schnorrkel::Signature::from_bytes(&response.signature).expect("64-byte signature"); + assert!( + keypair + .public + .verify_simple(b"substrate", b"hi", &signature) + .is_ok(), + "signature verifies over the unchanged wrapped message", + ); + assert!( + keypair + .public + .verify_simple( + b"substrate", + b"hi", + &signature + ) + .is_err(), + "payload was not double-wrapped", + ); + } + + #[test] + fn reactivation_invalidates_prior_session_snapshot() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("first activation"); + let stale = authority.current_session().expect("snapshot"); + + // Re-activate with different entropy: a fresh public key, hence a + // different validation id. + futures::executor::block_on(authority.activate_local_session([0xCD; 16].to_vec())) + .expect("second activation"); + assert_ne!( + authority.current_session().expect("session").public_key, + stale.public_key, + ); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { + bytes: vec![1, 2, 3], + }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &stale, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("stale snapshot rejected"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[test] + fn disconnect_clears_local_session() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + + futures::executor::block_on(authority.disconnect()); + assert!(authority.current_session().is_none()); + + let cx = CallContext::new(); + let request = v01::HostSignRawRequest { + account: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + payload: v01::RawPayload::Bytes { bytes: vec![1] }, + }; + let err = futures::executor::block_on(authority.sign_raw( + &cx, + &session, + SignRawAuthorityRequest::Product(request), + )) + .expect_err("no session after disconnect"); + assert_eq!(err, AuthorityError::Disconnected); + } + + #[test] + fn deferred_operations_return_unavailable() { + let (_services, authority) = signing_runtime(); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation"); + let session = authority.current_session().expect("connected"); + let cx = CallContext::new(); + + let alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + v01::ProductAccountId { + dot_ns_identifier: "other.dot".to_string(), + derivation_index: 0, + }, + "myapp.dot".to_string(), + )) + .expect_err("alias deferred"); + assert!(matches!(alias, AuthorityError::Unavailable { .. })); + + let alloc = futures::executor::block_on(authority.allocate_resources( + &cx, + &session, + "myapp.dot".to_string(), + v01::HostRequestResourceAllocationRequest { resources: vec![] }, + )) + .expect_err("allocation deferred"); + assert!(matches!(alloc, AuthorityError::Unavailable { .. })); + } +} diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs new file mode 100644 index 000000000..9da344b05 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -0,0 +1,44 @@ +use super::{SigningHost, product_authority_error}; +use crate::host_logic::product_account::derive_root_keypair_from_entropy; +use crate::host_logic::session::SessionInfo; +use crate::runtime::authority::AuthorityError; +use crate::runtime::connected_session_ui_info; + +use zeroize::Zeroizing; + +/// Establish a wallet-local session from host-held secret material. +/// +/// A signing host owns the user's keys, so it establishes sessions directly +/// rather than through the SSO pairing flow. Only [`SigningHost`] implements +/// this; pairing hosts have no local secret to activate. +#[async_trait::async_trait] +pub(crate) trait LocalActivation: Send + Sync { + /// Activate a local session from raw BIP-39 entropy, deriving the root + /// public key and marking the session connected. + async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError>; +} + +#[async_trait::async_trait] +impl LocalActivation for SigningHost { + async fn activate_local_session(&self, secret: Vec) -> Result<(), AuthorityError> { + let secret = Zeroizing::new(secret); + let root = derive_root_keypair_from_entropy(&secret).map_err(product_authority_error)?; + let public_key = root.public.to_bytes(); + *self + .root_entropy + .lock() + .expect("signing host entropy mutex poisoned") = Some(secret); + let session = SessionInfo { + public_key, + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }; + self.session_state.set_session(session.clone()); + self.auth_state + .connected(&connected_session_ui_info(&session)); + Ok(()) + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs new file mode 100644 index 000000000..ab6da4b58 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -0,0 +1,1189 @@ +//! SSO pairing (login): presents the pairing deeplink, watches the bootstrap +//! topic on the statement store (live subscription plus periodic snapshot +//! queries), and decrypts the wallet's V2 handshake response into a session. + +#[cfg(test)] +use std::sync::{Arc, Mutex}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::auth_state::AuthStateMachine; +use super::identity::resolve_session_identity_with_chain; +use super::pairing_host::PairingHost; +use super::statement_store_rpc; +use crate::host_logic::session::{SessionInfo, encode_persisted_session}; +use crate::host_logic::sso::pairing::{ + PairingBootstrap, PairingDeviceIdentity, VersionedHandshakeResponse, + create_pairing_bootstrap_from_identity, decode_app_handshake_data, + decrypt_v2_handshake_response, establish_sso_session_info, generate_pairing_device_identity, + v2, +}; +use crate::host_logic::statement_store::{ + decode_verified_statement_data, parse_new_statements_result, +}; +use crate::subscription::Spawner; + +use futures::channel::{mpsc, oneshot}; +use futures::{FutureExt, StreamExt, pin_mut}; +use parity_scale_codec::{Decode, Encode}; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::{debug, info, instrument}; +use truapi::CallError; +use truapi::v01; +use truapi::versioned::account::HostRequestLoginError; +#[cfg(test)] +use truapi::versioned::account::HostRequestLoginResponse; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +#[cfg(not(test))] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_secs(2); +#[cfg(test)] +const PAIRING_QUERY_INTERVAL: Duration = Duration::from_millis(1); +#[cfg(not(test))] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 15; +#[cfg(test)] +const PAIRING_QUERY_TIMEOUT_TICKS: u8 = 10; + +/// Terminal outcome of [`SsoPairingFlow::request_session`]. +pub(super) enum SsoPairingOutcome { + /// The login was cancelled (host `cancel_login`, `disconnect`, or a + /// cross-tab session win). + Cancelled, + /// Wallet handshake completed; the session is resolved and persisted. + Success(Box), +} + +/// Resets a `Pairing` state left behind by a dropped login future (e.g. the +/// transport dropping in-flight calls on connection close). A no-op once the +/// flow reached any terminal transition or a newer pairing took over. +struct AbandonedPairingGuard { + auth_state: AuthStateMachine, + epoch: u64, + active: bool, +} + +impl AbandonedPairingGuard { + fn disarm(&mut self) { + self.active = false; + } +} + +impl Drop for AbandonedPairingGuard { + fn drop(&mut self) { + if self.active { + self.auth_state.reset_abandoned_pairing(self.epoch); + } + } +} + +pub(super) struct SsoPairingFlow<'a> { + host: &'a PairingHost, +} + +impl<'a> SsoPairingFlow<'a> { + pub(super) fn new(host: &'a PairingHost) -> Self { + Self { host } + } + + /// `request_session` pairing flow: emits `AuthState::Pairing` for the host + /// to present, then races host cancellation against the wallet handshake + /// arriving on the statement store; on success it resolves identity, + /// persists the new session, and returns it to the pairing host. + pub(super) async fn request_session( + &self, + ) -> Result> { + let (mut pairing_identity, reused_identity) = + read_or_create_pairing_device_identity(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + let last_processed_statement = + 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. + if reused_identity { + debug!("regenerating stored pairing device identity"); + pairing_identity = create_fresh_pairing_device_identity(self.host.platform.as_ref()) + .await + .map_err(|reason| self.fail_before_pairing(reason))?; + } + let bootstrap = + create_pairing_bootstrap_from_identity(&self.host.host_config, pairing_identity) + .map_err(|err| self.fail_before_pairing(err.to_string()))?; + + let Some((cancel_rx, pairing_epoch)) = self + .host + .auth_state + .pairing_started(bootstrap.deeplink.clone()) + else { + return Err(CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { + reason: "login already in progress".to_string(), + }, + ))); + }; + info!("presenting pairing QR, waiting for wallet handshake"); + let mut reset_guard = AbandonedPairingGuard { + auth_state: self.host.auth_state.clone(), + epoch: pairing_epoch, + active: true, + }; + + match self + .run_pairing_flow(&bootstrap, cancel_rx, last_processed_statement) + .await + { + Ok(outcome @ SsoPairingOutcome::Cancelled) => { + reset_guard.disarm(); + Ok(outcome) + } + Ok(outcome @ SsoPairingOutcome::Success(_)) => { + reset_guard.disarm(); + Ok(outcome) + } + Err(reason) => { + self.host.auth_state.login_failed(reason.clone()); + Err(CallError::HostFailure { reason }) + } + } + } + + /// Emit `LoginFailed` for an error raised before the pairing was entered + /// and map it onto the `request_login` error shape. + fn fail_before_pairing(&self, reason: String) -> CallError { + self.host + .auth_state + .login_failed_before_pairing(reason.clone()); + CallError::Domain(HostRequestLoginError::V1( + v01::HostRequestLoginError::Unknown { reason }, + )) + } + + /// Everything between the `Pairing` emission and a terminal outcome. + /// Every error returned here maps to `AuthState::LoginFailed` at the + /// single exit in [`Self::request_login`]. + async fn run_pairing_flow( + &self, + bootstrap: &PairingBootstrap, + cancel_rx: oneshot::Receiver<()>, + last_processed_statement: Option>, + ) -> Result { + let mut cancel = cancel_rx.fuse(); + let statement_store = self.host.statement_store.clone(); + let statement_store_connect = statement_store.client("pairing statement-store").fuse(); + pin_mut!(statement_store_connect); + + let rpc_client = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + connect_result = statement_store_connect => connect_result?, + }; + let subscribe_client = rpc_client.clone(); + let live_topics = [bootstrap.topic]; + let live_subscription = + statement_store_rpc::subscribe_match_all(&subscribe_client, &live_topics).fuse(); + pin_mut!(live_subscription); + let live_subscription = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + subscribe_result = live_subscription => subscribe_result + .map_err(|err| format!("pairing statement-store subscribe failed: {err}"))?, + }; + debug!("subscribed to pairing topic, polling statement store"); + let pairing_response = wait_for_v2_pairing_success( + rpc_client, + live_subscription, + bootstrap.topic, + bootstrap.encryption_secret_key, + last_processed_statement, + self.host.spawner.clone(), + ) + .fuse(); + pin_mut!(pairing_response); + + let response = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + response_result = pairing_response => response_result?, + }; + write_last_processed_pairing_statement(self.host.platform.as_ref(), &response.statement) + .await; + let sso = establish_sso_session_info( + bootstrap, + response.peer_statement_account_id, + response.success.sso_enc_pub_key, + )?; + let session = SessionInfo { + public_key: response.success.root_account_id, + sso: Some(sso), + root_entropy_source: Some(response.success.root_entropy_source), + identity_account_id: Some(response.success.identity_account_id), + lite_username: None, + full_username: None, + }; + let resolve_session = resolve_session_identity_with_chain( + &self.host.chain, + self.host.host_config.people_chain_genesis_hash, + session, + ) + .fuse(); + pin_mut!(resolve_session); + let session = futures::select! { + _ = cancel => return Ok(SsoPairingOutcome::Cancelled), + session = resolve_session => session, + }; + let persist_session = self + .host + .platform + .write_core_storage( + CoreStorageKey::AuthSession, + encode_persisted_session(&session), + ) + .fuse(); + pin_mut!(persist_session); + futures::select! { + _ = cancel => { + clear_auth_session(self.host.platform.as_ref()).await; + return Ok(SsoPairingOutcome::Cancelled); + }, + persist_result = persist_session => persist_result + .map_err(|err| format!("session persist failed: {err:?}"))?, + }; + futures::select! { + _ = cancel => { + clear_auth_session(self.host.platform.as_ref()).await; + return Ok(SsoPairingOutcome::Cancelled); + }, + default => {} + }; + Ok(SsoPairingOutcome::Success(Box::new(session))) + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.create_fresh"))] +async fn create_fresh_pairing_device_identity( + storage: &(impl CoreStorage + ?Sized), +) -> Result { + let identity = generate_pairing_device_identity() + .map_err(|err| format!("pairing identity failed: {err}"))?; + storage + .write_core_storage(CoreStorageKey::PairingDeviceIdentity, identity.encode()) + .await + .map_err(|err| format!("pairing device identity write failed: {err:?}"))?; + Ok(identity) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing_device.read_or_create"))] +async fn read_or_create_pairing_device_identity( + storage: &(impl CoreStorage + ?Sized), +) -> Result<(PairingDeviceIdentity, bool), String> { + let stored = storage + .read_core_storage(CoreStorageKey::PairingDeviceIdentity) + .await + .map_err(|err| format!("pairing device identity read failed: {err:?}"))?; + if let Some(stored) = stored { + match PairingDeviceIdentity::decode(&mut stored.as_slice()) { + Ok(identity) => return Ok((identity, true)), + Err(err) => { + debug!("discarding invalid stored pairing device identity: {err}"); + } + } + } + + create_fresh_pairing_device_identity(storage) + .await + .map(|identity| (identity, false)) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.last_processed.read"))] +async fn read_last_processed_pairing_statement( + storage: &(impl CoreStorage + ?Sized), +) -> Result>, String> { + storage + .read_core_storage(CoreStorageKey::LastProcessedPairingStatement) + .await + .map_err(|err| format!("last processed pairing statement read failed: {err:?}")) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.last_processed.write"))] +async fn write_last_processed_pairing_statement( + storage: &(impl CoreStorage + ?Sized), + statement: &[u8], +) { + if let Err(err) = storage + .write_core_storage( + CoreStorageKey::LastProcessedPairingStatement, + statement.to_vec(), + ) + .await + { + debug!("last processed pairing statement write failed: {err:?}"); + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.auth_session.clear"))] +async fn clear_auth_session(storage: &(impl CoreStorage + ?Sized)) { + if let Err(err) = storage + .clear_core_storage(CoreStorageKey::AuthSession) + .await + { + debug!("auth session clear failed: {err:?}"); + } +} + +/// Decoded wallet handshake success plus the statement metadata needed to +/// persist the authenticated session and remember the handled statement. +struct PairingSuccess { + statement: Vec, + peer_statement_account_id: [u8; 32], + success: v2::Success, +} + +impl PairingSuccess { + /// Decode one retained statement-store response for the current pairing + /// topic. `Ok(None)` means the wallet has not produced a final response for + /// this statement yet; wallet failure statuses are surfaced as `Err`. + #[instrument(skip_all, fields(runtime.method = "sso.pairing.decode_statement"))] + fn from_v2_statement( + statement: &[u8], + core_encryption_secret_key: [u8; 32], + ) -> Result, String> { + let verified = + decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; + let VersionedHandshakeResponse::V2 { + encrypted_message, + public_key, + } = decode_app_handshake_data(&verified.data)?; + match decrypt_v2_handshake_response( + core_encryption_secret_key, + public_key, + &encrypted_message, + )? { + v2::EncryptedResponse::Pending(_) => Ok(None), + v2::EncryptedResponse::Failed(reason) => Err(reason), + v2::EncryptedResponse::Success(success) => Ok(Some(Self { + statement: statement.to_vec(), + peer_statement_account_id: verified.signer, + success: *success, + })), + } + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.wait_success"))] +async fn wait_for_v2_pairing_success( + rpc_client: RpcClient, + mut live_subscription: RpcSubscription, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], + last_processed_statement: Option>, + spawner: Spawner, +) -> Result { + let (query_tx, mut query_rx) = mpsc::unbounded(); + let mut query_active = false; + let poll = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(poll); + loop { + futures::select! { + item = live_subscription.next().fuse() => { + let Some(item) = item else { + return Err("pairing statement-store live subscription ended".to_string()); + }; + let value = item.map_err(|err| format!("pairing statement-store live error: {err}"))?; + if let Some(success) = handle_v2_pairing_result( + &value, + core_encryption_secret_key, + last_processed_statement.as_deref(), + )? { + return Ok(success); + } + } + query = query_rx.next().fuse() => { + query_active = false; + if let Some(query) = query + && let Some(success) = query? { + return Ok(success); + } + } + _ = poll => { + if !query_active { + query_active = true; + let rpc_client = rpc_client.clone(); + let query_tx = query_tx.clone(); + let last_processed_statement = last_processed_statement.clone(); + let fut = async move { + let result = run_pairing_snapshot_query( + rpc_client, + topic, + core_encryption_secret_key, + last_processed_statement, + ).await; + let _ = query_tx.unbounded_send(result); + }; + // `RpcClient` is transport-only here; spawning lets live + // notifications continue to be consumed while a snapshot + // query is waiting for backlog completion or timeout. + (spawner)(fut.boxed()); + } + poll.set(futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse()); + } + } + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.snapshot_query"))] +async fn run_pairing_snapshot_query( + rpc_client: RpcClient, + topic: [u8; 32], + core_encryption_secret_key: [u8; 32], + last_processed_statement: Option>, +) -> Result, String> { + let topics = [topic]; + let mut subscription = statement_store_rpc::subscribe_match_all(&rpc_client, &topics) + .await + .map_err(|err| format!("pairing statement-store query failed: {err}"))?; + for _ in 0..PAIRING_QUERY_TIMEOUT_TICKS { + let timeout = futures_timer::Delay::new(PAIRING_QUERY_INTERVAL).fuse(); + pin_mut!(timeout); + futures::select! { + item = subscription.next().fuse() => { + let Some(item) = item else { + return Ok(None); + }; + let value = item.map_err(|err| format!("pairing statement-store query item failed: {err}"))?; + if let Some(success) = handle_v2_pairing_result( + &value, + core_encryption_secret_key, + last_processed_statement.as_deref(), + )? { + return Ok(Some(success)); + } + let page = parse_new_statements_result("query".to_string(), &value) + .map_err(|err| err.to_string())?; + if page.remaining == Some(0) { + return Ok(None); + } + } + _ = timeout => {} + } + } + Ok(None) +} + +#[instrument(skip_all, fields(runtime.method = "sso.pairing.handle_result"))] +fn handle_v2_pairing_result( + value: &Value, + core_encryption_secret_key: [u8; 32], + last_processed_statement: Option<&[u8]>, +) -> Result, String> { + let page = + parse_new_statements_result("pairing".to_string(), value).map_err(|err| err.to_string())?; + for statement in page.statements { + if last_processed_statement == Some(statement.as_slice()) { + continue; + } + if let Some(success) = + PairingSuccess::from_v2_statement(&statement, core_encryption_secret_key)? + { + return Ok(Some(success)); + } + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::super::connected_session_ui_info; + use super::super::{PairingHostRole, ProductRuntimeHost}; + use super::*; + use crate::host_rpc_client::HostRpcClient; + use crate::test_support::{ + StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, + runtime_config, session_info, signed_test_statement, stub_platform, subscribe_ack_frame, + test_spawner, wallet_handshake_statement, + }; + use p256::elliptic_curve::sec1::ToEncodedPoint; + use truapi::CallContext; + use truapi::api::Account; + use truapi::versioned::account::{ + HostAccountConnectionStatusSubscribeItem, HostRequestLoginRequest, + }; + use truapi_platform::{AuthState, ChainProvider, CoreStorageKey}; + + /// Cancel the login as soon as the host observes the `Pairing` state, + /// mimicking a user dismissing the pairing UI immediately. + fn cancel_on_pairing(platform: &StubPlatform, pairing_host: Arc) { + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Pairing { .. }) { + pairing_host.cancel_login(); + } + })); + } + + #[test] + fn request_login_presents_pairing_and_rejects_when_cancelled() { + let platform = stub_platform(); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + match &auth_states[0] { + AuthState::Pairing { deeplink } => { + assert!(deeplink.starts_with("polkadotapp://pair?handshake=")); + } + other => panic!("expected pairing state first, got {other:?}"), + } + assert_eq!(auth_states[1], AuthState::Disconnected); + + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + if let Some(sent) = sent_rpc.first() { + let request: serde_json::Value = serde_json::from_str(sent).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAll"][0].as_str().unwrap().len(), + 66 + ); + } + } + + #[test] + fn request_login_regenerates_unmarked_pairing_device_identity_between_attempts() { + let platform = stub_platform(); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + + let first = futures::executor::block_on(host.request_login(&cx, request.clone())).unwrap(); + let second = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + first, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let deeplinks: Vec = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .filter_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) + .collect(); + assert_eq!(deeplinks.len(), 2); + assert_ne!( + pairing_device_from_deeplink(&deeplinks[0]), + pairing_device_from_deeplink(&deeplinks[1]) + ); + assert!( + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "cancelled pairing keeps the latest identity; the next unmarked reuse regenerates it" + ); + } + + #[test] + fn request_login_regenerates_marked_stored_pairing_device_identity() { + let platform = stub_platform(); + let identity = generate_pairing_device_identity().unwrap(); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::PairingDeviceIdentity), + identity.encode(), + ); + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .insert( + core_storage_test_key(CoreStorageKey::LastProcessedPairingStatement), + vec![0xde, 0xad], + ); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + let deeplink = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .find_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) + .expect("pairing state should be emitted"); + assert_ne!( + pairing_device_from_deeplink(&deeplink), + ( + identity.statement_store_public_key, + identity.encryption_public_key + ) + ); + assert!( + platform + .local_storage + .lock() + .expect("local storage mutex poisoned") + .contains_key(&core_storage_test_key( + CoreStorageKey::PairingDeviceIdentity + )), + "cancelled pairing keeps the rotated identity; the next login rotates again" + ); + } + + #[test] + fn request_login_waits_for_pairing_statement() { + let wallet_ephemeral_secret = p256::SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let handshake = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: wallet_ephemeral_public_bytes, + }; + let statement = signed_test_statement(handshake.encode()); + let notification = format!( + r#"{{"jsonrpc":"2.0","method":"statement_statement","params":{{"subscription":"remote-sub","result":{{"event":"newStatements","data":{{"statements":["0x{}"],"remaining":0}}}}}}}}"#, + hex::encode(statement) + ); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + r#"{"jsonrpc":"2.0","id":"truapi:1","result":"remote-sub"}"#.to_string(), + notification, + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + match err { + CallError::HostFailure { reason } => { + assert_eq!(reason, "encrypted SSO handshake answer is too short"); + } + other => panic!("expected handshake decrypt failure, got {other:?}"), + } + let sent_rpc = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let requests = sent_rpc + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .collect::>(); + let methods = requests + .iter() + .map(|request| request["method"].as_str().unwrap()) + .collect::>(); + assert_eq!( + methods.first().copied(), + Some("statement_subscribeStatement") + ); + assert!( + methods.contains(&"statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + let unsubscribe = requests + .iter() + .find(|request| request["method"].as_str() == Some("statement_unsubscribeStatement")) + .expect("pairing subscription should be cleaned up"); + assert_eq!(unsubscribe["params"][0], "remote-sub"); + } + + #[test] + fn request_login_accepts_valid_pairing_statement_and_persists_session() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + session_writes: session_writes.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let mut statuses = host.test_session_state().subscribe(); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Disconnected + ) + ); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert_eq!( + futures::executor::block_on(statuses.next()).unwrap(), + HostAccountConnectionStatusSubscribeItem::V1( + v01::HostAccountConnectionStatusSubscribeItem::Connected + ) + ); + + let session = host + .test_session_state() + .current() + .expect("paired session should be active"); + assert_eq!(session.public_key, session_info().public_key); + assert_eq!(session.root_entropy_source, Some([0x66; 32])); + assert_eq!( + session.sso.as_ref().unwrap().identity_account_id, + peer_statement_keypair().1 + ); + + let writes = session_writes + .lock() + .expect("session write list mutex poisoned"); + assert_eq!(writes.len(), 1); + assert_eq!( + crate::host_logic::session::decode_persisted_session(&writes[0]).unwrap(), + session + ); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 2, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert_eq!( + auth_states[1], + AuthState::Connected(connected_session_ui_info(&session)) + ); + drop(auth_states); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + assert!( + methods + .iter() + .any(|method| method == "statement_unsubscribeStatement"), + "pairing subscription should be cleaned up" + ); + } + + #[test] + fn request_login_surfaces_wallet_failure_status() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let platform = Arc::new(StubPlatform { + pairing_failure_response: true, + session_writes: session_writes.clone(), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + let expected_reason = + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)"; + assert_eq!( + err, + CallError::HostFailure { + reason: expected_reason.to_string() + } + ); + assert!( + session_writes + .lock() + .expect("session writes mutex poisoned") + .is_empty() + ); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert!( + auth_states + .iter() + .any(|state| matches!(state, AuthState::LoginFailed { reason } if reason == expected_reason)), + "wallet failure should be surfaced to the modal: {auth_states:?}" + ); + } + + #[test] + fn request_login_clears_auth_session_when_cancelled_after_persist() { + let session_writes = Arc::new(Mutex::new(Vec::new())); + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + session_writes: session_writes.clone(), + session_clears: session_clears.clone(), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let cancel_host = pairing_host.clone(); + *platform + .on_auth_session_write + .lock() + .expect("auth session write hook mutex poisoned") = Some(Arc::new(move || { + cancel_host.cancel_login(); + })); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + assert_eq!( + session_writes + .lock() + .expect("session write list mutex poisoned") + .len(), + 1 + ); + assert_eq!( + *session_clears + .lock() + .expect("session clear counter mutex poisoned"), + 1 + ); + } + + #[test] + fn request_login_connected_callback_can_clear_session_without_reinstalling_it() { + let platform = Arc::new(StubPlatform { + pairing_success_response: true, + ..Default::default() + }); + let host = Arc::new(ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + )); + let disconnect_host = host.clone(); + *platform + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") = Some(Arc::new(move |state| { + if matches!(state, AuthState::Connected(_)) { + disconnect_host.test_session_state().clear_session(); + } + })); + + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Success) + ); + assert!(host.test_session_state().current().is_none()); + + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert!(matches!(&auth_states[0], AuthState::Pairing { .. })); + assert!(matches!(&auth_states[1], AuthState::Connected(_))); + } + + /// Pairing success must also be decoded from a snapshot query page, not only + /// from the live pairing subscription. + #[test] + fn request_login_accepts_pairing_statement_from_snapshot_query_page() { + let (host_config, _) = runtime_config("myapp.dot"); + let pairing_identity = generate_pairing_device_identity().unwrap(); + let bootstrap = + create_pairing_bootstrap_from_identity(&host_config, pairing_identity).unwrap(); + let statement = wallet_handshake_statement(&bootstrap.deeplink); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "query-sub"), + crate::test_support::new_statements_frame("query-sub", vec![statement]), + ], + ..Default::default() + }); + let connection = + futures::executor::block_on(platform.connect(host_config.people_chain_genesis_hash)) + .unwrap(); + let rpc_client = RpcClient::new(HostRpcClient::new(Arc::from(connection), test_spawner())); + let success = futures::executor::block_on(run_pairing_snapshot_query( + rpc_client, + bootstrap.topic, + bootstrap.encryption_secret_key, + None, + )) + .unwrap() + .expect("snapshot query should return pairing success"); + + assert_eq!( + success.peer_statement_account_id, + peer_statement_keypair().1 + ); + assert_eq!(success.success.root_account_id, session_info().public_key); + + let methods = platform + .sent_rpc + .lock() + .expect("rpc list mutex poisoned") + .iter() + .map(|request| serde_json::from_str::(request).unwrap()) + .map(|request| request["method"].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!( + methods.first().map(String::as_str), + Some("statement_subscribeStatement") + ); + } + + #[test] + fn pairing_result_skips_last_processed_statement() { + let (host_config, _) = runtime_config("myapp.dot"); + let pairing_identity = generate_pairing_device_identity().unwrap(); + let bootstrap = + create_pairing_bootstrap_from_identity(&host_config, pairing_identity).unwrap(); + let statement = wallet_handshake_statement(&bootstrap.deeplink); + let page = serde_json::json!({ + "event": "newStatements", + "data": { + "statements": [format!("0x{}", hex::encode(&statement))], + "remaining": 0, + }, + }); + + let ignored = handle_v2_pairing_result( + &page, + bootstrap.encryption_secret_key, + Some(statement.as_slice()), + ) + .unwrap(); + assert!(ignored.is_none()); + + let accepted = handle_v2_pairing_result(&page, bootstrap.encryption_secret_key, None) + .unwrap() + .expect("unmarked statement should be accepted"); + assert_eq!( + accepted.peer_statement_account_id, + peer_statement_keypair().1 + ); + } + + #[test] + fn request_login_emits_login_failed_for_pre_pairing_errors() { + let platform = Arc::new(StubPlatform { + local_storage_error: Some("identity storage unavailable"), + ..Default::default() + }); + let host = ProductRuntimeHost::new_compat(platform.clone(), test_spawner()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let err = futures::executor::block_on(host.request_login(&cx, request)).unwrap_err(); + + assert!(matches!(err, CallError::Domain(_))); + let auth_states = platform + .auth_states + .lock() + .expect("auth state list mutex poisoned"); + assert_eq!(auth_states.len(), 1, "states: {auth_states:?}"); + assert!(matches!(&auth_states[0], AuthState::LoginFailed { reason } + if reason.contains("identity storage unavailable"))); + } + + #[test] + fn dropped_request_login_clears_single_flight_for_next_attempt() { + use std::future::Future; + use std::task::{Context, Poll}; + + let platform = Arc::new(StubPlatform { + chain_connect_pending: true, + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let cx = CallContext::new(); + let mut first_login = Box::pin(host.request_login(&cx, request.clone())); + let waker = futures::task::noop_waker(); + let mut task_cx = Context::from_waker(&waker); + + match first_login.as_mut().poll(&mut task_cx) { + Poll::Pending => {} + Poll::Ready(result) => panic!("first login should be pending, got {result:?}"), + } + assert!( + platform + .auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .any(|state| matches!(state, AuthState::Pairing { .. })), + "first login did not enter pairing state" + ); + + drop(first_login); + + assert!( + platform + .pending_connect_dropped + .load(std::sync::atomic::Ordering::SeqCst), + "dropping the login future should drop the pending statement-store connect" + ); + + cancel_on_pairing(&platform, pairing_host); + let second_cx = CallContext::new(); + let mut second_login = Box::pin(host.request_login(&second_cx, request)); + let second = match second_login.as_mut().poll(&mut task_cx) { + Poll::Ready(result) => result.expect("second login should complete after cancellation"), + Poll::Pending => panic!("second login stayed pending behind stale single-flight state"), + }; + assert_eq!( + second, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + } + + #[test] + fn request_login_does_not_restore_persisted_session_before_pairing() { + let stored = session_info(); + let platform = Arc::new(StubPlatform { + session_blob: Some(crate::host_logic::session::encode_persisted_session( + &stored, + )), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn request_login_ignores_corrupt_persisted_session_before_pairing() { + let session_clears = Arc::new(Mutex::new(0)); + let platform = Arc::new(StubPlatform { + session_blob: Some(vec![0xff]), + session_clears: session_clears.clone(), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + assert_eq!(*session_clears.lock().unwrap(), 0); + } + + #[test] + fn request_login_ignores_session_store_failure_before_pairing() { + let platform = Arc::new(StubPlatform { + session_error: Some("storage failed"), + ..Default::default() + }); + let (host, pairing_host) = + ProductRuntimeHost::new_compat_with_pairing(platform.clone(), test_spawner()); + let host = Arc::new(host); + cancel_on_pairing(&platform, pairing_host); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::Rejected) + ); + assert!(host.test_session_state().current().is_none()); + } + + #[test] + fn request_login_returns_already_connected_when_session_exists() { + let host = ProductRuntimeHost::new_compat(stub_platform(), test_spawner()); + host.test_session_state().set_session(session_info()); + let cx = CallContext::new(); + let request = HostRequestLoginRequest::V1(v01::HostRequestLoginRequest { reason: None }); + let response = futures::executor::block_on(host.request_login(&cx, request)).unwrap(); + assert_eq!( + response, + HostRequestLoginResponse::V1(v01::HostRequestLoginResponse::AlreadyConnected) + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs new file mode 100644 index 000000000..0fe318c7b --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -0,0 +1,571 @@ +//! SSO remote messaging over the people-chain statement store: submits an +//! encrypted request statement to the paired signing host and waits for the +//! matching response, honoring timeouts and local/peer disconnect signals. + +use core::mem; +use std::fmt::{self, Display}; +use std::sync::Mutex; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use super::statement_store_rpc; +use crate::host_logic::session::SsoSessionInfo; +use crate::host_logic::sso::messages::{ + SsoRemoteResponse, SsoSessionStatement, decode_sso_session_statement, +}; +use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; + +use futures::channel::oneshot; +use futures::future::BoxFuture; +use futures::stream::BoxStream; +use futures::{FutureExt, StreamExt, pin_mut}; +use serde_json::Value; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::RpcSubscription; +use tracing::instrument; +use truapi::{CancellationReason, CancellationToken}; + +/// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: +/// +const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// Disconnect reason reported when the local session logs out mid-request. +pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; +/// Disconnect reason reported when the paired signing host announces a disconnect. +pub(super) const SSO_PEER_DISCONNECT_REASON: &str = "SSO peer disconnected"; +/// Reason reported when the product caller cancels a pending SSO request. +const SSO_CALL_CANCELLED_REASON: &str = "SSO response wait cancelled by caller"; + +/// Registry of oneshot waiters resolved when the SSO session disconnects. +#[derive(Default)] +pub(super) struct SessionDisconnects { + inner: Mutex, +} + +#[derive(Default)] +struct SessionDisconnectsInner { + next_id: u64, + waiters: Vec<(u64, SsoSessionKey, oneshot::Sender)>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(super) struct SsoSessionKey { + own: [u8; 32], + peer: [u8; 32], +} + +impl SsoSessionKey { + pub(super) fn from_session(session: &SsoSessionInfo) -> Self { + Self { + own: session.session_id_own, + peer: session.session_id_peer, + } + } +} + +pub(super) struct SessionDisconnectGuard { + disconnects: std::sync::Arc, + id: u64, +} + +impl Drop for SessionDisconnectGuard { + fn drop(&mut self) { + self.disconnects.unsubscribe(self.id); + } +} + +impl SessionDisconnects { + /// Register a waiter; returns its id and the disconnect-reason receiver. + pub(super) fn subscribe( + self: &std::sync::Arc, + session: &SsoSessionInfo, + ) -> (SessionDisconnectGuard, oneshot::Receiver) { + let (tx, rx) = oneshot::channel(); + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + inner.next_id = inner.next_id.wrapping_add(1); + let id = inner.next_id; + inner + .waiters + .push((id, SsoSessionKey::from_session(session), tx)); + ( + SessionDisconnectGuard { + disconnects: self.clone(), + id, + }, + rx, + ) + } + + fn unsubscribe(&self, id: u64) { + self.inner + .lock() + .expect("session disconnect mutex poisoned") + .waiters + .retain(|(waiter_id, _, _)| *waiter_id != id); + } + + /// Resolve pending waiters for one SSO session with `reason`. + pub(super) fn notify(&self, session: &SsoSessionInfo, reason: &'static str) { + self.notify_key(SsoSessionKey::from_session(session), reason); + } + + pub(super) fn notify_key(&self, key: SsoSessionKey, reason: &'static str) { + let waiters = { + let mut inner = self + .inner + .lock() + .expect("session disconnect mutex poisoned"); + let mut matching = Vec::new(); + let mut pending = Vec::with_capacity(inner.waiters.len()); + for waiter in mem::take(&mut inner.waiters) { + if waiter.1 == key { + matching.push(waiter); + } else { + pending.push(waiter); + } + } + inner.waiters = pending; + matching + }; + for (_, _, waiter) in waiters { + let _ = waiter.send(reason.to_string()); + } + } +} + +pub(super) type StatementPageStream = BoxStream<'static, Result>; +pub(super) type StatementSubmitFuture = BoxFuture<'static, Result<(), SsoRemoteResponseError>>; + +pub(super) struct RemoteResponseWait<'a> { + pub(super) own_statements: StatementPageStream, + pub(super) peer_statements: StatementPageStream, + pub(super) submit: StatementSubmitFuture, + pub(super) session: &'a SsoSessionInfo, + pub(super) statement_request_id: &'a str, + pub(super) remote_message_id: &'a str, + pub(super) cancel: &'a CancellationToken, + pub(super) disconnect: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CancelError { + reason: CancellationReason, + remote_message_id: String, +} + +impl CancelError { + fn new(reason: CancellationReason, remote_message_id: &str) -> Self { + Self { + reason, + remote_message_id: remote_message_id.to_string(), + } + } + + pub(super) fn reason(&self) -> CancellationReason { + self.reason.clone() + } + + pub(super) fn remote_message_id(&self) -> &str { + &self.remote_message_id + } + + pub(super) fn with_remote_message_id(self, remote_message_id: &str) -> Self { + Self { + reason: self.reason, + remote_message_id: remote_message_id.to_string(), + } + } +} + +impl Display for CancelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.reason { + CancellationReason::Cancelled => { + write!( + f, + "{SSO_CALL_CANCELLED_REASON} for {}", + self.remote_message_id + ) + } + CancellationReason::TimedOut { timeout } => write!( + f, + "SSO response timed out after {} for {}", + format_timeout_duration(*timeout), + self.remote_message_id + ), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum SsoRemoteResponseError { + Cancelled(CancelError), + LocalDisconnected, + PeerDisconnected, + Failure(String), +} + +impl Display for SsoRemoteResponseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled(err) => err.fmt(f), + Self::LocalDisconnected => f.write_str(SSO_LOCAL_DISCONNECT_REASON), + Self::PeerDisconnected => f.write_str(SSO_PEER_DISCONNECT_REASON), + Self::Failure(reason) => f.write_str(reason), + } + } +} + +impl From for SsoRemoteResponseError { + fn from(reason: String) -> Self { + Self::Failure(reason) + } +} + +fn disconnect_error(reason: String) -> SsoRemoteResponseError { + match reason.as_str() { + SSO_LOCAL_DISCONNECT_REASON => SsoRemoteResponseError::LocalDisconnected, + SSO_PEER_DISCONNECT_REASON => SsoRemoteResponseError::PeerDisconnected, + _ => SsoRemoteResponseError::Failure(reason), + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] +pub(super) async fn wait_for_sso_remote_response( + wait: RemoteResponseWait<'_>, +) -> Result { + let RemoteResponseWait { + own_statements, + peer_statements, + submit, + session, + statement_request_id, + remote_message_id, + cancel, + disconnect, + } = wait; + let response = wait_for_sso_remote_response_inner( + own_statements, + peer_statements, + submit, + session, + statement_request_id, + remote_message_id, + ) + .fuse(); + let disconnect = async move { + match disconnect { + Some(rx) => match rx.await { + Ok(reason) => disconnect_error(reason), + Err(_) => SsoRemoteResponseError::LocalDisconnected, + }, + None => futures::future::pending::().await, + } + } + .fuse(); + let cancel_message_id = remote_message_id.to_string(); + let cancelled = async move { + let reason = cancel.cancelled().await; + SsoRemoteResponseError::Cancelled(CancelError::new(reason, &cancel_message_id)) + } + .fuse(); + pin_mut!(response, disconnect, cancelled); + futures::select! { + result = response => result, + reason = disconnect => Err(reason), + reason = cancelled => Err(reason), + } +} + +#[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait_inner"))] +async fn wait_for_sso_remote_response_inner( + own_statements: StatementPageStream, + peer_statements: StatementPageStream, + submit: StatementSubmitFuture, + session: &SsoSessionInfo, + statement_request_id: &str, + remote_message_id: &str, +) -> Result { + let mut own_statements = own_statements.fuse(); + let mut peer_statements = peer_statements.fuse(); + let mut submit = submit.fuse(); + let mut own_done = false; + let mut peer_done = false; + let mut request_accepted = false; + let mut pending_remote_response = None; + + loop { + if own_done && peer_done { + return Err(SsoRemoteResponseError::Failure(format!( + "SSO response stream ended before response for {}", + remote_message_id + ))); + } + futures::select! { + item = own_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), + None => own_done = true, + } + } + item = peer_statements.next() => { + match item { + Some(Ok(value)) => { + if let Some(response) = handle_sso_remote_statement_page( + session, + &value, + statement_request_id, + remote_message_id, + &mut request_accepted, + &mut pending_remote_response, + )? { + return Ok(response); + } + } + Some(Err(reason)) => return Err(SsoRemoteResponseError::Failure(reason)), + None => peer_done = true, + } + } + submit_result = submit => { + submit_result?; + } + } + } +} + +fn handle_sso_remote_statement_page( + session: &SsoSessionInfo, + value: &Value, + statement_request_id: &str, + remote_message_id: &str, + request_accepted: &mut bool, + pending_remote_response: &mut Option, +) -> Result, SsoRemoteResponseError> { + let page = parse_new_statements_result("sso-remote".to_string(), value) + .map_err(|err| SsoRemoteResponseError::Failure(err.to_string()))?; + for statement in page.statements { + match decode_sso_session_statement( + session, + &statement, + statement_request_id, + remote_message_id, + ) + .map_err(SsoRemoteResponseError::Failure)? + { + Some(SsoSessionStatement::RequestAccepted) => { + *request_accepted = true; + if let Some(response) = pending_remote_response.take() { + return Ok(Some(response)); + } + } + Some(SsoSessionStatement::RemoteResponse(response)) => { + if *request_accepted { + return Ok(Some(response)); + } + *pending_remote_response = Some(response); + } + Some(SsoSessionStatement::Disconnected) => { + return Err(SsoRemoteResponseError::PeerDisconnected); + } + None => {} + } + } + Ok(None) +} + +pub(super) async fn subscribe_statement_topic( + rpc_client: &RpcClient, + topic: [u8; 32], +) -> Result, subxt_rpcs::Error> { + statement_store_rpc::subscribe_match_all(rpc_client, &[topic]).await +} + +pub(super) fn statement_subscription_stream( + subscription: RpcSubscription, + label: &'static str, +) -> StatementPageStream { + subscription + .map(move |item| item.map_err(|err| format!("SSO {label} subscription failed: {err}"))) + .boxed() +} + +fn format_timeout_duration(duration: Duration) -> String { + if duration.subsec_millis() == 0 { + format!("{}s", duration.as_secs()) + } else { + format!("{}ms", duration.as_millis()) + } +} + +/// Fresh opaque message id for one SSO request. +pub(super) fn sso_message_id() -> String { + nanoid::nanoid!(8) +} + +pub(super) fn fresh_statement_expiry() -> u64 { + let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); + timestamp << 32 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::sso_session_info; + use futures::stream; + + #[test] + fn sso_message_id_uses_short_opaque_nanoids() { + let first = sso_message_id(); + let second = sso_message_id(); + + assert_eq!(first.len(), 8); + assert_eq!(second.len(), 8); + assert_ne!(first, "p:1"); + assert_ne!(second, "p:1"); + assert_ne!(first, second); + assert!(first.bytes().all(is_nanoid_safe_byte)); + assert!(second.bytes().all(is_nanoid_safe_byte)); + } + + fn is_nanoid_safe_byte(value: u8) -> bool { + value.is_ascii_alphanumeric() || value == b'_' || value == b'-' + } + + #[test] + fn sso_remote_response_waiter_reports_timeout_cancellation() { + let session = sso_session_info(); + let cancel = CancellationToken::new(); + cancel.cancel_with_reason(CancellationReason::TimedOut { + timeout: Duration::from_millis(1), + }); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + })) + .unwrap_err(); + + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response timed out after 1ms for request-1" + ); + } + + #[test] + fn sso_remote_response_waiter_reports_submit_rejections() { + let session = sso_session_info(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::ready(Err(SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string(), + ))) + .boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: None, + })) + .unwrap_err(); + + assert_eq!( + err, + SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string() + ) + ); + } + + #[test] + fn sso_remote_response_waiter_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) + .unwrap_err(); + + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); + } + + #[test] + fn sso_remote_response_waiter_without_timeout_stops_on_local_disconnect_signal() { + let session = sso_session_info(); + let (tx, rx) = oneshot::channel(); + tx.send(SSO_LOCAL_DISCONNECT_REASON.to_string()).unwrap(); + let err = futures::executor::block_on(wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &CancellationToken::new(), + disconnect: Some(rx), + })) + .unwrap_err(); + + assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); + } + + #[test] + fn sso_remote_response_waiter_stops_on_call_cancellation() { + let session = sso_session_info(); + let cancel = CancellationToken::new(); + let wait = wait_for_sso_remote_response(RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session: session.sso.as_ref().unwrap(), + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + }); + + cancel.cancel(); + let err = futures::executor::block_on(wait).unwrap_err(); + + let SsoRemoteResponseError::Cancelled(err) = err else { + panic!("expected cancellation error"); + }; + assert_eq!( + err.to_string(), + "SSO response wait cancelled by caller for request-1" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs new file mode 100644 index 000000000..c2607a372 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -0,0 +1,866 @@ +//! `StatementStore` surface: session-key statement proofs plus submit and +//! subscribe flows over the people-chain statement store. + +use core::pin::Pin; +use core::task::{Context, Poll}; + +use super::authority::{AuthorityError, StatementStoreAllowanceKey}; +use super::statement_store_rpc::{self, StatementStoreRpc}; +use super::{ + ProductRuntimeHost, REMOTE_PERMISSION_DENIED_REASON, remote_authority_call, + remote_authority_context, +}; +use crate::host_logic::product_account::derive_product_public_key; +use crate::host_logic::statement_store::{ + MAX_MATCH_ALL_TOPICS, MAX_MATCH_ANY_TOPICS, TopicFilterKind, decode_signed_statement, + parse_new_statements_result, sign_statement_fields, signed_statement_to_scale, + statement_fields_from_v01, statement_proof_to_v01, unsigned_statement_signing_payload, +}; + +use serde_json::Value; +use subxt_rpcs::client::RpcSubscription; +use tracing::instrument; +use truapi::api::StatementStore; +use truapi::v01; +use truapi::versioned::statement_store::{ + RemoteStatementStoreCreateProofAuthorizedError, + RemoteStatementStoreCreateProofAuthorizedRequest, + RemoteStatementStoreCreateProofAuthorizedResponse, RemoteStatementStoreCreateProofError, + RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, + RemoteStatementStoreSubmitError, RemoteStatementStoreSubmitRequest, + RemoteStatementStoreSubscribeError, RemoteStatementStoreSubscribeItem, + RemoteStatementStoreSubscribeRequest, +}; +use truapi::{CallContext, CallError, Subscription}; + +impl StatementStore for ProductRuntimeHost { + #[instrument(skip_all, fields(runtime.method = "statement_store.subscribe"))] + async fn subscribe( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubscribeRequest, + ) -> Result< + Subscription, + CallError, + > { + let (kind, topics) = match statement_store_topic_filter(request) { + Ok(value) => value, + Err(reason) => { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { reason }, + ))); + } + }; + let statement_store = self.statement_store_rpc(); + let rpc_client = statement_store + .client("statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason, + })) + })?; + let subscription = statement_store_rpc::subscribe(&rpc_client, kind, &topics) + .await + .map_err(|err| { + CallError::Domain(RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: format!("statement-store subscribe failed: {err}"), + })) + })?; + let Some(remote_subscription_id) = subscription.subscription_id().map(ToString::to_string) + else { + return Err(CallError::Domain(RemoteStatementStoreSubscribeError::V1( + v01::GenericError { + reason: "statement-store subscribe returned no subscription id".to_string(), + }, + ))); + }; + let stream = statement_store_subscription_stream(subscription, remote_subscription_id); + Ok(Subscription::new(Box::pin(stream))) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof"))] + async fn create_proof( + &self, + cx: &CallContext, + request: RemoteStatementStoreCreateProofRequest, + ) -> Result< + RemoteStatementStoreCreateProofResponse, + CallError, + > { + let RemoteStatementStoreCreateProofRequest::V1(mut inner) = request; + inner.product_account_id = Self::normalize_product_account_id(inner.product_account_id) + .map_err(|()| { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + )) + })?; + if !self.is_product_account_valid_for_caller(&inner.product_account_id.dot_ns_identifier) { + return Err(CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount, + ))); + } + let proof = self + .create_product_statement_proof(cx, inner.product_account_id, inner.statement) + .await + .map_err(statement_proof_error)?; + Ok(RemoteStatementStoreCreateProofResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.create_proof_authorized"))] + async fn create_proof_authorized( + &self, + cx: &CallContext, + request: RemoteStatementStoreCreateProofAuthorizedRequest, + ) -> Result< + RemoteStatementStoreCreateProofAuthorizedResponse, + CallError, + > { + let RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement) = request; + let proof = self + .create_authorized_statement_proof(cx, statement) + .await + .map_err(statement_proof_authorized_error)?; + Ok(RemoteStatementStoreCreateProofAuthorizedResponse::V1( + v01::RemoteStatementStoreCreateProofResponse { proof }, + )) + } + + #[instrument(skip_all, fields(runtime.method = "statement_store.submit"))] + async fn submit( + &self, + _cx: &CallContext, + request: RemoteStatementStoreSubmitRequest, + ) -> Result<(), CallError> { + let RemoteStatementStoreSubmitRequest::V1(statement) = request; + self.require_remote_permission( + v01::RemotePermission::StatementSubmit, + RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: REMOTE_PERMISSION_DENIED_REASON.to_string(), + }), + ) + .await?; + let statement = signed_statement_to_scale(statement).map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) + })?; + self.statement_store_rpc() + .submit(statement, "statement-store") + .await + .map_err(|reason| { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason: format!("statement-store submit failed: {reason}"), + })) + }) + } +} + +fn statement_store_topic_filter( + request: RemoteStatementStoreSubscribeRequest, +) -> Result<(TopicFilterKind, Vec<[u8; 32]>), String> { + match request { + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAll(topics), + ) => { + if topics.len() > MAX_MATCH_ALL_TOPICS { + return Err(format!( + "MatchAll has {} topics, maximum is {}", + topics.len(), + MAX_MATCH_ALL_TOPICS + )); + } + Ok((TopicFilterKind::MatchAll, topics)) + } + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ) => { + if topics.len() > MAX_MATCH_ANY_TOPICS { + let topic_count = topics.len(); + return Err(format!( + "MatchAny has {topic_count} topics, maximum is {MAX_MATCH_ANY_TOPICS}" + )); + } + Ok((TopicFilterKind::MatchAny, topics)) + } + } +} + +#[instrument(skip_all, fields(runtime.method = "statement_store.subscription_stream"))] +fn statement_store_subscription_stream( + subscription: RpcSubscription, + remote_subscription_id: String, +) -> impl futures::Stream + Send { + StatementStoreSubscriptionStream { + subscription, + remote_subscription_id, + is_complete: false, + } +} + +struct StatementStoreSubscriptionStream { + subscription: RpcSubscription, + remote_subscription_id: String, + is_complete: bool, +} + +impl futures::Stream for StatementStoreSubscriptionStream { + type Item = RemoteStatementStoreSubscribeItem; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let state = self.get_mut(); + loop { + let value = match Pin::new(&mut state.subscription).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(value))) => value, + Poll::Ready(Some(Err(_))) | Poll::Ready(None) => { + return Poll::Ready(None); + } + }; + let page = + match parse_new_statements_result(state.remote_subscription_id.clone(), &value) { + Ok(page) => page, + Err(_) => continue, + }; + + let was_complete = state.is_complete; + let is_complete = was_complete || page.remaining == Some(0); + state.is_complete = is_complete; + let statements = page + .statements + .into_iter() + .filter_map(|statement| decode_signed_statement(&statement).ok()) + .collect::>(); + if statements.is_empty() { + if is_complete && !was_complete { + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + continue; + } + + return Poll::Ready(Some(RemoteStatementStoreSubscribeItem::V1( + v01::RemoteStatementStoreSubscribeItem { + statements, + is_complete, + }, + ))); + } + } +} + +impl ProductRuntimeHost { + /// `StatementStoreRpc` bound to this runtime's people chain. + pub(super) fn statement_store_rpc(&self) -> StatementStoreRpc { + self.services.statement_store.clone() + } + + async fn create_product_statement_proof( + &self, + cx: &CallContext, + product_account_id: v01::ProductAccountId, + statement: v01::Statement, + ) -> Result { + let session = self + .authority + .current_session() + .ok_or(StatementProofFailure::NoSession)?; + let signer = derive_product_public_key( + session.public_key, + &product_account_id.dot_ns_identifier, + product_account_id.derivation_index, + ) + .map_err(|err| StatementProofFailure::UnableToSign(err.to_string()))?; + let fields = statement_fields_from_v01(statement) + .map_err(StatementProofFailure::InvalidStatement)?; + let payload = unsigned_statement_signing_payload(fields) + .map_err(StatementProofFailure::UnableToSign)?; + let cx = remote_authority_context(cx); + let signature = remote_authority_call( + &cx, + self.authority.sign_statement_store_product_payload( + &cx, + &session, + product_account_id, + payload, + ), + ) + .await + .map_err(statement_authority_failure)?; + Ok(v01::StatementProof::Sr25519 { signature, signer }) + } + + async fn create_authorized_statement_proof( + &self, + cx: &CallContext, + statement: v01::Statement, + ) -> Result { + let session = self + .authority + .current_session() + .ok_or(StatementProofFailure::NoSession)?; + let cx = remote_authority_context(cx); + let allowance = remote_authority_call( + &cx, + self.authority + .statement_store_allowance_key(&cx, &session, self.product_id()), + ) + .await + .map_err(statement_authority_failure)?; + create_statement_proof_with_key(statement, &allowance) + } +} + +fn create_statement_proof_with_key( + statement: v01::Statement, + key: &StatementStoreAllowanceKey, +) -> Result { + let fields = + statement_fields_from_v01(statement).map_err(StatementProofFailure::InvalidStatement)?; + let signed = sign_statement_fields(key.secret, key.public_key, fields) + .map_err(StatementProofFailure::UnableToSign)?; + signed + .into_iter() + .find_map(|field| match field { + crate::host_logic::statement_store::StatementField::Proof(proof) => { + Some(statement_proof_to_v01(proof)) + } + _ => None, + }) + .ok_or_else(|| StatementProofFailure::UnableToSign("missing proof".to_string())) +} + +enum StatementProofFailure { + NoSession, + InvalidStatement(String), + UnableToSign(String), +} + +fn statement_authority_failure(err: AuthorityError) -> StatementProofFailure { + match err { + AuthorityError::Disconnected => StatementProofFailure::NoSession, + err => StatementProofFailure::UnableToSign(err.reason()), + } +} + +fn statement_proof_v01_error( + failure: StatementProofFailure, +) -> v01::RemoteStatementStoreCreateProofError { + match failure { + StatementProofFailure::NoSession => v01::RemoteStatementStoreCreateProofError::UnableToSign, + StatementProofFailure::UnableToSign(_reason) => { + v01::RemoteStatementStoreCreateProofError::UnableToSign + } + StatementProofFailure::InvalidStatement(reason) => { + v01::RemoteStatementStoreCreateProofError::Unknown { reason } + } + } +} + +fn statement_proof_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + statement_proof_v01_error(failure), + )) +} + +fn statement_proof_authorized_error( + failure: StatementProofFailure, +) -> CallError { + CallError::Domain(RemoteStatementStoreCreateProofAuthorizedError::V1( + statement_proof_v01_error(failure), + )) +} + +#[cfg(test)] +mod tests { + use super::super::{LocalActivation, RuntimeServices, SigningHostRole}; + use super::*; + use crate::host_logic::product_account::{ + SR25519_SIGNING_CONTEXT, derive_product_keypair, derive_root_keypair_from_entropy, + }; + use crate::test_support::{ + StubPlatform, account_id, new_statements_frame, runtime_config, signed_statement, + sso_session_info, sso_success_response_script, statement, stub_platform, + submitted_remote_message, subscribe_ack_frame, test_spawner, + }; + use futures::StreamExt; + use parity_scale_codec::Encode; + use schnorrkel::{ExpansionMode, MiniSecretKey, PublicKey, Signature}; + use std::sync::Arc; + use truapi_platform::ProductContext; + + const ENTROPY: [u8; 16] = [0xAB; 16]; + + fn statement_payload(statement: v01::Statement) -> Vec { + unsigned_statement_signing_payload(statement_fields_from_v01(statement).unwrap()).unwrap() + } + + fn allowance_key(seed: u8) -> ([u8; 64], [u8; 32]) { + let mini_secret = MiniSecretKey::from_bytes(&[seed; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + (keypair.secret.to_bytes(), keypair.public.to_bytes()) + } + + fn assert_sr25519_signature(signer: [u8; 32], signature: [u8; 64], payload: &[u8]) { + let public = PublicKey::from_bytes(&signer).unwrap(); + let signature = Signature::from_bytes(&signature).unwrap(); + public + .verify_simple(SR25519_SIGNING_CONTEXT, payload, &signature) + .unwrap(); + } + + fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { + let platform: Arc = Arc::new(StubPlatform::default()); + let services = RuntimeServices::new(platform.clone(), [0; 32], test_spawner()); + let signing_host = SigningHostRole::new(platform); + futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let host = ProductRuntimeHost::from_services( + services, + signing_host.clone(), + ProductContext::new(product_id.to_string()).expect("valid product id"), + ); + (host, signing_host) + } + + #[test] + fn statement_store_create_proof_pairing_host_does_not_use_session_key() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("myapp.dot", 0), + statement: statement(), + }, + ); + + let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnableToSign + )) + )); + } + + #[test] + fn statement_store_create_proof_signing_host_uses_product_key() { + let (host, _signing_host) = signing_host_runtime("myapp.dot"); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let root = derive_root_keypair_from_entropy(&ENTROPY).unwrap(); + let product_keypair = derive_product_keypair(&root, "myapp.dot", 0).unwrap(); + let expected_signer = product_keypair.public.to_bytes(); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("myapp.dot", 0), + statement, + }, + ); + + let response = + futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)).unwrap(); + + let RemoteStatementStoreCreateProofResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_sr25519_signature(signer, signature, &payload); + } + + #[test] + fn statement_store_create_proof_rejects_wrong_product_account() { + let host = + ProductRuntimeHost::new(stub_platform(), runtime_config("myapp.dot"), test_spawner()); + host.test_session_state().set_session(sso_session_info()); + let cx = CallContext::new(); + let request = RemoteStatementStoreCreateProofRequest::V1( + v01::RemoteStatementStoreCreateProofRequest { + product_account_id: account_id("other.dot", 0), + statement: statement(), + }, + ); + + let err = futures::executor::block_on(StatementStore::create_proof(&host, &cx, request)) + .unwrap_err(); + + assert!(matches!( + err, + CallError::Domain(RemoteStatementStoreCreateProofError::V1( + v01::RemoteStatementStoreCreateProofError::UnknownAccount + )) + )); + } + + #[test] + fn statement_store_create_proof_authorized_signs_with_allowance_key() { + let session = sso_session_info(); + let statement = statement(); + let payload = statement_payload(statement.clone()); + let (allowance_secret, expected_signer) = allowance_key(11); + let platform = Arc::new(StubPlatform { + sso_response_script: Some(sso_success_response_script( + &session, + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-proof-auth-1".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( + crate::host_logic::sso::messages::ResourceAllocationResponse { + responding_to: "proof-auth-1".to_string(), + payload: Ok(vec![ + crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( + crate::host_logic::sso::messages::SsoAllocatedResource::StatementStoreAllowance { + slot_account_key: allowance_secret.to_vec(), + }, + ), + ]), + }, + ), + ), + }, + )), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + host.test_session_state().set_session(session.clone()); + let cx = CallContext::with_request_id("proof-auth-1".to_string()); + let request = RemoteStatementStoreCreateProofAuthorizedRequest::V1(statement); + + let response = futures::executor::block_on(StatementStore::create_proof_authorized( + &host, &cx, request, + )) + .unwrap(); + + let RemoteStatementStoreCreateProofAuthorizedResponse::V1(inner) = response; + let v01::StatementProof::Sr25519 { signer, signature } = inner.proof else { + panic!("expected sr25519 statement proof"); + }; + assert_eq!(signer, expected_signer); + assert_sr25519_signature(signer, signature, &payload); + + let message = submitted_remote_message(&platform, &session); + let crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationRequest(request), + ) = message.data + else { + panic!("expected resource allocation request"); + }; + assert_eq!(request.calling_product_id, "myapp.dot"); + assert_eq!( + request.on_existing, + crate::host_logic::sso::messages::OnExistingAllowancePolicy::Ignore + ); + assert_eq!( + request.resources, + vec![crate::host_logic::sso::messages::SsoAllocatableResource::StatementStoreAllowance] + ); + } + + #[test] + fn statement_store_submit_posts_signed_statement_and_waits_for_ack() { + let platform = Arc::new(StubPlatform { + rpc_responses: vec![r#"{"jsonrpc":"2.0","id":"truapi:1","result":"0xok"}"#.to_string()], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap(); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 1); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_submit"); + let statement_hex = request["params"][0].as_str().unwrap(); + let statement = + hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + assert_eq!( + crate::host_logic::statement_store::decode_signed_statement(&statement).unwrap(), + signed_statement([7; 32]) + ); + } + + #[test] + fn statement_store_submit_requires_remote_permission_before_rpc() { + let platform = Arc::new(StubPlatform { + remote_permission_denied: true, + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("submit-1".to_string()); + let request = RemoteStatementStoreSubmitRequest::V1(signed_statement([7; 32])); + + let err = + futures::executor::block_on(StatementStore::submit(&host, &cx, request)).unwrap_err(); + + match err { + CallError::Domain(RemoteStatementStoreSubmitError::V1(v01::GenericError { + reason, + })) => assert_eq!(reason, REMOTE_PERMISSION_DENIED_REASON), + other => panic!("expected statement-store permission denial, got {other:?}"), + } + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn statement_store_subscribe_maps_signed_pages() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub"), + new_statements_frame("remote-sub", vec![unsigned, signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-1".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert_eq!(inner.statements, vec![signed_statement([7; 32])]); + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + let request: serde_json::Value = serde_json::from_str(&sent[0]).unwrap(); + assert_eq!(request["method"], "statement_subscribeStatement"); + assert_eq!( + request["params"][0]["matchAny"][0], + "0x0707070707070707070707070707070707070707070707070707070707070707" + ); + } + + /// Pages that arrive before the subscribe ack are buffered by remote + /// subscription id and replayed once the ack confirms the subscription. + #[test] + fn statement_store_subscribe_buffers_pages_before_subscribe_ack() { + let rogue = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([9; 32]), + ) + .unwrap(); + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + new_statements_frame("remote-sub-pre", vec![rogue]), + subscribe_ack_frame("truapi:1", "remote-sub-pre"), + new_statements_frame("remote-sub-pre", vec![signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-pre".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("statement page"); + + assert_eq!( + item, + RemoteStatementStoreSubscribeItem::V1(v01::RemoteStatementStoreSubscribeItem { + statements: vec![signed_statement([9; 32])], + is_complete: true, + }) + ); + } + + #[test] + fn statement_store_subscribe_unsubscribes_remote_subscription_on_drop() { + let signed = crate::host_logic::statement_store::signed_statement_to_scale( + signed_statement([7; 32]), + ) + .unwrap(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-drop"), + new_statements_frame("remote-sub-drop", vec![signed]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-drop".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let _ = futures::executor::block_on(subscription.next()).expect("statement page"); + drop(subscription); + + let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); + assert_eq!(sent.len(), 2); + let unsubscribe: serde_json::Value = serde_json::from_str(&sent[1]).unwrap(); + assert_eq!(unsubscribe["method"], "statement_unsubscribeStatement"); + assert_eq!(unsubscribe["params"][0], "remote-sub-drop"); + } + + #[test] + fn statement_store_subscribe_emits_empty_completion_page_after_filtering() { + let unsigned = vec![crate::host_logic::statement_store::StatementField::Data( + vec![1], + )] + .encode(); + let platform = Arc::new(StubPlatform { + rpc_responses: vec![ + subscribe_ack_frame("truapi:1", "remote-sub-empty"), + new_statements_frame("remote-sub-empty", vec![unsigned]), + ], + ..Default::default() + }); + let host = ProductRuntimeHost::new(platform, runtime_config("myapp.dot"), test_spawner()); + let cx = CallContext::with_request_id("sub-empty-complete".to_string()); + let mut subscription = futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) + .unwrap(); + + let item = futures::executor::block_on(subscription.next()).expect("completion page"); + + let RemoteStatementStoreSubscribeItem::V1(inner) = item; + assert!(inner.is_complete); + assert!(inner.statements.is_empty()); + } + + #[test] + fn statement_store_subscribe_rejects_topic_limit_violations() { + let platform = stub_platform(); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-too-many".to_string()); + let topics = vec![[7; 32]; MAX_MATCH_ANY_TOPICS + 1]; + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(topics), + ), + )) { + Ok(_) => panic!("topic limit violation should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert_eq!( + reason.reason, + format!( + "MatchAny has {} topics, maximum is {}", + MAX_MATCH_ANY_TOPICS + 1, + MAX_MATCH_ANY_TOPICS + ) + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } + + #[test] + fn statement_store_subscribe_reports_chain_connect_failure() { + let platform = Arc::new(StubPlatform { + chain_connect_error: Some("chain unavailable"), + ..Default::default() + }); + let host = ProductRuntimeHost::new( + platform.clone(), + runtime_config("myapp.dot"), + test_spawner(), + ); + let cx = CallContext::with_request_id("sub-connect-fail".to_string()); + + let err = match futures::executor::block_on(StatementStore::subscribe( + &host, + &cx, + RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7; 32]]), + ), + )) { + Ok(_) => panic!("chain connect failure should fail subscription start"), + Err(err) => err, + }; + + let CallError::Domain(RemoteStatementStoreSubscribeError::V1(reason)) = err else { + panic!("expected statement-store subscribe domain error"); + }; + assert!( + reason + .reason + .contains("statement-store connect failed: GenericError"), + "unexpected reason: {}", + reason.reason + ); + assert!( + reason.reason.contains("chain unavailable"), + "unexpected reason: {}", + reason.reason + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs new file mode 100644 index 000000000..bec77132a --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/statement_store_rpc.rs @@ -0,0 +1,134 @@ +//! Runtime helper for People-chain statement-store JSON-RPC. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use subxt_rpcs::RpcClient; +use subxt_rpcs::client::{RpcSubscription, rpc_params}; +use truapi_platform::{JsonRpcConnection, Platform}; + +use crate::host_logic::statement_store::{ + SUBMIT_STATEMENT_METHOD, SUBSCRIBE_STATEMENT_METHOD, TopicFilterKind, + UNSUBSCRIBE_STATEMENT_METHOD, hex_topic, +}; +use crate::host_rpc_client::HostRpcClient; +use crate::subscription::Spawner; + +/// People-chain statement-store RPC client factory. +#[derive(Clone)] +pub(crate) struct StatementStoreRpc { + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, +} + +impl StatementStoreRpc { + /// Build a helper backed by the platform-owned chain provider. + pub(super) fn new( + platform: Arc, + people_chain_genesis_hash: [u8; 32], + spawner: Spawner, + ) -> Self { + Self { + platform, + people_chain_genesis_hash, + spawner, + } + } + + /// Open a statement-store RPC client over the host-provided People-chain + /// connection. + pub(super) async fn client(&self, label: &'static str) -> Result { + let connection = self.connect(label).await?; + Ok(RpcClient::new(HostRpcClient::new( + connection, + self.spawner.clone(), + ))) + } + + /// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. + pub(super) async fn submit( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let rpc_client = self.client(label).await?; + submit(&rpc_client, statement).await + } + + /// Submit a SCALE-encoded statement without waiting for the JSON-RPC ack. + pub(super) async fn submit_fire_and_forget( + &self, + statement: Vec, + label: &'static str, + ) -> Result<(), String> { + let connection = self.connect(label).await?; + HostRpcClient::new(connection, self.spawner.clone()) + .send_fire_and_forget( + SUBMIT_STATEMENT_METHOD, + rpc_params![format!("0x{}", hex::encode(&statement))].build(), + ) + .map_err(rpc_error_message) + } + + async fn connect(&self, label: &'static str) -> Result, String> { + self.platform + .connect(self.people_chain_genesis_hash) + .await + .map(Arc::from) + .map_err(|err| format!("{label} connect failed: {err:?}")) + } +} + +/// Subscribe to statements matching the requested topic filter. +pub(super) async fn subscribe( + rpc_client: &RpcClient, + kind: TopicFilterKind, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + rpc_client + .subscribe::( + SUBSCRIBE_STATEMENT_METHOD, + rpc_params![filter(kind, topics)], + UNSUBSCRIBE_STATEMENT_METHOD, + ) + .await +} + +/// Subscribe to statements matching every topic. +pub(super) async fn subscribe_match_all( + rpc_client: &RpcClient, + topics: &[[u8; 32]], +) -> Result, subxt_rpcs::Error> { + subscribe(rpc_client, TopicFilterKind::MatchAll, topics).await +} + +/// Submit a SCALE-encoded statement and wait for the JSON-RPC ack. +pub(super) async fn submit(rpc_client: &RpcClient, statement: Vec) -> Result<(), String> { + rpc_client + .request::( + SUBMIT_STATEMENT_METHOD, + rpc_params![format!("0x{}", hex::encode(&statement))], + ) + .await + .map(|_| ()) + .map_err(rpc_error_message) +} + +/// Statement-store topic filter encoded as JSON-RPC params. +pub(super) fn filter(kind: TopicFilterKind, topics: &[[u8; 32]]) -> Value { + let topics = topics.iter().map(hex_topic).collect::>(); + match kind { + TopicFilterKind::MatchAll => json!({ "matchAll": topics }), + TopicFilterKind::MatchAny => json!({ "matchAny": topics }), + } +} + +/// Human-readable JSON-RPC error message, preserving user error text when +/// provided by the remote endpoint. +pub(super) fn rpc_error_message(error: subxt_rpcs::Error) -> String { + match error { + subxt_rpcs::Error::User(error) => error.message, + other => other.to_string(), + } +} diff --git a/rust/crates/truapi-server/src/subscription.rs b/rust/crates/truapi-server/src/subscription.rs index 8b24fd986..b6766d168 100644 --- a/rust/crates/truapi-server/src/subscription.rs +++ b/rust/crates/truapi-server/src/subscription.rs @@ -284,6 +284,25 @@ impl SubscriptionManager { None => {} } } + + /// Cancel and forget every pending or live subscription owned by this + /// manager. Used when the product runtime is disposed, where no further + /// frames should be emitted and platform resources must be released. + pub fn cancel_all(&self) { + let cancellations = { + let mut active = self.active.lock().unwrap(); + active + .drain() + .filter_map(|(_, slot)| match slot { + Slot::Pending { .. } => None, + Slot::Live { cancel, .. } => Some(cancel), + }) + .collect::>() + }; + for cancel in cancellations { + cancel(); + } + } } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -291,6 +310,7 @@ mod tests { use super::*; use futures::stream; use std::sync::atomic::{AtomicUsize, Ordering}; + use std::task::{Context, Poll}; /// Transport that records every frame and notifies waiters when it /// reaches a target count. Used to wait for the subscription's @@ -347,6 +367,27 @@ mod tests { )) } + struct PendingDropStream { + dropped: Arc, + } + + impl Drop for PendingDropStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } + } + + impl futures::Stream for PendingDropStream { + type Item = SubscriptionOutput; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + } + /// Register a never-ending stream then immediately stop it. The /// stream's first poll must observe cancellation and exit without /// having pushed any frame. @@ -492,4 +533,31 @@ mod tests { "no leaked frames from the superseded stream" ); } + + #[test] + fn cancel_all_stops_live_subscription_streams() { + let transport_typed = Arc::new(RecordingTransport::new()); + let transport_dyn: Arc = transport_typed.clone(); + let manager = SubscriptionManager::new(thread_per_subscription_spawner()); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stream: SubscriptionStream = Box::pin(PendingDropStream { + dropped: dropped.clone(), + }); + + manager.register("p:1".to_string(), 99, 98, stream, transport_dyn); + manager.cancel_all(); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while !dropped.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "cancel_all did not drop the live subscription stream" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + transport_typed.sent().is_empty(), + "runtime disposal cancellation must not emit an interrupt frame" + ); + } } diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs new file mode 100644 index 000000000..652b4e416 --- /dev/null +++ b/rust/crates/truapi-server/src/test_support.rs @@ -0,0 +1,1323 @@ +//! Shared fixtures for the runtime test modules: a stub platform, a +//! recording json-rpc connection, and SSO statement/frame builders. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +#[cfg(target_arch = "wasm32")] +use web_time::Duration; + +use crate::host_logic::session::SessionInfo; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; +use crate::host_logic::sso::pairing; +use crate::subscription::Spawner; +#[cfg(not(target_arch = "wasm32"))] +use crate::subscription::thread_per_subscription_spawner; + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use futures::Stream; +use futures::stream::{self, BoxStream}; +use hkdf::Hkdf; +use p256::PublicKey as P256PublicKey; +use p256::SecretKey as P256SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi::v01; +use truapi::versioned::account::HostAccountGetAliasRequest; +use truapi::versioned::resource_allocation::HostRequestResourceAllocationRequest; +use truapi_platform::{ + AccountAccessReview, AuthPresenter, AuthState, BulletinAllowanceSigner, ChainProvider, + CoreStorage as PlatformCoreStorage, CoreStorageKey, Features as PlatformFeatures, HostInfo, + JsonRpcConnection, Navigation as PlatformNavigation, Notifications as PlatformNotifications, + PairingHostConfig, Permissions as PlatformPermissions, PlatformInfo, PreimageHost, + ProductContext, ProductStorage as PlatformProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; + +/// Test spawner that matches the current target. +pub(crate) fn test_spawner() -> Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + immediate_spawner() + } +} + +/// Synchronous spawner for tests that should complete work immediately. +#[cfg(target_arch = "wasm32")] +pub(crate) fn immediate_spawner() -> Spawner { + Arc::new(futures::executor::block_on) +} + +/// Test hook invoked after each recorded auth state. +pub type AuthStateHook = Arc; +/// Test hook invoked after an auth-session write is recorded. +pub type StorageWriteHook = Arc; + +/// Minimal Platform impl that only answers `feature_supported`. Every +/// other callback returns a unit value or empty stream, so the runtime +/// can exercise its delegation paths without pulling in a real backend. +#[derive(Default)] +pub(crate) struct StubPlatform { + pub(crate) remote_permission_denied: bool, + pub(crate) account_alias_confirmed: bool, + pub(crate) account_alias_error: Option<&'static str>, + pub(crate) account_access_confirmed: bool, + pub(crate) account_access_error: Option<&'static str>, + pub(crate) account_access_reviews: Arc>>, + pub(crate) identity_disclosure_confirmed: bool, + pub(crate) identity_disclosure_error: Option<&'static str>, + pub(crate) identity_disclosure_calls: Arc, + pub(crate) sign_payload_confirmed: bool, + pub(crate) sign_payload_error: Option<&'static str>, + pub(crate) sign_raw_confirmed: bool, + pub(crate) sign_raw_error: Option<&'static str>, + pub(crate) create_transaction_confirmed: bool, + pub(crate) create_transaction_error: Option<&'static str>, + pub(crate) resource_allocation_confirmed: bool, + pub(crate) resource_allocation_error: Option<&'static str>, + pub(crate) session_blob: Option>, + pub(crate) session_error: Option<&'static str>, + pub(crate) session_clears: Arc>, + pub(crate) session_writes: Arc>>>, + pub(crate) on_auth_session_write: Arc>>, + /// Every `auth_state_changed` emission in order. + pub(crate) auth_states: Arc>>, + /// Invoked after each recorded auth state, outside any stub lock, so a + /// test can react to a transition (e.g. cancel the login it observes). + pub(crate) on_auth_state: Arc>>, + /// Set when a `chain_connect_pending` connect future is dropped, which is + /// how a dropped login flow manifests on the stub. + pub(crate) pending_connect_dropped: Arc, + /// When true, `subscribe_theme` returns a never-ending stream. + pub(crate) theme_stream_pending: bool, + /// Set when the pending theme stream is dropped. + pub(crate) theme_stream_dropped: Arc, + pub(crate) pairing_success_response: bool, + /// Deliver a wallet failure status on the pairing subscription. + pub(crate) pairing_failure_response: bool, + /// Deliver the pairing success statement only through a snapshot + /// query page; the live subscription stays silent. + pub(crate) pairing_success_via_query: bool, + pub(crate) notification_id: v01::NotificationId, + pub(crate) pushed_notifications: Arc>>, + pub(crate) cancelled_notifications: Arc>>, + pub(crate) sent_rpc: Arc>>, + pub(crate) rpc_responses: Vec, + pub(crate) sso_response_script: Option, + pub(crate) chain_connect_error: Option<&'static str>, + pub(crate) chain_connect_pending: bool, + pub(crate) preimage_submits: Arc>>>, + pub(crate) preimage_submit_allowance_public_keys: Arc>>>, + pub(crate) preimage_submit_signatures: Arc>>>, + pub(crate) local_storage: Arc>>>, + /// When set, product/core storage reads fail with this reason. + pub(crate) local_storage_error: Option<&'static str>, +} + +#[derive(Clone)] +pub(crate) enum SsoResponseScript { + Success { + session: SessionInfo, + response: RemoteMessage, + }, + PeerDisconnect { + session: SessionInfo, + }, +} + +struct DropFlagGuard(Arc); + +impl Drop for DropFlagGuard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } +} + +struct PendingThemeStream { + dropped: Arc, +} + +impl Drop for PendingThemeStream { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +impl Stream for PendingThemeStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} + +/// First `Pairing` deeplink recorded on `auth_states`, if any. +pub(crate) fn first_pairing_deeplink(auth_states: &Mutex>) -> Option { + auth_states + .lock() + .expect("auth state list mutex poisoned") + .iter() + .find_map(|state| match state { + AuthState::Pairing { deeplink } => Some(deeplink.clone()), + _ => None, + }) +} + +/// Default stub platform wrapped in an `Arc`. +pub(crate) fn stub_platform() -> Arc { + Arc::new(StubPlatform::default()) +} + +/// Runtime configuration used by platform-backed runtime tests. +pub(crate) fn runtime_config(product_id: &str) -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new(product_id.to_string()).expect("test product context is valid"), + ) +} + +/// Basic connected session fixture without SSO channel material. +pub(crate) fn session_info() -> crate::host_logic::session::SessionInfo { + crate::host_logic::session::SessionInfo { + public_key: [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ], + sso: None, + root_entropy_source: Some([ + 0x15, 0xcb, 0x94, 0x34, 0x84, 0x0b, 0x56, 0xbe, 0x1f, 0xdd, 0x91, 0xc4, 0x6a, 0x13, + 0xf5, 0x20, 0xf4, 0x91, 0x61, 0x2e, 0xa5, 0xd6, 0x06, 0x92, 0x0d, 0x91, 0x38, 0xe8, + 0xbd, 0xd6, 0x3c, 0xb0, + ]), + identity_account_id: Some([ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, + 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, + 0xc1, 0xd7, 0x20, 0x23, + ]), + lite_username: Some("alice".to_string()), + full_username: Some("Alice Smith".to_string()), + } +} + +/// Connected session fixture with deterministic SSO channel material. +pub(crate) fn sso_session_info() -> crate::host_logic::session::SessionInfo { + let mut session = session_info(); + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + let (_, peer_public_key) = peer_statement_keypair(); + let core_secret = P256SecretKey::from_slice(&[1; 32]).unwrap(); + let peer_secret = P256SecretKey::from_slice(&[2; 32]).unwrap(); + session.sso = Some(crate::host_logic::session::SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: core_secret.to_bytes().into(), + peer_enc_pubkey: peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(), + identity_account_id: peer_public_key, + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + }); + session.root_entropy_source = Some(keypair.secret.to_bytes()[..32].try_into().unwrap()); + session +} + +/// Deterministic peer statement-store signing keypair. +pub(crate) fn peer_statement_keypair() -> ([u8; 64], [u8; 32]) { + let mini_secret = MiniSecretKey::from_bytes(&[9; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + (keypair.secret.to_bytes(), keypair.public.to_bytes()) +} + +/// SCALE-encoded statement signed by the deterministic peer keypair. +pub(crate) fn signed_test_statement(data: Vec) -> Vec { + let (secret, public) = peer_statement_keypair(); + crate::host_logic::statement_store::sign_statement_fields( + secret, + public, + vec![crate::host_logic::statement_store::StatementField::Data( + data, + )], + ) + .unwrap() + .encode() +} + +/// Last submitted SSO remote message decoded from the stub RPC log. +pub(crate) fn submitted_remote_message( + platform: &Arc, + session: &SessionInfo, +) -> RemoteMessage { + let submit = wait_for_statement_submit(&platform.sent_rpc); + let (_, message) = submitted_sso_request_from_submit(&submit, session); + message +} + +fn submitted_sso_request_from_submit( + submit: &str, + session: &SessionInfo, +) -> (String, RemoteMessage) { + let value: serde_json::Value = serde_json::from_str(submit).unwrap(); + let statement_hex = value["params"][0].as_str().unwrap(); + let statement = hex::decode(statement_hex.strip_prefix("0x").unwrap_or(statement_hex)).unwrap(); + let encrypted = crate::host_logic::statement_store::decode_statement_data(&statement) + .expect("statement data should decode"); + let data = pairing::decrypt_session_statement_data(session.sso.as_ref().unwrap(), &encrypted) + .expect("statement data should decrypt"); + let pairing::SsoStatementData::Request { request_id, data } = data else { + panic!("expected request statement data"); + }; + let message = + RemoteMessage::decode(&mut data[0].as_slice()).expect("remote message should decode"); + (request_id, message) +} + +fn submitted_sso_request( + sent: &Arc>>, + session: &SessionInfo, +) -> (String, RemoteMessage) { + let submit = wait_for_statement_submit(sent); + submitted_sso_request_from_submit(&submit, session) +} + +fn wait_for_statement_submit(sent: &Arc>>) -> String { + for _ in 0..100 { + if let Some(request) = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .rev() + .find(|request| request.contains("\"statement_submit\"")) + .cloned() + { + return request; + } + #[cfg(not(target_arch = "wasm32"))] + std::thread::sleep(Duration::from_millis(1)); + #[cfg(target_arch = "wasm32")] + futures::executor::block_on(futures_timer::Delay::new(Duration::from_millis(1))); + } + panic!("statement_submit request should be sent"); +} + +/// JSON-RPC response sequence for a successful SSO request/response exchange. +pub(crate) fn sso_success_responses( + session: &SessionInfo, + message_id: &str, + response: RemoteMessage, +) -> Vec { + let own_subscription_id = format!("own-sub-{message_id}"); + let peer_subscription_id = format!("peer-sub-{message_id}"); + vec![ + subscribe_ack_frame("truapi:1", &own_subscription_id), + subscribe_ack_frame("truapi:2", &peer_subscription_id), + statement_submit_ack_frame("truapi:3"), + new_statements_frame( + &own_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Response { + request_id: message_id.to_string(), + response_code: 0, + }, + 1, + )], + ), + new_statements_frame( + &peer_subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Request { + request_id: format!("wallet-response-{message_id}"), + data: vec![response.encode()], + }, + 2, + )], + ), + ] +} + +/// Dynamic JSON-RPC response script for a successful SSO request/response exchange. +pub(crate) fn sso_success_response_script( + session: &SessionInfo, + response: RemoteMessage, +) -> SsoResponseScript { + SsoResponseScript::Success { + session: session.clone(), + response, + } +} + +/// Dynamic JSON-RPC response script where the SSO peer sends `Disconnected`. +pub(crate) fn sso_peer_disconnect_response_script(session: &SessionInfo) -> SsoResponseScript { + SsoResponseScript::PeerDisconnect { + session: session.clone(), + } +} + +/// JSON-RPC response sequence for the background peer-disconnect monitor. +pub(crate) fn sso_peer_disconnect_monitor_responses( + session: &crate::host_logic::session::SessionInfo, +) -> Vec { + let subscription_id = "peer-disconnect-monitor-sub"; + vec![ + subscribe_ack_frame("truapi:1", subscription_id), + new_statements_frame( + subscription_id, + vec![sso_statement( + session, + pairing::SsoStatementData::Request { + request_id: "wallet-disconnect-monitor".to_string(), + data: vec![ + crate::host_logic::sso::messages::RemoteMessage { + message_id: "wallet-disconnect-monitor".to_string(), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::Disconnected, + ), + } + .encode(), + ], + }, + 1, + )], + ), + ] +} + +/// JSON-RPC subscription acknowledgement frame. +pub(crate) fn subscribe_ack_frame(request_id: &str, subscription_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": subscription_id, + }) + .to_string() +} + +fn statement_submit_ack_frame(request_id: &str) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "result": "0xok", + }) + .to_string() +} + +/// JSON-RPC `newStatements` notification carrying SCALE statements. +pub(crate) fn new_statements_frame(subscription_id: &str, statements: Vec>) -> String { + let statements = statements + .into_iter() + .map(|statement| format!("0x{}", hex::encode(statement))) + .collect::>(); + serde_json::json!({ + "jsonrpc": "2.0", + "method": "statement_subscribeStatement", + "params": { + "subscription": subscription_id, + "result": { + "event": "newStatements", + "data": { + "statements": statements, + "remaining": 0, + }, + }, + }, + }) + .to_string() +} + +fn sso_statement( + session: &crate::host_logic::session::SessionInfo, + data: pairing::SsoStatementData, + nonce_seed: u8, +) -> Vec { + let mut nonce = [0; pairing::AES_GCM_NONCE_LEN]; + nonce[0] = nonce_seed; + let encrypted = pairing::encrypt_session_statement_data_with_nonce( + session.sso.as_ref().unwrap(), + &data, + nonce, + ) + .unwrap(); + signed_test_statement(encrypted) +} + +fn core_encryption_public_key_from_deeplink(deeplink: &str) -> [u8; 65] { + pairing_device_from_deeplink(deeplink).1 +} + +/// Pairing device statement and encryption keys encoded in a deeplink. +pub(crate) fn pairing_device_from_deeplink(deeplink: &str) -> ([u8; 32], [u8; 65]) { + let encoded = deeplink + .split("handshake=") + .nth(1) + .expect("pairing deeplink should include handshake"); + let handshake = hex::decode(encoded).expect("handshake should be hex"); + let decoded = pairing::VersionedHandshakeProposal::decode(&mut handshake.as_slice()) + .expect("handshake should decode"); + let pairing::VersionedHandshakeProposal::V2(proposal) = decoded; + ( + proposal.device.statement_account_id, + proposal.device.encryption_public_key, + ) +} + +pub(crate) fn wallet_handshake_statement(deeplink: &str) -> Vec { + wallet_handshake_statement_with_response( + deeplink, + pairing::v2::EncryptedResponse::Success(Box::new(wallet_handshake_success())), + 0x44, + ) +} + +pub(crate) fn failed_wallet_handshake_statement(deeplink: &str, reason: &str) -> Vec { + wallet_handshake_statement_with_response( + deeplink, + pairing::v2::EncryptedResponse::Failed(reason.to_string()), + 0x45, + ) +} + +fn wallet_handshake_success() -> pairing::v2::Success { + let wallet_persistent_public: [u8; 65] = P256SecretKey::from_slice(&[2; 32]) + .unwrap() + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + pairing::v2::Success { + identity_account_id: peer_statement_keypair().1, + root_account_id: session_info().public_key, + identity_chat_private_key: [0x77; 32], + sso_enc_pub_key: wallet_persistent_public, + device_enc_pub_key: wallet_persistent_public, + root_entropy_source: [0x66; 32], + } +} + +fn wallet_handshake_statement_with_response( + deeplink: &str, + answer: pairing::v2::EncryptedResponse, + nonce_seed: u8, +) -> Vec { + let core_public_key = + P256PublicKey::from_sec1_bytes(&core_encryption_public_key_from_deeplink(deeplink)) + .expect("core encryption public key should decode"); + let wallet_ephemeral_secret = P256SecretKey::from_slice(&[3; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + let mut wallet_ephemeral_public_bytes = [0u8; 65]; + wallet_ephemeral_public_bytes.copy_from_slice(wallet_ephemeral_public.as_bytes()); + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_public_key.as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + let nonce = [nonce_seed; pairing::AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted_message = nonce.to_vec(); + encrypted_message.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), answer.encode().as_slice()) + .unwrap(), + ); + let handshake = pairing::VersionedHandshakeResponse::V2 { + encrypted_message, + public_key: wallet_ephemeral_public_bytes, + }; + + signed_test_statement(handshake.encode()) +} + +/// SSO signing response message for the given request id. +pub(crate) fn sign_response_message( + message_id: &str, + signature: Vec, + signed_transaction: Option>, +) -> crate::host_logic::sso::messages::RemoteMessage { + crate::host_logic::sso::messages::RemoteMessage { + message_id: format!("wallet-{message_id}"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + crate::host_logic::sso::messages::v1::RemoteMessage::SignResponse( + crate::host_logic::sso::messages::SigningResponse { + responding_to: message_id.to_string(), + payload: Ok( + crate::host_logic::sso::messages::SigningPayloadResponseData { + signature, + signed_transaction, + }, + ), + }, + ), + ), + } +} + +/// Product account id fixture for `identifier` and derivation slot. +pub(crate) fn account_id(identifier: &str, derivation_index: u32) -> v01::ProductAccountId { + v01::ProductAccountId { + dot_ns_identifier: identifier.to_string(), + derivation_index, + } +} + +/// Account-alias request fixture for a product identifier. +pub(crate) fn account_alias_request(identifier: &str) -> HostAccountGetAliasRequest { + HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { + product_account_id: account_id(identifier, 0), + }) +} + +/// Raw signing payload fixture. +pub(crate) fn raw_payload() -> v01::RawPayload { + v01::RawPayload::Bytes { + bytes: b"hello".to_vec(), + } +} + +/// Structured signing payload fixture. +pub(crate) fn sign_payload_data() -> v01::HostSignPayloadData { + v01::HostSignPayloadData { + block_hash: vec![0; 32], + block_number: vec![0; 4], + era: vec![0], + genesis_hash: vec![1; 32], + method: vec![0], + nonce: vec![0], + spec_version: vec![0], + tip: vec![0], + transaction_version: vec![0], + signed_extensions: vec![], + version: 4, + asset_id: None, + metadata_hash: None, + mode: None, + with_signed_transaction: None, + } +} + +/// Product transaction payload fixture for `identifier`. +pub(crate) fn product_tx_payload(identifier: &str) -> v01::ProductAccountTxPayload { + v01::ProductAccountTxPayload { + signer: account_id(identifier, 0), + genesis_hash: [1; 32], + call_data: vec![0], + extensions: vec![], + tx_ext_version: 0, + } +} + +/// Resource-allocation request fixture containing all supported resource kinds. +pub(crate) fn resource_allocation_request() -> HostRequestResourceAllocationRequest { + HostRequestResourceAllocationRequest::V1(v01::HostRequestResourceAllocationRequest { + resources: vec![ + v01::AllocatableResource::StatementStoreAllowance, + v01::AllocatableResource::AutoSigning, + ], + }) +} + +/// Unsigned statement fixture with channel, topics, expiry, and data. +pub(crate) fn statement() -> v01::Statement { + v01::Statement { + proof: None, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![[2; 32], [3; 32]], + data: Some(vec![4, 5, 6]), + } +} + +/// Signed statement fixture scoped to `topic`. +pub(crate) fn signed_statement(topic: [u8; 32]) -> v01::SignedStatement { + v01::SignedStatement { + proof: v01::StatementProof::Sr25519 { + signature: [9; 64], + signer: [8; 32], + }, + decryption_key: None, + expiry: Some(99), + channel: Some([1; 32]), + topics: vec![topic], + data: Some(vec![4, 5, 6]), + } +} + +#[truapi_platform::async_trait] +impl PlatformProductStorage for StubPlatform { + async fn read(&self, key: String) -> Result>, v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&key) + .cloned()) + } + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(key, value); + Ok(()) + } + async fn clear(&self, key: String) -> Result<(), v01::HostLocalStorageReadError> { + if let Some(reason) = self.local_storage_error { + return Err(v01::HostLocalStorageReadError::Unknown { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&key); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformCoreStorage for StubPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + if let Some(reason) = self.session_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + return Ok(self.session_blob.clone()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(self + .local_storage + .lock() + .expect("local storage mutex poisoned") + .get(&core_storage_test_key(key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + self.session_writes + .lock() + .expect("session write list mutex poisoned") + .push(value); + let hook = self + .on_auth_session_write + .lock() + .expect("auth session write hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + hook(); + } + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .insert(core_storage_test_key(key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), v01::GenericError> { + if let CoreStorageKey::AuthSession = key { + *self + .session_clears + .lock() + .expect("session clear counter mutex poisoned") += 1; + return Ok(()); + } + if let Some(reason) = self.local_storage_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + self.local_storage + .lock() + .expect("local storage mutex poisoned") + .remove(&core_storage_test_key(key)); + Ok(()) + } +} + +/// Stable string key used by the stub core-storage map. +pub(crate) fn core_storage_test_key(key: CoreStorageKey) -> String { + format!("core:{}", hex::encode(key.encode())) +} + +#[truapi_platform::async_trait] +impl PlatformNavigation for StubPlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformNotifications for StubPlatform { + async fn push_notification( + &self, + notification: v01::HostPushNotificationRequest, + ) -> Result { + self.pushed_notifications + .lock() + .expect("notification list mutex poisoned") + .push(notification); + Ok(v01::HostPushNotificationResponse { + id: self.notification_id, + }) + } + + async fn cancel_notification(&self, id: u32) -> Result<(), v01::GenericError> { + self.cancelled_notifications + .lock() + .expect("notification cancellation list mutex poisoned") + .push(id); + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl PlatformPermissions for StubPlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { + granted: !self.remote_permission_denied, + }) + } +} + +#[truapi_platform::async_trait] +impl PlatformFeatures for StubPlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct RecordingConnection { + sent: Arc>>, + responses: Vec, + sso_response_script: Option, + auth_states: Arc>>, + pairing_success_response: bool, + pairing_failure_response: bool, + pairing_success_via_query: bool, +} + +async fn wait_for_statement_subscribe_id(sent: Arc>>, index: usize) -> String { + wait_for_rpc_method_id(sent, "statement_subscribeStatement", index).await +} + +async fn wait_for_rpc_method_id( + sent: Arc>>, + method: &str, + index: usize, +) -> String { + for _ in 0..100 { + let ids = sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .filter_map(|request| { + let value: serde_json::Value = serde_json::from_str(request).ok()?; + (value.get("method")?.as_str()? == method) + .then(|| value.get("id")?.as_str().map(ToString::to_string))? + }) + .collect::>(); + if let Some(id) = ids.get(index) { + return id.clone(); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("{method} request {index} was not issued"); +} + +fn retarget_sso_response(mut response: RemoteMessage, message_id: &str) -> RemoteMessage { + response.message_id = format!("wallet-{message_id}"); + match &mut response.data { + RemoteMessageData::V1(v1::RemoteMessage::SignResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) => { + response.responding_to = message_id.to_string(); + } + RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionResponse(response)) => { + response.responding_to = message_id.to_string(); + } + _ => {} + } + response +} + +fn sso_scripted_responses( + sent: Arc>>, + script: SsoResponseScript, +) -> BoxStream<'static, String> { + Box::pin(stream::unfold(0, move |state| { + let sent = sent.clone(); + let script = script.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "own-sub"), 1)) + } + 1 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 1).await; + Some((subscribe_ack_frame(&id, "peer-sub"), 2)) + } + 2 => { + let id = wait_for_rpc_method_id(sent.clone(), "statement_submit", 0).await; + Some((statement_submit_ack_frame(&id), 3)) + } + 3 => match &script { + SsoResponseScript::Success { session, .. } + | SsoResponseScript::PeerDisconnect { session } => { + let (statement_request_id, _) = submitted_sso_request(&sent, session); + Some(( + new_statements_frame( + "own-sub", + vec![sso_statement( + session, + pairing::SsoStatementData::Response { + request_id: statement_request_id, + response_code: 0, + }, + 1, + )], + ), + 4, + )) + } + }, + 4 => match script { + SsoResponseScript::Success { session, response } => { + let (_, request) = submitted_sso_request(&sent, &session); + let response = retarget_sso_response(response, &request.message_id); + Some(( + new_statements_frame( + "peer-sub", + vec![sso_statement( + &session, + pairing::SsoStatementData::Request { + request_id: format!( + "wallet-response-{}", + request.message_id + ), + data: vec![response.encode()], + }, + 2, + )], + ), + 5, + )) + } + SsoResponseScript::PeerDisconnect { session } => { + let (_, request) = submitted_sso_request(&sent, &session); + let message_id = format!("wallet-disconnect-{}", request.message_id); + Some(( + new_statements_frame( + "peer-sub", + vec![sso_statement( + &session, + pairing::SsoStatementData::Request { + request_id: message_id.clone(), + data: vec![ + RemoteMessage { + message_id, + data: RemoteMessageData::V1( + v1::RemoteMessage::Disconnected, + ), + } + .encode(), + ], + }, + 2, + )], + ), + 5, + )) + } + }, + _ => futures::future::pending().await, + } + } + })) +} + +impl JsonRpcConnection for RecordingConnection { + fn send(&self, request: String) { + self.sent + .lock() + .expect("rpc list mutex poisoned") + .push(request); + } + fn responses(&self) -> BoxStream<'static, String> { + if self.pairing_success_via_query { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + let query_id = wait_for_statement_subscribe_id(sent.clone(), 1).await; + Some((subscribe_ack_frame(&query_id, "query-sub"), 2)) + } + 2 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "query-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 3, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.pairing_failure_response { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "pairing-sub", + vec![failed_wallet_handshake_statement( + &deeplink, + "The operation couldn't be completed. (SubstrateSdk.JSONRPCError error 1.)", + )], + ), + 2, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if self.pairing_success_response { + let auth_states = self.auth_states.clone(); + let sent = self.sent.clone(); + return Box::pin(stream::unfold(0, move |state| { + let auth_states = auth_states.clone(); + let sent = sent.clone(); + async move { + match state { + 0 => { + let id = wait_for_statement_subscribe_id(sent.clone(), 0).await; + Some((subscribe_ack_frame(&id, "pairing-sub"), 1)) + } + 1 => { + for _ in 0..100 { + if let Some(deeplink) = first_pairing_deeplink(&auth_states) { + return Some(( + new_statements_frame( + "pairing-sub", + vec![wallet_handshake_statement(&deeplink)], + ), + 2, + )); + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("pairing deeplink was not presented"); + } + _ => futures::future::pending().await, + } + } + })); + } + if let Some(script) = self.sso_response_script.clone() { + return sso_scripted_responses(self.sent.clone(), script); + } + if self.responses.is_empty() { + Box::pin(futures::stream::pending()) + } else { + let responses = self.responses.clone(); + let sent = self.sent.clone(); + Box::pin(stream::unfold(0, move |index| { + let responses = responses.clone(); + let sent = sent.clone(); + async move { + let Some(response) = responses.get(index).cloned() else { + return futures::future::pending().await; + }; + wait_for_matching_request_id(sent, &response).await; + Some((response, index + 1)) + } + })) + } + } + + fn close(&self) {} +} + +async fn wait_for_matching_request_id(sent: Arc>>, response: &str) { + let Some(id) = json_rpc_id(response) else { + return; + }; + for _ in 0..100 { + if sent + .lock() + .expect("rpc list mutex poisoned") + .iter() + .any(|request| json_rpc_id(request).as_deref() == Some(id.as_str())) + { + return; + } + futures_timer::Delay::new(Duration::from_millis(1)).await; + } + panic!("request {id} was not issued before scripted response"); +} + +fn json_rpc_id(frame: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(frame).ok()?; + match value.get("id")? { + serde_json::Value::String(value) => Some(value.clone()), + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + } +} + +#[truapi_platform::async_trait] +impl ChainProvider for StubPlatform { + async fn connect( + &self, + _genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + if let Some(reason) = self.chain_connect_error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + if self.chain_connect_pending { + let _guard = DropFlagGuard(self.pending_connect_dropped.clone()); + futures::future::pending::<()>().await; + } + Ok(Box::new(RecordingConnection { + sent: self.sent_rpc.clone(), + responses: self.rpc_responses.clone(), + sso_response_script: self.sso_response_script.clone(), + auth_states: self.auth_states.clone(), + pairing_success_response: self.pairing_success_response, + pairing_failure_response: self.pairing_failure_response, + pairing_success_via_query: self.pairing_success_via_query, + })) + } +} + +impl AuthPresenter for StubPlatform { + fn auth_state_changed(&self, state: AuthState) { + self.auth_states + .lock() + .expect("auth state list mutex poisoned") + .push(state.clone()); + let hook = self + .on_auth_state + .lock() + .expect("auth state hook mutex poisoned") + .clone(); + if let Some(hook) = hook { + hook(&state); + } + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for StubPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + let (error, confirmed) = match review { + UserConfirmationReview::SignPayload(_) => { + (self.sign_payload_error, self.sign_payload_confirmed) + } + UserConfirmationReview::SignRaw(_) => (self.sign_raw_error, self.sign_raw_confirmed), + UserConfirmationReview::CreateTransaction(_) => ( + self.create_transaction_error, + self.create_transaction_confirmed, + ), + UserConfirmationReview::AccountAlias(_) => { + (self.account_alias_error, self.account_alias_confirmed) + } + UserConfirmationReview::AccountAccess(review) => { + self.account_access_reviews + .lock() + .expect("account access review list mutex poisoned") + .push(review); + (self.account_access_error, self.account_access_confirmed) + } + UserConfirmationReview::IdentityDisclosure(_) => { + self.identity_disclosure_calls + .fetch_add(1, Ordering::SeqCst); + ( + self.identity_disclosure_error, + self.identity_disclosure_confirmed, + ) + } + UserConfirmationReview::ResourceAllocation(_) => ( + self.resource_allocation_error, + self.resource_allocation_confirmed, + ), + UserConfirmationReview::PreimageSubmit(_) => (None, true), + }; + if let Some(reason) = error { + return Err(v01::GenericError { + reason: reason.to_string(), + }); + } + Ok(confirmed) + } +} + +impl ThemeHost for StubPlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + if self.theme_stream_pending { + return Box::pin(PendingThemeStream { + dropped: self.theme_stream_dropped.clone(), + }); + } + Box::pin(stream::once(async { Ok(v01::ThemeVariant::Dark) })) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for StubPlatform { + async fn submit_preimage( + &self, + value: Vec, + bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { + self.preimage_submits + .lock() + .expect("preimage submit list mutex poisoned") + .push(value.clone()); + self.preimage_submit_allowance_public_keys + .lock() + .expect("preimage allowance public key list mutex poisoned") + .push(bulletin_allowance_signer.public_key().to_vec()); + let signature = bulletin_allowance_signer + .sign(b"preimage-submit-test") + .map_err(|err| v01::PreimageSubmitError::Unknown { reason: err.reason })?; + self.preimage_submit_signatures + .lock() + .expect("preimage allowance signature list mutex poisoned") + .push(signature.to_vec()); + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::once(async { Ok(Some(vec![9, 8, 7])) })) + } +} diff --git a/rust/crates/truapi-server/tests/common/mod.rs b/rust/crates/truapi-server/tests/common/mod.rs new file mode 100644 index 000000000..5819f53cd --- /dev/null +++ b/rust/crates/truapi-server/tests/common/mod.rs @@ -0,0 +1,206 @@ +#[cfg(target_arch = "wasm32")] +use std::sync::Arc; + +use std::sync::Mutex; + +use futures::stream::{self, BoxStream}; +use truapi::v01; +use truapi_platform::{ + AuthPresenter, BulletinAllowanceSigner, ChainProvider, CoreStorage, CoreStorageKey, Features, + HostInfo, JsonRpcConnection, Navigation, Notifications, PairingHostConfig, Permissions, + PlatformInfo, PreimageHost, ProductContext, ProductStorage, ThemeHost, UserConfirmation, + UserConfirmationReview, +}; +use truapi_server::frame::ProtocolMessage; +use truapi_server::transport::Transport; + +/// Transport stub that records every frame sent through it, for asserting +/// what the core emits during a dispatch. +#[derive(Default)] +pub struct RecordingTransport { + /// Frames captured in send order. + pub sent: Mutex>, +} + +impl Transport for RecordingTransport { + fn send(&self, message: ProtocolMessage) { + self.sent.lock().unwrap().push(message); + } + fn on_message( + &self, + _handler: Box, + ) -> Box { + Box::new(|| {}) + } +} + +/// Test spawner that matches the current target. +pub fn test_spawner() -> truapi_server::subscription::Spawner { + #[cfg(not(target_arch = "wasm32"))] + { + truapi_server::subscription::thread_per_subscription_spawner() + } + #[cfg(target_arch = "wasm32")] + { + Arc::new(futures::executor::block_on) + } +} + +/// Runtime configuration shared by integration tests. +pub fn test_runtime_config() -> (PairingHostConfig, ProductContext) { + ( + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://dot.li/dotli.png".to_string()), + version: None, + }, + PlatformInfo::default(), + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test host runtime config is valid"), + ProductContext::new("dotli.dot".to_string()).expect("test product context is valid"), + ) +} + +pub struct WireShapePlatform; + +#[truapi_platform::async_trait] +impl ProductStorage for WireShapePlatform { + async fn read(&self, _key: String) -> Result>, v01::HostLocalStorageReadError> { + Err(v01::HostLocalStorageReadError::Full) + } + async fn write( + &self, + _key: String, + _value: Vec, + ) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } + async fn clear(&self, _key: String) -> Result<(), v01::HostLocalStorageReadError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Navigation for WireShapePlatform { + async fn navigate_to(&self, _url: String) -> Result<(), v01::HostNavigateToError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Notifications for WireShapePlatform { + async fn push_notification( + &self, + _notification: v01::HostPushNotificationRequest, + ) -> Result { + Ok(v01::HostPushNotificationResponse { id: 0 }) + } + + async fn cancel_notification(&self, _id: u32) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl Permissions for WireShapePlatform { + async fn device_permission( + &self, + _request: v01::HostDevicePermissionRequest, + ) -> Result { + Ok(v01::HostDevicePermissionResponse { granted: true }) + } + async fn remote_permission( + &self, + _request: v01::RemotePermissionRequest, + ) -> Result { + Ok(v01::RemotePermissionResponse { granted: true }) + } +} + +#[truapi_platform::async_trait] +impl Features for WireShapePlatform { + async fn feature_supported( + &self, + _request: v01::HostFeatureSupportedRequest, + ) -> Result { + Ok(v01::HostFeatureSupportedResponse { supported: true }) + } +} + +struct DeadConnection; + +impl JsonRpcConnection for DeadConnection { + fn send(&self, _request: String) {} + fn responses(&self) -> BoxStream<'static, String> { + Box::pin(stream::empty()) + } + fn close(&self) {} +} + +#[truapi_platform::async_trait] +impl ChainProvider for WireShapePlatform { + async fn connect( + &self, + _genesis_hash: [u8; 32], + ) -> Result, v01::GenericError> { + Ok(Box::new(DeadConnection)) + } +} + +impl AuthPresenter for WireShapePlatform {} + +#[truapi_platform::async_trait] +impl CoreStorage for WireShapePlatform { + async fn read_core_storage( + &self, + _key: CoreStorageKey, + ) -> Result>, v01::GenericError> { + Ok(None) + } + async fn write_core_storage( + &self, + _key: CoreStorageKey, + _value: Vec, + ) -> Result<(), v01::GenericError> { + Ok(()) + } + async fn clear_core_storage(&self, _key: CoreStorageKey) -> Result<(), v01::GenericError> { + Ok(()) + } +} + +#[truapi_platform::async_trait] +impl UserConfirmation for WireShapePlatform { + async fn confirm_user_action( + &self, + _review: UserConfirmationReview, + ) -> Result { + Ok(false) + } +} + +impl ThemeHost for WireShapePlatform { + fn subscribe_theme(&self) -> BoxStream<'static, Result> { + Box::pin(stream::empty()) + } +} + +#[truapi_platform::async_trait] +impl PreimageHost for WireShapePlatform { + async fn submit_preimage( + &self, + value: Vec, + _bulletin_allowance_signer: BulletinAllowanceSigner, + ) -> Result, v01::PreimageSubmitError> { + Ok(value) + } + fn lookup_preimage( + &self, + _key: Vec, + ) -> BoxStream<'static, Result>, v01::GenericError>> { + Box::pin(stream::empty()) + } +} diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs new file mode 100644 index 000000000..80f429314 --- /dev/null +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -0,0 +1,226 @@ +#![cfg(target_arch = "wasm32")] + +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use hkdf::Hkdf; +use p256::SecretKey; +use p256::ecdh::diffie_hellman; +use p256::elliptic_curve::sec1::ToEncodedPoint; +use parity_scale_codec::{Decode, Encode}; +use schnorrkel::{ExpansionMode, MiniSecretKey}; +use sha2::Sha256; +use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; +use truapi_server::host_logic::entropy::derive_product_entropy; +use truapi_server::host_logic::product_account::{ + derive_product_public_key, product_public_key_to_address, +}; +use truapi_server::host_logic::session::SsoSessionInfo; +use truapi_server::host_logic::sso::pairing::{ + self, AES_GCM_NONCE_LEN, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, + VersionedHandshakeResponse, bootstrap_topic, build_pairing_deeplink, decode_app_handshake_data, + decrypt_session_statement_data, decrypt_v2_handshake_response, + encrypt_session_statement_data_with_nonce, establish_sso_session_info, +}; +use truapi_server::host_logic::statement_store::{ + build_signed_session_request_statement, decode_verified_statement_data, +}; +use wasm_bindgen_test::wasm_bindgen_test; + +const ROOT_PUBLIC_KEY: [u8; 32] = [ + 0x80, 0x05, 0x28, 0xc9, 0x55, 0x87, 0x3e, 0x4c, 0x78, 0xb7, 0xdf, 0x24, 0xf7, 0x1d, 0xb8, 0xf5, + 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, 0x20, 0x23, +]; + +const SS_PUBLIC: [u8; 32] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +]; + +const ENC_PUBLIC: [u8; 65] = [ + 0x04, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, + 0x1f, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, + 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, + 0x3f, +]; + +fn entropy_secret() -> [u8; 32] { + std::array::from_fn(|i| i as u8) +} + +fn runtime_config() -> PairingHostConfig { + PairingHostConfig::new( + HostInfo { + name: "Polkadot Web".to_string(), + icon: Some("https://example.invalid/dotli.png".to_string()), + version: Some("1.2.3".to_string()), + }, + PlatformInfo { + kind: Some("Firefox".to_string()), + version: Some("192.32".to_string()), + }, + [0xa2; 32], + "polkadotapp".to_string(), + ) + .expect("test runtime config is valid") +} + +fn statement_session() -> SsoSessionInfo { + let mini_secret = MiniSecretKey::from_bytes(&[7; 32]).unwrap(); + let keypair = mini_secret.expand_to_keypair(ExpansionMode::Ed25519); + SsoSessionInfo { + ss_secret: keypair.secret.to_bytes(), + ss_public_key: keypair.public.to_bytes(), + enc_secret: [1; 32], + peer_enc_pubkey: [2; 65], + identity_account_id: [3; 32], + session_id_own: [4; 32], + session_id_peer: [5; 32], + request_channel: [6; 32], + response_channel: [7; 32], + peer_request_channel: [8; 32], + } +} + +fn sso_session() -> SsoSessionInfo { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let core_public = core_secret.public_key().to_encoded_point(false); + let bootstrap = PairingBootstrap { + deeplink: "polkadotapp://pair?handshake=00".to_string(), + topic: [0x11; 32], + statement_store_public_key: [0x22; 32], + statement_store_secret: [0x33; 64], + encryption_public_key: core_public.as_bytes().try_into().unwrap(), + encryption_secret_key: [1; 32], + }; + let peer_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let peer_sso_enc_pub_key = peer_secret + .public_key() + .to_encoded_point(false) + .as_bytes() + .try_into() + .unwrap(); + + establish_sso_session_info(&bootstrap, [0x55; 32], peer_sso_enc_pub_key).unwrap() +} + +#[wasm_bindgen_test] +fn product_account_and_entropy_vectors_match_dotli() { + let derived = derive_product_public_key(ROOT_PUBLIC_KEY, "myapp.dot", 0).unwrap(); + assert_eq!( + hex::encode(derived), + "281489e3dd1c4dbe88cd670a59edcc9c44d64f510d302bd527ec306f10292f08" + ); + assert_eq!( + product_public_key_to_address(derived), + "5CyFsdhwjXy7wWpDEM6isungQ3LfGnu9UXkt7paBQ6DYRxk1" + ); + + let entropy = derive_product_entropy(&entropy_secret(), "myapp.dot", b"product-key").unwrap(); + assert_eq!( + hex::encode(entropy), + "ab1887248c9de3cf4b8c5a255782796d3d35a98c8eb2d7df61a410db8b14da36" + ); +} + +#[wasm_bindgen_test] +fn pairing_deeplink_topic_and_scale_vectors_match_dotli() { + let config = runtime_config(); + let deeplink = build_pairing_deeplink("polkadotapp", SS_PUBLIC, ENC_PUBLIC, &config); + assert!(deeplink.starts_with("polkadotapp://pair?handshake=01")); + let encoded = hex::decode(deeplink.split("handshake=").nth(1).unwrap()).unwrap(); + let decoded = VersionedHandshakeProposal::decode(&mut &encoded[..]).unwrap(); + let VersionedHandshakeProposal::V2(proposal) = decoded; + assert_eq!(proposal.device.statement_account_id, SS_PUBLIC); + assert_eq!(proposal.device.encryption_public_key, ENC_PUBLIC); + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostName, + "Polkadot Web".to_string() + ))); + assert!(proposal.metadata.contains(&pairing::v2::MetadataEntry( + pairing::v2::MetadataKey::HostIcon, + "https://example.invalid/dotli.png".to_string() + ))); + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, ENC_PUBLIC)), + "031c589833c39b1dfbe3c1304ced75fa7b0d841035db008e5b407bfadd2779a4" + ); + + let answer = VersionedHandshakeResponse::V2 { + encrypted_message: vec![0xde, 0xad], + public_key: ENC_PUBLIC, + }; + assert_eq!(decode_app_handshake_data(&answer.encode()).unwrap(), answer); +} + +#[wasm_bindgen_test] +fn p256_hkdf_aes_gcm_vectors_work_on_wasm() { + let core_secret = SecretKey::from_slice(&[1; 32]).unwrap(); + let wallet_ephemeral_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let wallet_ephemeral_public = wallet_ephemeral_secret.public_key().to_encoded_point(false); + + let shared_secret = diffie_hellman( + wallet_ephemeral_secret.to_nonzero_scalar(), + core_secret.public_key().as_affine(), + ); + let hkdf = Hkdf::::new(None, shared_secret.raw_secret_bytes()); + let mut aes_key = [0u8; 32]; + hkdf.expand(&[], &mut aes_key).unwrap(); + + let sensitive = pairing::v2::EncryptedResponse::Success(Box::new(pairing::v2::Success { + identity_account_id: [8; 32], + root_account_id: [7; 32], + identity_chat_private_key: [6; 32], + sso_enc_pub_key: ENC_PUBLIC, + device_enc_pub_key: ENC_PUBLIC, + root_entropy_source: [5; 32], + })); + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap(); + let mut encrypted = nonce.to_vec(); + encrypted.extend( + cipher + .encrypt(Nonce::from_slice(&nonce), sensitive.encode().as_slice()) + .unwrap(), + ); + + assert_eq!( + decrypt_v2_handshake_response( + core_secret.to_bytes().into(), + wallet_ephemeral_public.as_bytes().try_into().unwrap(), + &encrypted, + ) + .unwrap(), + sensitive + ); +} + +#[wasm_bindgen_test] +fn session_crypto_and_statement_proof_vectors_work_on_wasm() { + let session = sso_session(); + let data = SsoStatementData::Request { + request_id: "req-1".to_string(), + data: vec![vec![0xde, 0xad]], + }; + let nonce = [9u8; AES_GCM_NONCE_LEN]; + let encrypted = encrypt_session_statement_data_with_nonce(&session, &data, nonce).unwrap(); + + assert_eq!(&encrypted[..AES_GCM_NONCE_LEN], nonce); + assert_eq!( + SsoStatementData::decode(&mut &data.encode()[..]).unwrap(), + data + ); + assert_eq!( + decrypt_session_statement_data(&session, &encrypted).unwrap(), + data + ); + + let statement_session = statement_session(); + let statement = + build_signed_session_request_statement(&statement_session, vec![0xde, 0xad], 42).unwrap(); + let verified = + decode_verified_statement_data(&statement, Some(statement_session.ss_public_key)).unwrap(); + + assert_eq!(verified.signer, statement_session.ss_public_key); + assert_eq!(verified.data, vec![0xde, 0xad]); +} diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs new file mode 100644 index 000000000..16deb1f1e --- /dev/null +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -0,0 +1,433 @@ +//! Result-wire-shape regression test. +//! +//! The TS host/client codec expects every request response to be a +//! `Versioned>` envelope on the wire (one leading version byte, +//! then one result discriminant byte, then the SCALE-encoded value). This test stands up a +//! `TrUApiCore::from_platform_with_config` with a platform whose `Features` +//! impl returns `Ok(supported = true)` and asserts: +//! +//! - A `system_feature_supported_request` produces a response whose +//! payload begins with `0x00` (V1), then `0x00` (Ok), followed by the encoded +//! `HostFeatureSupportedResponse`. +//! - A `local_storage_read_request` whose stub returns +//! `Err(HostLocalStorageReadError::Full)` produces a response whose +//! payload begins with `0x00` (V1), then `0x01` (Err), followed by the encoded +//! `HostLocalStorageReadError::Full`. +//! +//! Both halves prove the wire layout stays in lockstep with the TS +//! `S.indexedTaggedUnion({ V1: S.Result(ok, err) })` codec. + +use std::sync::Arc; + +use parity_scale_codec::{Decode, Encode}; + +use truapi::versioned::system::HostFeatureSupportedRequest; +use truapi::versioned::{Versioned, account, payment, statement_store}; +use truapi::{CallError, v01}; + +use truapi_server::core::TrUApiCore; +use truapi_server::frame::{Payload, ProtocolMessage, request_ids, subscription_ids}; + +mod common; +use common::{RecordingTransport, WireShapePlatform, test_runtime_config, test_spawner}; + +const PAYMENTS_NOT_IMPLEMENTED: &str = "Payments are not supported in dot.li"; + +fn dispatch(core: &TrUApiCore, frame: ProtocolMessage) -> ProtocolMessage { + let encoded = frame.encode(); + let response_bytes = futures::executor::block_on(core.receive_from_product(&encoded)) + .expect("dispatcher emitted a response frame"); + ProtocolMessage::decode(&mut &response_bytes[..]).expect("decode response") +} + +#[test] +fn feature_supported_ok_response_uses_ok_discriminant() { + let core = make_core(); + let request = HostFeatureSupportedRequest::V1(v01::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0u8; 32], + }); + let ids = request_ids("system_feature_supported").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:1"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: [V1 disc=0x00][Ok disc=0x00][encoded response body]. + let mut expected = vec![0x00u8, 0x00u8]; + v01::HostFeatureSupportedResponse { supported: true }.encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x00)); +} + +#[test] +fn local_storage_read_err_response_uses_err_discriminant() { + let core = make_core(); + let request = truapi::versioned::local_storage::HostLocalStorageReadRequest::V1( + v01::HostLocalStorageReadRequest { + key: "missing".to_string(), + }, + ); + let ids = request_ids("local_storage_read").expect("known request method"); + let frame = ProtocolMessage { + request_id: "p:2".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }; + let response = dispatch(&core, frame); + assert_eq!(response.request_id, "p:2"); + assert_eq!(response.payload.id, ids.response_id); + + // Wire payload: + // [V1 disc=0x00][Err disc=0x01][CallError::Domain][V1 error][encoded error body]. + let mut expected = vec![0x00u8, 0x01u8]; + CallError::Domain( + truapi::versioned::local_storage::HostLocalStorageReadError::V1( + v01::HostLocalStorageReadError::Full, + ), + ) + .encode_to(&mut expected); + assert_eq!(response.payload.value, expected); + assert_eq!(response.payload.value.first(), Some(&0x00)); + assert_eq!(response.payload.value.get(1), Some(&0x01)); +} + +fn versioned_result_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version()), 0x01u8]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn versioned_interrupt_err_payload(error: E) -> Vec +where + E: Clone + Encode + Versioned, +{ + let mut expected = vec![version_index(error.version())]; + CallError::Domain(error).encode_to(&mut expected); + expected +} + +fn assert_request_returns_domain_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = request_ids(method).expect("known request method"); + let response = dispatch( + core, + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.request_id, + value, + }, + }, + ); + assert_eq!(response.request_id, request_id); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, versioned_result_err_payload(error)); +} + +fn assert_subscription_start_interrupts_error( + core: &TrUApiCore, + request_id: &str, + method: &str, + value: Vec, + error: E, +) where + E: Clone + Encode + Versioned, +{ + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: request_id.into(), + payload: Payload { + id: ids.start_id, + value, + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, request_id); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!( + sent[0].payload.value, + versioned_interrupt_err_payload(error) + ); +} + +fn version_index(version: u8) -> u8 { + version.saturating_sub(1) +} + +#[test] +fn deferred_account_proof_returns_framework_unsupported() { + let core = make_core(); + let request = account::HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { + product_account_id: v01::ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: 0, + }, + ring_location: v01::RingLocation { + genesis_hash: vec![0u8; 32], + ring_root_hash: vec![1u8; 32], + hints: None, + }, + context: Vec::new(), + }); + + let ids = request_ids("account_create_account_proof").expect("known request method"); + let response = dispatch( + &core, + ProtocolMessage { + request_id: "p:account-proof".into(), + payload: Payload { + id: ids.request_id, + value: request.encode(), + }, + }, + ); + assert_eq!(response.request_id, "p:account-proof"); + assert_eq!(response.payload.id, ids.response_id); + assert_eq!(response.payload.value, vec![0x00u8, 0x01u8, 0x02u8]); +} + +#[test] +fn deferred_payment_requests_return_dotli_not_implemented_errors() { + let core = make_core(); + let request = payment::HostPaymentRequest::V1(v01::HostPaymentRequest { + from: None, + amount: 1, + destination: [0u8; 32], + }); + + assert_request_returns_domain_error( + &core, + "p:payment", + "payment_request", + request.encode(), + payment::HostPaymentError::V1(v01::HostPaymentError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); + + let top_up = payment::HostPaymentTopUpRequest::V1(v01::HostPaymentTopUpRequest { + into: None, + amount: 1, + source: v01::PaymentTopUpSource::ProductAccount { + derivation_index: 0, + }, + }); + assert_request_returns_domain_error( + &core, + "p:top-up", + "payment_top_up", + top_up.encode(), + payment::HostPaymentTopUpError::V1(v01::HostPaymentTopUpError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }), + ); +} + +#[test] +fn deferred_payment_subscriptions_interrupt_dotli_not_implemented_errors() { + let core = make_core(); + let balance = + payment::HostPaymentBalanceSubscribeRequest::V1(v01::HostPaymentBalanceSubscribeRequest { + purse: None, + }); + assert_subscription_start_interrupts_error( + &core, + "p:balance", + "payment_balance_subscribe", + balance.encode(), + payment::HostPaymentBalanceSubscribeError::V1( + v01::HostPaymentBalanceSubscribeError::PermissionDenied, + ), + ); + + let status = + payment::HostPaymentStatusSubscribeRequest::V1(v01::HostPaymentStatusSubscribeRequest { + payment_id: "payment-id".to_string(), + }); + assert_subscription_start_interrupts_error( + &core, + "p:status", + "payment_status_subscribe", + status.encode(), + payment::HostPaymentStatusSubscribeError::V1( + v01::HostPaymentStatusSubscribeError::Unknown { + reason: PAYMENTS_NOT_IMPLEMENTED.to_string(), + }, + ), + ); +} + +#[test] +fn statement_store_subscribe_topic_limit_interrupts_with_typed_error() { + let core = make_core(); + let request = statement_store::RemoteStatementStoreSubscribeRequest::V1( + v01::RemoteStatementStoreSubscribeRequest::MatchAny(vec![[7u8; 32]; 129]), + ); + + assert_subscription_start_interrupts_error( + &core, + "p:ss-too-many", + "statement_store_subscribe", + request.encode(), + statement_store::RemoteStatementStoreSubscribeError::V1(v01::GenericError { + reason: "MatchAny has 129 topics, maximum is 128".to_string(), + }), + ); +} + +#[test] +fn malformed_result_subscription_start_interrupts_with_malformed_frame() { + let core = make_core(); + let method = "payment_balance_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let transport = Arc::new(RecordingTransport::default()); + + futures::executor::block_on(core.dispatch( + ProtocolMessage { + request_id: "p:malformed-sub".into(), + payload: Payload { + id: ids.start_id, + value: vec![0xff], + }, + }, + transport.clone(), + )); + + let sent = transport.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].request_id, "p:malformed-sub"); + assert_eq!(sent[0].payload.id, ids.interrupt_id); + assert_eq!(sent[0].payload.value.first(), Some(&0x00)); + + let mut payload = &sent[0].payload.value[1..]; + let error = CallError::::decode(&mut payload) + .expect("decode malformed interrupt error"); + assert!(payload.is_empty()); + match error { + CallError::MalformedFrame { reason } => assert!(!reason.is_empty()), + other => panic!("expected MalformedFrame interrupt, got {other:?}"), + } +} + +fn make_core() -> TrUApiCore { + let (host_config, product) = test_runtime_config(); + TrUApiCore::from_platform_with_config( + Arc::new(WireShapePlatform), + host_config, + product, + test_spawner(), + ) +} + +/// Untrusted product input that is not a decodable frame must be dropped +/// (return `None`), never panic. Exercises the decode-failure boundary in +/// `receive_from_product` that the happy-path tests above bypass. +#[test] +fn malformed_frames_are_dropped_without_panic() { + let core = make_core(); + + // Empty input and arbitrary garbage. + assert!(futures::executor::block_on(core.receive_from_product(&[])).is_none()); + assert!( + futures::executor::block_on(core.receive_from_product(&[0xff, 0xff, 0xff, 0xff])).is_none() + ); + + // A truncated SCALE string header (claims length but no body). + assert!( + futures::executor::block_on(core.receive_from_product(&[200u8 << 2, 0x61, 0x62])).is_none() + ); + + // A well-formed requestId envelope carrying an unknown wire discriminant. + let mut unknown_disc = Vec::new(); + "p:1".to_string().encode_to(&mut unknown_disc); + unknown_disc.push(0xFA); + unknown_disc.extend_from_slice(&[0u8; 4]); + assert!(futures::executor::block_on(core.receive_from_product(&unknown_disc)).is_none()); +} + +/// Drive a subscription through the encoded-frame boundary: `_start` yields +/// the initial `_receive`, then `_stop` tears it down so a later session +/// change produces no further frames. Covers the wire layer the in-crate +/// `subscription.rs` unit tests bypass. +#[test] +fn subscription_start_receive_stop_through_wire_boundary() { + use std::time::{Duration, Instant}; + use truapi_server::transport::Transport; + + let core = make_core(); + let transport = Arc::new(RecordingTransport::default()); + let dyn_transport: Arc = transport.clone(); + + let method = "account_connection_status_subscribe"; + let ids = subscription_ids(method).expect("known subscription method"); + let start = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(start, dyn_transport.clone())); + + // Wait for the initial `_receive` item (Disconnected). + let deadline = Instant::now() + Duration::from_secs(2); + while transport.sent.lock().unwrap().is_empty() { + assert!(Instant::now() < deadline, "no initial _receive frame"); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(transport.sent.lock().unwrap()[0].payload.id, ids.receive_id); + + // Stop the subscription, then push a session change. A live subscription + // would emit a Connected `_receive`; a stopped one must stay silent. + let stop = ProtocolMessage { + request_id: "p:1".into(), + payload: Payload { + id: ids.stop_id, + value: Vec::new(), + }, + }; + futures::executor::block_on(core.dispatch(stop, dyn_transport)); + std::thread::sleep(Duration::from_millis(50)); + + core.session_state() + .set_session(truapi_server::host_logic::session::SessionInfo { + public_key: [7u8; 32], + sso: None, + root_entropy_source: None, + identity_account_id: None, + lite_username: None, + full_username: None, + }); + std::thread::sleep(Duration::from_millis(50)); + + assert_eq!( + transport.sent.lock().unwrap().len(), + 1, + "stopped subscription must emit no further frames" + ); +} diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index b4baf520a..a8cd326d3 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -7,10 +7,14 @@ #![allow(async_fn_in_trait)] use core::convert::Infallible; +use core::fmt; +use core::future::Future; +use core::mem; use core::pin::Pin; -use core::task::{Context, Poll}; +use core::task::{Context, Poll, Waker}; +use core::time::Duration; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; use futures::Stream; use parity_scale_codec::{Decode, Encode}; @@ -23,14 +27,18 @@ pub mod latest { use crate::versioned::{self, Versioned}; pub use crate::v01::{ - AccountId, AllocatableResource, GenericError, HostSignPayloadData, NotificationId, - ProductAccountId, RawPayload, RemotePermission, ThemeVariant, + AccountId, AllocatableResource, AllocationOutcome, GenericError, HostSignPayloadData, + NotificationId, OperationStartedResult, ProductAccountId, RawPayload, RemotePermission, + RuntimeApi, RuntimeSpec, RuntimeType, StorageQueryItem, StorageQueryType, + StorageResultItem, ThemeVariant, TxPayloadExtension, }; pub type LatestOf = ::Latest; pub type HostAccountGetAliasResponse = LatestOf; + pub type HostCreateTransactionResponse = + LatestOf; pub type HostDevicePermissionRequest = LatestOf; pub type HostDevicePermissionResponse = @@ -45,9 +53,14 @@ pub mod latest { LatestOf; pub type HostPushNotificationResponse = LatestOf; + pub type HostRequestLoginError = LatestOf; + pub type HostRequestLoginResponse = LatestOf; pub type HostRequestResourceAllocationRequest = LatestOf; + pub type HostRequestResourceAllocationResponse = + LatestOf; pub type HostSignPayloadRequest = LatestOf; + pub type HostSignPayloadResponse = LatestOf; pub type HostSignPayloadWithLegacyAccountRequest = LatestOf; pub type HostSignRawRequest = LatestOf; @@ -57,6 +70,13 @@ pub mod latest { LatestOf; pub type PreimageSubmitError = LatestOf; pub type ProductAccountTxPayload = LatestOf; + pub type RemoteChainHeadFollowItem = LatestOf; + pub type RemoteChainHeadFollowRequest = + LatestOf; + pub type RemoteChainHeadStorageRequest = + LatestOf; + pub type RemoteChainHeadStorageResponse = + LatestOf; pub type RemotePermissionRequest = LatestOf; pub type RemotePermissionResponse = LatestOf; } @@ -96,11 +116,62 @@ pub type FrameworkOnlyError = CallError; /// Cooperative cancellation token exposed to handlers. /// /// Current one-shot request frames have no cancel control message, so request -/// tokens only fire when a future runtime explicitly cancels them. Subscription -/// runtimes can cancel this token when the peer sends `_stop` or disconnects. -#[derive(Debug, Clone, Default)] +/// tokens fire when a runtime explicitly cancels them or attaches a timeout. +/// Subscription runtimes can cancel this token when the peer sends `_stop` or +/// disconnects. pub struct CancellationToken { - cancelled: Arc, + inner: Arc, +} + +#[derive(Default)] +struct CancellationInner { + state: Mutex, +} + +#[derive(Default)] +struct CancellationState { + reason: Option, + next_id: u64, + wakers: Vec<(u64, Waker)>, +} + +/// Cause attached to a cancelled call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CancellationReason { + /// The caller or runtime explicitly cancelled the call. + Cancelled, + /// The call exceeded the configured timeout. + TimedOut { timeout: Duration }, +} + +/// Future resolved when a [`CancellationToken`] is cancelled. +pub struct CancellationFuture { + inner: Arc, + id: Option, +} + +impl fmt::Debug for CancellationToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CancellationToken") + .field("reason", &self.reason()) + .finish_non_exhaustive() + } +} + +impl Clone for CancellationToken { + fn clone(&self) -> Self { + Self { + inner: self.inner.clone(), + } + } +} + +impl Default for CancellationToken { + fn default() -> Self { + Self { + inner: Arc::new(CancellationInner::default()), + } + } } impl CancellationToken { @@ -111,19 +182,98 @@ impl CancellationToken { /// Mark the token as cancelled. pub fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); + self.cancel_with_reason(CancellationReason::Cancelled); + } + + /// Mark the token as cancelled with an explicit `reason`. + pub fn cancel_with_reason(&self, reason: CancellationReason) { + let wakers = { + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.reason = Some(reason); + mem::take(&mut state.wakers) + }; + for (_, waker) in wakers { + waker.wake(); + } + } + + /// Returns the cancellation reason, if cancellation has been requested. + pub fn reason(&self) -> Option { + self.inner + .state + .lock() + .expect("cancel state poisoned") + .reason + .clone() } /// Returns whether cancellation has been requested. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::SeqCst) + self.reason().is_some() + } + + /// Future resolved with the cancellation reason when cancellation is requested. + pub fn cancelled(&self) -> CancellationFuture { + CancellationFuture { + inner: self.inner.clone(), + id: None, + } + } +} + +impl Future for CancellationFuture { + type Output = CancellationReason; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let this = self.get_mut(); + let mut state = this.inner.state.lock().expect("cancel state poisoned"); + if let Some(reason) = state.reason.clone() { + this.id = None; + return Poll::Ready(reason); + } + + if let Some(id) = this.id + && let Some((_, waker)) = state + .wakers + .iter_mut() + .find(|(waiter_id, _)| *waiter_id == id) + { + if !waker.will_wake(cx.waker()) { + *waker = cx.waker().clone(); + } + return Poll::Pending; + } + + state.next_id = state.next_id.wrapping_add(1); + let id = state.next_id; + state.wakers.push((id, cx.waker().clone())); + this.id = Some(id); + Poll::Pending + } +} + +impl Drop for CancellationFuture { + fn drop(&mut self) { + let Some(id) = self.id.take() else { + return; + }; + let mut state = self.inner.state.lock().expect("cancel state poisoned"); + if state.reason.is_some() { + return; + } + state.wakers.retain(|(waiter_id, _)| *waiter_id != id); } } /// Ambient context passed to every trait method. +#[derive(Clone)] pub struct CallContext { request_id: RequestId, cancel: CancellationToken, + timeout: Option, } impl CallContext { @@ -137,12 +287,22 @@ impl CallContext { Self { request_id, cancel: CancellationToken::new(), + timeout: None, } } /// Construct a context from explicit `request_id` and `cancel` parts. pub fn with_parts(request_id: RequestId, cancel: CancellationToken) -> Self { - Self { request_id, cancel } + Self { + request_id, + cancel, + timeout: None, + } + } + + /// Attach a timeout to this call. + pub fn set_timeout(&mut self, timeout: Duration) { + self.timeout = Some(timeout); } /// Return the request id this context is associated with. @@ -154,6 +314,11 @@ impl CallContext { pub fn cancel(&self) -> &CancellationToken { &self.cancel } + + /// Return the timeout attached to this call, if any. + pub fn timeout(&self) -> Option { + self.timeout + } } impl Default for CallContext { @@ -192,3 +357,35 @@ impl Subscription { Self::new(Box::pin(futures::stream::empty())) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_context_timeout_can_be_set_and_replaced() { + let default = Duration::from_secs(180); + let explicit = Duration::from_millis(25); + + let mut cx = CallContext::with_request_id("request-1".to_string()); + assert_eq!(cx.timeout(), None); + cx.set_timeout(default); + assert_eq!(cx.timeout(), Some(default)); + + cx.set_timeout(explicit); + assert_eq!(cx.timeout(), Some(explicit)); + } + + #[test] + fn cancellation_token_clones_share_cancellation() { + let token = CancellationToken::new(); + let cloned = token.clone(); + let wait = cloned.cancelled(); + + token.cancel(); + + let reason = futures::executor::block_on(wait); + assert_eq!(reason, CancellationReason::Cancelled); + assert!(cloned.is_cancelled()); + } +}