diff --git a/CLAUDE.md b/CLAUDE.md index 340511b9b..f8cade281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,14 @@ scripts/truapi-host-installer.sh rather than importing a concrete protocol version. Runtime crates may use `truapi::versioned::*` for wire envelopes, but should unwrap them into latest payloads immediately. +- Inter-host SSO uses `#[sso_service]` as described + in the [macro guide](rust/crates/truapi-macros/README.md). Keep per-variant pairing, + dispatch, and correlation in that macro; do not add manual per-variant catalogs. + Requests with a caller share `ProductRequest

` around canonical payloads; + handler method names select request variants. Responses share `Response

`; + handler signatures name their result payload + and response variant. Transcript classification belongs in shared reply handling + or the handler; request context carries only the call and signing session. - Native bindings expose canonical Rust domain and protocol types directly. Add feature-gated UniFFI derives to those types and custom conversions for unsupported leaf values instead of defining parallel `Native*` mirrors. diff --git a/Cargo.lock b/Cargo.lock index 92401e469..49772c936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5676,6 +5676,7 @@ dependencies = [ "tracing", "tracing-subscriber", "truapi", + "truapi-macros", "truapi-platform", "unicode-normalization", "uniffi", diff --git a/README.md b/README.md index d6a684304..7ec96f70d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,10 @@ returning a typed outcome: response bytes to post back, a disconnect marker, or ignored) and `prepareDisconnectRequest` (builds the SCALE-encoded wire message for a wallet-initiated disconnect) on `TrUAPIHostRuntime`. Response posting and session-record cleanup remain on the wallet side. +See the core's [inter-host SSO design](rust/crates/truapi-server/README.md#inter-host-sso) +for typed handlers, canonical resource types, and consent bound to the signing session. +Product and SSO signing share canonical payloads and the one-byte `OptionBool` +encoding for `with_signed_transaction`. ### JS Host SDKs diff --git a/docs/rfcs/0024-personhood-as-product.md b/docs/rfcs/0024-personhood-as-product.md index 595dca471..2a2301822 100644 --- a/docs/rfcs/0024-personhood-as-product.md +++ b/docs/rfcs/0024-personhood-as-product.md @@ -329,7 +329,7 @@ struct ListRingVrfKeysResponse { } ``` -A Host holding a current registry snapshot answers `list` locally. `RingVrfProofRequest` and `RingVrfAliasRequest` gain `key_handle: ProductAccountId` alongside the `calling_product_id` they already carry, and a `RingVrfSignRequest` / `Response` pair mirrors `account_ring_vrf_sign` with the same two fields plus `message`. `RingVrfError` gains `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`. +A Host holding a current registry snapshot answers `list` locally. `CreateAccountProofRequest` and `GetAccountAliasRequest` carry `key_handle: ProductAccountId` and `calling_product_id`. The `RingVrfSignRequest` / `RingVrfSignResponse` pair mirrors `account_ring_vrf_sign` with the same two request fields plus `message`. `RingVrfError` includes `KeyNotRegistered`, `KeyNotInRing`, and `NotAllowlisted`. **Registration always reaches the Account Holder, but never blocks on it.** The phone is the authoritative registry — it needs the complete set to serve slot assignment and PGAS claims, and to show the user what their keys are used for. A Host holding the product's domain entropy answers immediately and mirrors the registration fire-and-forget; registration is idempotent, so re-notifying the phone about an entry it already has costs nothing. Without the entropy the Host issues the request and waits. diff --git a/js/packages/truapi/src/wire-equality.test.ts b/js/packages/truapi/src/wire-equality.test.ts index af656c48f..fed3fe014 100644 --- a/js/packages/truapi/src/wire-equality.test.ts +++ b/js/packages/truapi/src/wire-equality.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from "bun:test"; import { str } from "./scale.js"; import { decodeWireMessage, encodeWireMessage } from "./transport.js"; import * as W from "./generated/wire-table.js"; +import { HostSignPayloadRequest } from "./generated/types.js"; function toHex(u: Uint8Array): string { return Array.from(u) @@ -38,6 +39,43 @@ function unwrap(result: Result, message: string): T { } describe("encodeWireMessage / decodeWireMessage wire equality", () => { + it("matches Rust signing bytes for absent, true, and false transaction flags", () => { + for (const [flag, byte] of [ + [undefined, "00"], + [true, "01"], + [false, "02"], + ] as const) { + const request: HostSignPayloadRequest = { + account: { + dotNsIdentifier: "myapp.dot", + derivationIndex: { tag: "Index", value: 7 }, + }, + payload: { + blockHash: "0x", + blockNumber: "0x", + era: "0x", + genesisHash: "0x", + method: "0x", + nonce: "0x", + specVersion: "0x", + tip: "0x", + transactionVersion: "0x", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, + withSignedTransaction: flag, + }, + }; + const encoded = HostSignPayloadRequest.enc(request); + expect(toHex(encoded)).toBe( + `246d796170702e646f7400070000000000000000000000000004000000000000${byte}`, + ); + expect(HostSignPayloadRequest.dec(encoded)).toEqual(request); + } + }); + it("encodes handshake_request (discriminant 0) to match the Rust reference", () => { const inner = new Uint8Array([0x00, 0x01]); // V1 variant + codec_version=1 const encoded = unwrap( diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 52e139637..838235f1c 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -2883,6 +2883,7 @@ fn ts_inner_option(ty: &TypeRef) -> Result { fn ts_inner_option_with_named(ty: &TypeRef, qualified: bool, mode: NameMode<'_>) -> Result { match ty { TypeRef::Option(inner) => ts_type_with_named(inner, qualified, mode), + TypeRef::Primitive(name) if name == "optionBool" => Ok("boolean".to_string()), other => ts_type_with_named(other, qualified, mode), } } @@ -2897,7 +2898,8 @@ fn ts_type_qualified_preserve(ty: &TypeRef) -> Result { fn ts_field_name(name: &str, ty: &TypeRef) -> (String, bool) { let camel = to_camel_case(name); - let optional = matches!(ty, TypeRef::Option(_)); + let optional = matches!(ty, TypeRef::Option(_)) + || matches!(ty, TypeRef::Primitive(name) if name == "optionBool"); (camel, optional) } @@ -3054,6 +3056,19 @@ mod tests { } } + #[test] + fn option_bool_fields_are_optional_and_keep_their_compact_codec() { + let api = api_with_payload_fields(vec![( + "with_signed_transaction", + TypeRef::Primitive("optionBool".to_string()), + )]); + + let source = generate_types(&api, 1).expect("generate types"); + + assert!(source.contains("withSignedTransaction?: boolean;")); + assert!(source.contains("withSignedTransaction: S.OptionBool")); + } + #[test] fn schema_hash_moves_when_a_payload_field_type_changes() { // The drift class this fingerprint exists to catch: same frame ids, same diff --git a/rust/crates/truapi-codegen/tests/golden/wire_table.rs b/rust/crates/truapi-codegen/tests/golden/wire_table.rs index 5d59d7f93..278c6b671 100644 --- a/rust/crates/truapi-codegen/tests/golden/wire_table.rs +++ b/rust/crates/truapi-codegen/tests/golden/wire_table.rs @@ -50,7 +50,7 @@ pub enum WireKind { /// `TRUAPI_WIRE_SCHEMA_HASH`. A host stamps it on each debug envelope so /// the debugger refuses to decode a frame whose contract differs from /// its own, even when the coarse handshake codec version is unchanged. -pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "0449982638d57658"; +pub const TRUAPI_WIRE_SCHEMA_HASH: &str = "43581e5572c0315a"; /// Wire discriminants for `system_handshake`. pub const SYSTEM_HANDSHAKE: RequestFrameIds = RequestFrameIds { diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 390556255..2af00655d 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -33,6 +33,7 @@ ws-bridge = ["dep:tokio", "dep:tokio-tungstenite", "dep:rand", "dep:base64"] [dependencies] truapi = { path = "../truapi" } truapi-platform = { path = "../truapi-platform" } +truapi-macros = { path = "../truapi-macros" } async-trait = "0.1" derive_more = { version = "2", features = ["debug", "display", "error", "from"] } futures = "0.3" diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index ae4369389..1e391e642 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -250,6 +250,32 @@ role-specific lifecycle, so no method exists on a role that can't mean it: session/SSO crypto, key derivation, and permission policy, while all I/O (statement-store RPC, storage, prompts, chain RPC) stays in the layers above. +### Inter-host SSO + +`PairingHost::call(request)` sends typed requests to +[`SigningHostSsoService`](src/runtime/signing_host/sso_service.rs). Handlers own +consent and business logic; `sso_responder.rs` owns the transport loop and shared +allowance helpers. Resource consent is bound to the request's signing session: +account changes, disconnects, and reactivation invalidate pending approval before +allocation or key return. Allocation failure details stay in local transcripts. +Allocation requests use the canonical `truapi::latest::AllocatableResource` type. +Signing uses canonical request and result types. Product-scoped VRF requests use +`ProductRequest

` to attach the caller to a canonical payload. Both product and +SSO signing encode `with_signed_transaction` with the one-byte `OptionBool` codec. + +The `host_logic::sso::messages::v1::RemoteMessage` enum owns the SCALE wire +contract. Its response variants wrap named result payloads in `Response

`, +which carries `responding_to` once. Macros generate request/response pairing +and dispatch; see the +[macro guide](../truapi-macros/README.md) for handler signatures and reply handling. +A new operation needs payload definitions, wire variants, a handler, and a typed +client call. + +Rust consumers must update renamed SSO types and helpers even when SCALE encoding +is unchanged. Use `RemoteMessage::request(message_id, request)` to construct +requests. Decoded `SsoSessionStatement::RemoteMessages` preserves message order; +match variants directly or use the request's `SsoRequest::response_from_message`. + ## Wire envelope Every frame on the wire is encoded as: diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index c0863017e..b5b79c34a 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -30,11 +30,12 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; -use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequestOutcome, v1}; +use crate::host_logic::sso::messages::{RemoteMessage, SsoRequestOutcome}; +use crate::runtime::sso_service::Dispatch; use crate::runtime::{ ChatConnection, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT, LocalActivation, PairedSsoPeer, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, - SigningHostRole, answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, + SigningHostRole, SigningHostSsoService, establish_pairing, respond_to_pairing, resume_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -780,20 +781,11 @@ impl SigningHostRuntime { &self, message: RemoteMessage, ) -> SsoRequestOutcome { - let RemoteMessageData::V1(request) = message.data; - if matches!(request, v1::RemoteMessage::Disconnected) { - return SsoRequestOutcome::Disconnected; - } - match answer_remote_message( - &self.services, - &self.signing_host, - message.message_id, - request, - ) - .await - { - Some(answer) => SsoRequestOutcome::Response(answer.response), - None => SsoRequestOutcome::Ignored, + let service = SigningHostSsoService::new(self.signing_host.clone()); + match service.dispatch(service.current_session(), message).await { + Dispatch::Response(answer) => SsoRequestOutcome::Response(answer.message), + Dispatch::Disconnected => SsoRequestOutcome::Disconnected, + Dispatch::NotARequest(_) => SsoRequestOutcome::Ignored, } } } @@ -2245,9 +2237,7 @@ mod tests { #[test] fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { - use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1, - }; + use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1}; use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; const ENTROPY: [u8; 32] = [0xab; 32]; @@ -2279,10 +2269,10 @@ mod tests { let response_variant = RemoteMessage { message_id: "m2".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse( - SignRawLegacyResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountResponse( + Response { responding_to: "m2".to_string(), - signature: Ok(vec![]), + payload: Ok(vec![]), }, )), }; @@ -2336,6 +2326,6 @@ mod tests { panic!("expected a product subtree response payload"); }; assert_eq!(payload.responding_to, "m3"); - assert!(payload.product_public_key.is_ok()); + assert!(payload.payload.is_ok()); } } diff --git a/rust/crates/truapi-server/src/host_logic/sso.rs b/rust/crates/truapi-server/src/host_logic/sso.rs index 04f2cd46e..35271ad74 100644 --- a/rust/crates/truapi-server/src/host_logic/sso.rs +++ b/rust/crates/truapi-server/src/host_logic/sso.rs @@ -1,6 +1,7 @@ //! Inter-host SSO with the paired wallet: `pairing` bootstraps the //! QR/deeplink handshake, `messages` carries the session-channel payloads -//! exchanged afterwards. +//! exchanged afterwards, `wire` types the request/response pairing. pub mod messages; pub mod pairing; +pub mod wire; diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 076703c62..bf66c958c 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -1,11 +1,10 @@ //! SCALE codecs for host-papp SSO session-channel messages. //! //! These are the encrypted payloads carried inside statement-store -//! `SsoStatementData::Request` / `Response` frames. -//! The runtime builds them when forwarding TrUAPI account, signing, resource -//! allocation, and transaction requests to the paired signing host, then -//! decodes the signing host's responses while waiting on the SSO -//! statement-store channels. +//! `SsoStatementData::Request` / `Response` frames. A pairing host sends a +//! request with [`RemoteMessage::request`]; each wire response carries a +//! [`Response`] envelope. Annotated handler signatures pair requests with their +//! response variants (see `sso::wire`). //! The encrypted statement envelope and message identifiers are specified in //! host-spec: //! @@ -23,13 +22,13 @@ use core::fmt; -use parity_scale_codec::{Decode, Encode, OptionBool}; +use parity_scale_codec::{Decode, Encode}; use truapi::latest::{ - AccountId, AllocatableResource, DerivationIndex, HostAccountCreateProofResponse, - HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, ProductAccountTxPayload, - ProductProofContext, RawPayload, RingLocation, + AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, + HostAccountSignVrfError, HostSignPayloadRequest, HostSignPayloadResponse, HostSignRawRequest, + LegacyAccountTxPayload, ProductAccountTxPayload, RawPayload, RegisteredRingVrfKey, + VrfSignature, }; -use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -75,8 +74,7 @@ impl TryFrom for SsoResponseCode { } /// Top-level remote message sent over the encrypted SSO channel. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] -#[display("{data}")] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub struct RemoteMessage { /// Correlation id used to match signing-host responses to pairing-host requests. pub message_id: String, @@ -85,13 +83,23 @@ pub struct RemoteMessage { } /// Versioned remote message body. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessageData { /// Version 1 of the remote message catalog. - #[display("{_0}")] V1(v1::RemoteMessage), } +/// A response payload addressed to the request it answers. +/// +/// SCALE encodes the correlation id before the payload for every response variant. +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +pub struct Response

{ + /// `message_id` of the request being answered. + pub responding_to: String, + /// The operation's result, without transport metadata. + pub payload: P, +} + /// Outcome of answering one SSO remote message on behalf of a caller that /// owns the session transport. Generic over the response representation: /// the typed runtime layer carries a decoded [`RemoteMessage`], the FFI @@ -107,127 +115,24 @@ pub enum SsoRequestOutcome { Ignored, } -/// Signing request flavor sent to the signing host. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum SigningRequest { - /// Sign a full Substrate extrinsic payload. - Payload(Box), - /// Sign raw bytes or a string message. - Raw(SigningRawRequest), -} - -/// Request sent when a product asks the paired signing host to sign a Substrate -/// payload with a product-derived account. +/// A product's canonical request payload and the identity of its caller. /// -/// Built from [`truapi::v01::HostSignPayloadRequest`] but kept as a dedicated wire type -/// because the host-papp SSO dialect flattens the public request payload and -/// encodes `with_signed_transaction` as `OptionBool`. +/// SCALE encodes the caller followed directly by the payload fields. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SigningPayloadRequest { - /// Product account that signs the payload. - pub product_account_id: ProductAccountId, - /// Reference block hash. - pub block_hash: Vec, - /// Reference block number. - pub block_number: Vec, - /// Mortality era encoding. - pub era: Vec, - /// Chain genesis hash. - pub genesis_hash: Vec, - /// SCALE-encoded call data. - pub method: Vec, - /// Account nonce. - pub nonce: Vec, - /// Runtime spec version. - pub spec_version: Vec, - /// Transaction tip. - pub tip: Vec, - /// Transaction format version. - pub transaction_version: Vec, - /// Extension identifiers. - pub signed_extensions: Vec, - /// Extrinsic version. - pub version: u32, - /// For multi-asset tips. - pub asset_id: Option>, - /// CheckMetadataHash extension. - pub metadata_hash: Option>, - /// Metadata mode. - pub mode: Option, - /// Request the full signed transaction back. - pub with_signed_transaction: OptionBool, -} - -impl SigningPayloadRequest { - fn from_host_request(value: truapi::v01::HostSignPayloadRequest) -> Self { - let payload = value.payload; - Self { - product_account_id: value.account, - block_hash: payload.block_hash, - block_number: payload.block_number, - era: payload.era, - genesis_hash: payload.genesis_hash, - method: payload.method, - nonce: payload.nonce, - spec_version: payload.spec_version, - tip: payload.tip, - transaction_version: payload.transaction_version, - signed_extensions: payload.signed_extensions, - version: payload.version, - asset_id: payload.asset_id, - metadata_hash: payload.metadata_hash, - mode: payload.mode, - with_signed_transaction: OptionBool(payload.with_signed_transaction), - } - } -} - -impl From for truapi::v01::HostSignPayloadRequest { - fn from(value: SigningPayloadRequest) -> Self { - Self { - account: value.product_account_id, - payload: truapi::v01::HostSignPayloadData { - block_hash: value.block_hash, - block_number: value.block_number, - era: value.era, - genesis_hash: value.genesis_hash, - method: value.method, - nonce: value.nonce, - spec_version: value.spec_version, - tip: value.tip, - transaction_version: value.transaction_version, - signed_extensions: value.signed_extensions, - version: value.version, - asset_id: value.asset_id, - metadata_hash: value.metadata_hash, - mode: value.mode, - with_signed_transaction: value.with_signed_transaction.0, - }, - } - } +pub struct ProductRequest

{ + /// Product making the request. + pub calling_product_id: String, + /// Canonical payload sent to the signing host. + pub payload: P, } -/// Request sent when a product asks the paired signing host to sign raw bytes or a -/// string message with a product-derived account. -/// -/// Built from [`truapi::v01::HostSignRawRequest`] and wrapped in -/// [`v1::RemoteMessage::SignRequest`] before being encrypted into an SSO session -/// statement. +/// Signing request flavor sent to the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SigningRawRequest { - /// Product account that signs the payload. - pub product_account_id: ProductAccountId, - /// Raw bytes or string message to sign. - pub data: SigningRawPayload, -} - -impl SigningRawRequest { - fn from_host_request(value: truapi::v01::HostSignRawRequest) -> Self { - Self { - product_account_id: value.account, - data: value.payload.into(), - } - } +pub enum SignRequest { + /// Sign a full Substrate extrinsic payload. + Payload(Box), + /// Sign raw bytes or a string message. + Raw(HostSignRawRequest), } /// Request sent when a product asks the paired signing host to sign raw data with a @@ -236,106 +141,30 @@ impl SigningRawRequest { /// Unlike product-account signing, the signer is the raw account id selected /// from the user's legacy accounts. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SignRawLegacyRequest { +pub struct SignRawWithLegacyAccountRequest { /// Legacy account that signs the payload. pub account: AccountId, /// Raw bytes or string message to sign. - pub data: SigningRawPayload, -} - -/// Raw data accepted by SSO signing requests. -/// -/// Used by both product-account raw signing and legacy-account raw signing to -/// distinguish binary payloads from string messages on the session-channel -/// wire. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum SigningRawPayload { - /// Raw binary payload. - Bytes(Vec), - /// String message payload. - Payload(String), -} - -impl From for SigningRawPayload { - fn from(value: RawPayload) -> Self { - match value { - RawPayload::Bytes { bytes } => Self::Bytes(bytes), - RawPayload::Payload { payload } => Self::Payload(payload), - } - } -} - -impl From for RawPayload { - fn from(value: SigningRawPayload) -> Self { - match value { - SigningRawPayload::Bytes(bytes) => Self::Bytes { bytes }, - SigningRawPayload::Payload(payload) => Self::Payload { payload }, - } - } -} - -impl From for truapi::v01::HostSignRawRequest { - fn from(value: SigningRawRequest) -> Self { - Self { - account: value.product_account_id, - payload: value.data.into(), - } - } + pub data: RawPayload, } /// Response returned by the signing host for a product-account signing request. /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SigningResponse { - /// `message_id` of the signing request being answered. - pub responding_to: String, - /// Signing result, or an error description from the signing host. - pub payload: Result, -} - -/// Successful product-account signing result returned by the signing host. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SigningPayloadResponseData { - /// The cryptographic signature. - pub signature: Vec, - /// Full signed transaction, when the request asked for it. - pub signed_transaction: Option>, -} +pub type SignResponse = Result; /// Response returned by the signing host for a legacy-account raw signing request. /// -/// Decoded from [`v1::RemoteMessage::SignRawLegacyResponse`] and mapped back to +/// Decoded from [`v1::RemoteMessage::SignRawWithLegacyAccountResponse`] and mapped back to /// the public raw-signing response shape. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SignRawLegacyResponse { - /// `message_id` of the legacy raw-signing request being answered. - pub responding_to: String, - /// Signature bytes, or an error description from the signing host. - pub signature: Result, String>, -} - -/// RFC-0023 VRF-signing request forwarded to the Account Holder. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SignVrfRequest { - /// Product making the request, used for the Account Holder confirmation. - pub calling_product_id: String, - /// Product account and ordered Merlin transcript. - pub payload: HostAccountSignVrfRequest, -} +pub type SignRawWithLegacyAccountResponse = Result, String>; /// RFC-0023 VRF-signing response returned by the Account Holder. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct SignVrfResponse { - /// `message_id` of the VRF-signing request being answered. - pub responding_to: String, - /// Fixed-width schnorrkel VRF signature or the public API error. - pub payload: Result, -} +pub type SignVrfResponse = Result; /// Failure returned by the Account Holder for RFC-0024 ring-VRF operations. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum RingVrfError { /// The `RingLocation` did not resolve to a known ring. RingNotFound, @@ -350,124 +179,27 @@ pub enum RingVrfError { /// User or Account Holder rejected the request. Rejected, /// Catch-all failure, carrying a diagnostic reason. + #[display("Unknown: {reason}")] Unknown { /// Diagnostic failure description. reason: String, }, } -/// Request sent when a product asks the Account Holder for a contextual alias. -/// -/// Used by `Account::get_account_alias`; `calling_product_id` names the caller, -/// `key_handle` selects a registered member key, and `context` binds the alias. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfAliasRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Explicit ring-VRF key handle. - pub key_handle: ProductAccountId, - /// Context that scopes the derived alias. - pub context: ProductProofContext, - /// Ring whose member key derives the alias. - pub ring_location: RingLocation, -} - /// Response returned by the Account Holder for a ring-VRF alias request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfAliasResponse { - /// `message_id` of the alias request being answered. - pub responding_to: String, - /// Derived alias, or the ring-VRF failure. - pub payload: Result, -} - -/// Request sent when a product asks the Account Holder for a ring-VRF proof. -/// -/// Used by `Account::create_account_proof`; carries the same `(context, -/// ring_location)` as the alias request plus the opaque `message` bound into -/// the proof (RFC 0004). -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfProofRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Explicit ring-VRF key handle. - pub key_handle: ProductAccountId, - /// Context that scopes the proof. - pub context: ProductProofContext, - /// Ring whose member key produces the proof. - pub ring_location: RingLocation, - /// Opaque message bound into the proof. - pub message: Vec, -} - -/// Request to register a ring-VRF key with the Account Holder. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RegisterRingVrfKeyRequest { - /// Product id of the calling product and key owner. - pub calling_product_id: String, - /// Key derivation index within the owner's ring-VRF domain. - pub index: DerivationIndex, - /// Ring declared for the key. - pub ring: RingLocation, -} +pub type GetAccountAliasResponse = Result; /// Response returned by the Account Holder for key registration. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RegisterRingVrfKeyResponse { - /// `message_id` of the registration request being answered. - pub responding_to: String, - /// Member public key or ring-VRF failure. - pub payload: Result<[u8; 32], RingVrfError>, -} - -/// Request to list registered ring-VRF keys. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ListRingVrfKeysRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Product whose registry entries should be listed. - pub owner: String, - /// Disclosure requested by the caller. - pub disclosure: truapi::v01::RingVrfKeyDisclosure, -} +pub type RegisterRingVrfKeyResponse = Result<[u8; 32], RingVrfError>; /// Response returned by the Account Holder for registry listing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ListRingVrfKeysResponse { - /// `message_id` of the listing request being answered. - pub responding_to: String, - /// Registry entries or ring-VRF failure. - pub payload: Result, RingVrfError>, -} - -/// Request to sign bytes with a ring-VRF key. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfSignRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Registered key handle. - pub key_handle: ProductAccountId, - /// Message to sign. - pub message: Vec, -} +pub type ListRingVrfKeysResponse = Result, RingVrfError>; /// Response returned by the Account Holder for direct ring-VRF signing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfSignResponse { - /// `message_id` of the signing request being answered. - pub responding_to: String, - /// Signature bytes or ring-VRF failure. - pub payload: Result, RingVrfError>, -} +pub type RingVrfSignResponse = Result, RingVrfError>; /// Response returned by the Account Holder for a ring-VRF proof request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfProofResponse { - /// `message_id` of the proof request being answered. - pub responding_to: String, - /// Created proof, or the ring-VRF failure. - pub payload: Result, -} +pub type CreateAccountProofResponse = Result; /// Request sent when a product asks the signing host to allocate SSO-backed /// resources. @@ -480,38 +212,11 @@ pub struct ResourceAllocationRequest { /// Product id the allocation is requested for. pub calling_product_id: String, /// Resources to allocate; outcomes come back in the same order. - pub resources: Vec, + pub resources: Vec, /// Policy applied when an allocation already exists for this product. pub on_existing: OnExistingAllowancePolicy, } -/// Resources the signing host may allocate for the calling product. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum SsoAllocatableResource { - /// Statement Store slot allowance for the product's allowance account. - StatementStoreAllowance, - /// Bulletin chain slot allowance for the product's allowance account. - BulletinAllowance, - /// Pre-warmed PGAS balance for the product account selected by this - /// derivation index. - SmartContractAllowance(DerivationIndex), - /// Transfer of the product subtree key so the host can sign locally. - AutoSigning, -} - -impl From for SsoAllocatableResource { - fn from(value: AllocatableResource) -> Self { - match value { - AllocatableResource::StatementStoreAllowance => Self::StatementStoreAllowance, - AllocatableResource::BulletinAllowance => Self::BulletinAllowance, - AllocatableResource::SmartContractAllowance(index) => { - Self::SmartContractAllowance(index) - } - AllocatableResource::AutoSigning => Self::AutoSigning, - } - } -} - /// Signing-host policy for already-existing resource allowance. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)] pub enum OnExistingAllowancePolicy { @@ -522,13 +227,7 @@ pub enum OnExistingAllowancePolicy { } /// Response returned by the signing host for a resource-allocation request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ResourceAllocationResponse { - /// `message_id` of the allocation request being answered. - pub responding_to: String, - /// Per-resource outcomes in request order, or an error description. - pub payload: Result, String>, -} +pub type ResourceAllocationResponse = Result, String>; /// Per-resource allocation result from the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -591,13 +290,7 @@ pub struct ProductSubtreeRequest { } /// Account Holder response carrying a product subtree public key. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ProductSubtreeResponse { - /// `message_id` of the subtree request being answered. - pub responding_to: String, - /// Raw 32-byte sr25519 subtree public key or a stable failure reason. - pub product_public_key: Result<[u8; 32], String>, -} +pub type ProductSubtreeResponse = Result<[u8; 32], String>; /// Request sent when a product asks the signing host to create a transaction /// for a product-derived account. @@ -617,7 +310,7 @@ pub enum CreateTransactionPayload { /// Request sent when a product asks the signing host to create a transaction /// for a user-imported legacy account. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct CreateTransactionLegacyRequest { +pub struct CreateTransactionWithLegacyAccountRequest { /// Transaction payload to build. pub payload: CreateTransactionLegacyPayload, } @@ -629,72 +322,18 @@ pub enum CreateTransactionLegacyPayload { V1(LegacyAccountTxPayload), } -/// Response returned by the signing host for either product-account or legacy-account -/// transaction creation. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct CreateTransactionResponse { - /// `message_id` of the transaction-creation request being answered. - pub responding_to: String, - /// SCALE-encoded transaction, or an error description. Signed unless the - /// request supplied its own V5 `VerifyMultiSignature` extension. - pub signed_transaction: Result, String>, -} +/// SCALE-encoded transaction for a product or legacy account, or an error description. +/// Signed unless the request supplied its own V5 `VerifyMultiSignature` extension. +pub type CreateTransactionResponse = Result, String>; /// Decoded inbound statement-channel outcome. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SsoSessionStatement { /// The outbound request statement was acknowledged with a success code. RequestAccepted, - /// Remote response matching the pending remote message id. - RemoteResponse(SsoRemoteResponse), - /// The peer ended the SSO session. - Disconnected, -} - -/// Signing-host response variants that can satisfy a pending remote request. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SsoRemoteResponse { - /// Product-account signing response. - Sign(SigningResponse), - /// Legacy-account raw-signing response. - SignRawLegacy(SignRawLegacyResponse), - /// Product-account VRF signing response. - SignVrf(SignVrfResponse), - /// Contextual-alias response. - RingVrfAlias(RingVrfAliasResponse), - /// Ring-VRF proof response. - RingVrfProof(RingVrfProofResponse), - /// Resource-allocation response. - ResourceAllocation(ResourceAllocationResponse), - /// Transaction-creation response. - CreateTransaction(CreateTransactionResponse), - /// Product subtree public-key response. - ProductSubtree(ProductSubtreeResponse), - /// Ring-VRF key registration response. - RegisterRingVrfKey(RegisterRingVrfKeyResponse), - /// Ring-VRF key listing response. - ListRingVrfKeys(ListRingVrfKeysResponse), - /// Direct ring-VRF signing response. - RingVrfSign(RingVrfSignResponse), -} - -impl SsoRemoteResponse { - /// Stable response discriminant that never formats response payloads. - pub(crate) const fn kind(&self) -> &'static str { - match self { - Self::Sign(_) => "sign", - Self::SignRawLegacy(_) => "sign-raw-legacy", - Self::SignVrf(_) => "sign-vrf", - Self::RingVrfAlias(_) => "ring-vrf-alias", - Self::RingVrfProof(_) => "ring-vrf-proof", - Self::ResourceAllocation(_) => "resource-allocation", - Self::CreateTransaction(_) => "create-transaction", - Self::ProductSubtree(_) => "product-subtree", - Self::RegisterRingVrfKey(_) => "register-ring-vrf-key", - Self::ListRingVrfKeys(_) => "list-ring-vrf-keys", - Self::RingVrfSign(_) => "ring-vrf-sign", - } - } + /// Application messages in wire order, each decoded on its own so a + /// consumer can stop at its match before a later undecodable message. + RemoteMessages(Vec>), } /// Decode and classify an inbound encrypted SSO session statement. @@ -702,7 +341,6 @@ pub fn decode_sso_session_statement( session: &SsoSessionInfo, statement: &[u8], expected_statement_request_id: &str, - expected_remote_message_id: &str, ) -> Result, String> { let verified = decode_verified_statement_data(statement, None).map_err(|err| err.to_string())?; @@ -738,23 +376,16 @@ pub fn decode_sso_session_statement( classify_response_ack(request_id, response_code).map(Some) } SsoStatementData::Response { .. } => Ok(None), - SsoStatementData::Request { data, .. } => { - for message in data { - let message = decode_remote_message(&message)?; - if matches!( - &message.data, - RemoteMessageData::V1(v1::RemoteMessage::Disconnected) - ) { - return Ok(Some(SsoSessionStatement::Disconnected)); - } - if let Some(response) = - remote_response_for_message(message, expected_remote_message_id) - { - return Ok(Some(SsoSessionStatement::RemoteResponse(response))); - } - } - Ok(None) - } + SsoStatementData::Request { data, .. } => Ok(Some(SsoSessionStatement::RemoteMessages( + data.iter() + .map(|message| { + decode_remote_message(message).map(|message| { + let RemoteMessageData::V1(message) = message.data; + message + }) + }) + .collect(), + ))), } } @@ -769,287 +400,6 @@ fn classify_response_ack( } } -fn remote_response_for_message( - message: RemoteMessage, - expected_remote_message_id: &str, -) -> Option { - let RemoteMessageData::V1(data) = message.data; - match data { - v1::RemoteMessage::SignResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::Sign(response)) - } - v1::RemoteMessage::RingVrfAliasResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::RingVrfAlias(response)) - } - v1::RemoteMessage::RingVrfProofResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::RingVrfProof(response)) - } - v1::RemoteMessage::SignRawLegacyResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::SignRawLegacy(response)) - } - v1::RemoteMessage::SignVrfResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::SignVrf(response)) - } - v1::RemoteMessage::ResourceAllocationResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::ResourceAllocation(response)) - } - v1::RemoteMessage::CreateTransactionResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::CreateTransaction(response)) - } - v1::RemoteMessage::ProductSubtreeResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::ProductSubtree(response)) - } - v1::RemoteMessage::RegisterRingVrfKeyResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::RegisterRingVrfKey(response)) - } - v1::RemoteMessage::ListRingVrfKeysResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::ListRingVrfKeys(response)) - } - v1::RemoteMessage::RingVrfSignResponse(response) - if response.responding_to == expected_remote_message_id => - { - Some(SsoRemoteResponse::RingVrfSign(response)) - } - _ => None, - } -} - -/// Build an RFC-0024 ring-VRF key registration request for the Account Holder. -pub fn register_ring_vrf_key_message( - message_id: String, - calling_product_id: String, - index: DerivationIndex, - ring: RingLocation, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyRequest( - RegisterRingVrfKeyRequest { - calling_product_id, - index, - ring, - }, - )), - } -} - -/// Build an RFC-0024 ring-VRF key listing request for the Account Holder. -pub fn list_ring_vrf_keys_message( - message_id: String, - calling_product_id: String, - owner: String, - disclosure: truapi::v01::RingVrfKeyDisclosure, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysRequest( - ListRingVrfKeysRequest { - calling_product_id, - owner, - disclosure, - }, - )), - } -} - -/// Build an RFC-0024 direct ring-VRF signing request for the Account Holder. -pub fn ring_vrf_sign_message( - message_id: String, - calling_product_id: String, - key_handle: ProductAccountId, - message: Vec, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { - calling_product_id, - key_handle, - message, - })), - } -} - -/// Build an RFC-0023 VRF-signing request for the Account Holder. -pub fn sign_vrf_message( - message_id: String, - calling_product_id: String, - payload: HostAccountSignVrfRequest, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::SignVrfRequest(SignVrfRequest { - calling_product_id, - payload, - })), - } -} - -/// Build a signing-host payload-signing request message. -pub fn sign_payload_message( - message_id: String, - request: truapi::v01::HostSignPayloadRequest, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Payload(Box::new(SigningPayloadRequest::from_host_request(request))), - ))), - } -} - -/// Build a signing-host raw-signing request message. -pub fn sign_raw_message( - message_id: String, - request: truapi::v01::HostSignRawRequest, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::SignRequest(Box::new( - SigningRequest::Raw(SigningRawRequest::from_host_request(request)), - ))), - } -} - -/// Build a signing-host legacy raw-signing request message. -pub fn sign_raw_legacy_message( - message_id: String, - account: AccountId, - payload: RawPayload, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyRequest( - SignRawLegacyRequest { - account, - data: payload.into(), - }, - )), - } -} - -/// Build an Account Holder contextual-alias request message. -pub fn alias_request_message( - message_id: String, - calling_product_id: String, - key_handle: ProductAccountId, - context: ProductProofContext, - ring_location: RingLocation, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest( - RingVrfAliasRequest { - calling_product_id, - key_handle, - context, - ring_location, - }, - )), - } -} - -/// Build an Account Holder ring-VRF proof request message. -pub fn proof_request_message( - message_id: String, - calling_product_id: String, - key_handle: ProductAccountId, - context: ProductProofContext, - ring_location: RingLocation, - message: Vec, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofRequest( - RingVrfProofRequest { - calling_product_id, - key_handle, - context, - ring_location, - message, - }, - )), - } -} - -/// Build a consent-free product subtree public-key request. -pub fn product_subtree_request_message(message_id: String, product_id: String) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeRequest( - ProductSubtreeRequest { product_id }, - )), - } -} - -/// Build a signing-host resource-allocation request message. -pub fn resource_allocation_message( - message_id: String, - calling_product_id: String, - resources: Vec, - on_existing: OnExistingAllowancePolicy, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest( - ResourceAllocationRequest { - calling_product_id, - resources: resources.into_iter().map(Into::into).collect(), - on_existing, - }, - )), - } -} - -/// Build a signing-host transaction-creation request message. -pub fn create_transaction_message( - message_id: String, - payload: ProductAccountTxPayload, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionRequest( - CreateTransactionRequest { - payload: CreateTransactionPayload::V1(payload), - }, - )), - } -} - -/// Build a signing-host legacy-account transaction-creation request message. -pub fn create_transaction_legacy_message( - message_id: String, - payload: LegacyAccountTxPayload, -) -> RemoteMessage { - RemoteMessage { - message_id, - data: RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionLegacyRequest( - CreateTransactionLegacyRequest { - payload: CreateTransactionLegacyPayload::V1(payload), - }, - )), - } -} - /// Inbound request decoded from a peer-signed session statement. #[derive(Debug, Clone, PartialEq, Eq)] pub struct IncomingSsoRequest { @@ -1227,10 +577,16 @@ fn outgoing_request_data( mod tests { use super::*; use crate::host_logic::sso::pairing::decrypt_session_statement_data; + use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{ StatementField, build_signed_statement, decode_statement_data, }; + use crate::test_support::sso_host_and_responder_sessions; use schnorrkel::{ExpansionMode, MiniSecretKey}; + use truapi::latest::{ + DerivationIndex, HostAccountSignVrfRequest, ProductAccountId, ProductProofContext, + RingLocation, + }; use truapi::latest::{HostSignPayloadData, TxPayloadExtension}; use truapi::v01::RingLocationJunction; use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519SecretKey}; @@ -1277,19 +633,19 @@ mod tests { }; assert_eq!(message.encode(), vec![0, 0, 0]); - assert_eq!(message.to_string(), "disconnected"); + assert_eq!(message.name(), "Disconnected"); } #[test] fn raw_sign_request_uses_remote_message_variant_indices() { - let message = sign_raw_message( + let message = RemoteMessage::request( "m1".to_string(), - truapi::latest::HostSignRawRequest { + SignRequest::Raw(truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Bytes { bytes: vec![0xde, 0xad], }, - }, + }), ); let encoded = message.encode(); @@ -1309,65 +665,82 @@ mod tests { dot_ns_identifier: "peopl.dot".to_string(), derivation_index: DerivationIndex::Index(0), }; - let legacy_tx = create_transaction_legacy_message( + let legacy_tx = RemoteMessage::request( + String::new(), + CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(LegacyAccountTxPayload { + signer: [1; 32], + genesis_hash: [2; 32], + call_data: Vec::new(), + extensions: Vec::new(), + tx_ext_version: 0, + }), + }, + ) + .encode(); + let legacy_raw = RemoteMessage::request( String::new(), - LegacyAccountTxPayload { - signer: [1; 32], - genesis_hash: [2; 32], - call_data: Vec::new(), - extensions: Vec::new(), - tx_ext_version: 0, + SignRawWithLegacyAccountRequest { + account: [1; 32], + data: RawPayload::Bytes { bytes: vec![] }, }, ) .encode(); - let legacy_raw = - sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) - .encode(); - let register = register_ring_vrf_key_message( + let register = RemoteMessage::request( String::new(), - "caller.dot".to_string(), - DerivationIndex::Index(0), - ring_location.clone(), + ProductRequest { + calling_product_id: "caller.dot".to_string(), + payload: truapi::latest::HostAccountRegisterRingVrfKeyRequest { + index: DerivationIndex::Index(0), + ring: ring_location.clone(), + }, + }, ) .encode(); let register_response = RemoteMessage { message_id: String::new(), - data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse( - RegisterRingVrfKeyResponse { - responding_to: String::new(), - payload: Ok([1; 32]), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse(Response { + responding_to: String::new(), + payload: Ok([1; 32]), + })), } .encode(); - let list = list_ring_vrf_keys_message( + let list = RemoteMessage::request( String::new(), - "caller.dot".to_string(), - "peopl.dot".to_string(), - truapi::v01::RingVrfKeyDisclosure::Anonymized, + ProductRequest { + calling_product_id: "caller.dot".to_string(), + payload: truapi::latest::HostAccountListRingVrfKeysRequest { + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::Anonymized, + }, + }, ) .encode(); let list_response = RemoteMessage { message_id: String::new(), - data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse( - ListRingVrfKeysResponse { - responding_to: String::new(), - payload: Ok(Vec::new()), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse(Response { + responding_to: String::new(), + payload: Ok(Vec::new()), + })), } .encode(); - let sign = - ring_vrf_sign_message(String::new(), "caller.dot".to_string(), key_handle, vec![]) - .encode(); + let sign = RemoteMessage::request( + String::new(), + ProductRequest { + calling_product_id: "caller.dot".to_string(), + payload: truapi::latest::HostAccountRingVrfSignRequest { + key_handle, + message: vec![], + }, + }, + ) + .encode(); let sign_response = RemoteMessage { message_id: String::new(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse( - RingVrfSignResponse { - responding_to: String::new(), - payload: Ok(Vec::new()), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse(Response { + responding_to: String::new(), + payload: Ok(Vec::new()), + })), } .encode(); @@ -1408,20 +781,26 @@ mod tests { derivation_index: DerivationIndex::Index(0), }; let messages = [ - v1::RemoteMessage::RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest { + v1::RemoteMessage::RegisterRingVrfKeyRequest(ProductRequest { calling_product_id: "game.dot".to_string(), - index: DerivationIndex::Index(4), - ring: ring.clone(), + payload: truapi::latest::HostAccountRegisterRingVrfKeyRequest { + index: DerivationIndex::Index(4), + ring: ring.clone(), + }, }), - v1::RemoteMessage::ListRingVrfKeysRequest(ListRingVrfKeysRequest { + v1::RemoteMessage::ListRingVrfKeysRequest(ProductRequest { calling_product_id: "game.dot".to_string(), - owner: "peopl.dot".to_string(), - disclosure: truapi::v01::RingVrfKeyDisclosure::PublicKey, + payload: truapi::latest::HostAccountListRingVrfKeysRequest { + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::PublicKey, + }, }), - v1::RemoteMessage::RingVrfSignRequest(RingVrfSignRequest { + v1::RemoteMessage::RingVrfSignRequest(ProductRequest { calling_product_id: "game.dot".to_string(), - key_handle: handle, - message: (0..16).collect(), + payload: truapi::latest::HostAccountRingVrfSignRequest { + key_handle: handle, + message: (0..16).collect(), + }, }), ]; let expected = [ @@ -1452,20 +831,28 @@ mod tests { derivation_index: DerivationIndex::Index(0), }; - let alias = alias_request_message( + let alias = RemoteMessage::request( "m-alias".to_string(), - "caller.dot".to_string(), - key_handle.clone(), - context.clone(), - ring_location.clone(), + ProductRequest { + calling_product_id: "caller.dot".to_string(), + payload: truapi::latest::HostAccountGetAliasRequest { + key_handle: key_handle.clone(), + context: context.clone(), + ring_location: ring_location.clone(), + }, + }, ); - let proof = proof_request_message( + let proof = RemoteMessage::request( "m-proof".to_string(), - "caller.dot".to_string(), - key_handle, - context, - ring_location, - b"vote".to_vec(), + ProductRequest { + calling_product_id: "caller.dot".to_string(), + payload: truapi::latest::HostAccountCreateProofRequest { + key_handle, + context, + ring_location, + message: b"vote".to_vec(), + }, + }, ); assert_host_papp_0_8_11_fixture( @@ -1486,26 +873,22 @@ mod tests { }; let alias_response = RemoteMessage { message_id: "r-alias".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( - RingVrfAliasResponse { - responding_to: "m-alias".to_string(), - payload: Ok(contextual_alias.clone()), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse(Response { + responding_to: "m-alias".to_string(), + payload: Ok(contextual_alias.clone()), + })), }; let proof_response = RemoteMessage { message_id: "r-proof".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( - RingVrfProofResponse { - responding_to: "m-proof".to_string(), - payload: Ok(HostAccountCreateProofResponse { - proof: vec![0x55, 0x66], - contextual_alias, - ring_index: 7, - ring_revision: 9, - }), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse(Response { + responding_to: "m-proof".to_string(), + payload: Ok(HostAccountCreateProofResponse { + proof: vec![0x55, 0x66], + contextual_alias, + ring_index: 7, + ring_revision: 9, + }), + })), }; assert_host_papp_0_8_11_fixture( @@ -1531,8 +914,12 @@ mod tests { #[test] fn product_subtree_messages_match_mobile_wire_contract() { - let request = - product_subtree_request_message("request".to_string(), "browse.dot".to_string()); + let request = RemoteMessage::request( + "request".to_string(), + ProductSubtreeRequest { + product_id: "browse.dot".to_string(), + }, + ); assert_eq!( hex::encode(request.encode()), "1c7265717565737400102862726f7773652e646f74" @@ -1540,12 +927,10 @@ mod tests { let response = RemoteMessage { message_id: "response".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse( - ProductSubtreeResponse { - responding_to: "request".to_string(), - product_public_key: Ok([0xAB; 32]), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(Response { + responding_to: "request".to_string(), + payload: Ok([0xAB; 32]), + })), }; assert_eq!( hex::encode(response.encode()), @@ -1554,12 +939,13 @@ mod tests { "ab".repeat(32) ) ); + let RemoteMessageData::V1(data) = response.data; assert_eq!( - remote_response_for_message(response, "request"), - Some(SsoRemoteResponse::ProductSubtree(ProductSubtreeResponse { + ProductSubtreeRequest::response_from_message(data), + Some(Response { responding_to: "request".to_string(), - product_public_key: Ok([0xAB; 32]), - })) + payload: Ok([0xAB; 32]), + }) ); } @@ -1576,7 +962,13 @@ mod tests { value: vec![1, 2], }], }; - let request = sign_vrf_message("req".to_string(), "browse.dot".to_string(), payload); + let request = RemoteMessage::request( + "req".to_string(), + ProductRequest { + calling_product_id: "browse.dot".to_string(), + payload, + }, + ); assert_eq!( hex::encode(request.encode()), "0c726571000e2862726f7773652e646f742862726f7773652e646f7400070000000c6374780418646f6d61696e080102" @@ -1584,7 +976,7 @@ mod tests { let response = RemoteMessage { message_id: "resp".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignVrfResponse(SignVrfResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignVrfResponse(Response { responding_to: "req".to_string(), payload: Ok(VrfSignature { pre_output: [0x11; 32], @@ -1600,12 +992,15 @@ mod tests { "22".repeat(64) ) ); + let RemoteMessageData::V1(data) = response.data; assert!(matches!( - remote_response_for_message(response, "req"), - Some(SsoRemoteResponse::SignVrf(SignVrfResponse { + ProductRequest::::response_from_message( + data + ), + Some(Response { payload: Ok(VrfSignature { .. }), .. - })) + }) )); } @@ -1613,17 +1008,15 @@ mod tests { fn auto_signing_secret_is_fixed_width_on_the_mobile_wire() { let message = RemoteMessage { message_id: "m".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( - ResourceAllocationResponse { - responding_to: "r".to_string(), - payload: Ok(vec![SsoAllocationOutcome::Allocated( - SsoAllocatedResource::AutoSigning { - product_root_private_key: sequential_bytes(0), - ring_vrf_domain_entropy: sequential_bytes(64), - }, - )]), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(Response { + responding_to: "r".to_string(), + payload: Ok(vec![SsoAllocationOutcome::Allocated( + SsoAllocatedResource::AutoSigning { + product_root_private_key: sequential_bytes(0), + ring_vrf_domain_entropy: sequential_bytes(64), + }, + )]), + })), }; assert_eq!( hex::encode(message.encode()), @@ -1677,7 +1070,7 @@ mod tests { ); let auto_signing_secret = [0xA5; 64]; - let response = ResourceAllocationResponse { + let response = Response { responding_to: "secret-test".to_string(), payload: Ok(vec![SsoAllocationOutcome::Allocated( SsoAllocatedResource::AutoSigning { @@ -1690,8 +1083,9 @@ mod tests { assert!(response_debug.contains("auto-signing")); assert!(!response_debug.contains("165, 165")); - let remote_response = SsoRemoteResponse::ResourceAllocation(response.clone()); - let session_statement = SsoSessionStatement::RemoteResponse(remote_response); + let session_statement = SsoSessionStatement::RemoteMessages(vec![Ok( + v1::RemoteMessage::ResourceAllocationResponse(response.clone()), + )]); let statement_debug = format!("{session_statement:?}"); assert!(statement_debug.contains("ResourceAllocation")); assert!(statement_debug.contains("auto-signing")); @@ -1715,16 +1109,18 @@ mod tests { #[test] fn resource_allocation_message_wire_shape_pin() { - let message = resource_allocation_message( + let message = RemoteMessage::request( "m-resource".to_string(), - "truapi-playground.dot".to_string(), - vec![ - AllocatableResource::StatementStoreAllowance, - AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), - AllocatableResource::AutoSigning, - ], - OnExistingAllowancePolicy::Increase, + ResourceAllocationRequest { + calling_product_id: "truapi-playground.dot".to_string(), + resources: vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), + AllocatableResource::AutoSigning, + ], + on_existing: OnExistingAllowancePolicy::Increase, + }, ); assert_host_papp_0_8_11_fixture( @@ -1735,21 +1131,23 @@ mod tests { #[test] fn create_transaction_message_wire_shape_pin() { - let message = create_transaction_message( + let message = RemoteMessage::request( "m-product-tx".to_string(), - ProductAccountTxPayload { - signer: ProductAccountId { - dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_index: DerivationIndex::Index(0), - }, - genesis_hash: sequential_bytes(32), - call_data: vec![0, 0], - extensions: vec![TxPayloadExtension { - id: "CheckNonce".to_string(), - extra: vec![1], - additional_signed: vec![2, 3], - }], - tx_ext_version: 0, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(ProductAccountTxPayload { + signer: ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: DerivationIndex::Index(0), + }, + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }), }, ); @@ -1761,21 +1159,23 @@ mod tests { #[test] fn playground_create_transaction_message_wire_shape_pin() { - let message = create_transaction_message( + let message = RemoteMessage::request( "create-transaction-1".to_string(), - ProductAccountTxPayload { - signer: ProductAccountId { - dot_ns_identifier: "truapi-playground.dot".to_string(), - derivation_index: DerivationIndex::Index(0), - }, - genesis_hash: [ - 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, 0x43, - 0xe2, 0x6f, 0xdc, 0x2a, 0x4e, 0xcd, 0x74, 0xcf, 0x87, 0xdd, 0x1b, 0x4b, 0x1e, - 0xeb, 0x99, 0xae, 0x4e, 0xf1, 0x9f, - ], - call_data: vec![0, 0], - extensions: vec![], - tx_ext_version: 0, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(ProductAccountTxPayload { + signer: ProductAccountId { + dot_ns_identifier: "truapi-playground.dot".to_string(), + derivation_index: DerivationIndex::Index(0), + }, + genesis_hash: [ + 0xbf, 0x04, 0x88, 0xdb, 0xe9, 0xda, 0xa1, 0xde, 0x1c, 0x08, 0xc5, 0xf7, + 0x43, 0xe2, 0x6f, 0xdc, 0x2a, 0x4e, 0xcd, 0x74, 0xcf, 0x87, 0xdd, 0x1b, + 0x4b, 0x1e, 0xeb, 0x99, 0xae, 0x4e, 0xf1, 0x9f, + ], + call_data: vec![0, 0], + extensions: vec![], + tx_ext_version: 0, + }), }, ); @@ -1787,18 +1187,20 @@ mod tests { #[test] fn create_transaction_legacy_message_matches_host_papp_0_8_11_fixture() { - let message = create_transaction_legacy_message( + let message = RemoteMessage::request( "m-legacy-tx".to_string(), - LegacyAccountTxPayload { - signer: sequential_bytes(0), - genesis_hash: sequential_bytes(32), - call_data: vec![0, 0], - extensions: vec![TxPayloadExtension { - id: "CheckNonce".to_string(), - extra: vec![1], - additional_signed: vec![2, 3], - }], - tx_ext_version: 0, + CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(LegacyAccountTxPayload { + signer: sequential_bytes(0), + genesis_hash: sequential_bytes(32), + call_data: vec![0, 0], + extensions: vec![TxPayloadExtension { + id: "CheckNonce".to_string(), + extra: vec![1], + additional_signed: vec![2, 3], + }], + tx_ext_version: 0, + }), }, ); @@ -1811,21 +1213,25 @@ mod tests { #[test] fn sign_raw_legacy_messages_match_host_papp_0_8_11_fixtures() { assert_host_papp_0_8_11_fixture( - sign_raw_legacy_message( + RemoteMessage::request( "m-legacy-raw".to_string(), - sequential_bytes(0), - RawPayload::Bytes { - bytes: b"Hi".to_vec(), + SignRawWithLegacyAccountRequest { + account: sequential_bytes(0), + data: RawPayload::Bytes { + bytes: b"Hi".to_vec(), + }, }, ), "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", ); assert_host_papp_0_8_11_fixture( - sign_raw_legacy_message( + RemoteMessage::request( "m-legacy-raw-payload".to_string(), - sequential_bytes(0), - RawPayload::Payload { - payload: "Hi".to_string(), + SignRawWithLegacyAccountRequest { + account: sequential_bytes(0), + data: RawPayload::Payload { + payload: "Hi".to_string(), + }, }, ), "0x506d2d6c65676163792d7261772d7061796c6f6164000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f01443c42797465733e48693c2f42797465733e", @@ -1851,62 +1257,34 @@ mod tests { asset_id: None, metadata_hash: None, mode: None, - with_signed_transaction: Some(true), + with_signed_transaction: parity_scale_codec::OptionBool(Some(true)), }, }; - let true_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); - request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); - request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from_host_request(request).encode(); - - assert_eq!(true_encoded.last(), Some(&1)); - assert_eq!(false_encoded.last(), Some(&2)); - assert_eq!(none_encoded.last(), Some(&0)); - } - - #[test] - fn maps_public_resource_names_to_sso_dialect() { - let message = resource_allocation_message( - "alloc".to_string(), - "myapp.dot".to_string(), - vec![ - AllocatableResource::StatementStoreAllowance, - AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), - AllocatableResource::AutoSigning, - ], - OnExistingAllowancePolicy::Increase, - ); - let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) = - message.data - else { - panic!("expected resource allocation request"); - }; - - assert_eq!( - request.resources, - vec![ - SsoAllocatableResource::StatementStoreAllowance, - SsoAllocatableResource::BulletinAllowance, - SsoAllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), - SsoAllocatableResource::AutoSigning, - ] - ); - assert_eq!(request.on_existing, OnExistingAllowancePolicy::Increase); + for (flag, byte) in [(None, 0), (Some(true), 1), (Some(false), 2)] { + request.payload.with_signed_transaction = parity_scale_codec::OptionBool(flag); + let expected = hex::decode(format!( + "246d796170702e646f7400070000000000000000000000000004000000000000{byte:02x}" + )) + .unwrap(); + assert_eq!(request.encode(), expected); + assert_eq!( + HostSignPayloadRequest::decode(&mut expected.as_slice()).unwrap(), + request + ); + } } #[test] fn builds_signed_encrypted_outgoing_request_statement() { let session = session(); - let remote_message = sign_raw_message( + let remote_message = RemoteMessage::request( "remote-1".to_string(), - truapi::latest::HostSignRawRequest { + SignRequest::Raw(truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), }, - }, + }), ); let statement = build_outgoing_request_statement_with_nonce( @@ -1939,14 +1317,14 @@ mod tests { #[test] fn ignores_own_echoed_session_request_statement() { let session = session(); - let remote_message = sign_raw_message( + let remote_message = RemoteMessage::request( "remote-1".to_string(), - truapi::latest::HostSignRawRequest { + SignRequest::Raw(truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), }, - }, + }), ); let statement = build_outgoing_request_statement_with_nonce( &session, @@ -1957,74 +1335,25 @@ mod tests { ) .unwrap(); - let decoded = - decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + let decoded = decode_sso_session_statement(&session, &statement, "statement-1").unwrap(); assert_eq!(decoded, None); } - fn host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { - use crate::host_logic::sso::pairing::{ - ResponderIdentity, create_pairing_bootstrap, derive_x25519_keypair_from_entropy, - establish_responder_session_info, establish_sso_session_info, - }; - use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; - - let config = PairingHostConfig::new( - HostInfo { - name: "Test Host".to_string(), - icon: None, - version: None, - platform: truapi::latest::HostPlatform::Unknown, - }, - PlatformInfo::default(), - [0; 32], - [0xbb; 32], - [0xcc; 32], - "polkadotapp".to_string(), - ) - .expect("test pairing config is valid"); - let bootstrap = create_pairing_bootstrap(&config).unwrap(); - let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) - .unwrap() - .expand_to_keypair(ExpansionMode::Ed25519); - let (encryption_secret_key, encryption_public_key) = - derive_x25519_keypair_from_entropy(&[0xAB; 16], b"sso"); - let responder = ResponderIdentity { - statement_secret: statement_keypair.secret.to_bytes(), - statement_public_key: statement_keypair.public.to_bytes(), - encryption_secret_key, - encryption_public_key, - }; - let responder_session = establish_responder_session_info( - &responder, - bootstrap.statement_store_public_key, - bootstrap.encryption_public_key, - ) - .unwrap(); - let host_session = establish_sso_session_info( - &bootstrap, - responder.statement_public_key, - responder.encryption_public_key, - ) - .unwrap(); - (host_session, responder_session) - } - /// A host-built request statement decodes on the responder side into the /// batched remote messages, and the responder's ack plus response /// statements resolve the host's pending wait. #[test] fn host_request_round_trips_through_responder_statements() { - let (host_session, responder_session) = host_and_responder_sessions(); - let request = sign_raw_message( + let (host_session, responder_session) = sso_host_and_responder_sessions(); + let request = RemoteMessage::request( "remote-1".to_string(), - truapi::latest::HostSignRawRequest { + SignRequest::Raw(truapi::latest::HostSignRawRequest { account: account(), payload: RawPayload::Payload { payload: "hello".to_string(), }, - }, + }), ); let expiry = fresh_expiry(); let host_statement = build_outgoing_request_statement( @@ -2055,15 +1384,15 @@ mod tests { ) .unwrap(); assert_eq!( - decode_sso_session_statement(&host_session, &ack, "statement-1", "remote-1").unwrap(), + decode_sso_session_statement(&host_session, &ack, "statement-1").unwrap(), Some(SsoSessionStatement::RequestAccepted) ); let response = RemoteMessage { message_id: "resp-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SigningResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(Response { responding_to: "remote-1".to_string(), - payload: Ok(SigningPayloadResponseData { + payload: Ok(HostSignPayloadResponse { signature: vec![9; 64], signed_transaction: None, }), @@ -2076,30 +1405,26 @@ mod tests { fresh_expiry(), ) .unwrap(); - let decoded = decode_sso_session_statement( - &host_session, - &response_statement, - "statement-1", - "remote-1", - ) - .unwrap(); + let decoded = + decode_sso_session_statement(&host_session, &response_statement, "statement-1") + .unwrap(); assert_eq!( decoded, - Some(SsoSessionStatement::RemoteResponse( - SsoRemoteResponse::Sign(SigningResponse { + Some(SsoSessionStatement::RemoteMessages(vec![Ok( + v1::RemoteMessage::SignResponse(Response { responding_to: "remote-1".to_string(), - payload: Ok(SigningPayloadResponseData { + payload: Ok(HostSignPayloadResponse { signature: vec![9; 64], signed_transaction: None, }), }) - )) + )])) ); } #[test] fn responder_ignores_own_echo_and_transport_acks() { - let (host_session, responder_session) = host_and_responder_sessions(); + let (host_session, responder_session) = sso_host_and_responder_sessions(); let own_statement = build_outgoing_request_statement( &responder_session, "resp-statement-1".to_string(), @@ -2130,7 +1455,7 @@ mod tests { #[test] fn responder_ignores_expired_host_request() { - let (host_session, responder_session) = host_and_responder_sessions(); + let (host_session, responder_session) = sso_host_and_responder_sessions(); let stale_statement = build_outgoing_request_statement( &host_session, "statement-1".to_string(), @@ -2150,7 +1475,7 @@ mod tests { #[test] fn responder_recovers_request_id_from_undecodable_messages() { - let (host_session, responder_session) = host_and_responder_sessions(); + let (host_session, responder_session) = sso_host_and_responder_sessions(); let encrypted = encrypt_session_statement_data( &host_session, &SsoStatementData::Request { @@ -2193,8 +1518,7 @@ mod tests { let session = session(); let statement = response_ack_statement(&session, fresh_expiry()); - let decoded = - decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + let decoded = decode_sso_session_statement(&session, &statement, "statement-1").unwrap(); assert_eq!(decoded, Some(SsoSessionStatement::RequestAccepted)); } @@ -2206,8 +1530,7 @@ mod tests { let session = session(); let statement = response_ack_statement(&session, elapsed_expiry()); - let decoded = - decode_sso_session_statement(&session, &statement, "statement-1", "remote-1").unwrap(); + let decoded = decode_sso_session_statement(&session, &statement, "statement-1").unwrap(); assert_eq!(decoded, None); } diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs index 68f9c09dc..1532e8506 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages/v1.rs @@ -6,102 +6,82 @@ //! use parity_scale_codec::{Decode, Encode}; +use truapi::latest::{ + HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfRequest, +}; use super::{ - CreateTransactionLegacyRequest, CreateTransactionRequest, CreateTransactionResponse, - ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, - RegisterRingVrfKeyRequest, RegisterRingVrfKeyResponse, ResourceAllocationRequest, - ResourceAllocationResponse, RingVrfAliasRequest, RingVrfAliasResponse, RingVrfProofRequest, - RingVrfProofResponse, RingVrfSignRequest, RingVrfSignResponse, SignRawLegacyRequest, - SignRawLegacyResponse, SignVrfRequest, SignVrfResponse, SigningRequest, SigningResponse, + CreateAccountProofResponse, CreateTransactionRequest, CreateTransactionResponse, + CreateTransactionWithLegacyAccountRequest, GetAccountAliasResponse, ListRingVrfKeysResponse, + ProductRequest, ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyResponse, + ResourceAllocationRequest, ResourceAllocationResponse, Response, RingVrfSignResponse, + SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, + SignVrfResponse, }; /// v1 messages exchanged with the paired signing host over the encrypted SSO channel. /// /// The variant order is part of the SCALE wire protocol used inside /// statement-store session statements. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum RemoteMessage { /// The peer is ending the SSO session. - #[display("disconnected")] Disconnected, /// Ask the signing host to sign a payload or raw data with a product account. - #[display("sign_request")] - SignRequest(Box), + SignRequest(SignRequest), /// Signing host's answer to [`RemoteMessage::SignRequest`]. - #[display("sign_response")] - SignResponse(SigningResponse), + SignResponse(Response), /// Ask the Account Holder for a contextual alias. - #[display("get_account_alias")] - RingVrfAliasRequest(RingVrfAliasRequest), - /// Account Holder's answer to [`RemoteMessage::RingVrfAliasRequest`]. - #[display("get_account_alias_response")] - RingVrfAliasResponse(RingVrfAliasResponse), + GetAccountAliasRequest(ProductRequest), + /// Account Holder's answer to [`RemoteMessage::GetAccountAliasRequest`]. + GetAccountAliasResponse(Response), /// Ask the signing host to allocate SSO-backed resources. - #[display("resource_allocation")] ResourceAllocationRequest(ResourceAllocationRequest), /// Signing host's answer to [`RemoteMessage::ResourceAllocationRequest`]. - #[display("resource_allocation_response")] - ResourceAllocationResponse(ResourceAllocationResponse), + ResourceAllocationResponse(Response), /// Ask the signing host to create a signed product-account transaction. - #[display("create_transaction")] CreateTransactionRequest(CreateTransactionRequest), /// Signing host's answer to either transaction-creation request. - #[display("create_transaction_response")] - CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionResponse(Response), /// Ask the signing host to create a signed legacy-account transaction. - #[display("create_transaction_legacy")] - CreateTransactionLegacyRequest(CreateTransactionLegacyRequest), + CreateTransactionWithLegacyAccountRequest(CreateTransactionWithLegacyAccountRequest), /// Ask the signing host to sign raw data with a legacy account. - #[display("sign_raw_legacy")] - SignRawLegacyRequest(SignRawLegacyRequest), - /// Signing host's answer to [`RemoteMessage::SignRawLegacyRequest`]. - #[display("sign_raw_legacy_response")] - SignRawLegacyResponse(SignRawLegacyResponse), + SignRawWithLegacyAccountRequest(SignRawWithLegacyAccountRequest), + /// Signing host's answer to [`RemoteMessage::SignRawWithLegacyAccountRequest`]. + SignRawWithLegacyAccountResponse(Response), /// Ask the Account Holder for a ring-VRF proof. - #[display("create_account_proof")] - RingVrfProofRequest(RingVrfProofRequest), - /// Account Holder's answer to [`RemoteMessage::RingVrfProofRequest`]. - #[display("create_account_proof_response")] - RingVrfProofResponse(RingVrfProofResponse), + CreateAccountProofRequest(ProductRequest), + /// Account Holder's answer to [`RemoteMessage::CreateAccountProofRequest`]. + CreateAccountProofResponse(Response), /// Ask the Account Holder to sign an RFC-0023 sr25519 VRF transcript. #[codec(index = 14)] - #[display("sign_vrf")] - SignVrfRequest(SignVrfRequest), + SignVrfRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::SignVrfRequest`]. #[codec(index = 15)] - #[display("sign_vrf_response")] - SignVrfResponse(SignVrfResponse), + SignVrfResponse(Response), /// Consent-free request for a product's hard-subtree public key. #[codec(index = 16)] - #[display("product_subtree")] ProductSubtreeRequest(ProductSubtreeRequest), /// Account Holder's answer to [`RemoteMessage::ProductSubtreeRequest`]. #[codec(index = 17)] - #[display("product_subtree_response")] - ProductSubtreeResponse(ProductSubtreeResponse), + ProductSubtreeResponse(Response), /// Register a ring-VRF key with the Account Holder. #[codec(index = 18)] - #[display("register_ring_vrf_key")] - RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), + RegisterRingVrfKeyRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. #[codec(index = 19)] - #[display("register_ring_vrf_key_response")] - RegisterRingVrfKeyResponse(RegisterRingVrfKeyResponse), + RegisterRingVrfKeyResponse(Response), /// List registered ring-VRF keys. #[codec(index = 20)] - #[display("list_ring_vrf_keys")] - ListRingVrfKeysRequest(ListRingVrfKeysRequest), + ListRingVrfKeysRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. #[codec(index = 21)] - #[display("list_ring_vrf_keys_response")] - ListRingVrfKeysResponse(ListRingVrfKeysResponse), + ListRingVrfKeysResponse(Response), /// Sign bytes with a registered ring-VRF key. #[codec(index = 22)] - #[display("ring_vrf_sign")] - RingVrfSignRequest(RingVrfSignRequest), + RingVrfSignRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. #[codec(index = 23)] - #[display("ring_vrf_sign_response")] - RingVrfSignResponse(RingVrfSignResponse), + RingVrfSignResponse(Response), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs new file mode 100644 index 000000000..1f9fb98a4 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -0,0 +1,120 @@ +//! Typed SSO requests and their response variants. +//! +//! `#[sso_service]` generates each request's wire conversions from its handler +//! signature. Responses share the [`Response`] envelope; the request identifies +//! the response variant even when different operations have identical payload types. + +use core::fmt::Display; + +use truapi::latest::HostAccountSignVrfError; + +use super::messages::{RemoteMessage, RemoteMessageData, Response, RingVrfError, v1}; + +/// A request payload and the wire response selected by its handler declaration. +pub trait SsoRequest: Sized { + /// Method name used for tracing. + const NAME: &'static str; + /// The handler's result payload, without correlation metadata. + type Response; + /// Wrap into the request variant. + fn into_message(self) -> v1::RemoteMessage; + /// Wrap an envelope into this request's response variant. + fn response_into_message(response: Response) -> v1::RemoteMessage; + /// Unwrap this request's response variant; `None` for any other message. + fn response_from_message(message: v1::RemoteMessage) -> Option>; +} + +/// Failure payload that can express "no signing session". +pub trait SsoError: Display { + /// The signing host has no active session to serve the request with. + fn not_connected() -> Self; +} + +impl SsoError for String { + fn not_connected() -> Self { + "signing host session is not active".to_string() + } +} + +impl SsoError for RingVrfError { + fn not_connected() -> Self { + Self::Unknown { + reason: String::not_connected(), + } + } +} + +impl SsoError for HostAccountSignVrfError { + fn not_connected() -> Self { + Self::NotConnected + } +} + +/// Outcome code and reason recorded in the SSO transcript for one response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponseOutcome { + /// Stable outcome code such as `ok`, `error`, or `publish_failed`. + pub outcome: &'static str, + /// Single-line failure description, when any. + pub reason: Option, +} + +impl ResponseOutcome { + /// Classify a plain payload: `ok`, or `error` with the failure's reason. + pub fn from_payload(payload: &Result) -> Self { + match payload { + Ok(_) => Self { + outcome: "ok", + reason: None, + }, + Err(err) => Self { + outcome: "error", + reason: Some(err.to_string()), + }, + } + } +} + +impl RemoteMessage { + /// Service method name for requests; variant name for other messages. + pub(crate) fn name(&self) -> &'static str { + let RemoteMessageData::V1(message) = &self.data; + message.name() + } + + /// Outgoing request carrying `request` under `message_id`. + pub fn request(message_id: String, request: R) -> Self { + Self { + message_id, + data: RemoteMessageData::V1(request.into_message()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{ + CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, + SignRawWithLegacyAccountRequest, + }; + + #[test] + fn identical_payloads_keep_their_request_specific_response_variants() { + let response = Response { + responding_to: "m-1".to_string(), + payload: Ok(vec![7]), + }; + let transaction = CreateTransactionRequest::response_into_message(response.clone()); + assert_eq!(transaction.name(), "CreateTransactionResponse"); + assert_eq!( + CreateTransactionWithLegacyAccountRequest::response_from_message(transaction.clone()), + Some(response.clone()), + ); + assert!(SignRawWithLegacyAccountRequest::response_from_message(transaction).is_none()); + + let signature = SignRawWithLegacyAccountRequest::response_into_message(response); + assert_eq!(signature.name(), "SignRawWithLegacyAccountResponse"); + assert!(CreateTransactionRequest::response_from_message(signature).is_none()); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/transaction.rs b/rust/crates/truapi-server/src/host_logic/transaction.rs index 66552c0ca..8e4074452 100644 --- a/rust/crates/truapi-server/src/host_logic/transaction.rs +++ b/rust/crates/truapi-server/src/host_logic/transaction.rs @@ -155,7 +155,7 @@ mod tests { asset_id: None, metadata_hash: None, mode: None, - with_signed_transaction: None, + with_signed_transaction: parity_scale_codec::OptionBool(None), } } diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index ec240ab58..79b503084 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -3517,7 +3517,7 @@ mod tests { panic!("expected a product subtree response payload"); }; assert_eq!(payload.responding_to, "m9"); - assert!(payload.product_public_key.is_ok()); + assert!(payload.payload.is_ok()); } #[test] diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..bc7ba5dd3 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -27,6 +27,7 @@ mod signing_host; pub(crate) mod sso_pairing; /// SSO remote request/response messaging over the statement store. pub(crate) mod sso_remote; +pub(crate) mod sso_service; /// Native Statement Store and Bulletin allowance allocation. #[cfg(not(target_arch = "wasm32"))] pub mod statement_allowance; @@ -51,7 +52,7 @@ pub(crate) use services::RuntimeServices; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; pub(crate) use signing_host::{ - LocalActivation, SigningHost as SigningHostRole, answer_remote_message, establish_pairing, + LocalActivation, SigningHost as SigningHostRole, SigningHostSsoService, establish_pairing, respond_to_pairing, resume_pairing, }; pub use signing_host::{PairedSsoPeer, ResponderExit}; @@ -751,17 +752,7 @@ fn validate_vrf_transcript(request: &v01::HostAccountSignVrfRequest) -> Result<( } fn vrf_call_error(err: AuthorityError) -> CallError { - let error = match err { - AuthorityError::Disconnected => v01::HostAccountSignVrfError::NotConnected, - AuthorityError::Rejected => v01::HostAccountSignVrfError::Rejected, - AuthorityError::Cancelled(err) => v01::HostAccountSignVrfError::Unknown { - reason: err.to_string(), - }, - AuthorityError::Unavailable { reason } - | AuthorityError::NotSupported { reason } - | AuthorityError::Unknown { reason } => v01::HostAccountSignVrfError::Unknown { reason }, - }; - CallError::Domain(HostAccountSignVrfError::V1(error)) + CallError::Domain(HostAccountSignVrfError::V1(err.into())) } fn account_get_authority_error(err: AuthorityError) -> CallError { let error = match err { diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 302473ea3..256fa5501 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -3,25 +3,29 @@ //! 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. +//! Alias, proof, and ring-VRF operations reuse the request payloads in +//! `host_logic::sso::messages` for both local calls and SSO transport. use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ - AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, - HostAccountRingVrfSignResponse, HostCreateTransactionResponse, - HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, - HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, - HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, - ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, + AccountId, HostAccountCreateProofRequest, HostAccountCreateProofResponse, + HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountListRingVrfKeysRequest, + HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, + HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, + HostCreateTransactionResponse, HostRequestResourceAllocationRequest, + HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, + ProductAccountTxPayload, VrfSignature, }; -use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; use crate::host_logic::session::{SessionInfo, SessionState}; -use crate::host_logic::sso::messages::RingVrfError; +use crate::host_logic::sso::messages::{ProductRequest, RingVrfError}; use crate::host_logic::statement_store::statement_public_key_from_secret; /// Secret key allocated for Bulletin preimage submission. @@ -159,6 +163,21 @@ impl From for RingVrfError { } } +impl From for HostAccountSignVrfError { + fn from(err: AuthorityError) -> Self { + match err { + AuthorityError::Disconnected => Self::NotConnected, + AuthorityError::Rejected => Self::Rejected, + AuthorityError::Cancelled(err) => Self::Unknown { + reason: err.to_string(), + }, + AuthorityError::Unavailable { reason } + | AuthorityError::NotSupported { reason } + | AuthorityError::Unknown { reason } => Self::Unknown { reason }, + } + } +} + /// Cancellation cause for an account-authority call. #[derive(Debug, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] #[display( @@ -224,67 +243,6 @@ pub(crate) enum CreateTransactionAuthorityRequest { IdentityAccount(LegacyAccountTxPayload), } -/// Contextual-alias request forwarded to the account authority. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct AccountAliasAuthorityRequest { - /// Calling product, so the Account Holder can scope context derivation. - pub calling_product_id: String, - /// Explicit ring-VRF key handle. - pub key_handle: ProductAccountId, - /// Product-scoped context the derived alias is bound to. - pub context: ProductProofContext, - /// Ring the explicit key must be registered for. - pub ring_location: RingLocation, -} - -/// Ring-VRF proof request forwarded to the account authority. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct CreateProofAuthorityRequest { - /// Calling product, so the Account Holder can scope context derivation. - pub calling_product_id: String, - /// Explicit ring-VRF key handle. - pub key_handle: ProductAccountId, - /// Product-scoped context the derived alias is bound to. - pub context: ProductProofContext, - /// Ring the explicit key must be registered for. - pub ring_location: RingLocation, - /// Opaque message bound into the proof. - pub message: Vec, -} - -/// Ring-VRF key registration request forwarded to the account authority. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct RegisterRingVrfKeyAuthorityRequest { - /// Calling product that owns the key. - pub calling_product_id: String, - /// Key derivation index within the caller's ring-VRF domain. - pub index: truapi::v01::DerivationIndex, - /// Declared ring for the key. - pub ring: RingLocation, -} - -/// Ring-VRF key listing request forwarded to the account authority. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct ListRingVrfKeysAuthorityRequest { - /// Calling product requesting the list. - pub calling_product_id: String, - /// Owner product whose entries should be listed. - pub owner: String, - /// Disclosure requested by the caller. - pub disclosure: truapi::v01::RingVrfKeyDisclosure, -} - -/// Direct ring-VRF member-key signing request. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct RingVrfSignAuthorityRequest { - /// Calling product requesting the signature. - pub calling_product_id: String, - /// Registered key handle. - pub key_handle: ProductAccountId, - /// Message to sign. - pub message: Vec, -} - /// Statement-store allowance signing material held by the authority layer. #[derive(Clone, PartialEq, Eq)] pub(crate) struct StatementStoreAllowanceKey { @@ -418,7 +376,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: ProductRequest, ) -> Result; /// Create a ring-VRF proof bound to a context and message. @@ -429,7 +387,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: ProductRequest, ) -> Result; /// Register a ring-VRF key owned by the calling product. @@ -437,7 +395,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) -> Result; /// List registered ring-VRF keys. @@ -445,7 +403,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ProductRequest, ) -> Result; /// Sign bytes directly with a registered ring-VRF key. @@ -453,7 +411,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: ProductRequest, ) -> Result; /// Ask the account authority to allocate product-scoped resources. diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index 51c12c999..c60cb57fe 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -25,10 +25,7 @@ use truapi_platform::{ normalize_product_identifier, }; -use crate::runtime::authority::{ - AccountAliasAuthorityRequest, CreateProofAuthorityRequest, ListRingVrfKeysAuthorityRequest, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, -}; +use crate::host_logic::sso::messages::ProductRequest; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, remote_authority_call, remote_authority_context, ring_vrf_alias_error, ring_vrf_list_error, @@ -126,18 +123,15 @@ impl Account for ProductRuntimeHost { cx: &CallContext, request: HostAccountGetAliasRequest, ) -> Result> { - let HostAccountGetAliasRequest::V1(v01::HostAccountGetAliasRequest { - key_handle, - context, - ring_location, - }) = request; - let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { - CallError::Domain(HostAccountGetAliasError::V1( - v01::HostAccountGetAliasError::Unknown { - reason: "Invalid key handle".to_string(), - }, - )) - })?; + let HostAccountGetAliasRequest::V1(mut request) = request; + request.key_handle = + Self::normalize_product_account_id(request.key_handle).map_err(|()| { + CallError::Domain(HostAccountGetAliasError::V1( + v01::HostAccountGetAliasError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountGetAliasError::V1( v01::HostAccountGetAliasError::Rejected, @@ -151,11 +145,9 @@ impl Account for ProductRuntimeHost { self.authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + ProductRequest { calling_product_id, - key_handle, - context, - ring_location, + payload: request, }, ), ) @@ -170,20 +162,16 @@ impl Account for ProductRuntimeHost { cx: &CallContext, request: HostAccountCreateProofRequest, ) -> Result> { - let HostAccountCreateProofRequest::V1(v01::HostAccountCreateProofRequest { - key_handle, - context, - ring_location, - message, - }) = request; - let key_handle = Self::normalize_product_account_id(key_handle).map_err(|()| { - CallError::Domain(HostAccountCreateProofError::V1( - v01::HostAccountCreateProofError::Unknown { - reason: "Invalid key handle".to_string(), - }, - )) - })?; - if key_handle.dot_ns_identifier != self.product_id() { + let HostAccountCreateProofRequest::V1(mut request) = request; + request.key_handle = + Self::normalize_product_account_id(request.key_handle).map_err(|()| { + CallError::Domain(HostAccountCreateProofError::V1( + v01::HostAccountCreateProofError::Unknown { + reason: "Invalid key handle".to_string(), + }, + )) + })?; + if request.key_handle.dot_ns_identifier != self.product_id() { return Err(CallError::Domain(HostAccountCreateProofError::V1( v01::HostAccountCreateProofError::NotAllowlisted, ))); @@ -202,12 +190,9 @@ impl Account for ProductRuntimeHost { self.authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + ProductRequest { calling_product_id, - key_handle, - context, - ring_location, - message, + payload: request, }, ), ) @@ -225,10 +210,7 @@ impl Account for ProductRuntimeHost { request: HostAccountRegisterRingVrfKeyRequest, ) -> Result> { - let HostAccountRegisterRingVrfKeyRequest::V1(v01::HostAccountRegisterRingVrfKeyRequest { - index, - ring, - }) = request; + let HostAccountRegisterRingVrfKeyRequest::V1(request) = request; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountRegisterRingVrfKeyError::V1( v01::HostAccountRegisterRingVrfKeyError::NotConnected, @@ -241,10 +223,9 @@ impl Account for ProductRuntimeHost { self.authority.register_ring_vrf_key( &cx, &session, - RegisterRingVrfKeyAuthorityRequest { + ProductRequest { calling_product_id, - index, - ring, + payload: request, }, ), ) @@ -264,16 +245,13 @@ impl Account for ProductRuntimeHost { request: HostAccountListRingVrfKeysRequest, ) -> Result> { - let HostAccountListRingVrfKeysRequest::V1(v01::HostAccountListRingVrfKeysRequest { - owner, - disclosure, - }) = request; + let HostAccountListRingVrfKeysRequest::V1(mut request) = request; let Some(session) = self.authority.current_session() else { return Err(CallError::Domain(HostAccountListRingVrfKeysError::V1( v01::HostAccountListRingVrfKeysError::NotConnected, ))); }; - let owner = normalize_product_identifier(&owner).map_err(|err| { + request.owner = normalize_product_identifier(&request.owner).map_err(|err| { CallError::Domain(HostAccountListRingVrfKeysError::V1( v01::HostAccountListRingVrfKeysError::Unknown { reason: err.to_string(), @@ -287,10 +265,9 @@ impl Account for ProductRuntimeHost { self.authority.list_ring_vrf_keys( &cx, &session, - ListRingVrfKeysAuthorityRequest { + ProductRequest { calling_product_id, - owner, - disclosure, + payload: request, }, ), ) @@ -335,10 +312,9 @@ impl Account for ProductRuntimeHost { self.authority.ring_vrf_sign( &cx, &session, - RingVrfSignAuthorityRequest { + ProductRequest { calling_product_id, - key_handle: request.key_handle, - message: request.message, + payload: request, }, ), ) diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index df9453c1a..9af8733da 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -10,6 +10,10 @@ use std::collections::HashMap; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, Weak}; +use truapi::latest::{ + HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, +}; use futures::channel::oneshot; use parity_scale_codec::{Decode, Encode}; @@ -20,11 +24,10 @@ use zeroize::Zeroize; use super::allowances::{self, AllowanceCacheKey, AllowanceResource}; use super::auth_state::AuthStateMachine; use super::authority::{ - AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, AutoSigningKey, - BulletinAllowanceKey, CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, - RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, authority_session, require_current_session, + AuthorityError, AuthoritySession, AutoSigningKey, 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; @@ -43,7 +46,7 @@ use crate::host_logic::product_account::{ }; use crate::host_logic::session::{SessionInfo, SessionState, encode_persisted_session}; use crate::host_logic::session_store::SessionStoreChangeNotifier; -use crate::host_logic::sso::messages::RingVrfError; +use crate::host_logic::sso::messages::{ProductRequest, RingVrfError}; use crate::subscription::Spawner; use futures::StreamExt; @@ -62,59 +65,6 @@ use super::signing_host::ring_vrf::{ development_context_bytes, member_from_entropy, sign_from_entropy, }; -/// Distinguishes all remote authority request entrypoints by wire label. -#[derive(Clone, Copy, Debug, derive_more::Display)] -pub(super) enum AuthorityRequestKind { - /// `sign_payload` with a product account. - #[display("sign-payload")] - SignPayload, - /// `sign_raw` with a product account. - #[display("sign-raw")] - SignRaw, - /// `create_transaction` with a product account. - #[display("create-transaction")] - CreateTransaction, - /// `sign_payload` through the legacy-account API. - #[display("legacy-sign-payload")] - LegacySignPayload, - /// `sign_raw` through the legacy-account API. - #[display("legacy-sign-raw")] - LegacySignRaw, - /// `create_transaction` through the legacy-account API. - #[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 - } - CreateTransactionAuthorityRequest::IdentityAccount(_) => Self::LegacyCreateTransaction, - } - } -} - struct LoginInFlight { waiters: Vec>>, } @@ -2024,7 +1974,7 @@ impl PairingHost { fn mirror_ring_vrf_registration( &self, session: SessionInfo, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) { let weak_self = self.weak_self.clone(); (self.spawner)(Box::pin(async move { @@ -2136,21 +2086,23 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: ProductRequest, ) -> Result { let private_session = self.current_private_session(session)?; - if request.calling_product_id == request.key_handle.dot_ns_identifier + if request.calling_product_id == request.payload.key_handle.dot_ns_identifier && let Some(entropy) = self .local_ring_vrf_entropy_for_ring( &private_session, - &request.key_handle, - &request.ring_location, + &request.payload.key_handle, + &request.payload.ring_location, ) .await? { - self.ring_resolver.validate(&request.ring_location).await?; + self.ring_resolver + .validate(&request.payload.ring_location) + .await?; self.current_private_session(session)?; - let context = development_context_bytes(&request.context); + let context = development_context_bytes(&request.payload.context); let alias = alias_from_entropy(&entropy, &context)?; return Ok(v01::ContextualAlias { context, @@ -2165,26 +2117,30 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: ProductRequest, ) -> Result { - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.payload.key_handle)?; let private_session = self.current_private_session(session)?; if let Some(entropy) = self .local_ring_vrf_entropy_for_ring( &private_session, - &request.key_handle, - &request.ring_location, + &request.payload.key_handle, + &request.payload.ring_location, ) .await? { let member = member_from_entropy(&entropy)?; let resolved = self .ring_resolver - .resolve(&request.ring_location, &[MemberCandidate { member }]) + .resolve( + &request.payload.ring_location, + &[MemberCandidate { member }], + ) .await?; self.current_private_session(session)?; - let context = development_context_bytes(&request.context); - let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + let context = development_context_bytes(&request.payload.context); + let (proof, alias) = + create_proof(&entropy, &resolved, &context, &request.payload.message)?; return Ok(v01::HostAccountCreateProofResponse { proof, contextual_alias: v01::ContextualAlias { @@ -2203,7 +2159,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) -> Result { let private_session = self.current_private_session(session)?; let handle = v01::ProductAccountId { @@ -2212,25 +2168,25 @@ impl PairingHost { reason: error.to_string(), }, )?, - derivation_index: request.index.clone(), + derivation_index: request.payload.index.clone(), }; if let Some(auto_signing) = self .auto_signing_key(&private_session, &request.calling_product_id) .await .map_err(RingVrfError::from)? { - self.ring_resolver.validate(&request.ring).await?; + self.ring_resolver.validate(&request.payload.ring).await?; self.current_private_session(session)?; let entropy = Zeroizing::new(derive_ring_vrf_entropy_from_domain( auto_signing.ring_vrf_domain_entropy(), - &request.index, + &request.payload.index, )); let public_key = member_from_entropy(&entropy)?; self.ring_vrf_registry .register( private_session.public_key, handle, - request.ring.clone(), + request.payload.ring.clone(), public_key, ) .await?; @@ -2242,7 +2198,12 @@ impl PairingHost { .remote_register_ring_vrf_key(cx, &private_session, request.clone()) .await?; self.ring_vrf_registry - .register(private_session.public_key, handle, request.ring, public_key) + .register( + private_session.public_key, + handle, + request.payload.ring, + public_key, + ) .await?; self.current_private_session(session)?; Ok(public_key) @@ -2252,10 +2213,10 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { let private_session = self.current_private_session(session)?; - let owner = normalize_product_identifier(&request.owner).map_err(|error| { + let owner = normalize_product_identifier(&request.payload.owner).map_err(|error| { RingVrfError::Unknown { reason: error.to_string(), } @@ -2267,13 +2228,13 @@ impl PairingHost { .await? { self.current_private_session(session)?; - apply_ring_vrf_disclosure(&mut entries, request.disclosure); + apply_ring_vrf_disclosure(&mut entries, request.payload.disclosure); return Ok(entries); } - let requested_disclosure = request.disclosure; + let requested_disclosure = request.payload.disclosure; let mut remote_request = request; if remote_request.calling_product_id == owner { - remote_request.disclosure = v01::RingVrfKeyDisclosure::PublicKey; + remote_request.payload.disclosure = v01::RingVrfKeyDisclosure::PublicKey; } let mut entries = self .remote_list_ring_vrf_keys(cx, &private_session, remote_request) @@ -2294,16 +2255,16 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.payload.key_handle)?; let private_session = self.current_private_session(session)?; if let Some(entropy) = self - .local_ring_vrf_entropy(&private_session, &request.key_handle) + .local_ring_vrf_entropy(&private_session, &request.payload.key_handle) .await? { self.current_private_session(session)?; - return sign_from_entropy(&entropy, &request.message); + return sign_from_entropy(&entropy, &request.payload.message); } self.remote_ring_vrf_sign(cx, &private_session, request) .await @@ -2516,7 +2477,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: ProductRequest, ) -> Result { PairingHost::account_alias(self, cx, session, request).await } @@ -2525,7 +2486,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: ProductRequest, ) -> Result { PairingHost::create_proof(self, cx, session, request).await } @@ -2534,7 +2495,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) -> Result { PairingHost::register_ring_vrf_key(self, cx, session, request).await } @@ -2543,7 +2504,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { PairingHost::list_ring_vrf_keys(self, cx, session, request).await } @@ -2552,7 +2513,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { PairingHost::ring_vrf_sign(self, cx, session, request).await } diff --git a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index a19a7084b..b7bc2d386 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs @@ -1,73 +1,32 @@ //! SSO statement-store channel to the paired remote signing host. use super::super::authority::{ - AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, BulletinAllowanceKey, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, RegisterRingVrfKeyAuthorityRequest, - RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, - StatementStoreAllowanceKey, + 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, + SsoRemoteResponseError, SsoSessionKey, fresh_statement_expiry, reply_matcher, 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, RingVrfError, - SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, SsoSessionStatement, - alias_request_message, build_outgoing_request_statement, create_transaction_legacy_message, - create_transaction_message, decode_sso_session_statement, list_ring_vrf_keys_message, - product_subtree_request_message, proof_request_message, register_ring_vrf_key_message, - resource_allocation_message, ring_vrf_sign_message, sign_payload_message, - sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, + CreateTransactionLegacyPayload, CreateTransactionPayload, CreateTransactionRequest, + CreateTransactionWithLegacyAccountRequest, OnExistingAllowancePolicy, ProductRequest, + ProductSubtreeRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, + RingVrfError, SignRawWithLegacyAccountRequest, SignRequest, SsoAllocatedResource, + SsoAllocationOutcome, SsoSessionStatement, build_outgoing_request_statement, + decode_sso_session_statement, v1, }; +use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; use tracing::{debug, instrument, warn}; -use truapi::{CallContext, latest, v01}; - -const UNEXPECTED_SSO_SIGNING_RESPONSE: &str = "Unexpected SSO response for signing request"; -const UNEXPECTED_SSO_TRANSACTION_RESPONSE: &str = "Unexpected SSO response for transaction request"; -const UNEXPECTED_SSO_ALIAS_RESPONSE: &str = "Unexpected SSO response for account alias request"; -const UNEXPECTED_SSO_PROOF_RESPONSE: &str = "Unexpected SSO response for ring-VRF proof request"; -const UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE: &str = - "Unexpected SSO response for ring-VRF key registration request"; -const UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE: &str = - "Unexpected SSO response for ring-VRF key listing request"; -const UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE: &str = - "Unexpected SSO response for ring-VRF signing request"; - -fn unexpected_response_reason(context: &str, response_kind: &str) -> String { - format!("{context}: {response_kind}") -} - -#[derive(Clone, Copy, Debug, derive_more::Display)] -enum RemoteAction { - #[display("{_0}")] - Signing(AuthorityRequestKind), - #[display("account-alias")] - RingVrfAlias, - #[display("ring-vrf-proof")] - RingVrfProof, - #[display("register-ring-vrf-key")] - RegisterRingVrfKey, - #[display("list-ring-vrf-keys")] - ListRingVrfKeys, - #[display("ring-vrf-sign")] - RingVrfSign, - #[display("sign-vrf")] - SignVrf, - #[display("resource-allocation")] - ResourceAllocation, - #[display("product-subtree")] - ProductSubtree, -} +use truapi::{CallContext, latest}; /// Active peer-disconnect watcher for one SSO session; aborts on drop. pub(super) struct SsoDisconnectMonitor { @@ -82,32 +41,6 @@ impl Drop for SsoDisconnectMonitor { } 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 response_kind = response.kind(); - let SsoRemoteResponse::Sign(response) = response else { - return Err(SsoRemoteResponseError::Failure(unexpected_response_reason( - UNEXPECTED_SSO_SIGNING_RESPONSE, - response_kind, - ))); - }; - 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() @@ -207,15 +140,17 @@ impl PairingHost { 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( + /// Send `request` to the paired signing host and await its typed answer. + /// + /// The outer error is the transport's; the inner result is the peer's + /// payload for this request type. + #[instrument(skip_all, fields(runtime.method = "sso.remote_message.submit", action = R::NAME))] + async fn call( &self, cx: &CallContext, session: &SessionInfo, - action: RemoteAction, - message: RemoteMessage, - ) -> Result { + request: R, + ) -> Result { let sso = session .sso .as_ref() @@ -225,11 +160,11 @@ impl PairingHost { if !session_matches_key(&self.session_state, key) { return Err(SsoRemoteResponseError::LocalDisconnected); } - let message_id = message.message_id.clone(); + let message_id = sso_message_id(); let statement = build_outgoing_request_statement( sso, message_id.clone(), - vec![message], + vec![RemoteMessage::request(message_id.clone(), request)], fresh_statement_expiry(), ) .map_err(SsoRemoteResponseError::Failure)?; @@ -265,18 +200,21 @@ impl PairingHost { }) } .boxed(); - let action = action.to_string(); + let action = R::NAME; 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), - }) + 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), + }, + reply_matcher::(&message_id), + ) .await; let result = result.map_err(|reason| match reason { SsoRemoteResponseError::Cancelled(err) if !cx.request_id().is_empty() => { @@ -291,7 +229,7 @@ impl PairingHost { if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { self.handle_signing_host_disconnected(key).await; } - result + result.map(|response| response.payload) } /// Resolve a product's hard-subtree public key, asking the Account Holder @@ -308,24 +246,10 @@ impl PairingHost { if let Some(public_key) = self.known_product_subtree(session, cache_key.clone()).await { return Ok(public_key); } - - let message_id = sso_message_id(); - let message = product_subtree_request_message(message_id, product_id); - let response = self - .submit_remote_message(cx, session, RemoteAction::ProductSubtree, message) + let public_key = self + .call(cx, session, ProductSubtreeRequest { product_id }) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::ProductSubtree(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for product subtree request", - response_kind, - ), - }); - }; - let public_key = response - .product_public_key + .map_err(remote_authority_error)? .map_err(remote_authority_error)?; if !self .persist_product_subtree_if_current(session, lifecycle_epoch, cache_key, public_key) @@ -342,39 +266,38 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, calling_product_id: String, - request: v01::HostAccountSignVrfRequest, - ) -> Result { - let message_id = sso_message_id(); - let message = sign_vrf_message(message_id, calling_product_id, request); - let response = self - .submit_remote_message(cx, session, RemoteAction::SignVrf, message) - .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::SignVrf(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for VRF signing request", - response_kind, - ), - }); - }; - response.payload.map_err(|err| match err { - v01::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, - v01::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, - v01::HostAccountSignVrfError::Unknown { reason } => AuthorityError::Unknown { reason }, + request: latest::HostAccountSignVrfRequest, + ) -> Result { + self.call( + cx, + session, + ProductRequest { + calling_product_id, + payload: request, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(|err| match err { + latest::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, + latest::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, + latest::HostAccountSignVrfError::Unknown { reason } => { + AuthorityError::Unknown { reason } + } }) } /// Forward a payload-signing request to the paired signing host. + #[instrument(skip_all, fields(account_kind = match &request { + SignPayloadAuthorityRequest::Product(_) => "product", + SignPayloadAuthorityRequest::LegacyAccount { .. } => "legacy", + }))] 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 { @@ -385,101 +308,105 @@ impl PairingHost { payload: request.payload, }, }; - let message = sign_payload_message(message_id, request); - self.submit_sign_request(cx, session, action, message) + self.call(cx, session, SignRequest::Payload(Box::new(request))) .await + .map_err(remote_authority_error)? .map_err(remote_authority_error) } /// Forward a raw-signing request to the paired signing host. + #[instrument(skip_all, fields(account_kind = match &request { + SignRawAuthorityRequest::Product(_) => "product", + SignRawAuthorityRequest::LegacyAccount { .. } => "legacy", + }))] 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 (message, expects_legacy_response) = match request { - SignRawAuthorityRequest::Product(request) => { - (sign_raw_message(message_id, request), false) - } - SignRawAuthorityRequest::LegacyAccount { account, request } => ( - sign_raw_legacy_message(message_id, account, request.payload), - true, - ), - }; - let response = self - .submit_remote_message(cx, session, RemoteAction::Signing(action), message) - .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - match (expects_legacy_response, response) { - (false, SsoRemoteResponse::Sign(response)) => response - .payload - .map(|payload| latest::HostSignPayloadResponse { - signature: payload.signature, - signed_transaction: payload.signed_transaction, - }) + match request { + SignRawAuthorityRequest::Product(request) => self + .call(cx, session, SignRequest::Raw(request)) + .await + .map_err(remote_authority_error)? .map_err(remote_authority_error), - (true, SsoRemoteResponse::SignRawLegacy(response)) => response - .signature - .map(|signature| latest::HostSignPayloadResponse { + SignRawAuthorityRequest::LegacyAccount { account, request } => { + let signature = self + .call( + cx, + session, + SignRawWithLegacyAccountRequest { + account, + data: request.payload, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error)?; + Ok(latest::HostSignPayloadResponse { signature, signed_transaction: None, }) - .map_err(remote_authority_error), - _ => Err(AuthorityError::Unknown { - reason: unexpected_response_reason(UNEXPECTED_SSO_SIGNING_RESPONSE, response_kind), - }), + } } } /// Forward a transaction-creation request to the paired signing host. + #[instrument(skip_all, fields(account_kind = match &request { + CreateTransactionAuthorityRequest::Product(_) => "product", + CreateTransactionAuthorityRequest::LegacyAccount { .. } => "legacy", + CreateTransactionAuthorityRequest::IdentityAccount(_) => "identity", + }))] 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 message = match request { - CreateTransactionAuthorityRequest::Product(request) => { - create_transaction_message(message_id, request) + let signed = match request { + CreateTransactionAuthorityRequest::Product(payload) => { + self.call( + cx, + session, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(payload), + }, + ) + .await } CreateTransactionAuthorityRequest::LegacyAccount { product_account, request, - } => create_transaction_message( - message_id, - 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, - }, - ), - CreateTransactionAuthorityRequest::IdentityAccount(request) => { - create_transaction_legacy_message(message_id, request) + } => { + self.call( + cx, + session, + CreateTransactionRequest { + payload: CreateTransactionPayload::V1(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, + }), + }, + ) + .await + } + CreateTransactionAuthorityRequest::IdentityAccount(payload) => { + self.call( + cx, + session, + CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(payload), + }, + ) + .await } }; - let response = self - .submit_remote_message(cx, session, RemoteAction::Signing(action), message) - .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::CreateTransaction(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - UNEXPECTED_SSO_TRANSACTION_RESPONSE, - response_kind, - ), - }); - }; - response - .signed_transaction + signed + .map_err(remote_authority_error)? .map(|transaction| latest::HostCreateTransactionResponse { transaction }) .map_err(remote_authority_error) } @@ -489,27 +416,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: AccountAliasAuthorityRequest, + request: ProductRequest, ) -> Result { - let message_id = sso_message_id(); - let message = alias_request_message( - message_id, - request.calling_product_id, - request.key_handle, - request.context, - request.ring_location, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::RingVrfAlias, message) + self.call(cx, session, request) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::RingVrfAlias(response) = response else { - return Err(RingVrfError::Unknown { - reason: unexpected_response_reason(UNEXPECTED_SSO_ALIAS_RESPONSE, response_kind), - }); - }; - response.payload + .map_err(ring_vrf_transport_error)? } /// Forward a ring-VRF proof request to the paired signing host. @@ -517,28 +428,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: CreateProofAuthorityRequest, + request: ProductRequest, ) -> Result { - let message_id = sso_message_id(); - let message = proof_request_message( - message_id, - request.calling_product_id, - request.key_handle, - request.context, - request.ring_location, - request.message, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::RingVrfProof, message) + self.call(cx, session, request) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::RingVrfProof(response) = response else { - return Err(RingVrfError::Unknown { - reason: unexpected_response_reason(UNEXPECTED_SSO_PROOF_RESPONSE, response_kind), - }); - }; - response.payload + .map_err(ring_vrf_transport_error)? } /// Forward a ring-VRF key registration request to the paired signing host. @@ -546,29 +440,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) -> Result { - let message_id = sso_message_id(); - let message = register_ring_vrf_key_message( - message_id, - request.calling_product_id, - request.index, - request.ring, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::RegisterRingVrfKey, message) + self.call(cx, session, request) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::RegisterRingVrfKey(response) = response else { - return Err(RingVrfError::Unknown { - reason: unexpected_response_reason( - UNEXPECTED_SSO_REGISTER_RING_VRF_KEY_RESPONSE, - response_kind, - ), - }); - }; - response.payload + .map_err(ring_vrf_transport_error)? } /// Forward a ring-VRF key listing request to the paired signing host. @@ -576,29 +452,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: ListRingVrfKeysAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { - let message_id = sso_message_id(); - let message = list_ring_vrf_keys_message( - message_id, - request.calling_product_id, - request.owner, - request.disclosure, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::ListRingVrfKeys, message) + self.call(cx, session, request) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::ListRingVrfKeys(response) = response else { - return Err(RingVrfError::Unknown { - reason: unexpected_response_reason( - UNEXPECTED_SSO_LIST_RING_VRF_KEYS_RESPONSE, - response_kind, - ), - }); - }; - response.payload + .map_err(ring_vrf_transport_error)? } /// Forward a direct ring-VRF signing request to the paired signing host. @@ -606,29 +464,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RingVrfSignAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { - let message_id = sso_message_id(); - let message = ring_vrf_sign_message( - message_id, - request.calling_product_id, - request.key_handle, - request.message, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::RingVrfSign, message) + self.call(cx, session, request) .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::RingVrfSign(response) = response else { - return Err(RingVrfError::Unknown { - reason: unexpected_response_reason( - UNEXPECTED_SSO_RING_VRF_SIGN_RESPONSE, - response_kind, - ), - }); - }; - response.payload + .map_err(ring_vrf_transport_error)? } /// Ask the paired signing host to allocate product resources, caching any @@ -641,27 +481,19 @@ impl PairingHost { request: latest::HostRequestResourceAllocationRequest, ) -> Result { let lifecycle_epoch = self.current_session_lifecycle_epoch(); - 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) + let outcomes = self + .call( + cx, + session, + ResourceAllocationRequest { + calling_product_id: product_id.clone(), + resources: request.resources, + on_existing: OnExistingAllowancePolicy::Increase, + }, + ) .await + .map_err(remote_authority_error)? .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::ResourceAllocation(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for resource allocation request", - response_kind, - ), - }); - }; - let outcomes = response.payload.map_err(remote_authority_error)?; self.cache_allowance_outcomes(cx, session, lifecycle_epoch, &product_id, &outcomes) .await?; Ok(latest::HostRequestResourceAllocationResponse { @@ -669,6 +501,41 @@ impl PairingHost { }) } + /// Allocate exactly one allowance for the product and return its material. + async fn remote_allowance_slot( + &self, + cx: &CallContext, + session: &SessionInfo, + product_id: &str, + resource: latest::AllocatableResource, + on_existing: OnExistingAllowancePolicy, + ) -> Result { + let name = allowance_name(&resource); + let outcomes = self + .call( + cx, + session, + ResourceAllocationRequest { + calling_product_id: product_id.to_string(), + resources: vec![resource], + on_existing, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error)?; + match outcomes.into_iter().next() { + Some(SsoAllocationOutcome::Allocated(resource)) => Ok(resource), + Some(SsoAllocationOutcome::Rejected) => Err(AuthorityError::Rejected), + Some(SsoAllocationOutcome::NotAvailable) => Err(AuthorityError::Unavailable { + reason: format!("{name} is not available"), + }), + None => Err(AuthorityError::Unknown { + reason: format!("Empty {name} response"), + }), + } + } + /// Statement-store allowance key for the product, served from the cache /// or allocated by the paired signing host. pub(super) async fn remote_statement_store_allowance_key( @@ -684,38 +551,17 @@ impl PairingHost { { 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 response_kind = response.kind(); - let SsoRemoteResponse::ResourceAllocation(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for statement-store allowance request", - response_kind, - ), - }); - }; - 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, - }) => { + match self + .remote_allowance_slot( + cx, + session, + &product_id, + latest::AllocatableResource::StatementStoreAllowance, + OnExistingAllowancePolicy::Ignore, + ) + .await? + { + SsoAllocatedResource::StatementStoreAllowance { slot_account_key } => { self.cache_statement_store_allowance_key( session, lifecycle_epoch, @@ -724,16 +570,7 @@ impl PairingHost { ) .await } - SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected statement-store allowance response resource", - other.kind(), - ), - }), - SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), - SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { - reason: "statement-store allowance is not available".to_string(), - }), + other => Err(unexpected_resource("statement-store allowance", &other)), } } @@ -752,61 +589,18 @@ impl PairingHost { { return Ok(cached); } - - let message_id = sso_message_id(); - let message = resource_allocation_message( - message_id, - product_id.clone(), - vec![latest::AllocatableResource::BulletinAllowance], + self.allocate_bulletin_allowance_key( + cx, + session, + lifecycle_epoch, + product_id, OnExistingAllowancePolicy::Ignore, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) - .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::ResourceAllocation(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for bulletin allowance request", - response_kind, - ), - }); - }; - 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, - lifecycle_epoch, - &product_id, - slot_account_key, - ) - .await - } - SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected bulletin allowance response resource", - other.kind(), - ), - }), - SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), - SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { - reason: "bulletin allowance is not available".to_string(), - }), - } + ) + .await } /// Evict the cached Bulletin allowance key and allocate a fresh one with - /// an increased allowance. + /// an increased allowance, so a stale or exhausted slot is never reused. pub(super) async fn remote_refresh_bulletin_allowance_key( &self, cx: &CallContext, @@ -814,43 +608,37 @@ impl PairingHost { product_id: String, ) -> Result { let lifecycle_epoch = self.current_session_lifecycle_epoch(); - // Drop the cached (and persisted) key so a stale/exhausted slot is not - // reused, then request a fresh allocation with `Increase` so the - // wallet grants a new allowance rather than echoing the old slot. self.evict_bulletin_allowance_key(session, lifecycle_epoch, &product_id) .await?; - - let message_id = sso_message_id(); - let message = resource_allocation_message( - message_id, - product_id.clone(), - vec![latest::AllocatableResource::BulletinAllowance], + self.allocate_bulletin_allowance_key( + cx, + session, + lifecycle_epoch, + product_id, OnExistingAllowancePolicy::Increase, - ); - let response = self - .submit_remote_message(cx, session, RemoteAction::ResourceAllocation, message) - .await - .map_err(remote_authority_error)?; - let response_kind = response.kind(); - let SsoRemoteResponse::ResourceAllocation(response) = response else { - return Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected SSO response for bulletin allowance refresh", - response_kind, - ), - }); - }; - 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 refresh response".to_string(), - })?; - match outcome { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key, - }) => { + ) + .await + } + + async fn allocate_bulletin_allowance_key( + &self, + cx: &CallContext, + session: &SessionInfo, + lifecycle_epoch: u64, + product_id: String, + on_existing: OnExistingAllowancePolicy, + ) -> Result { + match self + .remote_allowance_slot( + cx, + session, + &product_id, + latest::AllocatableResource::BulletinAllowance, + on_existing, + ) + .await? + { + SsoAllocatedResource::BulletinAllowance { slot_account_key } => { self.cache_bulletin_allowance_key( session, lifecycle_epoch, @@ -859,16 +647,7 @@ impl PairingHost { ) .await } - SsoAllocationOutcome::Allocated(other) => Err(AuthorityError::Unknown { - reason: unexpected_response_reason( - "Unexpected bulletin allowance refresh resource", - other.kind(), - ), - }), - SsoAllocationOutcome::Rejected => Err(AuthorityError::Rejected), - SsoAllocationOutcome::NotAvailable => Err(AuthorityError::Unavailable { - reason: "bulletin allowance is not available".to_string(), - }), + other => Err(unexpected_resource("bulletin allowance", &other)), } } @@ -936,6 +715,26 @@ pub(super) fn session_matches_key(session_state: &SessionState, key: SsoSessionK }) } +fn ring_vrf_transport_error(reason: SsoRemoteResponseError) -> RingVrfError { + remote_authority_error(reason).into() +} + +fn allowance_name(resource: &latest::AllocatableResource) -> &'static str { + match resource { + latest::AllocatableResource::StatementStoreAllowance => "statement-store allowance", + latest::AllocatableResource::BulletinAllowance => "bulletin allowance", + _ => "resource", + } +} + +/// Reason for an allocation that came back as a different resource kind; names +/// only the kind so no key material reaches logs. +fn unexpected_resource(label: &str, resource: &SsoAllocatedResource) -> AuthorityError { + AuthorityError::Unknown { + reason: format!("Unexpected {label} response resource: {}", resource.kind()), + } +} + fn remote_authority_error(reason: impl Into) -> AuthorityError { match reason.into() { SsoRemoteResponseError::Cancelled(err) => AuthorityError::Cancelled( @@ -972,16 +771,18 @@ async fn wait_for_sso_peer_disconnect( 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(()); + let Some(SsoSessionStatement::RemoteMessages(messages)) = decode_sso_session_statement( + &session, + &statement, + "truapi:sso-peer-disconnect-monitor", + )? + else { + continue; + }; + for message in messages { + if message? == v1::RemoteMessage::Disconnected { + return Ok(()); + } } } } @@ -1001,35 +802,24 @@ impl From for latest::AllocationOutcome { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::ResourceAllocationResponse; #[test] - fn unexpected_response_reasons_include_only_safe_discriminants() { - let private_key = [0xA5; 64]; + fn unexpected_resource_reasons_include_only_safe_discriminants() { let resource = SsoAllocatedResource::AutoSigning { - product_root_private_key: private_key, + product_root_private_key: [0xA5; 64], ring_vrf_domain_entropy: [0x5A; 32], }; - let resource_reason = unexpected_response_reason( - "Unexpected statement-store allowance response resource", - resource.kind(), - ); - assert_eq!( - resource_reason, - "Unexpected statement-store allowance response resource: auto-signing" - ); - assert!(!resource_reason.contains("165, 165")); - let response = SsoRemoteResponse::ResourceAllocation(ResourceAllocationResponse { - responding_to: "secret-test".to_string(), - payload: Ok(vec![SsoAllocationOutcome::Allocated(resource)]), - }); - let response_reason = - unexpected_response_reason(UNEXPECTED_SSO_SIGNING_RESPONSE, response.kind()); + let AuthorityError::Unknown { reason } = + unexpected_resource("statement-store allowance", &resource) + else { + panic!("expected an unknown authority error"); + }; + assert_eq!( - response_reason, - "Unexpected SSO response for signing request: resource-allocation" + reason, + "Unexpected statement-store allowance response resource: auto-signing" ); - assert!(!response_reason.contains("165, 165")); + assert!(!reason.contains("165, 165")); } } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c61543307..ece69f522 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -18,9 +18,14 @@ mod local_activation; pub(super) mod ring_vrf; mod sso_replay; mod sso_responder; +mod sso_service; use std::collections::HashSet; use std::sync::{Arc, Mutex}; +use truapi::latest::{ + HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, +}; use parity_scale_codec::Encode; use subxt::utils::{AccountId32, MultiSignature}; @@ -29,15 +34,12 @@ use subxt::utils::{AccountId32, MultiSignature}; pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::{PairedSsoPeer, ResponderExit}; -pub(crate) use sso_responder::{ - answer_remote_message, establish_pairing, respond_to_pairing, resume_pairing, -}; +pub(crate) use sso_responder::{establish_pairing, respond_to_pairing, resume_pairing}; +pub(crate) use sso_service::SigningHostSsoService; use super::authority::{ - AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - ListRingVrfKeysAuthorityRequest, ProductAuthority, RegisterRingVrfKeyAuthorityRequest, - RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, + AuthorityError, AuthoritySession, BulletinAllowanceKey, CreateTransactionAuthorityRequest, + ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session_validation_id, }; use super::ring_vrf_registry::RingVrfRegistryStore; @@ -57,7 +59,7 @@ use crate::host_logic::product_account::{ derive_full_person_ring_vrf_entropy, derive_lite_person_ring_vrf_entropy, }; use crate::host_logic::session::{SessionInfo, SessionState}; -use crate::host_logic::sso::messages::{OnExistingAllowancePolicy, RingVrfError}; +use crate::host_logic::sso::messages::{OnExistingAllowancePolicy, ProductRequest, RingVrfError}; use crate::host_logic::transaction::{extrinsic_payload_extensions, extrinsic_payload_preimage}; use crate::runtime::auth_state::AuthStateMachine; #[cfg(not(target_arch = "wasm32"))] @@ -843,13 +845,13 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: ProductRequest, ) -> Result { self.require_current_session(session)?; match super::account_access_authorization( self.services.platform.as_ref(), &request.calling_product_id, - &request.key_handle.dot_ns_identifier, + &request.payload.key_handle.dot_ns_identifier, ) .await { @@ -865,10 +867,16 @@ impl ProductAuthority for SigningHost { } } let entropy = self - .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) + .resolve_ring_vrf_key_for_ring( + session, + &request.payload.key_handle, + &request.payload.ring_location, + ) .await?; - self.ring_resolver.validate(&request.ring_location).await?; - let context = development_context_bytes(&request.context); + self.ring_resolver + .validate(&request.payload.ring_location) + .await?; + let context = development_context_bytes(&request.payload.context); let alias = alias_from_entropy(&entropy, &context)?; Ok(v01::ContextualAlias { context, @@ -880,23 +888,27 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: ProductRequest, ) -> Result { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.payload.key_handle)?; let entropy = self - .resolve_ring_vrf_key_for_ring(session, &request.key_handle, &request.ring_location) + .resolve_ring_vrf_key_for_ring( + session, + &request.payload.key_handle, + &request.payload.ring_location, + ) .await?; let candidate = self.ring_vrf_member_candidate(&entropy)?; let resolved = self .ring_resolver - .resolve(&request.ring_location, &[candidate]) + .resolve(&request.payload.ring_location, &[candidate]) .await?; // Reject a stale request if the local session disconnected or changed // while its chain snapshot was being resolved. self.require_current_session(session)?; - let context = development_context_bytes(&request.context); - let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + let context = development_context_bytes(&request.payload.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.payload.message)?; Ok(v01::HostAccountCreateProofResponse { proof, contextual_alias: v01::ContextualAlias { @@ -912,10 +924,10 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: ProductRequest, ) -> Result { self.require_current_session(session)?; - self.ring_resolver.validate(&request.ring).await?; + self.ring_resolver.validate(&request.payload.ring).await?; let handle = v01::ProductAccountId { dot_ns_identifier: normalize_product_identifier(&request.calling_product_id).map_err( @@ -923,12 +935,12 @@ impl ProductAuthority for SigningHost { reason: err.to_string(), }, )?, - derivation_index: request.index, + derivation_index: request.payload.index, }; let entropy = self.ring_vrf_entropy(session, &handle)?; let public_key = member_from_entropy(&entropy)?; self.ring_vrf_registry - .register(session.public_key, handle, request.ring, public_key) + .register(session.public_key, handle, request.payload.ring, public_key) .await?; Ok(public_key) } @@ -937,13 +949,14 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; - let owner = - normalize_product_identifier(&request.owner).map_err(|err| RingVrfError::Unknown { + let owner = normalize_product_identifier(&request.payload.owner).map_err(|err| { + RingVrfError::Unknown { reason: err.to_string(), - })?; + } + })?; if request.calling_product_id != owner { match super::account_access_authorization( self.services.platform.as_ref(), @@ -969,7 +982,7 @@ impl ProductAuthority for SigningHost { .ring_vrf_registry .owner_entries(session.public_key, &owner) .await?; - if request.disclosure == v01::RingVrfKeyDisclosure::Anonymized { + if request.payload.disclosure == v01::RingVrfKeyDisclosure::Anonymized { for entry in &mut entries { entry.public_key = None; } @@ -981,14 +994,14 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: ProductRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; - Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; + Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.payload.key_handle)?; let entropy = self - .resolve_registered_ring_vrf_key(session, &request.key_handle) + .resolve_registered_ring_vrf_key(session, &request.payload.key_handle) .await?; - sign_from_entropy(&entropy, &request.message) + sign_from_entropy(&entropy, &request.payload.message) } async fn allocate_resources( @@ -1006,6 +1019,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_statement_store_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1016,6 +1030,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1026,6 +1041,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_smart_contract_allowance( &self.services, self, + session, &product_id, index, OnExistingAllowancePolicy::Increase, @@ -1059,6 +1075,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_statement_store_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Ignore, ) @@ -1077,6 +1094,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Ignore, ) @@ -1095,6 +1113,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1161,7 +1180,7 @@ fn sign_extrinsic_payload( .sign_simple(SR25519_SIGNING_CONTEXT, &preimage, &keypair.public) .to_bytes(); let signature = MultiSignature::Sr25519(raw_signature); - let signed_transaction = payload.with_signed_transaction.unwrap_or(false).then(|| { + let signed_transaction = payload.with_signed_transaction.0.unwrap_or(false).then(|| { let extensions = extrinsic_payload_extensions(&payload) .expect("preimage construction already validated signed extensions"); build_signed_extrinsic_v4_with_signature( @@ -1287,9 +1306,7 @@ mod tests { use std::sync::Arc; use super::super::authority::{ - AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, - CreateProofAuthorityRequest, CreateTransactionAuthorityRequest, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; @@ -1304,12 +1321,17 @@ mod tests { derive_identity_keypair, derive_product_keypair, derive_ring_vrf_entropy, derive_root_keypair_from_entropy, index_bytes, }; + use crate::host_logic::sso::messages::ProductRequest; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, }; use crate::runtime::statement_allowance::collection::PersonhoodCollection; use crate::test_support::{StubPlatform, test_spawner}; use truapi::api::{Account, Entropy, ResourceAllocation, Signing}; + use truapi::latest::{ + HostAccountCreateProofRequest, HostAccountGetAliasRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, + }; use truapi::versioned::account::{HostAccountGetError, HostAccountGetRequest}; use truapi::versioned::entropy::HostDeriveEntropyRequest; use truapi::versioned::resource_allocation::{ @@ -1470,10 +1492,12 @@ mod tests { futures::executor::block_on(authority.register_ring_vrf_key( &CallContext::default(), session, - RegisterRingVrfKeyAuthorityRequest { + ProductRequest { calling_product_id: "peopl.dot".to_string(), - index: v01::DerivationIndex::Index(0), - ring: ring.clone(), + payload: HostAccountRegisterRingVrfKeyRequest { + index: v01::DerivationIndex::Index(0), + ring: ring.clone(), + }, }, )) .expect("full person key registration succeeds"); @@ -1580,23 +1604,27 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + ProductRequest { calling_product_id: "peopl.dot".to_string(), - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring_location.clone(), + payload: HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring_location.clone(), + }, }, )) .expect("alias succeeds"); let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + ProductRequest { calling_product_id: "peopl.dot".to_string(), - key_handle: full_person_key_handle(), - context, - ring_location, - message: b"prove me".to_vec(), + payload: HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context, + ring_location, + message: b"prove me".to_vec(), + }, }, )) .expect("proof succeeds"); @@ -1621,16 +1649,18 @@ mod tests { let error = futures::executor::block_on(authority.account_alias( &CallContext::default(), &session, - AccountAliasAuthorityRequest { + ProductRequest { calling_product_id: "peopl.dot".to_string(), - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "myapp.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), - }, - ring_location: v01::RingLocation { - chain_id: registered_ring.chain_id, - junctions: vec![], + payload: HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: v01::RingLocation { + chain_id: registered_ring.chain_id, + junctions: vec![], + }, }, }, )) @@ -1662,10 +1692,12 @@ mod tests { let error = futures::executor::block_on(authority.ring_vrf_sign( &CallContext::default(), &session, - RingVrfSignAuthorityRequest { + ProductRequest { calling_product_id: "myapp.dot".to_string(), - key_handle: handle, - message: b"reject mismatched registry state".to_vec(), + payload: HostAccountRingVrfSignRequest { + key_handle: handle, + message: b"reject mismatched registry state".to_vec(), + }, }, )) .unwrap_err(); @@ -1695,11 +1727,13 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + ProductRequest { calling_product_id: "myapp.dot".to_string(), - key_handle: full_person_key_handle(), - context: context.clone(), - ring_location: ring_location.clone(), + payload: HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: context.clone(), + ring_location: ring_location.clone(), + }, }, )); assert_eq!(alias, Err(RingVrfError::Rejected)); @@ -1707,12 +1741,14 @@ mod tests { let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + ProductRequest { calling_product_id: "myapp.dot".to_string(), - key_handle: full_person_key_handle(), - context, - ring_location, - message: b"prove me".to_vec(), + payload: HostAccountCreateProofRequest { + key_handle: full_person_key_handle(), + context, + ring_location, + message: b"prove me".to_vec(), + }, }, )); assert_eq!(proof, Err(RingVrfError::NotAllowlisted)); @@ -1738,16 +1774,18 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); let cx = CallContext::default(); - let request = AccountAliasAuthorityRequest { + let request = ProductRequest { calling_product_id: "myapp.dot".to_string(), - key_handle: full_person_key_handle(), - context: v01::ProductProofContext { - product_id: "other.dot".to_string(), - suffix: v01::DerivationIndex::Index(0), + payload: HostAccountGetAliasRequest { + key_handle: full_person_key_handle(), + context: v01::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: v01::DerivationIndex::Index(0), + }, + ring_location: full_person_ring_location(), }, - ring_location: full_person_ring_location(), }; - register_full_person_key(&authority, &session, &request.ring_location); + register_full_person_key(&authority, &session, &request.payload.ring_location); futures::executor::block_on(authority.account_alias(&cx, &session, request.clone())) .expect("first alias succeeds"); @@ -2143,7 +2181,7 @@ mod tests { "CheckNonce".to_string(), "ChargeTransactionPayment".to_string(), ]; - payload.with_signed_transaction = Some(true); + payload.with_signed_transaction = parity_scale_codec::OptionBool(Some(true)); let preimage = extrinsic_payload_preimage(&payload).expect("preimage builds"); let product_response = futures::executor::block_on(authority.sign_payload( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs index 393232f7a..d72167d0a 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_responder.rs @@ -17,49 +17,38 @@ use std::time::Instant; use parity_scale_codec::Encode; use tracing::{debug, instrument, warn}; -use truapi::{CallContext, latest as api, v01}; -use truapi_platform::{ - CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, - UserConfirmationReview, -}; +use truapi::v01; -use super::SigningHost; use super::sso_replay::{ReplayExecution, SsoReplayScope, execute_once}; +use super::{SigningHost, SigningHostSsoService}; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::derive_sr25519_hard_path; use crate::host_logic::product_account::{ - ProductAccountError, derive_identity_keypair, derive_ring_vrf_domain_entropy, - derive_root_keypair_from_entropy, product_public_key_to_address, + ProductAccountError, derive_identity_keypair, derive_root_keypair_from_entropy, }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ - self, CreateTransactionPayload, IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessage, - RemoteMessageData, ResourceAllocationResponse, RingVrfAliasResponse, RingVrfError, - RingVrfProofResponse, RingVrfSignResponse, SignRawLegacyResponse, SignVrfResponse, - SigningPayloadResponseData, SigningRequest, SigningResponse, SsoAllocatableResource, - SsoAllocatedResource, SsoAllocationOutcome, SsoResponseCode, build_outgoing_request_statement, - build_signed_session_response_statement, decode_incoming_sso_request, v1, + IncomingSsoRequest, OnExistingAllowancePolicy, RemoteMessageData, SsoResponseCode, + build_outgoing_request_statement, build_signed_session_response_statement, + decode_incoming_sso_request, v1, }; use crate::host_logic::sso::pairing::{ ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, derive_identity_chat_private_key, derive_x25519_keypair_from_entropy, encrypt_v2_handshake_response, establish_responder_session_info, v2, x25519_public_key, }; +use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::{ build_signed_statement, current_unix_secs as statement_current_unix_secs, parse_new_statements_result, }; -use crate::runtime::authority::{ - AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, - CreateTransactionAuthorityRequest, ListRingVrfKeysAuthorityRequest, ProductAuthority, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, SignPayloadAuthorityRequest, - SignRawAuthorityRequest, -}; +use crate::runtime::authority::{AuthorityError, AuthoritySession}; use crate::runtime::services::RuntimeServices; use crate::runtime::sso_remote::fresh_statement_expiry; +use crate::runtime::sso_service::Dispatch; #[cfg(not(target_arch = "wasm32"))] use crate::runtime::statement_allowance::StatementAllowanceError; use crate::runtime::statement_store_rpc; @@ -258,8 +247,8 @@ pub(crate) async fn establish_pairing( } async fn establish_pairing_session( - services: &Arc, - signing_host: &Arc, + services: &RuntimeServices, + signing_host: &SigningHost, deeplink: &str, ) -> Result { let peer = PairedSsoPeer::from_deeplink(deeplink)?; @@ -364,6 +353,7 @@ async fn serve_session( session: SsoSessionInfo, replay_scope: SsoReplayScope, ) -> Result { + let service = SigningHostSsoService::new(signing_host.clone()); let rpc_client = services .statement_store .client("sso-responder session") @@ -414,14 +404,16 @@ async fn serve_session( for message in &incoming.messages { let cli_summary = format!( "Incoming SSO request · {}\nstatement_request_id={}\nremote_message_id={}", - message, incoming.request_id, message.message_id + message.name(), + incoming.request_id, + message.message_id ); tracing::event!( target: "truapi_server::sso_transcript", tracing::Level::DEBUG, cli_summary = cli_summary.as_str(), cli_event = "request_received", - request = %message, + request = message.name(), statement_request_id = %incoming.request_id, remote_message_id = %message.message_id, ); @@ -436,7 +428,7 @@ async fn serve_session( &request_id, expires_at_unix_secs, statement_current_unix_secs(), - || serve_request(&services, &signing_host, &session, incoming), + || serve_request(&services, &service, &session, incoming), ) .await?; let exit = match execution { @@ -456,33 +448,30 @@ async fn serve_session( /// Ack one inbound request statement and answer its batched messages. async fn serve_request( - services: &Arc, - signing_host: &Arc, + services: &RuntimeServices, + service: &SigningHostSsoService, session: &SsoSessionInfo, incoming: IncomingSsoRequest, ) -> Result, String> { acknowledge_request(services, session, &incoming.request_id).await?; for message in incoming.messages { - let RemoteMessageData::V1(request) = message.data; - if matches!(request, v1::RemoteMessage::Disconnected) { - debug!("pairing host disconnected the SSO session"); - return Ok(Some(ResponderExit::PeerDisconnected)); - } - let request_name = request.to_string(); + let request_name = message.name(); let responding_to = message.message_id.clone(); let started = Instant::now(); - let Some(answer) = - answer_remote_message(services, signing_host, message.message_id, request).await - else { - continue; + let (response, outcome) = match service.dispatch(service.current_session(), message).await { + Dispatch::Response(answer) => (answer.message, answer.outcome), + Dispatch::Disconnected => { + debug!("pairing host disconnected the SSO session"); + return Ok(Some(ResponderExit::PeerDisconnected)); + } + Dispatch::NotARequest(name) => { + warn!(name, "peer sent a response variant as a request"); + continue; + } }; - let response = answer.response; let response_message_id = response.message_id.clone(); - let response_result = answer - .response_result - .unwrap_or_else(|| remote_response_result(&response.data)); - let statement_request_id = format!("resp:{}", response.message_id); + let statement_request_id = format!("resp:{response_message_id}"); let statement = build_outgoing_request_statement( session, statement_request_id, @@ -498,11 +487,11 @@ async fn serve_request( Ok(()) => { let cli_summary = response_cli_summary( "SSO response sent", - &request_name, + request_name, &incoming.request_id, &responding_to, &response_message_id, - &response_result, + &outcome, elapsed_ms, ); tracing::event!( @@ -510,23 +499,23 @@ async fn serve_request( tracing::Level::DEBUG, cli_summary = cli_summary.as_str(), cli_event = "response_sent", - request = request_name.as_str(), + request = request_name, statement_request_id = %incoming.request_id, responding_to = %responding_to, %response_message_id, - outcome = response_result.outcome, - reason = response_result.reason.as_deref().unwrap_or_default(), + outcome = outcome.outcome, + reason = outcome.reason.as_deref().unwrap_or_default(), elapsed_ms = elapsed_ms as u64, ); } Err(reason) => { - let failure = ResponseResult { + let failure = ResponseOutcome { outcome: "publish_failed", reason: Some(reason.clone()), }; let cli_summary = response_cli_summary( "SSO response failed", - &request_name, + request_name, &incoming.request_id, &responding_to, &response_message_id, @@ -538,7 +527,7 @@ async fn serve_request( tracing::Level::WARN, cli_summary = cli_summary.as_str(), cli_event = "response_failed", - request = request_name.as_str(), + request = request_name, statement_request_id = %incoming.request_id, responding_to = %responding_to, %response_message_id, @@ -554,7 +543,7 @@ async fn serve_request( } async fn acknowledge_request( - services: &Arc, + services: &RuntimeServices, session: &SsoSessionInfo, request_id: &str, ) -> Result<(), String> { @@ -583,185 +572,13 @@ fn duplicate_request_exit(incoming: &IncomingSsoRequest) -> Option, -} - -/// Result of answering one remote message: the response envelope and an -/// optional pre-classified outcome for logging. -pub(crate) struct AnsweredRemoteMessage { - /// Response to post back over the session transport. - pub(crate) response: RemoteMessage, - /// Pre-classified outcome summary for SSO transcript logging (outcome code and error reason). - response_result: Option, -} - -struct ResourceAllocationAnswer { - payload: Result, String>, - item_failures: Vec, -} - -fn remote_response_result(message: &RemoteMessageData) -> ResponseResult { - let RemoteMessageData::V1(message) = message; - let error = match message { - v1::RemoteMessage::SignResponse(response) => response.payload.as_ref().err().cloned(), - v1::RemoteMessage::RingVrfAliasResponse(response) => { - response.payload.as_ref().err().map(ring_vrf_error_reason) - } - v1::RemoteMessage::RingVrfProofResponse(response) => { - response.payload.as_ref().err().map(ring_vrf_error_reason) - } - v1::RemoteMessage::RegisterRingVrfKeyResponse(response) => { - response.payload.as_ref().err().map(ring_vrf_error_reason) - } - v1::RemoteMessage::ListRingVrfKeysResponse(response) => { - response.payload.as_ref().err().map(ring_vrf_error_reason) - } - v1::RemoteMessage::RingVrfSignResponse(response) => { - response.payload.as_ref().err().map(ring_vrf_error_reason) - } - v1::RemoteMessage::ResourceAllocationResponse(response) => { - return resource_allocation_payload_result(&response.payload, &[]); - } - v1::RemoteMessage::CreateTransactionResponse(response) => { - response.signed_transaction.as_ref().err().cloned() - } - v1::RemoteMessage::SignRawLegacyResponse(response) => { - response.signature.as_ref().err().cloned() - } - v1::RemoteMessage::SignVrfResponse(response) => { - response.payload.as_ref().err().map(sign_vrf_error_reason) - } - _ => None, - }; - ResponseResult { - outcome: if error.is_some() { "error" } else { "ok" }, - reason: error, - } -} - -fn resource_allocation_payload_result( - payload: &Result, String>, - item_failures: &[String], -) -> ResponseResult { - let outcomes = match payload { - Ok(outcomes) => outcomes, - Err(reason) => { - return ResponseResult { - outcome: "error", - reason: Some(reason.clone()), - }; - } - }; - if outcomes.is_empty() { - return ResponseResult { - outcome: "ok", - reason: None, - }; - } - - let allocated = outcomes - .iter() - .filter(|outcome| matches!(outcome, SsoAllocationOutcome::Allocated(_))) - .count(); - let rejected = outcomes - .iter() - .filter(|outcome| matches!(outcome, SsoAllocationOutcome::Rejected)) - .count(); - let unavailable = outcomes - .iter() - .filter(|outcome| matches!(outcome, SsoAllocationOutcome::NotAvailable)) - .count(); - let total = outcomes.len(); - - if allocated == total { - return ResponseResult { - outcome: "ok", - reason: None, - }; - } - if allocated > 0 { - let mut reason = format!("{allocated} of {total} requested resources allocated"); - if rejected > 0 { - reason.push_str(&format!("; {rejected} rejected")); - } - if unavailable > 0 { - reason.push_str(&format!("; {unavailable} unavailable")); - } - return allocation_result_with_failures( - ResponseResult { - outcome: "partial", - reason: Some(reason), - }, - item_failures, - ); - } - if rejected > 0 { - let reason = if rejected == total { - if total == 1 { - "Requested resource was rejected".to_string() - } else { - format!("All {total} requested resources were rejected") - } - } else { - format!("No resources allocated; {rejected} rejected; {unavailable} unavailable") - }; - return allocation_result_with_failures( - ResponseResult { - outcome: "rejected", - reason: Some(reason), - }, - item_failures, - ); - } - - allocation_result_with_failures( - ResponseResult { - outcome: "not_available", - reason: Some(if total == 1 { - "Requested resource is not available".to_string() - } else { - format!("None of the {total} requested resources are available") - }), - }, - item_failures, - ) -} - -fn allocation_result_with_failures( - mut result: ResponseResult, - item_failures: &[String], -) -> ResponseResult { - if !item_failures.is_empty() { - let details = item_failures.join("; ").replace(['\r', '\n'], " "); - result.reason = Some(match result.reason { - Some(summary) => format!("{summary}: {details}"), - None => details, - }); - } - result -} - -fn ring_vrf_error_reason(error: &RingVrfError) -> String { - match error { - RingVrfError::RingNotFound => "RingNotFound".to_string(), - RingVrfError::NotMember => "NotMember".to_string(), - RingVrfError::KeyNotRegistered => "KeyNotRegistered".to_string(), - RingVrfError::KeyNotInRing => "KeyNotInRing".to_string(), - RingVrfError::NotAllowlisted => "NotAllowlisted".to_string(), - RingVrfError::Rejected => "Rejected".to_string(), - RingVrfError::Unknown { reason } => format!("Unknown: {reason}"), - } -} - fn response_cli_summary( heading: &str, request_name: &str, statement_request_id: &str, responding_to: &str, response_message_id: &str, - result: &ResponseResult, + result: &ResponseOutcome, elapsed_ms: u128, ) -> String { let mut summary = format!( @@ -775,273 +592,11 @@ fn response_cli_summary( summary } -/// Answer one application-level request message; `None` for message kinds -/// that take no response (responses echoed by the peer, unknown variants). -pub(crate) async fn answer_remote_message( - services: &Arc, - signing_host: &Arc, - message_id: String, - request: v1::RemoteMessage, -) -> Option { - let response_id = format!("{message_id}:response"); - let mut response_result = None; - let data = match request { - v1::RemoteMessage::SignRequest(request) => v1::RemoteMessage::SignResponse( - sign_response(services, signing_host, &message_id, *request).await, - ), - v1::RemoteMessage::RingVrfAliasRequest(request) => { - let payload = account_alias_response(signing_host, request).await; - v1::RemoteMessage::RingVrfAliasResponse(RingVrfAliasResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::RingVrfProofRequest(request) => { - let payload = create_proof_response(signing_host, request).await; - v1::RemoteMessage::RingVrfProofResponse(RingVrfProofResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::RegisterRingVrfKeyRequest(request) => { - let payload = register_ring_vrf_key_response(signing_host, request).await; - v1::RemoteMessage::RegisterRingVrfKeyResponse(messages::RegisterRingVrfKeyResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::ListRingVrfKeysRequest(request) => { - let payload = list_ring_vrf_keys_response(signing_host, request).await; - v1::RemoteMessage::ListRingVrfKeysResponse(messages::ListRingVrfKeysResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::RingVrfSignRequest(request) => { - let payload = ring_vrf_sign_response(signing_host, request).await; - v1::RemoteMessage::RingVrfSignResponse(RingVrfSignResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::ResourceAllocationRequest(request) => { - let answer = resource_allocation_response(services, signing_host, request).await; - if let Err(reason) = &answer.payload { - warn!(%reason, "resource allocation request failed"); - } - response_result = Some(resource_allocation_payload_result( - &answer.payload, - &answer.item_failures, - )); - v1::RemoteMessage::ResourceAllocationResponse(ResourceAllocationResponse { - responding_to: message_id, - payload: answer.payload, - }) - } - v1::RemoteMessage::CreateTransactionRequest(request) => { - let CreateTransactionPayload::V1(payload) = request.payload; - let signed_transaction = create_transaction_response( - services, - signing_host, - CreateTransactionReview::Product(payload.clone()), - CreateTransactionAuthorityRequest::Product(payload), - ) - .await; - v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { - responding_to: message_id, - signed_transaction, - }) - } - v1::RemoteMessage::CreateTransactionLegacyRequest(request) => { - let messages::CreateTransactionLegacyPayload::V1(payload) = request.payload; - let signed_transaction = create_transaction_response( - services, - signing_host, - CreateTransactionReview::LegacyAccount(payload.clone()), - CreateTransactionAuthorityRequest::IdentityAccount(payload), - ) - .await; - v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { - responding_to: message_id, - signed_transaction, - }) - } - v1::RemoteMessage::SignRawLegacyRequest(request) => { - let signature = sign_raw_legacy_response(services, signing_host, request).await; - v1::RemoteMessage::SignRawLegacyResponse(SignRawLegacyResponse { - responding_to: message_id, - signature, - }) - } - v1::RemoteMessage::SignVrfRequest(request) => { - let payload = sign_vrf_response(signing_host, message_id.clone(), request).await; - v1::RemoteMessage::SignVrfResponse(SignVrfResponse { - responding_to: message_id, - payload, - }) - } - v1::RemoteMessage::ProductSubtreeRequest(request) => { - let product_public_key = match signing_host.current_session() { - Some(session) => signing_host - .product_subtree_public_key( - &CallContext::with_request_id(message_id.clone()), - &session, - request.product_id, - ) - .await - .map_err(|err| err.to_string()), - None => Err("signing host is disconnected".to_string()), - }; - v1::RemoteMessage::ProductSubtreeResponse(messages::ProductSubtreeResponse { - responding_to: message_id, - product_public_key, - }) - } - v1::RemoteMessage::Disconnected - | v1::RemoteMessage::SignResponse(_) - | v1::RemoteMessage::RingVrfAliasResponse(_) - | v1::RemoteMessage::RingVrfProofResponse(_) - | v1::RemoteMessage::RegisterRingVrfKeyResponse(_) - | v1::RemoteMessage::ListRingVrfKeysResponse(_) - | v1::RemoteMessage::RingVrfSignResponse(_) - | v1::RemoteMessage::ResourceAllocationResponse(_) - | v1::RemoteMessage::CreateTransactionResponse(_) - | v1::RemoteMessage::SignRawLegacyResponse(_) - | v1::RemoteMessage::ProductSubtreeResponse(_) - | v1::RemoteMessage::SignVrfResponse(_) => return None, - }; - Some(AnsweredRemoteMessage { - response: RemoteMessage { - message_id: response_id, - data: RemoteMessageData::V1(data), - }, - response_result, - }) -} - -async fn resource_allocation_response( - services: &Arc, - signing_host: &Arc, - request: messages::ResourceAllocationRequest, -) -> ResourceAllocationAnswer { - let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { - calling_product_id: request.calling_product_id.clone(), - resources: request - .resources - .iter() - .map(public_allocatable_resource) - .collect(), - }); - match services.platform.confirm_user_action(review).await { - Ok(true) => {} - Ok(false) => { - return ResourceAllocationAnswer { - payload: Ok(vec![ - SsoAllocationOutcome::Rejected; - request.resources.len() - ]), - item_failures: Vec::new(), - }; - } - Err(err) => { - return ResourceAllocationAnswer { - payload: Err(format!("confirmation failed: {}", err.reason)), - item_failures: Vec::new(), - }; - } - } - - let mut outcomes = Vec::with_capacity(request.resources.len()); - let mut item_failures = Vec::new(); - for resource in request.resources { - let outcome = match resource { - SsoAllocatableResource::StatementStoreAllowance => allocate_statement_store_allowance( - services, - signing_host, - &request.calling_product_id, - request.on_existing, - ) - .await - .map(|slot_account_key| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { - slot_account_key, - }) - }), - SsoAllocatableResource::BulletinAllowance => allocate_bulletin_allowance( - services, - signing_host, - &request.calling_product_id, - request.on_existing, - ) - .await - .map(|slot_account_key| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key, - }) - }), - SsoAllocatableResource::SmartContractAllowance(index) => { - allocate_smart_contract_allowance( - services, - signing_host, - &request.calling_product_id, - index.clone(), - request.on_existing, - ) - .await - .map(|()| { - SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) - }) - } - SsoAllocatableResource::AutoSigning => (|| -> Result<_, AllowanceAllocationError> { - let product_root_private_key = signing_host - .product_subtree_secret(&request.calling_product_id) - .map_err(AllowanceAllocationError::Authority)?; - let root_entropy = signing_host.root_entropy()?; - let ring_vrf_domain_entropy = - derive_ring_vrf_domain_entropy(&root_entropy, &request.calling_product_id) - .map_err(super::product_authority_error) - .map_err(AllowanceAllocationError::Authority)?; - Ok(SsoAllocationOutcome::Allocated( - SsoAllocatedResource::AutoSigning { - product_root_private_key, - ring_vrf_domain_entropy, - }, - )) - })(), - }; - match outcome { - Ok(outcome) => outcomes.push(outcome), - Err(err) => { - let reason = err.to_string(); - warn!(%reason, "resource allocation item failed"); - item_failures.push(reason); - outcomes.push(SsoAllocationOutcome::NotAvailable); - } - } - } - ResourceAllocationAnswer { - payload: Ok(outcomes), - item_failures, - } -} - -fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::AllocatableResource { - match resource { - SsoAllocatableResource::StatementStoreAllowance => { - api::AllocatableResource::StatementStoreAllowance - } - SsoAllocatableResource::BulletinAllowance => api::AllocatableResource::BulletinAllowance, - SsoAllocatableResource::SmartContractAllowance(index) => { - api::AllocatableResource::SmartContractAllowance(index.clone()) - } - SsoAllocatableResource::AutoSigning => api::AllocatableResource::AutoSigning, - } -} - #[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_statement_store_allowance( - services: &Arc, + services: &RuntimeServices, signing_host: &SigningHost, + session: &AuthoritySession, product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1051,14 +606,12 @@ pub(super) async fn allocate_statement_store_allowance( register_statement_account_pooled, scan_collections, }; + signing_host.require_current_session(session)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "statement-store", product_id])?; let target = allowance.public.to_bytes(); - let session = signing_host - .current_session() - .ok_or(AuthorityError::Disconnected)?; - let candidates = signing_host.reserved_person_collection_candidates(&session)?; + let candidates = signing_host.reserved_person_collection_candidates(session)?; let client = services .statement_store .chain_client("statement-store allowance") @@ -1096,6 +649,7 @@ pub(super) async fn allocate_statement_store_allowance( %collection, "statement-store allowance already allocated" ); + signing_host.require_current_session(session)?; return Ok(allowance.secret.to_bytes().to_vec()); } @@ -1107,6 +661,7 @@ pub(super) async fn allocate_statement_store_allowance( resource: "statement-store", }); } + signing_host.require_current_session(session)?; let outcome = register_statement_account_pooled( rpc, &chain.metadata, @@ -1151,6 +706,7 @@ pub(super) async fn allocate_statement_store_allowance( ); } } + signing_host.require_current_session(session)?; if let Err(reason) = allowance_renewal::track( signing_host, vec![StatementRenewalTarget::ProductStatementAllowance { @@ -1161,13 +717,15 @@ pub(super) async fn allocate_statement_store_allowance( { warn!(%product_id, %reason, "failed to record statement-store renewal target"); } + signing_host.require_current_session(session)?; Ok(allowance.secret.to_bytes().to_vec()) } #[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_bulletin_allowance( - services: &Arc, + services: &RuntimeServices, signing_host: &SigningHost, + session: &AuthoritySession, product_id: &str, policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1177,6 +735,7 @@ pub(super) async fn allocate_bulletin_allowance( wait_bulletin_authorization, }; + signing_host.require_current_session(session)?; let entropy = signing_host.root_entropy()?; let allowance = derive_sr25519_hard_path(&entropy, &["allowance", "bulletin", product_id])?; let target = allowance.public.to_bytes(); @@ -1195,6 +754,7 @@ pub(super) async fn allocate_bulletin_allowance( if matches!(policy, OnExistingAllowancePolicy::Ignore) && current_allowance.is_some_and(|allowance| allowance.available()) { + signing_host.require_current_session(session)?; return Ok(allowance.secret.to_bytes().to_vec()); } @@ -1205,10 +765,7 @@ pub(super) async fn allocate_bulletin_allowance( let people_rpc = people_client.rpc(); let chain = services.chain_context.get(&people_client).await?; let network_suffix = statement_allowance::slot::read_network_suffix(people_rpc).await?; - let session = signing_host - .current_session() - .ok_or(AuthorityError::Disconnected)?; - let candidates = signing_host.reserved_person_collection_candidates(&session)?; + let candidates = signing_host.reserved_person_collection_candidates(session)?; // Statement-store slots and PGAS claims are each bounded by a per-collection // constant, so their budgets are meant to be spent per collection. Long-term // storage is bounded by `Resources.LongTermStorageClaimsPerPeriod` alone, with @@ -1232,6 +789,7 @@ pub(super) async fn allocate_bulletin_allowance( current_unix_secs()?, period_duration, )?; + signing_host.require_current_session(session)?; let outcome = claim_long_term_storage(statement_allowance::LongTermStorageClaim { rpc: people_rpc, metadata: &chain.metadata, @@ -1269,13 +827,15 @@ pub(super) async fn allocate_bulletin_allowance( remained_transactions = authorization.remained_transactions, "Bulletin authorization visible" ); + signing_host.require_current_session(session)?; Ok(allowance.secret.to_bytes().to_vec()) } #[cfg(target_arch = "wasm32")] pub(super) async fn allocate_statement_store_allowance( - _services: &Arc, + _services: &RuntimeServices, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1297,8 +857,9 @@ pub(super) async fn allocate_statement_store_allowance( /// whatever chain a stale hash happens to reach. #[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_smart_contract_allowance( - services: &Arc, + services: &RuntimeServices, signing_host: &SigningHost, + session: &AuthoritySession, product_id: &str, derivation_index: v01::DerivationIndex, policy: OnExistingAllowancePolicy, @@ -1308,9 +869,7 @@ pub(super) async fn allocate_smart_contract_allowance( use crate::host_logic::features; use crate::runtime::statement_allowance::{self, ChainClient, find_including_rings, pgas}; - let session = signing_host - .current_session() - .ok_or(AuthorityError::Disconnected)?; + signing_host.require_current_session(session)?; // PGAS credits the product account the caller named. let target = signing_host @@ -1347,6 +906,7 @@ pub(super) async fn allocate_smart_contract_allowance( && pgas::holds_a_full_claim(asset_hub_client.rpc(), &asset_hub.metadata, &target).await? { debug!(%product_id, "PGAS allowance already funded; leaving it alone"); + signing_host.require_current_session(session)?; return Ok(()); } let network_suffix = @@ -1359,7 +919,7 @@ pub(super) async fn allocate_smart_contract_allowance( let people_rpc = people_client.rpc(); let people = services.chain_context.get(&people_client).await?; - let candidates = signing_host.reserved_person_collection_candidates(&session)?; + let candidates = signing_host.reserved_person_collection_candidates(session)?; // A single claim needs one collection, so take the strongest membership the // person actually holds rather than assuming light personhood. let membership = find_including_rings(people_rpc, &people.metadata, &candidates, u32::MAX) @@ -1368,6 +928,7 @@ pub(super) async fn allocate_smart_contract_allowance( .next() .ok_or(AllowanceAllocationError::MissingPersonhoodMembership { resource: "PGAS" })?; + signing_host.require_current_session(session)?; let outcome = pgas::claim_pgas(pgas::PgasClaim { asset_hub_rpc: asset_hub_client.rpc(), asset_hub: &asset_hub, @@ -1387,14 +948,16 @@ pub(super) async fn allocate_smart_contract_allowance( block = %outcome.block_hash, "claimed PGAS allowance" ); + signing_host.require_current_session(session)?; Ok(()) } /// PGAS claims need chain access the wasm host does not have. #[cfg(target_arch = "wasm32")] pub(super) async fn allocate_smart_contract_allowance( - _services: &Arc, + _services: &RuntimeServices, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _derivation_index: v01::DerivationIndex, _policy: OnExistingAllowancePolicy, @@ -1404,8 +967,9 @@ pub(super) async fn allocate_smart_contract_allowance( #[cfg(target_arch = "wasm32")] pub(super) async fn allocate_bulletin_allowance( - _services: &Arc, + _services: &RuntimeServices, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1422,284 +986,23 @@ pub(super) fn current_unix_secs() -> Result { .map_err(|_| AllowanceAllocationError::SystemClockBeforeUnixEpoch) } -/// Confirm and serve a payload or raw signing request. -async fn sign_response( - services: &Arc, - signing_host: &Arc, - message_id: &str, - request: SigningRequest, -) -> SigningResponse { - let payload = serve_sign_request(services, signing_host, request).await; - if let Err(reason) = &payload { - warn!(%reason, "sign request failed"); - } - SigningResponse { - responding_to: message_id.to_string(), - payload, - } -} - -async fn serve_sign_request( - services: &Arc, - signing_host: &Arc, - request: SigningRequest, -) -> Result { - let session = signing_host - .current_session() - .ok_or_else(|| "signing host session is not active".to_string())?; - let cx = CallContext::default(); - let response = match request { - SigningRequest::Payload(request) => { - let request: api::HostSignPayloadRequest = (*request).into(); - confirm( - services, - UserConfirmationReview::SignPayload(SignPayloadReview::Product(request.clone())), - ) - .await?; - signing_host - .sign_payload(&cx, &session, SignPayloadAuthorityRequest::Product(request)) - .await - } - SigningRequest::Raw(request) => { - let request: api::HostSignRawRequest = request.into(); - confirm( - services, - UserConfirmationReview::SignRaw(SignRawReview::Product(request.clone())), - ) - .await?; - signing_host - .sign_raw(&cx, &session, SignRawAuthorityRequest::Product(request)) - .await - } - } - .map_err(|err| err.to_string())?; - Ok(SigningPayloadResponseData { - signature: response.signature, - signed_transaction: response.signed_transaction, - }) -} - -async fn sign_raw_legacy_response( - services: &Arc, - signing_host: &Arc, - request: messages::SignRawLegacyRequest, -) -> Result, String> { - let public_request = api::HostSignRawWithLegacyAccountRequest { - signer: product_public_key_to_address(request.account), - payload: request.data.into(), - }; - confirm( - services, - UserConfirmationReview::SignRaw(SignRawReview::LegacyAccount(public_request.clone())), - ) - .await?; - let session = signing_host - .current_session() - .ok_or_else(|| "signing host session is not active".to_string())?; - signing_host - .sign_raw( - &CallContext::default(), - &session, - SignRawAuthorityRequest::LegacyAccount { - account: request.account, - request: public_request, - }, - ) - .await - .map(|response| response.signature) - .map_err(|err| err.to_string()) -} - -fn sign_vrf_error_reason(error: &v01::HostAccountSignVrfError) -> String { - match error { - v01::HostAccountSignVrfError::NotConnected => "NotConnected".to_string(), - v01::HostAccountSignVrfError::Rejected => "Rejected".to_string(), - v01::HostAccountSignVrfError::Unknown { reason } => reason.clone(), - } -} - -async fn sign_vrf_response( - signing_host: &Arc, - message_id: String, - request: messages::SignVrfRequest, -) -> Result { - let session = signing_host - .current_session() - .ok_or(v01::HostAccountSignVrfError::NotConnected)?; - signing_host - .sign_vrf( - &CallContext::with_request_id(message_id), - &session, - request.calling_product_id, - request.payload, - ) - .await - .map_err(|err| match err { - AuthorityError::Disconnected => v01::HostAccountSignVrfError::NotConnected, - AuthorityError::Rejected => v01::HostAccountSignVrfError::Rejected, - AuthorityError::Cancelled(err) => v01::HostAccountSignVrfError::Unknown { - reason: err.to_string(), - }, - AuthorityError::Unavailable { reason } - | AuthorityError::NotSupported { reason } - | AuthorityError::Unknown { reason } => { - v01::HostAccountSignVrfError::Unknown { reason } - } - }) -} - -/// Confirm and serve a transaction-creation request. -async fn create_transaction_response( - services: &Arc, - signing_host: &Arc, - review: CreateTransactionReview, - request: CreateTransactionAuthorityRequest, -) -> Result, String> { - let session = signing_host - .current_session() - .ok_or_else(|| "signing host session is not active".to_string())?; - confirm(services, UserConfirmationReview::CreateTransaction(review)).await?; - let cx = CallContext::default(); - signing_host - .create_transaction(&cx, &session, request) - .await - .map(|response| response.transaction) - .map_err(|err| err.to_string()) -} - -async fn account_alias_response( - signing_host: &Arc, - request: messages::RingVrfAliasRequest, -) -> Result { - let session = signing_host - .current_session() - .ok_or_else(disconnected_ring_vrf)?; - let cx = CallContext::default(); - signing_host - .account_alias( - &cx, - &session, - AccountAliasAuthorityRequest { - calling_product_id: request.calling_product_id, - key_handle: request.key_handle, - context: request.context, - ring_location: request.ring_location, - }, - ) - .await -} - -async fn create_proof_response( - signing_host: &Arc, - request: messages::RingVrfProofRequest, -) -> Result { - let session = signing_host - .current_session() - .ok_or_else(disconnected_ring_vrf)?; - let cx = CallContext::default(); - signing_host - .create_proof( - &cx, - &session, - CreateProofAuthorityRequest { - calling_product_id: request.calling_product_id, - key_handle: request.key_handle, - context: request.context, - ring_location: request.ring_location, - message: request.message, - }, - ) - .await -} - -async fn register_ring_vrf_key_response( - signing_host: &Arc, - request: messages::RegisterRingVrfKeyRequest, -) -> Result { - let session = signing_host - .current_session() - .ok_or_else(disconnected_ring_vrf)?; - signing_host - .register_ring_vrf_key( - &CallContext::default(), - &session, - RegisterRingVrfKeyAuthorityRequest { - calling_product_id: request.calling_product_id, - index: request.index, - ring: request.ring, - }, - ) - .await -} - -async fn list_ring_vrf_keys_response( - signing_host: &Arc, - request: messages::ListRingVrfKeysRequest, -) -> Result, RingVrfError> { - let session = signing_host - .current_session() - .ok_or_else(disconnected_ring_vrf)?; - signing_host - .list_ring_vrf_keys( - &CallContext::default(), - &session, - ListRingVrfKeysAuthorityRequest { - calling_product_id: request.calling_product_id, - owner: request.owner, - disclosure: request.disclosure, - }, - ) - .await -} - -async fn ring_vrf_sign_response( - signing_host: &Arc, - request: messages::RingVrfSignRequest, -) -> Result, RingVrfError> { - let session = signing_host - .current_session() - .ok_or_else(disconnected_ring_vrf)?; - signing_host - .ring_vrf_sign( - &CallContext::default(), - &session, - RingVrfSignAuthorityRequest { - calling_product_id: request.calling_product_id, - key_handle: request.key_handle, - message: request.message, - }, - ) - .await -} - -fn disconnected_ring_vrf() -> RingVrfError { - RingVrfError::Unknown { - reason: "signing host session is not active".to_string(), - } -} - -/// Run the platform confirmation seam; rejection and failure both refuse the -/// operation with an opaque reason (host-spec B.7). -async fn confirm( - services: &Arc, - review: UserConfirmationReview, -) -> Result<(), String> { - match services.platform.confirm_user_action(review).await { - Ok(true) => Ok(()), - Ok(false) => Err("Rejected".to_string()), - Err(err) => Err(format!("confirmation failed: {}", err.reason)), - } -} - #[cfg(test)] mod tests { use super::super::LocalActivation; use super::*; use crate::host_logic::extrinsic::tests::split_v4; + use crate::host_logic::product_account::derive_ring_vrf_domain_entropy; + use crate::host_logic::sso::messages::{ + self, GetAccountAliasResponse, RemoteMessage, RingVrfError, SsoAllocatedResource, + SsoAllocationOutcome, + }; + use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::decode_verified_statement_data; + use crate::runtime::authority::ProductAuthority; use crate::runtime::services::RuntimeServices; use crate::test_support::{StubPlatform, test_spawner}; use std::sync::Arc; + use truapi::latest as api; use truapi_platform::{HostInfo, Platform, PlatformInfo, SigningHostConfig}; const ENTROPY: [u8; 16] = [0xab; 16]; @@ -1806,10 +1109,12 @@ mod tests { // unbounded test would hang instead of reporting. The bound is generous // because it is catching a hang, not asserting latency. let secret = futures::executor::block_on(async { + let session = signing_host.current_session().unwrap(); futures::select! { result = allocate_statement_store_allowance( &services, &signing_host, + &session, product_id, OnExistingAllowancePolicy::Ignore, ) @@ -1989,55 +1294,78 @@ mod tests { ); } - fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { - let RemoteMessageData::V1(data) = answer.response.data; + fn answer( + signing_host: &Arc, + message_id: &str, + request: v1::RemoteMessage, + ) -> v1::RemoteMessage { + let service = SigningHostSsoService::new(signing_host.clone()); + let message = RemoteMessage { + message_id: message_id.to_string(), + data: RemoteMessageData::V1(request), + }; + let Dispatch::Response(answer) = + futures::executor::block_on(service.dispatch(service.current_session(), message)) + else { + panic!("expected a response"); + }; + let RemoteMessageData::V1(data) = answer.message.data; data } #[test] - fn account_alias_requires_confirmation_for_cross_product_request() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - - let response = futures::executor::block_on(answer_remote_message( - &services, - &signing_host, - "alias-1".to_string(), - v1::RemoteMessage::RingVrfAliasRequest(messages::RingVrfAliasRequest { - calling_product_id: "myapp.dot".to_string(), - key_handle: api::ProductAccountId { - dot_ns_identifier: "peopl.dot".to_string(), - derivation_index: api::DerivationIndex::Index(0), - }, - context: api::ProductProofContext { - product_id: "other.dot".to_string(), - suffix: api::DerivationIndex::Index(0), + fn dispatch_without_session_distinguishes_requests_responses_and_disconnects() { + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let service = SigningHostSsoService::new(signing_host); + let dispatch = |data| { + futures::executor::block_on(service.dispatch( + None, + RemoteMessage { + message_id: "m-1".to_string(), + data: RemoteMessageData::V1(data), }, - ring_location: api::RingLocation { - chain_id: [0; 32], - junctions: vec![], - }, - }), - )) - .expect("response is emitted"); + )) + }; - let v1::RemoteMessage::RingVrfAliasResponse(response) = response_payload(response) else { - panic!("expected alias response"); + assert_eq!( + dispatch(v1::RemoteMessage::Disconnected), + Dispatch::Disconnected + ); + assert_eq!( + dispatch(v1::RemoteMessage::ProductSubtreeResponse( + messages::Response { + responding_to: "m-1".to_string(), + payload: Ok([7; 32]), + } + )), + Dispatch::NotARequest("ProductSubtreeResponse"), + ); + let Dispatch::Response(answer) = dispatch(v1::RemoteMessage::ProductSubtreeRequest( + messages::ProductSubtreeRequest { + product_id: "myapp.dot".to_string(), + }, + )) else { + panic!("expected a disconnected error response"); }; - assert_eq!(response.payload.unwrap_err(), RingVrfError::Rejected); + let RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(response)) = + answer.message.data + else { + panic!("expected the request's response variant"); + }; + assert_eq!(response.responding_to, "m-1"); + assert_eq!( + response.payload, + Err("signing host session is not active".to_string()) + ); } #[test] fn response_summary_reports_protocol_errors_without_multiline_output() { - let response = RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( - RingVrfAliasResponse { - responding_to: "alias-1".to_string(), - payload: Err(RingVrfError::Unknown { - reason: "chain RPC\ntimed out".to_string(), - }), - }, - )); + let payload: GetAccountAliasResponse = Err(RingVrfError::Unknown { + reason: "chain RPC\ntimed out".to_string(), + }); - let result = remote_response_result(&response); + let result = ResponseOutcome::from_payload(&payload); let summary = response_cli_summary( "SSO response sent", "get_account_alias", @@ -2060,85 +1388,114 @@ mod tests { ); } + #[cfg(not(target_arch = "wasm32"))] #[test] - fn resource_allocation_summary_reflects_per_resource_outcomes() { - let result = - resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::Rejected]), &[]); - assert_eq!(result.outcome, "rejected"); - assert_eq!( - result.reason.as_deref(), - Some("Requested resource was rejected") + fn allocation_failure_details_reach_the_response_transcript() { + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("allocation node unavailable"), + ..StubPlatform::default() + })); + let service = SigningHostSsoService::new(signing_host); + let request = RemoteMessage::request( + "allocation-1".to_string(), + messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![ + api::AllocatableResource::AutoSigning, + api::AllocatableResource::BulletinAllowance, + api::AllocatableResource::StatementStoreAllowance, + ], + on_existing: OnExistingAllowancePolicy::Ignore, + }, ); - - let result = resource_allocation_payload_result( - &Ok(vec![ - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key: vec![1; 64], - }), - SsoAllocationOutcome::Rejected, + let Dispatch::Response(answer) = + futures::executor::block_on(service.dispatch(service.current_session(), request)) + else { + panic!("expected an allocation response"); + }; + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = + answer.message.data + else { + panic!("expected an allocation response"); + }; + assert!(matches!( + response.payload.unwrap().as_slice(), + [ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { .. }), SsoAllocationOutcome::NotAvailable, - ]), - &[], - ); - assert_eq!(result.outcome, "partial"); + SsoAllocationOutcome::NotAvailable, + ] + )); + assert_eq!(answer.outcome.outcome, "partial"); + let reason = answer.outcome.reason.as_deref().unwrap(); + assert!(reason.starts_with("1 of 3 requested resources allocated; 2 unavailable")); assert_eq!( - result.reason.as_deref(), - Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") - ); - - let result = resource_allocation_payload_result( - &Ok(vec![SsoAllocationOutcome::NotAvailable]), - &["timed out waiting for Bulletin authorization".to_string()], + reason.matches("allocation node unavailable").count(), + 2, + "{reason}" ); - assert_eq!(result.outcome, "not_available"); - assert_eq!( - result.reason.as_deref(), - Some( - "Requested resource is not available: timed out waiting for Bulletin authorization" - ) + assert!(!reason.contains(['\r', '\n'])); + let cli = response_cli_summary( + "SSO response sent", + "resource_allocation", + "allocation-1", + "allocation-1", + &answer.message.message_id, + &answer.outcome, + 0, ); + assert!(cli.contains(&format!("reason={reason}"))); } #[test] - fn response_summary_classifies_resource_allocation_batches() { - let response = RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( - ResourceAllocationResponse { - responding_to: "allocation-1".to_string(), - payload: Ok(vec![ - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ]), - }, - )); - - let result = remote_response_result(&response); + fn account_alias_requires_confirmation_for_cross_product_request() { + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - assert_eq!(result.outcome, "rejected"); - assert_eq!( - result.reason.as_deref(), - Some("No resources allocated; 1 rejected; 1 unavailable") + let response = answer( + &signing_host, + "alias-1", + v1::RemoteMessage::GetAccountAliasRequest(messages::ProductRequest { + calling_product_id: "myapp.dot".to_string(), + payload: api::HostAccountGetAliasRequest { + key_handle: api::ProductAccountId { + dot_ns_identifier: "peopl.dot".to_string(), + derivation_index: api::DerivationIndex::Index(0), + }, + context: api::ProductProofContext { + product_id: "other.dot".to_string(), + suffix: api::DerivationIndex::Index(0), + }, + ring_location: api::RingLocation { + chain_id: [0; 32], + junctions: vec![], + }, + }, + }), ); + + let v1::RemoteMessage::GetAccountAliasResponse(response) = response else { + panic!("expected alias response"); + }; + assert_eq!(response.payload.unwrap_err(), RingVrfError::Rejected); } #[test] fn resource_allocation_requires_confirmation_before_allocation() { let platform = Arc::new(StubPlatform::default()); - let (services, signing_host) = signing_fixture(platform.clone()); + let (_, signing_host) = signing_fixture(platform.clone()); - let response = futures::executor::block_on(answer_remote_message( - &services, + let response = answer( &signing_host, - "alloc-1".to_string(), + "alloc-1", v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { calling_product_id: "myapp.dot".to_string(), - resources: vec![SsoAllocatableResource::StatementStoreAllowance], + resources: vec![api::AllocatableResource::StatementStoreAllowance], on_existing: messages::OnExistingAllowancePolicy::Ignore, }), - )) - .expect("response is emitted"); + ); - let v1::RemoteMessage::ResourceAllocationResponse(response) = response_payload(response) - else { + let v1::RemoteMessage::ResourceAllocationResponse(response) = response else { panic!("expected resource allocation response"); }; assert_eq!( @@ -2162,7 +1519,7 @@ mod tests { resource_allocation_confirmed: true, ..StubPlatform::default() }); - let (services, signing_host) = signing_fixture(platform); + let (_, signing_host) = signing_fixture(platform); let expected_secret = signing_host .product_subtree_secret("myapp.dot") .expect("product subtree secret derives"); @@ -2170,20 +1527,17 @@ mod tests { derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot") .expect("ring-VRF domain entropy derives"); - let response = futures::executor::block_on(answer_remote_message( - &services, + let response = answer( &signing_host, - "alloc-auto-signing".to_string(), + "alloc-auto-signing", v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { calling_product_id: "myapp.dot".to_string(), - resources: vec![SsoAllocatableResource::AutoSigning], + resources: vec![api::AllocatableResource::AutoSigning], on_existing: messages::OnExistingAllowancePolicy::Ignore, }), - )) - .expect("response is emitted"); + ); - let v1::RemoteMessage::ResourceAllocationResponse(response) = response_payload(response) - else { + let v1::RemoteMessage::ResourceAllocationResponse(response) = response else { panic!("expected resource allocation response"); }; assert_eq!( @@ -2197,9 +1551,76 @@ mod tests { ); } + fn allocation_after_session_change(replacement: Option>) { + use futures::{FutureExt, channel::oneshot}; + + let (release, gate) = oneshot::channel(); + let platform = Arc::new(StubPlatform { + resource_allocation_confirmed: true, + resource_allocation_confirmation_gate: std::sync::Mutex::new(Some(gate)), + ..StubPlatform::default() + }); + let (_, signing_host) = signing_fixture(platform.clone()); + let service = SigningHostSsoService::new(signing_host.clone()); + let message = RemoteMessage::request( + "alloc-stale".to_string(), + messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![api::AllocatableResource::AutoSigning], + on_existing: OnExistingAllowancePolicy::Ignore, + }, + ); + + futures::executor::block_on(async { + let answer = service.dispatch(service.current_session(), message); + futures::pin_mut!(answer); + assert!(answer.as_mut().now_or_never().is_none()); + assert_eq!( + platform.resource_allocation_reviews.lock().unwrap().len(), + 1 + ); + + signing_host.disconnect().await; + if let Some(entropy) = replacement { + signing_host.activate_local_session(entropy).await.unwrap(); + } + release.send(()).unwrap(); + + let Dispatch::Response(answer) = answer.await else { + panic!("expected an allocation response"); + }; + let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) = + answer.message.data + else { + panic!("expected an allocation response"); + }; + assert_eq!(response.responding_to, "alloc-stale"); + assert!( + response.payload.is_err(), + "stale consent must not release keys" + ); + assert!(platform.sent_rpc.lock().unwrap().is_empty()); + }); + } + + #[test] + fn resource_consent_cannot_authorize_a_replacement_account() { + allocation_after_session_change(Some(vec![0xcd; 16])); + } + + #[test] + fn resource_consent_cannot_survive_same_account_reactivation() { + allocation_after_session_change(Some(ENTROPY.to_vec())); + } + + #[test] + fn resource_consent_cannot_survive_disconnect() { + allocation_after_session_change(None); + } + #[test] fn legacy_transaction_request_uses_the_controlled_identity_account() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform { create_transaction_confirmed: true, ..StubPlatform::default() })); @@ -2216,25 +1637,20 @@ mod tests { tx_ext_version: 0, }; - let response = futures::executor::block_on(answer_remote_message( - &services, + let response = answer( &signing_host, - "legacy-tx-1".to_string(), - v1::RemoteMessage::CreateTransactionLegacyRequest( - messages::CreateTransactionLegacyRequest { + "legacy-tx-1", + v1::RemoteMessage::CreateTransactionWithLegacyAccountRequest( + messages::CreateTransactionWithLegacyAccountRequest { payload: messages::CreateTransactionLegacyPayload::V1(payload), }, ), - )) - .expect("response is emitted"); + ); - let v1::RemoteMessage::CreateTransactionResponse(response) = response_payload(response) - else { + let v1::RemoteMessage::CreateTransactionResponse(response) = response else { panic!("expected create transaction response"); }; - let transaction = response - .signed_transaction - .expect("identity transaction succeeds"); + let transaction = response.payload.expect("identity transaction succeeds"); let (account, signature, tail) = split_v4(&transaction); assert_eq!(account, identity.public.to_bytes()); assert_eq!(tail, vec![1, 0x00, 0x00]); @@ -2249,18 +1665,16 @@ mod tests { #[test] fn product_subtree_request_is_consent_free_and_hard_derived() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - let response = futures::executor::block_on(answer_remote_message( - &services, + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let response = answer( &signing_host, - "subtree-1".to_string(), + "subtree-1", v1::RemoteMessage::ProductSubtreeRequest(messages::ProductSubtreeRequest { product_id: "browse.dot".to_string(), }), - )) - .expect("response is emitted"); + ); - let v1::RemoteMessage::ProductSubtreeResponse(response) = response_payload(response) else { + let v1::RemoteMessage::ProductSubtreeResponse(response) = response else { panic!("expected product subtree response"); }; let root = @@ -2271,7 +1685,7 @@ mod tests { .public .to_bytes(); assert_eq!(response.responding_to, "subtree-1"); - assert_eq!(response.product_public_key, Ok(expected)); + assert_eq!(response.payload, Ok(expected)); } #[test] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs new file mode 100644 index 000000000..f47cfba98 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -0,0 +1,552 @@ +//! The signing host's answers to paired hosts: consent prompts, then the +//! local authority. + +use std::sync::Arc; + +use tracing::warn; +use truapi::latest as api; +use truapi_platform::{ + CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, + UserConfirmationReview, +}; + +use super::SigningHost; +use super::sso_responder::{ + AllowanceAllocationError, allocate_bulletin_allowance, allocate_smart_contract_allowance, + allocate_statement_store_allowance, +}; +use crate::host_logic::product_account::{ + derive_ring_vrf_domain_entropy, product_public_key_to_address, +}; +use crate::host_logic::sso::messages::{ + CreateAccountProofResponse, CreateTransactionLegacyPayload, CreateTransactionPayload, + CreateTransactionRequest, CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, + GetAccountAliasResponse, ListRingVrfKeysResponse, OnExistingAllowancePolicy, ProductRequest, + ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyResponse, + ResourceAllocationRequest, ResourceAllocationResponse, RingVrfSignResponse, + SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, + SignVrfResponse, SsoAllocatedResource, SsoAllocationOutcome, +}; +use crate::host_logic::sso::wire::ResponseOutcome; +use crate::runtime::authority::{ + AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; +use crate::runtime::sso_service::{SsoReply, SsoRequestContext}; + +/// SSO handlers served by a locally activated [`SigningHost`]. +pub(crate) struct SigningHostSsoService { + signing_host: Arc, +} + +impl SigningHostSsoService { + /// Serve requests and prompt through the signing host's platform. + pub(crate) fn new(signing_host: Arc) -> Self { + Self { signing_host } + } + + /// The signing session captured before dispatching one request. + pub(crate) fn current_session(&self) -> Option { + self.signing_host.current_session() + } + + /// Run the platform confirmation seam; rejection and failure both refuse + /// the operation with an opaque reason (host-spec B.7). + async fn confirm(&self, review: UserConfirmationReview) -> Result<(), String> { + match self.signing_host.platform.confirm_user_action(review).await { + Ok(true) => Ok(()), + Ok(false) => Err("Rejected".to_string()), + Err(err) => Err(format!("confirmation failed: {}", err.reason)), + } + } + + async fn serve_sign( + &self, + cx: &SsoRequestContext, + request: SignRequest, + ) -> Result { + match request { + SignRequest::Payload(request) => { + let request = *request; + self.confirm(UserConfirmationReview::SignPayload( + SignPayloadReview::Product(request.clone()), + )) + .await?; + self.signing_host + .sign_payload( + &cx.call, + &cx.session, + SignPayloadAuthorityRequest::Product(request), + ) + .await + } + SignRequest::Raw(request) => { + self.confirm(UserConfirmationReview::SignRaw(SignRawReview::Product( + request.clone(), + ))) + .await?; + self.signing_host + .sign_raw( + &cx.call, + &cx.session, + SignRawAuthorityRequest::Product(request), + ) + .await + } + } + .map_err(|err| err.to_string()) + } + + async fn serve_create_transaction( + &self, + cx: &SsoRequestContext, + review: CreateTransactionReview, + request: CreateTransactionAuthorityRequest, + ) -> Result, String> { + self.confirm(UserConfirmationReview::CreateTransaction(review)) + .await?; + self.signing_host + .create_transaction(&cx.call, &cx.session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.to_string()) + } + + async fn allocate( + &self, + session: &AuthoritySession, + calling_product_id: &str, + resource: api::AllocatableResource, + on_existing: OnExistingAllowancePolicy, + ) -> Result { + let signing_host = &self.signing_host; + let services = &signing_host.services; + match resource { + api::AllocatableResource::StatementStoreAllowance => { + allocate_statement_store_allowance( + services, + signing_host, + session, + calling_product_id, + on_existing, + ) + .await + .map(|slot_account_key| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::StatementStoreAllowance { + slot_account_key, + }) + }) + } + api::AllocatableResource::BulletinAllowance => allocate_bulletin_allowance( + services, + signing_host, + session, + calling_product_id, + on_existing, + ) + .await + .map(|slot_account_key| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key, + }) + }), + api::AllocatableResource::SmartContractAllowance(index) => { + allocate_smart_contract_allowance( + services, + signing_host, + session, + calling_product_id, + index, + on_existing, + ) + .await + .map(|()| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) + }) + } + api::AllocatableResource::AutoSigning => { + let product_root_private_key = signing_host + .product_subtree_secret(calling_product_id) + .map_err(AllowanceAllocationError::Authority)?; + let root_entropy = signing_host.root_entropy()?; + let ring_vrf_domain_entropy = + derive_ring_vrf_domain_entropy(&root_entropy, calling_product_id) + .map_err(super::product_authority_error) + .map_err(AllowanceAllocationError::Authority)?; + Ok(SsoAllocationOutcome::Allocated( + SsoAllocatedResource::AutoSigning { + product_root_private_key, + ring_vrf_domain_entropy, + }, + )) + } + } + } +} + +fn allocation_reply( + payload: Result, String>, + failures: Vec, +) -> SsoReply { + let mut outcome = resource_allocation_outcome(&payload); + if !failures.is_empty() { + let details = failures.join("; ").replace(['\r', '\n'], " "); + outcome.reason = Some(match outcome.reason { + Some(summary) => format!("{summary}: {details}"), + None => details, + }); + } + SsoReply::from(payload).with_outcome(outcome) +} + +/// Transcript outcome for an allocation batch: `ok` only when every requested +/// resource was allocated; otherwise `rejected`, `partial`, or `not_available` +/// with a count summary. +fn resource_allocation_outcome( + payload: &Result, String>, +) -> ResponseOutcome { + let outcomes = match payload { + Ok(outcomes) => outcomes, + Err(reason) => { + return ResponseOutcome { + outcome: "error", + reason: Some(reason.clone()), + }; + } + }; + let total = outcomes.len(); + let count = |wanted: fn(&SsoAllocationOutcome) -> bool| { + outcomes.iter().filter(|outcome| wanted(outcome)).count() + }; + let allocated = count(|outcome| matches!(outcome, SsoAllocationOutcome::Allocated(_))); + let rejected = count(|outcome| matches!(outcome, SsoAllocationOutcome::Rejected)); + let unavailable = count(|outcome| matches!(outcome, SsoAllocationOutcome::NotAvailable)); + if allocated == total { + return ResponseOutcome { + outcome: "ok", + reason: None, + }; + } + if allocated > 0 { + let mut reason = format!("{allocated} of {total} requested resources allocated"); + if rejected > 0 { + reason.push_str(&format!("; {rejected} rejected")); + } + if unavailable > 0 { + reason.push_str(&format!("; {unavailable} unavailable")); + } + return ResponseOutcome { + outcome: "partial", + reason: Some(reason), + }; + } + if rejected > 0 { + let reason = if rejected == total { + if total == 1 { + "Requested resource was rejected".to_string() + } else { + format!("All {total} requested resources were rejected") + } + } else { + format!("No resources allocated; {rejected} rejected; {unavailable} unavailable") + }; + return ResponseOutcome { + outcome: "rejected", + reason: Some(reason), + }; + } + ResponseOutcome { + outcome: "not_available", + reason: Some(if total == 1 { + "Requested resource is not available".to_string() + } else { + format!("None of the {total} requested resources are available") + }), + } +} + +#[truapi_macros::sso_service] +impl SigningHostSsoService { + /// Sign a payload or raw bytes with a product account. + async fn sign(&self, cx: &SsoRequestContext, request: SignRequest) -> SignResponse { + let payload = self.serve_sign(cx, request).await; + if let Err(reason) = &payload { + warn!(%reason, "sign request failed"); + } + payload + } + + /// Derive a contextual alias for a registered ring-VRF key. + async fn get_account_alias( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> GetAccountAliasResponse { + self.signing_host + .account_alias(&cx.call, &cx.session, request) + .await + } + + /// Allocate SSO-backed resources for a product. + async fn resource_allocation( + &self, + cx: &SsoRequestContext, + request: ResourceAllocationRequest, + ) -> ResourceAllocationResponse { + let mut failures = Vec::new(); + let payload = async { + let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { + calling_product_id: request.calling_product_id.clone(), + resources: request.resources.clone(), + }); + match self.signing_host.platform.confirm_user_action(review).await { + Ok(true) => {} + Ok(false) => { + return Ok(vec![ + SsoAllocationOutcome::Rejected; + request.resources.len() + ]); + } + Err(err) => return Err(format!("confirmation failed: {}", err.reason)), + } + + self.signing_host + .require_current_session(&cx.session) + .map_err(|err| err.to_string())?; + let mut outcomes = Vec::with_capacity(request.resources.len()); + for resource in request.resources { + self.signing_host + .require_current_session(&cx.session) + .map_err(|err| err.to_string())?; + let outcome = self + .allocate( + &cx.session, + &request.calling_product_id, + resource, + request.on_existing, + ) + .await; + self.signing_host + .require_current_session(&cx.session) + .map_err(|err| err.to_string())?; + outcomes.push(outcome.unwrap_or_else(|err| { + let reason = err.to_string(); + warn!(%reason, "resource allocation item failed"); + failures.push(reason); + SsoAllocationOutcome::NotAvailable + })); + } + Ok(outcomes) + } + .await; + if let Err(reason) = &payload { + warn!(%reason, "resource allocation request failed"); + } + allocation_reply(payload, failures) + } + + /// Build a signed transaction for a product account. + async fn create_transaction( + &self, + cx: &SsoRequestContext, + request: CreateTransactionRequest, + ) -> CreateTransactionResponse { + let CreateTransactionPayload::V1(payload) = request.payload; + self.serve_create_transaction( + cx, + CreateTransactionReview::Product(payload.clone()), + CreateTransactionAuthorityRequest::Product(payload), + ) + .await + } + + /// Build a signed transaction for the wallet's identity account. + async fn create_transaction_with_legacy_account( + &self, + cx: &SsoRequestContext, + request: CreateTransactionWithLegacyAccountRequest, + ) -> CreateTransactionResponse { + let CreateTransactionLegacyPayload::V1(payload) = request.payload; + self.serve_create_transaction( + cx, + CreateTransactionReview::LegacyAccount(payload.clone()), + CreateTransactionAuthorityRequest::IdentityAccount(payload), + ) + .await + } + + /// Sign raw data with a legacy account. + async fn sign_raw_with_legacy_account( + &self, + cx: &SsoRequestContext, + request: SignRawWithLegacyAccountRequest, + ) -> SignRawWithLegacyAccountResponse { + let public_request = api::HostSignRawWithLegacyAccountRequest { + signer: product_public_key_to_address(request.account), + payload: request.data, + }; + self.confirm(UserConfirmationReview::SignRaw( + SignRawReview::LegacyAccount(public_request.clone()), + )) + .await?; + self.signing_host + .sign_raw( + &cx.call, + &cx.session, + SignRawAuthorityRequest::LegacyAccount { + account: request.account, + request: public_request, + }, + ) + .await + .map(|response| response.signature) + .map_err(|err| err.to_string()) + } + + /// Create a ring-VRF proof bound to a context and message. + async fn create_account_proof( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> CreateAccountProofResponse { + self.signing_host + .create_proof(&cx.call, &cx.session, request) + .await + } + + /// Sign an RFC-0023 VRF transcript. + async fn sign_vrf( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> SignVrfResponse { + self.signing_host + .sign_vrf( + &cx.call, + &cx.session, + request.calling_product_id, + request.payload, + ) + .await + .map_err(api::HostAccountSignVrfError::from) + } + + /// Consent-free product hard-subtree public key. + async fn product_subtree( + &self, + cx: &SsoRequestContext, + request: ProductSubtreeRequest, + ) -> ProductSubtreeResponse { + self.signing_host + .product_subtree_public_key(&cx.call, &cx.session, request.product_id) + .await + .map_err(|err| err.to_string()) + } + + /// Register a ring-VRF key owned by the calling product. + async fn register_ring_vrf_key( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> RegisterRingVrfKeyResponse { + self.signing_host + .register_ring_vrf_key(&cx.call, &cx.session, request) + .await + } + + /// List registered ring-VRF keys. + async fn list_ring_vrf_keys( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> ListRingVrfKeysResponse { + self.signing_host + .list_ring_vrf_keys(&cx.call, &cx.session, request) + .await + } + + /// Sign bytes directly with a registered ring-VRF key. + async fn ring_vrf_sign( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> RingVrfSignResponse { + self.signing_host + .ring_vrf_sign(&cx.call, &cx.session, request) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_allocation_summary_reflects_per_resource_outcomes() { + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Allocated( + SsoAllocatedResource::SmartContractAllowance, + )])); + assert_eq!((result.outcome, result.reason), ("ok", None)); + + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Rejected])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("rejected", Some("Requested resource was rejected")) + ); + + let result = resource_allocation_outcome(&Ok(vec![ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key: vec![1; 64], + }), + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ( + "partial", + Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") + ) + ); + + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::NotAvailable])); + assert_eq!( + (result.outcome, result.reason.as_deref()), + ("not_available", Some("Requested resource is not available")) + ); + } + + #[test] + fn mixed_unallocated_resources_report_rejection() { + let result = resource_allocation_outcome(&Ok(vec![ + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ])); + + assert_eq!( + (result.outcome, result.reason.as_deref()), + ( + "rejected", + Some("No resources allocated; 1 rejected; 1 unavailable") + ) + ); + } + + #[test] + fn allocation_transcript_includes_single_line_item_failures() { + let answer = allocation_reply( + Ok(vec![SsoAllocationOutcome::NotAvailable]), + vec!["rpc\nfailed".to_string(), "provider\rdown".to_string()], + ) + .finish( + "allocation-1", + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse, + ); + + assert_eq!(answer.outcome.outcome, "not_available"); + assert_eq!( + answer.outcome.reason.as_deref(), + Some("Requested resource is not available: rpc failed; provider down") + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index c4125090d..c444b6776 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -9,8 +9,9 @@ use std::sync::Mutex; use super::statement_store_rpc; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ - SsoRemoteResponse, SsoSessionStatement, decode_sso_session_statement, + Response, SsoSessionStatement, decode_sso_session_statement, v1, }; +use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; use futures::channel::oneshot; @@ -244,12 +245,35 @@ fn disconnect_error(reason: String) -> SsoRemoteResponseError { } } -/// Wait for the response matching `remote_message_id`, racing the statement -/// streams against submit failure, cancellation, and disconnect signals. +/// Matcher for [`wait_for_sso_remote_response`]: the response to the request +/// sent as `message_id`. A response addressed to it but of another kind is an +/// error, so a confused peer fails the call instead of stalling it. +pub(super) fn reply_matcher( + message_id: &str, +) -> impl Fn(v1::RemoteMessage) -> Option, String>> + '_ { + move |message| { + if message.responding_to() != Some(message_id) { + return None; + } + let kind = message.name(); + Some( + R::response_from_message(message) + .ok_or_else(|| format!("Unexpected SSO response for {}: {kind}", R::NAME)), + ) + } +} + +/// Wait for the reply `matches` accepts, racing the statement streams against +/// submit failure, cancellation, and disconnect signals. +/// +/// `matches` sees every peer message in wire order: `None` skips a message +/// meant for someone else, `Some(Err(reason))` fails the wait for a message +/// addressed to this request but of the wrong kind. #[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait"))] -pub(super) async fn wait_for_sso_remote_response( +pub(super) async fn wait_for_sso_remote_response( wait: RemoteResponseWait<'_>, -) -> Result { + matches: impl Fn(v1::RemoteMessage) -> Option>, +) -> Result { let RemoteResponseWait { own_statements, peer_statements, @@ -267,6 +291,7 @@ pub(super) async fn wait_for_sso_remote_response( session, statement_request_id, remote_message_id, + &matches, ) .fuse(); let disconnect = async move { @@ -294,14 +319,15 @@ pub(super) async fn wait_for_sso_remote_response( } #[instrument(skip_all, fields(runtime.method = "sso.remote_response.wait_inner"))] -async fn wait_for_sso_remote_response_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 { + matches: &impl Fn(v1::RemoteMessage) -> Option>, +) -> Result { let mut own_statements = own_statements.fuse(); let mut peer_statements = peer_statements.fuse(); let mut submit = submit.fuse(); @@ -325,7 +351,7 @@ async fn wait_for_sso_remote_response_inner( session, &value, statement_request_id, - remote_message_id, + matches, &mut request_accepted, &mut pending_remote_response, )? { @@ -343,7 +369,7 @@ async fn wait_for_sso_remote_response_inner( session, &value, statement_request_id, - remote_message_id, + matches, &mut request_accepted, &mut pending_remote_response, )? { @@ -361,24 +387,19 @@ async fn wait_for_sso_remote_response_inner( } } -fn handle_sso_remote_statement_page( +fn handle_sso_remote_statement_page( session: &SsoSessionInfo, value: &Value, statement_request_id: &str, - remote_message_id: &str, + matches: &impl Fn(v1::RemoteMessage) -> Option>, request_accepted: &mut bool, - pending_remote_response: &mut Option, -) -> Result, SsoRemoteResponseError> { + 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)? + match decode_sso_session_statement(session, &statement, statement_request_id) + .map_err(SsoRemoteResponseError::Failure)? { Some(SsoSessionStatement::RequestAccepted) => { *request_accepted = true; @@ -386,14 +407,22 @@ fn handle_sso_remote_statement_page( return Ok(Some(response)); } } - Some(SsoSessionStatement::RemoteResponse(response)) => { - if *request_accepted { - return Ok(Some(response)); + Some(SsoSessionStatement::RemoteMessages(messages)) => { + for message in messages { + let message = message.map_err(SsoRemoteResponseError::Failure)?; + if message == v1::RemoteMessage::Disconnected { + return Err(SsoRemoteResponseError::PeerDisconnected); + } + let Some(matched) = matches(message) else { + continue; + }; + let response = matched.map_err(SsoRemoteResponseError::Failure)?; + if *request_accepted { + return Ok(Some(response)); + } + *pending_remote_response = Some(response); + break; } - *pending_remote_response = Some(response); - } - Some(SsoSessionStatement::Disconnected) => { - return Err(SsoRemoteResponseError::PeerDisconnected); } None => {} } @@ -444,8 +473,17 @@ fn next_statement_expiry(last: u64, expiry_floor: u64) -> u64 { #[cfg(test)] mod tests { use super::*; - use crate::test_support::sso_session_info; + use crate::host_logic::sso::messages::{ + ProductSubtreeRequest, ProductSubtreeResponse, RemoteMessage, RemoteMessageData, + build_outgoing_request_statement, build_signed_session_response_statement, + }; + use crate::host_logic::sso::pairing::{SsoStatementData, encrypt_session_statement_data}; + use crate::host_logic::statement_store::build_signed_session_request_statement; + use crate::test_support::{ + new_statements_frame, sso_host_and_responder_sessions, sso_session_info, + }; use futures::stream; + use parity_scale_codec::Encode; use std::time::Duration; #[test] @@ -477,6 +515,22 @@ mod tests { value.is_ascii_alphanumeric() || value == b'_' || value == b'-' } + fn pending_wait<'a>( + session: &'a SsoSessionInfo, + cancel: &'a CancellationToken, + ) -> RemoteResponseWait<'a> { + RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::pending().boxed(), + submit: futures::future::pending().boxed(), + session, + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel, + disconnect: None, + } + } + #[test] fn sso_remote_response_waiter_reports_timeout_cancellation() { let session = sso_session_info(); @@ -484,16 +538,10 @@ mod tests { 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, - })) + let err = futures::executor::block_on(wait_for_sso_remote_response( + pending_wait(session.sso.as_ref().unwrap(), &cancel), + |_| None::>, + )) .unwrap_err(); let SsoRemoteResponseError::Cancelled(err) = err else { @@ -508,19 +556,16 @@ mod tests { #[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::default(), - disconnect: None, - })) + let err = futures::executor::block_on(wait_for_sso_remote_response( + RemoteResponseWait { + submit: futures::future::ready(Err(SsoRemoteResponseError::Failure( + "SSO statement submit failed: no allowance".to_string(), + ))) + .boxed(), + ..pending_wait(session.sso.as_ref().unwrap(), &CancellationToken::default()) + }, + |_| None::>, + )) .unwrap_err(); assert_eq!( @@ -536,36 +581,13 @@ mod tests { 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::default(), - 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::default(), - disconnect: Some(rx), - })) + let err = futures::executor::block_on(wait_for_sso_remote_response( + RemoteResponseWait { + disconnect: Some(rx), + ..pending_wait(session.sso.as_ref().unwrap(), &CancellationToken::default()) + }, + |_| None::>, + )) .unwrap_err(); assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); @@ -575,16 +597,10 @@ mod tests { fn sso_remote_response_waiter_stops_on_call_cancellation() { let session = sso_session_info(); let cancel = CancellationToken::default(); - 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, - }); + let wait = wait_for_sso_remote_response( + pending_wait(session.sso.as_ref().unwrap(), &cancel), + |_| None::>, + ); cancel.cancel(); let err = futures::executor::block_on(wait).unwrap_err(); @@ -597,4 +613,149 @@ mod tests { "SSO response wait cancelled by caller for request-1" ); } + + fn peer_page(statement: Vec) -> Value { + let frame: Value = serde_json::from_str(&new_statements_frame("sub", vec![statement])) + .expect("frame is json"); + frame["params"]["result"].clone() + } + + fn subtree_response(responding_to: &str) -> RemoteMessage { + RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(Response { + responding_to: responding_to.to_string(), + payload: Ok([7; 32]), + })), + } + } + + fn wait_for_subtree( + host: &SsoSessionInfo, + pages: Vec, + ) -> Result, SsoRemoteResponseError> { + let cancel = CancellationToken::default(); + futures::executor::block_on(wait_for_sso_remote_response( + RemoteResponseWait { + own_statements: stream::empty().boxed(), + peer_statements: stream::iter(pages.into_iter().map(Ok)).boxed(), + submit: futures::future::ready(Ok(())).boxed(), + ..pending_wait(host, &cancel) + }, + reply_matcher::("request-1"), + )) + } + + /// A peer that answers with another response kind must fail the call at + /// once; skipping it would leave the product waiting for the timeout. + #[test] + fn a_reply_of_the_wrong_kind_fails_instead_of_waiting() { + let (host, responder) = sso_host_and_responder_sessions(); + let wrong_kind = RemoteMessage { + message_id: "resp-1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(Response { + responding_to: "request-1".to_string(), + payload: Err("nope".to_string()), + })), + }; + let statement = build_outgoing_request_statement( + &responder, + "resp-statement".to_string(), + vec![wrong_kind], + fresh_statement_expiry(), + ) + .unwrap(); + + let err = wait_for_subtree(&host, vec![peer_page(statement)]).unwrap_err(); + + assert_eq!( + err, + SsoRemoteResponseError::Failure( + "Unexpected SSO response for product_subtree: SignResponse".to_string() + ) + ); + } + + /// Messages are read in wire order: a reply that arrives before the peer's + /// `Disconnected` in the same statement is still delivered. + #[test] + fn a_matching_reply_earlier_in_a_batch_wins_over_a_later_disconnect() { + let (host, responder) = sso_host_and_responder_sessions(); + let ack = build_signed_session_response_statement( + &responder, + "request-1".to_string(), + 0, + fresh_statement_expiry(), + ) + .unwrap(); + let disconnect = RemoteMessage { + message_id: "bye".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let statement = build_outgoing_request_statement( + &responder, + "resp-statement".to_string(), + vec![subtree_response("request-1"), disconnect], + fresh_statement_expiry(), + ) + .unwrap(); + + let response = wait_for_subtree(&host, vec![peer_page(ack), peer_page(statement)]).unwrap(); + + assert_eq!( + response, + Response { + responding_to: "request-1".to_string(), + payload: Ok([7; 32]), + } + ); + } + + /// Only messages read before the match have to decode; garbage after it + /// belongs to nobody the waiter cares about. + #[test] + fn an_undecodable_message_only_matters_before_the_match() { + let (host, responder) = sso_host_and_responder_sessions(); + let ack = build_signed_session_response_statement( + &responder, + "request-1".to_string(), + 0, + fresh_statement_expiry(), + ) + .unwrap(); + let statement_with = |data: Vec>| { + let encrypted = encrypt_session_statement_data( + &responder, + &SsoStatementData::Request { + request_id: "resp-statement".to_string(), + data, + }, + ) + .unwrap(); + build_signed_session_request_statement(&responder, encrypted, fresh_statement_expiry()) + .unwrap() + }; + let garbage = vec![0xff, 0xff, 0xff]; + let reply = subtree_response("request-1").encode(); + + let after = wait_for_subtree( + &host, + vec![ + peer_page(ack.clone()), + peer_page(statement_with(vec![reply.clone(), garbage.clone()])), + ], + ); + let before = wait_for_subtree( + &host, + vec![ + peer_page(ack), + peer_page(statement_with(vec![garbage, reply])), + ], + ); + + assert!(after.is_ok()); + assert!( + matches!(before, Err(SsoRemoteResponseError::Failure(reason)) if reason.contains("invalid SSO remote message")) + ); + } } diff --git a/rust/crates/truapi-server/src/runtime/sso_service.rs b/rust/crates/truapi-server/src/runtime/sso_service.rs new file mode 100644 index 000000000..4e0daf1ff --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_service.rs @@ -0,0 +1,114 @@ +//! Context and reply types shared by SSO handlers and generated dispatch. + +use core::fmt::Display; + +use truapi::{CallContext, RequestId}; + +use super::authority::AuthoritySession; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1}; +use crate::host_logic::sso::wire::ResponseOutcome; + +/// Per-request context handed to every service method. +pub(crate) struct SsoRequestContext { + /// Call context correlated to the request's `message_id`. + pub(crate) call: CallContext, + /// Signing session resolved once for the request. + pub(crate) session: AuthoritySession, +} + +impl SsoRequestContext { + /// Context for the request sent as `message_id`. + pub(crate) fn new(message_id: &str, session: AuthoritySession) -> Self { + Self { + call: CallContext::with_request_id(RequestId::from(message_id)), + session, + } + } +} + +/// What the service dispatcher produced for one wire message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Dispatch { + /// Response to post back, with its transcript outcome. + Response(Box), + /// The peer ended the session. + Disconnected, + /// A response variant arrived where only requests are expected. + NotARequest(&'static str), +} + +/// A served request: the response envelope and how the transcript reports it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Answer { + /// Response envelope, `message_id` suffixed with `:response`. + pub(crate) message: RemoteMessage, + /// Transcript classification of the payload. + pub(crate) outcome: ResponseOutcome, +} + +/// A handler's payload and optional transcript outcome, before wire wrapping. +pub(crate) struct SsoReply

{ + payload: P, + outcome: Option, +} + +impl

From

for SsoReply

{ + fn from(payload: P) -> Self { + Self { + payload, + outcome: None, + } + } +} + +impl

SsoReply

{ + /// Supply a transcript outcome when the payload alone does not describe the result. + pub(crate) fn with_outcome(mut self, outcome: ResponseOutcome) -> Self { + self.outcome = Some(outcome); + self + } +} + +impl SsoReply> { + /// Address the reply and wrap it in the response variant selected by the request. + pub(crate) fn finish( + self, + message_id: &str, + wrap: impl FnOnce(Response>) -> v1::RemoteMessage, + ) -> Answer { + let outcome = self + .outcome + .unwrap_or_else(|| ResponseOutcome::from_payload(&self.payload)); + Answer { + message: RemoteMessage { + message_id: format!("{message_id}:response"), + data: RemoteMessageData::V1(wrap(Response { + responding_to: message_id.to_string(), + payload: self.payload, + })), + }, + outcome, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{ProductSubtreeRequest, ProductSubtreeResponse}; + use crate::host_logic::sso::wire::SsoRequest; + + #[test] + fn finish_addresses_the_response_to_the_request() { + let answer = SsoReply::::from(Err("nope".to_string())) + .finish("m-1", ProductSubtreeRequest::response_into_message); + + assert_eq!(answer.message.message_id, "m-1:response"); + let RemoteMessageData::V1(data) = answer.message.data; + let response = ProductSubtreeRequest::response_from_message(data).unwrap(); + assert_eq!(response.responding_to, "m-1"); + assert_eq!(response.payload, Err("nope".to_string())); + assert_eq!(answer.outcome.outcome, "error"); + assert_eq!(answer.outcome.reason.as_deref(), Some("nope")); + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b9849d763..560614e8b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -571,7 +571,7 @@ mod tests { 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 { + crate::host_logic::sso::messages::Response { responding_to: "proof-auth-1".to_string(), payload: Ok(vec![ crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( @@ -622,7 +622,7 @@ mod tests { ); assert_eq!( request.resources, - vec![crate::host_logic::sso::messages::SsoAllocatableResource::StatementStoreAllowance] + vec![truapi::latest::AllocatableResource::StatementStoreAllowance] ); } diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index f900e821b..9c6b54c58 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -55,9 +55,7 @@ use truapi_platform::{AuthState, CoreStorageKey, PermissionAuthorizationRequest} use super::*; use crate::host_logic::product_account::index_bytes; -use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, RingVrfAliasResponse, RingVrfProofResponse, v1, -}; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1}; use crate::test_support::*; fn test_product_subtree(product_id: &str) -> [u8; 32] { @@ -1196,15 +1194,13 @@ fn get_account_alias_forwards_without_pairing_host_confirmation() { &session, RemoteMessage { message_id: "wallet-alias-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( - RingVrfAliasResponse { - responding_to: "alias-1".to_string(), - payload: Ok(v01::ContextualAlias { - context: [9; 32], - alias: vec![1, 2, 3], - }), - }, - )), + data: RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse(Response { + responding_to: "alias-1".to_string(), + payload: Ok(v01::ContextualAlias { + context: [9; 32], + alias: vec![1, 2, 3], + }), + })), }, )), ..Default::default() @@ -1224,13 +1220,13 @@ fn get_account_alias_forwards_without_pairing_host_confirmation() { assert_eq!(inner.context, [9; 32]); assert_eq!(inner.alias, vec![1, 2, 3]); let message = submitted_remote_message(&platform, &session); - let RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasRequest(request)) = message.data + let RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasRequest(request)) = message.data else { panic!("expected ring VRF alias request"); }; assert_eq!(request.calling_product_id, "myapp.dot"); - assert_eq!(request.context.product_id, "myapp.dot"); - assert_eq!(request.ring_location.chain_id, [1; 32]); + assert_eq!(request.payload.context.product_id, "myapp.dot"); + assert_eq!(request.payload.ring_location.chain_id, [1; 32]); } #[test] @@ -1258,8 +1254,8 @@ fn create_account_proof_returns_sso_proof() { &session, RemoteMessage { message_id: "wallet-proof-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( - RingVrfProofResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse( + Response { responding_to: "proof-1".to_string(), payload: Ok(v01::HostAccountCreateProofResponse { proof: vec![0xaa, 0xbb], @@ -1292,13 +1288,13 @@ fn create_account_proof_returns_sso_proof() { assert_eq!(inner.ring_index, 5); assert_eq!(inner.ring_revision, 7); let message = submitted_remote_message(&platform, &session); - let RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofRequest(request)) = message.data + let RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofRequest(request)) = message.data else { panic!("expected ring VRF proof request"); }; assert_eq!(request.calling_product_id, "myapp.dot"); - assert_eq!(request.context.product_id, "myapp.dot"); - assert_eq!(request.message, vec![4, 5, 6]); + assert_eq!(request.payload.context.product_id, "myapp.dot"); + assert_eq!(request.payload.message, vec![4, 5, 6]); } #[test] @@ -1309,8 +1305,8 @@ fn create_account_proof_maps_not_member_error() { &session, RemoteMessage { message_id: "wallet-proof-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( - RingVrfProofResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse( + Response { responding_to: "proof-1".to_string(), payload: Err(RingVrfError::NotMember), }, @@ -1858,9 +1854,9 @@ fn legacy_create_transaction_accepts_identity_account_then_routes_legacy_request message_id: "wallet-identity-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 { + crate::host_logic::sso::messages::Response { responding_to: "identity-create-tx-1".to_string(), - signed_transaction: Ok(vec![0xca, 0xfe]), + payload: Ok(vec![0xca, 0xfe]), }, ), ), @@ -1891,7 +1887,7 @@ fn legacy_create_transaction_accepts_identity_account_then_routes_legacy_request 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::CreateTransactionLegacyRequest( + crate::host_logic::sso::messages::v1::RemoteMessage::CreateTransactionWithLegacyAccountRequest( request, ), ) = message.data @@ -1915,9 +1911,9 @@ fn legacy_create_transaction_accepts_derived_key_then_returns_sso_response() { 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 { + crate::host_logic::sso::messages::Response { responding_to: "legacy-create-tx-1".to_string(), - signed_transaction: Ok(vec![0xca, 0xfe]), + payload: Ok(vec![0xca, 0xfe]), }, ), ), @@ -2031,7 +2027,7 @@ fn resource_allocation_respects_a_shorter_call_context_timeout() { message_id: "wallet-allocation-timeout".to_string(), data: crate::host_logic::sso::messages::RemoteMessageData::V1( crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse( - crate::host_logic::sso::messages::ResourceAllocationResponse { + crate::host_logic::sso::messages::Response { responding_to: message_id.to_string(), payload: Ok(vec![]), }, @@ -2089,7 +2085,7 @@ fn resource_allocation_accepts_confirmation_then_returns_sso_response() { 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 { + crate::host_logic::sso::messages::Response { responding_to: "alloc-1".to_string(), payload: Ok(vec![ crate::host_logic::sso::messages::SsoAllocationOutcome::Allocated( @@ -2147,7 +2143,7 @@ fn auto_signing_test_platform(session: &SessionInfo, request_id: &str) -> Arc, pub(crate) resource_allocation_confirmed: bool, pub(crate) resource_allocation_error: Option<&'static str>, + /// Pause a resource review until the test releases its confirmation. + pub(crate) resource_allocation_confirmation_gate: + Mutex>>, /// Every `ResourceAllocation` review passed to `confirm_user_action`, in order. pub(crate) resource_allocation_reviews: Arc>>, pub(crate) session_blob: Option>, @@ -259,6 +262,56 @@ pub(crate) fn session_info() -> crate::host_logic::session::SessionInfo { } } +/// A pairing host and the signing host it paired with, each holding its own +/// side of one SSO session. +pub(crate) fn sso_host_and_responder_sessions() -> (SsoSessionInfo, SsoSessionInfo) { + use crate::host_logic::sso::pairing::{ + ResponderIdentity, create_pairing_bootstrap, derive_x25519_keypair_from_entropy, + establish_responder_session_info, establish_sso_session_info, + }; + use truapi_platform::{HostInfo, PairingHostConfig, PlatformInfo}; + + let config = PairingHostConfig::new( + HostInfo { + name: "Test Host".to_string(), + icon: None, + version: None, + platform: truapi::latest::HostPlatform::Unknown, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + [0xcc; 32], + "polkadotapp".to_string(), + ) + .expect("test pairing config is valid"); + let bootstrap = create_pairing_bootstrap(&config).unwrap(); + let statement_keypair = MiniSecretKey::from_bytes(&[7; 32]) + .unwrap() + .expand_to_keypair(ExpansionMode::Ed25519); + let (encryption_secret_key, encryption_public_key) = + derive_x25519_keypair_from_entropy(&[0xAB; 16], b"sso"); + let responder = ResponderIdentity { + statement_secret: statement_keypair.secret.to_bytes(), + statement_public_key: statement_keypair.public.to_bytes(), + encryption_secret_key, + encryption_public_key, + }; + let responder_session = establish_responder_session_info( + &responder, + bootstrap.statement_store_public_key, + bootstrap.encryption_public_key, + ) + .unwrap(); + let host_session = establish_sso_session_info( + &bootstrap, + responder.statement_public_key, + responder.encryption_public_key, + ) + .unwrap(); + (host_session, responder_session) +} + /// Connected session fixture with deterministic SSO channel material. pub(crate) fn sso_session_info() -> crate::host_logic::session::SessionInfo { let mut session = session_info(); @@ -593,14 +646,12 @@ pub(crate) fn sign_response_message( 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 { + crate::host_logic::sso::messages::Response { responding_to: message_id.to_string(), - payload: Ok( - crate::host_logic::sso::messages::SigningPayloadResponseData { - signature, - signed_transaction, - }, - ), + payload: Ok(truapi::latest::HostSignPayloadResponse { + signature, + signed_transaction, + }), }, ), ), @@ -615,10 +666,10 @@ pub(crate) fn sign_raw_legacy_response_message( 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::SignRawLegacyResponse( - crate::host_logic::sso::messages::SignRawLegacyResponse { + crate::host_logic::sso::messages::v1::RemoteMessage::SignRawWithLegacyAccountResponse( + crate::host_logic::sso::messages::Response { responding_to: message_id.to_string(), - signature: Ok(signature), + payload: Ok(signature), }, ), ), @@ -699,7 +750,7 @@ pub(crate) fn sign_payload_data() -> v01::HostSignPayloadData { asset_id: None, metadata_hash: None, mode: None, - with_signed_transaction: None, + with_signed_transaction: parity_scale_codec::OptionBool(None), } } @@ -1001,42 +1052,8 @@ async fn wait_for_rpc_method_id( 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::RingVrfProofResponse(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::SignVrfResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::RegisterRingVrfKeyResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::ListRingVrfKeysResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse(response)) => { - response.responding_to = message_id.to_string(); - } - RemoteMessageData::V1(v1::RemoteMessage::CreateTransactionResponse(response)) => { - response.responding_to = message_id.to_string(); - } - _ => {} - } + let RemoteMessageData::V1(data) = response.data; + response.data = RemoteMessageData::V1(data.with_responding_to(message_id.to_string())); response } @@ -1544,6 +1561,14 @@ impl UserConfirmation for StubPlatform { .lock() .expect("resource allocation review list mutex poisoned") .push(review); + let gate = self + .resource_allocation_confirmation_gate + .lock() + .expect("resource confirmation gate mutex poisoned") + .take(); + if let Some(gate) = gate { + gate.await.expect("resource confirmation gate was released"); + } ( self.resource_allocation_error, self.resource_allocation_confirmed, diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06d3557f4..9b13fb6fd 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -54,14 +54,17 @@ pub mod latest { AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, - ContextualAlias, DerivationIndex, GenericError, HostPlatform, HostSignPayloadData, + ContextualAlias, DerivationIndex, GenericError, HostAccountCreateProofRequest, + HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, + HostAccountSignVrfError, HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, NotificationId, OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, RingVrfKeyDisclosure, RingVrfPublicKey, RuntimeApi, RuntimeSpec, RuntimeType, SignedStatement, Statement, StatementProof, StorageQueryItem, StorageQueryType, - StorageResultItem, ThemeName, ThemeVariant, TxPayloadExtension, + StorageResultItem, ThemeName, ThemeVariant, TxPayloadExtension, VrfSignature, }; /// Latest payload type of a versioned envelope. diff --git a/rust/crates/truapi/src/v01/account.rs b/rust/crates/truapi/src/v01/account.rs index 56fadf35a..b65acdd2d 100644 --- a/rust/crates/truapi/src/v01/account.rs +++ b/rust/crates/truapi/src/v01/account.rs @@ -399,13 +399,14 @@ pub struct VrfSignature { } /// Error returned when VRF signing fails. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum HostAccountSignVrfError { /// User is not logged in. NotConnected, /// User or host rejected the signing confirmation. Rejected, /// Catch-all. + #[display("{reason}")] Unknown { /// Human-readable failure reason. reason: String, diff --git a/rust/crates/truapi/src/v01/signing.rs b/rust/crates/truapi/src/v01/signing.rs index 45459878d..f32cc6139 100644 --- a/rust/crates/truapi/src/v01/signing.rs +++ b/rust/crates/truapi/src/v01/signing.rs @@ -1,4 +1,4 @@ -use parity_scale_codec::{Decode, Encode}; +use parity_scale_codec::{Decode, Encode, OptionBool}; use super::ProductAccountId; @@ -35,8 +35,8 @@ pub struct HostSignPayloadData { pub metadata_hash: Option>, /// Metadata mode. pub mode: Option, - /// Request signed transaction back. - pub with_signed_transaction: Option, + /// Request signed transaction back, encoded as one byte: absent, true, or false. + pub with_signed_transaction: OptionBool, } /// Request to sign an extrinsic payload with a product account.