From b08460aa2e5b3f73d057a236f4cc03d5cc2477de Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 8 Sep 2026 20:53:16 +0000 Subject: [PATCH 1/8] refactor(sso): serve inter-host requests through typed handlers --- CLAUDE.md | 21 + Cargo.lock | 1 + README.md | 4 + docs/rfcs/0024-personhood-as-product.md | 2 +- rust/crates/truapi-server/Cargo.toml | 1 + rust/crates/truapi-server/README.md | 72 +- rust/crates/truapi-server/src/host_core.rs | 30 +- .../truapi-server/src/host_logic/sso.rs | 3 +- .../src/host_logic/sso/messages.rs | 941 +++++-------- .../src/host_logic/sso/messages/v1.rs | 64 +- .../truapi-server/src/host_logic/sso/wire.rs | 416 ++++++ rust/crates/truapi-server/src/runtime.rs | 3 +- .../truapi-server/src/runtime/authority.rs | 80 +- .../src/runtime/capabilities/account.rs | 16 +- .../truapi-server/src/runtime/pairing_host.rs | 89 +- .../src/runtime/pairing_host/sso_channel.rs | 768 ++++------- .../truapi-server/src/runtime/signing_host.rs | 57 +- .../src/runtime/signing_host/sso_responder.rs | 1206 +++++------------ .../src/runtime/signing_host/sso_service.rs | 474 +++++++ .../truapi-server/src/runtime/sso_remote.rs | 370 +++-- .../truapi-server/src/runtime/sso_service.rs | 130 ++ .../crates/truapi-server/src/runtime/tests.rs | 20 +- .../src/runtime/tests/signing.rs | 11 +- rust/crates/truapi-server/src/test_support.rs | 107 +- 24 files changed, 2551 insertions(+), 2335 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/sso/wire.rs create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs create mode 100644 rust/crates/truapi-server/src/runtime/sso_service.rs diff --git a/CLAUDE.md b/CLAUDE.md index 1db2fb102..176443f27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,27 @@ 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 messages (`truapi-server/src/host_logic/sso/`) are an RPC + surface: the hand-written `v1::RemoteMessage` enum is the wire spec and + derives `SsoWire` for classification, request wrapping, and correlation + helpers (variant names equal payload type names). Response structs derive + `SsoResponse`. A dedicated inherent `impl SigningHostSsoService` in + `runtime/signing_host/sso_service.rs` carries `#[sso_service]`; every method + in that block is a handler naming its wire request and wire response. + The macro generates request/response pairing and an exhaustive `dispatch()` + method from those signatures. Constructors and helpers live in a separate, + unannotated impl. Shared responses are named directly by both handlers. + Bodies return ordinary `Result` payloads or explicit replies with a transcript + outcome; the service uses native async methods. + Method-specific diagnostics are collected and summarized by their handler; + other replies derive the outcome from the wire response payload. + The macro wraps handler results in `SsoReply`; dispatch adds + correlation ids. Request context carries only the call and signing session. + The pairing host sends through + `PairingHost::call(request)` using the same generated pairing. + Never add per-variant match lists for pairing, correlation, or transcript + outcome outside those derives; a new message is two payload structs, two + enum variants, and one handler; the client uses the typed `call` method. - 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 289c402a4..3fe8d12de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5482,6 +5482,7 @@ dependencies = [ "tracing", "tracing-subscriber", "truapi", + "truapi-macros", "truapi-platform", "unicode-normalization", "uniffi", diff --git a/README.md b/README.md index d6a684304..a37262ce1 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. +SSO resource consent is bound to the signing session that received the request; +switching accounts or reconnecting while approval is pending invalidates it. +See the core's [inter-host SSO design](rust/crates/truapi-server/README.md#inter-host-sso) +for the handler declarations and typed wire contracts. ### 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/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..c939fa354 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -226,14 +226,24 @@ role-specific lifecycle, so no method exists on a role that can't mean it: - **`PairingHost`** (seedless): the user's keys live in an external wallet, so signing/aliases/entropy relay over an encrypted SSO channel (statement store - on the People chain; the channel lives in `pairing_host/sso_channel.rs`). The - v2 wire protocol uses raw X25519 keys, HKDF-SHA256, and - ChaCha20-Poly1305. It owns pairing/login state, persisted auth-session reload, - and remote signing-host liveness monitoring. + on the People chain; the channel lives in `pairing_host/sso_channel.rs`, + whose one generic `call(request)` sends any `SsoRequest` and returns its + typed response payload). The v2 wire protocol uses raw X25519 keys, + HKDF-SHA256, and ChaCha20-Poly1305. It owns pairing/login state, persisted + auth-session reload, and remote signing-host liveness monitoring. - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session - from host-held secret material. Its public identity is the RFC-0022 - `uid.` index-0 product account of the configured network. RFC-0024 ring-VRF keys are explicit, + from host-held secret material. Paired hosts' requests reach it through the + `SigningHostSsoService` handlers (`signing_host/sso_service.rs`, one method + per wire request in an inherent impl annotated with `#[sso_service]`). The + macro generates the service's dispatcher. Handlers own consent prompts and + revalidate the request's signing session after resource consent and allocation; + `signing_host/sso_responder.rs` runs the statement-store serve loop and holds + the shared allowance helpers. Those helpers use the caller's session through + chain reads and revalidate it before allocation and key return. + Its public identity is the RFC-0022 + `uid.` index-0 product account of the configured network. RFC-0024 + ring-VRF keys are explicit, product-owned registry entries; aliases, proofs, direct signatures, and internal personhood flows use the requested or user-selected registered key without a compiled-in fallback. It resolves RFC-0004 `RingLocation` values @@ -247,8 +257,54 @@ role-specific lifecycle, so no method exists on a role that can't mean it: call. `host_logic` stays pure: the orchestrators above call into it for codecs, -session/SSO crypto, key derivation, and permission policy, while all I/O -(statement-store RPC, storage, prompts, chain RPC) stays in the layers above. +session/SSO crypto, SSO wire types and traits (`sso/wire.rs`), key derivation, +and permission policy. The runtime service supplies request/response pairing; +all I/O (statement-store RPC, storage, prompts, chain RPC) stays in the layers +above. + +### Inter-host SSO + +The hand-written `host_logic::sso::messages::v1::RemoteMessage` enum defines +the wire variants and their SCALE indices. `SsoWire` derives classification, +request wrapping, correlation helpers, and message names from that enum. +These helpers do not require a runtime service implementation. Response +structs derive `SsoResponse` to expose their `Result` payload and classify +its transcript outcome. + +Each method in the annotated `impl SigningHostSsoService` names its wire request +and wire response directly. `#[sso_service]` derives request/response pairing +and an exhaustive `dispatch` method from those handler signatures. It wraps +ordinary `Result` bodies in `SsoReply`, preserving `?` and early +returns. Constructors and helpers live in a separate, unannotated impl; service +methods use native async functions. Dispatch adds the correlation id and constructs +the wire response. Request context holds the call context and captured signing +session. The allocation handler collects +item failures locally and supplies a transcript outcome with those details; +other replies derive their outcome from the response payload. + +An additional SSO operation requires payload definitions, wire variants, one +handler in the annotated impl, and a typed client call. The macro's checked-in +compiler tests cover valid handler bodies and reject incomplete or incompatible +contracts. + +`PairingHost::call(request)` uses the generated pairing and rejects a response +of the wrong kind immediately. Alias, proof, and ring-VRF operations share +request types between the local authority and SSO service. Transcripts and the +client's `action` field use the service method name; forwarding spans distinguish +payload signing, raw signing, and transaction creation, with `account_kind` +identifying product, legacy, or identity accounts as applicable. + +The SSO macros share `truapi-macros`' proc-macro infrastructure. They are +server-specific: their generated `crate::host_logic` and `crate::runtime` +paths resolve only when invoked inside `truapi-server`. The canonical `truapi` +crate uses the other macros and has no dependency on the server runtime. + +Rust consumers of the public `host_logic::sso` module depend on its Rust API +as well as the wire format. Stable SCALE indices and payload layouts do not +make renamed types or removed helpers source-compatible. Construct outgoing +requests with `RemoteMessage::request(message_id, typed_request)`; decoded +`SsoSessionStatement::RemoteMessages` contains ordered wire messages, which can +be matched directly or unwrapped with `SsoResponse::from_message`. ## Wire envelope diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index c0863017e..e26a3a8fd 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.services.clone(), 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, } } } @@ -2246,7 +2238,7 @@ mod tests { #[test] fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1, + RemoteMessage, RemoteMessageData, SignRawWithLegacyAccountResponse, v1, }; use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; @@ -2279,8 +2271,8 @@ mod tests { let response_variant = RemoteMessage { message_id: "m2".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse( - SignRawLegacyResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountResponse( + SignRawWithLegacyAccountResponse { responding_to: "m2".to_string(), signature: Ok(vec![]), }, 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..9eba5541e 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`]; the signing host answers through +//! [`RemoteMessage::response`]. Which response answers which request is typed +//! by the annotated handler signatures (see `sso::wire`). //! The encrypted statement envelope and message identifiers are specified in //! host-spec: //! @@ -30,6 +29,7 @@ use truapi::latest::{ ProductProofContext, RawPayload, RingLocation, }; use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; +use truapi_macros::SsoResponse; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -37,6 +37,7 @@ use crate::host_logic::sso::pairing::{ encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, peer_response_channel, }; +use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::{ build_signed_session_request_statement, build_signed_statement, current_unix_secs, decode_verified_statement_data, statement_expiry_elapsed, @@ -75,8 +76,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,10 +85,9 @@ 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), } @@ -109,7 +108,7 @@ pub enum SsoRequestOutcome { /// Signing request flavor sent to the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum SigningRequest { +pub enum SignRequest { /// Sign a full Substrate extrinsic payload. Payload(Box), /// Sign raw bytes or a string message. @@ -158,8 +157,8 @@ pub struct SigningPayloadRequest { pub with_signed_transaction: OptionBool, } -impl SigningPayloadRequest { - fn from_host_request(value: truapi::v01::HostSignPayloadRequest) -> Self { +impl From for SigningPayloadRequest { + fn from(value: truapi::v01::HostSignPayloadRequest) -> Self { let payload = value.payload; Self { product_account_id: value.account, @@ -221,22 +220,13 @@ pub struct SigningRawRequest { pub data: SigningRawPayload, } -impl SigningRawRequest { - fn from_host_request(value: truapi::v01::HostSignRawRequest) -> Self { - Self { - product_account_id: value.account, - data: value.payload.into(), - } - } -} - /// Request sent when a product asks the paired signing host to sign raw data with a /// user-imported legacy account. /// /// 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. @@ -274,6 +264,15 @@ impl From for RawPayload { } } +impl From for SigningRawRequest { + fn from(value: truapi::v01::HostSignRawRequest) -> Self { + Self { + product_account_id: value.account, + data: value.payload.into(), + } + } +} + impl From for truapi::v01::HostSignRawRequest { fn from(value: SigningRawRequest) -> Self { Self { @@ -287,8 +286,8 @@ impl From for truapi::v01::HostSignRawRequest { /// /// 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 { +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] +pub struct SignResponse { /// `message_id` of the signing request being answered. pub responding_to: String, /// Signing result, or an error description from the signing host. @@ -306,10 +305,10 @@ pub struct SigningPayloadResponseData { /// 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 { +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] +pub struct SignRawWithLegacyAccountResponse { /// `message_id` of the legacy raw-signing request being answered. pub responding_to: String, /// Signature bytes, or an error description from the signing host. @@ -326,7 +325,7 @@ pub struct SignVrfRequest { } /// RFC-0023 VRF-signing response returned by the Account Holder. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct SignVrfResponse { /// `message_id` of the VRF-signing request being answered. pub responding_to: String, @@ -361,7 +360,7 @@ pub enum RingVrfError { /// 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 { +pub struct GetAccountAliasRequest { /// Product id of the calling product. pub calling_product_id: String, /// Explicit ring-VRF key handle. @@ -373,8 +372,8 @@ pub struct RingVrfAliasRequest { } /// Response returned by the Account Holder for a ring-VRF alias request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfAliasResponse { +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] +pub struct GetAccountAliasResponse { /// `message_id` of the alias request being answered. pub responding_to: String, /// Derived alias, or the ring-VRF failure. @@ -387,7 +386,7 @@ pub struct RingVrfAliasResponse { /// 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 { +pub struct CreateAccountProofRequest { /// Product id of the calling product. pub calling_product_id: String, /// Explicit ring-VRF key handle. @@ -412,7 +411,7 @@ pub struct RegisterRingVrfKeyRequest { } /// Response returned by the Account Holder for key registration. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct RegisterRingVrfKeyResponse { /// `message_id` of the registration request being answered. pub responding_to: String, @@ -432,7 +431,7 @@ pub struct ListRingVrfKeysRequest { } /// Response returned by the Account Holder for registry listing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct ListRingVrfKeysResponse { /// `message_id` of the listing request being answered. pub responding_to: String, @@ -452,7 +451,7 @@ pub struct RingVrfSignRequest { } /// Response returned by the Account Holder for direct ring-VRF signing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct RingVrfSignResponse { /// `message_id` of the signing request being answered. pub responding_to: String, @@ -461,8 +460,8 @@ pub struct RingVrfSignResponse { } /// Response returned by the Account Holder for a ring-VRF proof request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfProofResponse { +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] +pub struct CreateAccountProofResponse { /// `message_id` of the proof request being answered. pub responding_to: String, /// Created proof, or the ring-VRF failure. @@ -522,7 +521,8 @@ pub enum OnExistingAllowancePolicy { } /// Response returned by the signing host for a resource-allocation request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] +#[sso(outcome = resource_allocation_outcome)] pub struct ResourceAllocationResponse { /// `message_id` of the allocation request being answered. pub responding_to: String, @@ -541,6 +541,72 @@ pub enum SsoAllocationOutcome { NotAvailable, } +/// Transcript outcome for an allocation batch: `ok` only when every requested +/// resource was allocated; otherwise `rejected`, `partial`, or `not_available` +/// with a count summary. +pub 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") + }), + } +} + /// Resource material allocated by the signing host. #[derive(Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatedResource { @@ -591,7 +657,7 @@ pub struct ProductSubtreeRequest { } /// Account Holder response carrying a product subtree public key. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct ProductSubtreeResponse { /// `message_id` of the subtree request being answered. pub responding_to: String, @@ -617,7 +683,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, } @@ -631,7 +697,7 @@ pub enum CreateTransactionLegacyPayload { /// Response returned by the signing host for either product-account or legacy-account /// transaction creation. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] +#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] pub struct CreateTransactionResponse { /// `message_id` of the transaction-creation request being answered. pub responding_to: String, @@ -645,56 +711,9 @@ pub struct CreateTransactionResponse { 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 +721,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 +756,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 +780,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,9 +957,11 @@ fn outgoing_request_data( mod tests { use super::*; use crate::host_logic::sso::pairing::decrypt_session_statement_data; + use crate::host_logic::sso::wire::SsoResponse; 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::{HostSignPayloadData, TxPayloadExtension}; use truapi::v01::RingLocationJunction; @@ -1277,19 +1009,21 @@ 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 { - account: account(), - payload: RawPayload::Bytes { - bytes: vec![0xde, 0xad], + SignRequest::Raw(SigningRawRequest::from( + truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Bytes { + bytes: vec![0xde, 0xad], + }, }, - }, + )), ); let encoded = message.encode(); @@ -1309,25 +1043,34 @@ 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(), - LegacyAccountTxPayload { - signer: [1; 32], - genesis_hash: [2; 32], - call_data: Vec::new(), - extensions: Vec::new(), - tx_ext_version: 0, + 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 = - sign_raw_legacy_message(String::new(), [1; 32], RawPayload::Bytes { bytes: vec![] }) - .encode(); - let register = register_ring_vrf_key_message( + let legacy_raw = RemoteMessage::request( String::new(), - "caller.dot".to_string(), - DerivationIndex::Index(0), - ring_location.clone(), + SignRawWithLegacyAccountRequest { + account: [1; 32], + data: RawPayload::Bytes { bytes: vec![] }.into(), + }, + ) + .encode(); + let register = RemoteMessage::request( + String::new(), + RegisterRingVrfKeyRequest { + calling_product_id: "caller.dot".to_string(), + index: DerivationIndex::Index(0), + ring: ring_location.clone(), + }, ) .encode(); let register_response = RemoteMessage { @@ -1340,11 +1083,13 @@ mod tests { )), } .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, + ListRingVrfKeysRequest { + calling_product_id: "caller.dot".to_string(), + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::Anonymized, + }, ) .encode(); let list_response = RemoteMessage { @@ -1357,9 +1102,15 @@ mod tests { )), } .encode(); - let sign = - ring_vrf_sign_message(String::new(), "caller.dot".to_string(), key_handle, vec![]) - .encode(); + let sign = RemoteMessage::request( + String::new(), + RingVrfSignRequest { + calling_product_id: "caller.dot".to_string(), + key_handle, + message: vec![], + }, + ) + .encode(); let sign_response = RemoteMessage { message_id: String::new(), data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfSignResponse( @@ -1452,20 +1203,24 @@ 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(), + GetAccountAliasRequest { + calling_product_id: "caller.dot".to_string(), + 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(), + CreateAccountProofRequest { + calling_product_id: "caller.dot".to_string(), + key_handle, + context, + ring_location, + message: b"vote".to_vec(), + }, ); assert_host_papp_0_8_11_fixture( @@ -1486,8 +1241,8 @@ mod tests { }; let alias_response = RemoteMessage { message_id: "r-alias".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfAliasResponse( - RingVrfAliasResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse( + GetAccountAliasResponse { responding_to: "m-alias".to_string(), payload: Ok(contextual_alias.clone()), }, @@ -1495,8 +1250,8 @@ mod tests { }; let proof_response = RemoteMessage { message_id: "r-proof".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::RingVrfProofResponse( - RingVrfProofResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse( + CreateAccountProofResponse { responding_to: "m-proof".to_string(), payload: Ok(HostAccountCreateProofResponse { proof: vec![0x55, 0x66], @@ -1531,8 +1286,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" @@ -1554,12 +1313,13 @@ mod tests { "ab".repeat(32) ) ); + let RemoteMessageData::V1(data) = response.data; assert_eq!( - remote_response_for_message(response, "request"), - Some(SsoRemoteResponse::ProductSubtree(ProductSubtreeResponse { + ProductSubtreeResponse::from_message(data), + Some(ProductSubtreeResponse { responding_to: "request".to_string(), product_public_key: Ok([0xAB; 32]), - })) + }) ); } @@ -1576,7 +1336,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(), + SignVrfRequest { + calling_product_id: "browse.dot".to_string(), + payload, + }, + ); assert_eq!( hex::encode(request.encode()), "0c726571000e2862726f7773652e646f742862726f7773652e646f7400070000000c6374780418646f6d61696e080102" @@ -1600,12 +1366,13 @@ mod tests { "22".repeat(64) ) ); + let RemoteMessageData::V1(data) = response.data; assert!(matches!( - remote_response_for_message(response, "req"), - Some(SsoRemoteResponse::SignVrf(SignVrfResponse { + SignVrfResponse::from_message(data), + Some(SignVrfResponse { payload: Ok(VrfSignature { .. }), .. - })) + }) )); } @@ -1690,8 +1457,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 +1483,21 @@ 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, + ] + .into_iter() + .map(Into::into) + .collect(), + on_existing: OnExistingAllowancePolicy::Increase, + }, ); assert_host_papp_0_8_11_fixture( @@ -1735,21 +1508,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 +1536,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 +1564,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 +1590,27 @@ 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(), + } + .into(), }, ), "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(), + } + .into(), }, ), "0x506d2d6c65676163792d7261772d7061796c6f6164000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f01443c42797465733e48693c2f42797465733e", @@ -1854,11 +1639,11 @@ mod tests { with_signed_transaction: Some(true), }, }; - let true_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); + let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from_host_request(request.clone()).encode(); + let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from_host_request(request).encode(); + let none_encoded = SigningPayloadRequest::from(request).encode(); assert_eq!(true_encoded.last(), Some(&1)); assert_eq!(false_encoded.last(), Some(&2)); @@ -1867,16 +1652,21 @@ mod tests { #[test] fn maps_public_resource_names_to_sso_dialect() { - let message = resource_allocation_message( + let message = RemoteMessage::request( "alloc".to_string(), - "myapp.dot".to_string(), - vec![ - AllocatableResource::StatementStoreAllowance, - AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), - AllocatableResource::AutoSigning, - ], - OnExistingAllowancePolicy::Increase, + ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![ + AllocatableResource::StatementStoreAllowance, + AllocatableResource::BulletinAllowance, + AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), + AllocatableResource::AutoSigning, + ] + .into_iter() + .map(Into::into) + .collect(), + on_existing: OnExistingAllowancePolicy::Increase, + }, ); let RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationRequest(request)) = message.data @@ -1899,14 +1689,16 @@ mod tests { #[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 { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), + SignRequest::Raw(SigningRawRequest::from( + truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, }, - }, + )), ); let statement = build_outgoing_request_statement_with_nonce( @@ -1939,14 +1731,16 @@ 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 { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), + SignRequest::Raw(SigningRawRequest::from( + truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, }, - }, + )), ); let statement = build_outgoing_request_statement_with_nonce( &session, @@ -1957,74 +1751,27 @@ 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 { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), + SignRequest::Raw(SigningRawRequest::from( + truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), + }, }, - }, + )), ); let expiry = fresh_expiry(); let host_statement = build_outgoing_request_statement( @@ -2055,13 +1802,13 @@ 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(SignResponse { responding_to: "remote-1".to_string(), payload: Ok(SigningPayloadResponseData { signature: vec![9; 64], @@ -2076,30 +1823,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(SignResponse { responding_to: "remote-1".to_string(), payload: Ok(SigningPayloadResponseData { 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 +1873,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 +1893,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 +1936,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 +1948,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..47f9db53a 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,80 @@ //! use parity_scale_codec::{Decode, Encode}; +use truapi_macros::SsoWire; 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, + CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionRequest, + CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, + GetAccountAliasResponse, ListRingVrfKeysRequest, ListRingVrfKeysResponse, + ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, + RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, + SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, 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, SsoWire)] 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(Box), /// Signing host's answer to [`RemoteMessage::SignRequest`]. - #[display("sign_response")] - SignResponse(SigningResponse), + SignResponse(SignResponse), /// 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(GetAccountAliasRequest), + /// Account Holder's answer to [`RemoteMessage::GetAccountAliasRequest`]. + GetAccountAliasResponse(GetAccountAliasResponse), /// 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), /// 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), /// 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(SignRawWithLegacyAccountResponse), /// 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(CreateAccountProofRequest), + /// Account Holder's answer to [`RemoteMessage::CreateAccountProofRequest`]. + CreateAccountProofResponse(CreateAccountProofResponse), /// Ask the Account Holder to sign an RFC-0023 sr25519 VRF transcript. #[codec(index = 14)] - #[display("sign_vrf")] SignVrfRequest(SignVrfRequest), /// Account Holder's answer to [`RemoteMessage::SignVrfRequest`]. #[codec(index = 15)] - #[display("sign_vrf_response")] SignVrfResponse(SignVrfResponse), /// 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), /// Register a ring-VRF key with the Account Holder. #[codec(index = 18)] - #[display("register_ring_vrf_key")] RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. #[codec(index = 19)] - #[display("register_ring_vrf_key_response")] RegisterRingVrfKeyResponse(RegisterRingVrfKeyResponse), /// List registered ring-VRF keys. #[codec(index = 20)] - #[display("list_ring_vrf_keys")] ListRingVrfKeysRequest(ListRingVrfKeysRequest), /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. #[codec(index = 21)] - #[display("list_ring_vrf_keys_response")] ListRingVrfKeysResponse(ListRingVrfKeysResponse), /// Sign bytes with a registered ring-VRF key. #[codec(index = 22)] - #[display("ring_vrf_sign")] RingVrfSignRequest(RingVrfSignRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. #[codec(index = 23)] - #[display("ring_vrf_sign_response")] RingVrfSignResponse(RingVrfSignResponse), } diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs new file mode 100644 index 000000000..ea7932435 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -0,0 +1,416 @@ +//! Typed pairing of SSO request and response payloads. +//! +//! [`SsoRequest`] is implemented by `#[sso_service]` from each handler's wire +//! request parameter and wire response return type. `#[derive(SsoWire)]` on +//! [`v1::RemoteMessage`] provides wire classification. [`SsoResponse`] is +//! derived on each response struct and exposes its payload without the +//! correlation id. + +use truapi::v01::HostAccountSignVrfError; + +use super::messages::{RemoteMessage, RemoteMessageData, RingVrfError, v1}; + +/// A request payload carried by one `v1::RemoteMessage` variant. +pub trait SsoRequest: Sized { + /// Method name used for tracing. + const NAME: &'static str; + /// Response payload the signing host answers with. + type Response: SsoResponse; + /// Wrap into the request variant. + fn into_message(self) -> v1::RemoteMessage; + /// Unwrap from the request variant; `None` for any other message. + fn from_message(message: v1::RemoteMessage) -> Option; +} + +/// A response payload carried by one `v1::RemoteMessage` variant. +pub trait SsoResponse: Sized { + /// Successful payload. + type Ok; + /// Failure payload. + type Err: SsoError; + /// Build the response for the request identified by `responding_to`. + fn new(responding_to: String, payload: Result) -> Self; + /// `message_id` of the request being answered. + fn responding_to(&self) -> &str; + /// Strip the correlation id. + fn into_payload(self) -> Result; + /// Wrap into the response variant. + fn into_message(self) -> v1::RemoteMessage; + /// Unwrap from the response variant; `None` for any other message. + fn from_message(message: v1::RemoteMessage) -> Option; + /// Transcript classification of the payload. + fn outcome(&self) -> ResponseOutcome; +} + +/// A wire response's `Result` payload, without its correlation id. +/// +/// The typed client returns this result. Server replies carry the same payload +/// alongside local diagnostics; dispatch adds the wire envelope. +pub type ResponsePayload = Result<::Ok, ::Err>; + +/// Failure payload that can express "no signing session". +pub trait SsoError { + /// The signing host has no active session to serve the request with. + fn not_connected() -> Self; + /// Single-line description for transcripts. + fn reason(&self) -> String; +} + +impl SsoError for String { + fn not_connected() -> Self { + "signing host session is not active".to_string() + } + + fn reason(&self) -> String { + self.clone() + } +} + +impl SsoError for RingVrfError { + fn not_connected() -> Self { + RingVrfError::Unknown { + reason: String::not_connected(), + } + } + + fn reason(&self) -> String { + match self { + 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}"), + } + } +} + +impl SsoError for HostAccountSignVrfError { + fn not_connected() -> Self { + HostAccountSignVrfError::NotConnected + } + + fn reason(&self) -> String { + match self { + HostAccountSignVrfError::NotConnected => "NotConnected".to_string(), + HostAccountSignVrfError::Rejected => "Rejected".to_string(), + HostAccountSignVrfError::Unknown { reason } => reason.clone(), + } + } +} + +/// 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.reason()), + }, + } + } +} + +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 truapi::latest::{ + DerivationIndex, HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, + ProductAccountTxPayload, ProductProofContext, RingLocation, + }; + use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, RingVrfKeyDisclosure}; + + use super::*; + use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; + use crate::host_logic::sso::messages::{ + CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionLegacyPayload, + CreateTransactionPayload, CreateTransactionRequest, CreateTransactionResponse, + CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, GetAccountAliasResponse, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, OnExistingAllowancePolicy, + ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, + RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, + SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, + SignVrfResponse, SigningPayloadResponseData, SigningRawPayload, SigningRawRequest, + SsoAllocationOutcome, + }; + + fn account() -> ProductAccountId { + ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: DerivationIndex::Index(7), + } + } + + fn ring() -> RingLocation { + RingLocation { + chain_id: [0x11; 32], + junctions: vec![], + } + } + + fn context() -> ProductProofContext { + ProductProofContext { + product_id: "voting.dot".to_string(), + suffix: DerivationIndex::Index(0), + } + } + + fn product_tx() -> ProductAccountTxPayload { + ProductAccountTxPayload { + signer: account(), + genesis_hash: [2; 32], + call_data: vec![1, 2, 3], + extensions: vec![], + tx_ext_version: 0, + } + } + + fn legacy_tx() -> LegacyAccountTxPayload { + LegacyAccountTxPayload { + signer: [1; 32], + genesis_hash: [2; 32], + call_data: vec![4], + extensions: vec![], + tx_ext_version: 0, + } + } + + fn assert_request_round_trip(request: R) + where + R: SsoRequest + Clone + PartialEq + core::fmt::Debug, + { + let message = request.clone().into_message(); + assert_eq!(message.name(), R::NAME); + assert_eq!(R::from_message(message), Some(request)); + } + + fn assert_response_round_trip(payload: Result) + where + Q: SsoResponse + Clone + PartialEq + core::fmt::Debug, + Q::Ok: Clone + PartialEq + core::fmt::Debug, + Q::Err: Clone + PartialEq + core::fmt::Debug, + { + let response = Q::new("m-1".to_string(), payload.clone()); + assert_eq!(response.responding_to(), "m-1"); + assert_eq!(response.clone().into_payload(), payload); + assert_eq!( + Q::from_message(response.clone().into_message()), + Some(response) + ); + } + + #[test] + fn request_payloads_round_trip_through_their_variants() { + assert_request_round_trip(SignRequest::Raw(SigningRawRequest { + product_account_id: account(), + data: SigningRawPayload::Bytes(vec![0xde]), + })); + assert_request_round_trip(GetAccountAliasRequest { + calling_product_id: "caller.dot".to_string(), + key_handle: account(), + context: context(), + ring_location: ring(), + }); + assert_request_round_trip(ResourceAllocationRequest { + calling_product_id: "caller.dot".to_string(), + resources: vec![], + on_existing: OnExistingAllowancePolicy::Increase, + }); + assert_request_round_trip(CreateTransactionRequest { + payload: CreateTransactionPayload::V1(product_tx()), + }); + assert_request_round_trip(CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(legacy_tx()), + }); + assert_request_round_trip(SignRawWithLegacyAccountRequest { + account: [1; 32], + data: SigningRawPayload::Payload("hi".to_string()), + }); + assert_request_round_trip(CreateAccountProofRequest { + calling_product_id: "caller.dot".to_string(), + key_handle: account(), + context: context(), + ring_location: ring(), + message: b"vote".to_vec(), + }); + assert_request_round_trip(SignVrfRequest { + calling_product_id: "caller.dot".to_string(), + payload: HostAccountSignVrfRequest { + account: account(), + transcript_label: b"label".to_vec(), + items: vec![], + }, + }); + assert_request_round_trip(ProductSubtreeRequest { + product_id: "browse.dot".to_string(), + }); + assert_request_round_trip(RegisterRingVrfKeyRequest { + calling_product_id: "game.dot".to_string(), + index: DerivationIndex::Index(4), + ring: ring(), + }); + assert_request_round_trip(ListRingVrfKeysRequest { + calling_product_id: "game.dot".to_string(), + owner: "peopl.dot".to_string(), + disclosure: RingVrfKeyDisclosure::PublicKey, + }); + assert_request_round_trip(RingVrfSignRequest { + calling_product_id: "game.dot".to_string(), + key_handle: account(), + message: vec![9], + }); + } + + #[test] + fn response_payloads_round_trip_through_their_variants() { + assert_response_round_trip::(Ok(SigningPayloadResponseData { + signature: vec![1], + signed_transaction: None, + })); + assert_response_round_trip::(Err("nope".to_string())); + assert_response_round_trip::(Err(HostAccountSignVrfError::Rejected)); + assert_response_round_trip::(Ok(HostAccountGetAliasResponse { + context: [0x22; 32], + alias: vec![0x33], + })); + assert_response_round_trip::(Err(RingVrfError::NotMember)); + assert_response_round_trip::(Ok([1; 32])); + assert_response_round_trip::(Ok(vec![])); + assert_response_round_trip::(Ok(vec![5])); + assert_response_round_trip::(Ok(vec![ + SsoAllocationOutcome::Rejected, + ])); + assert_response_round_trip::(Ok([7; 32])); + assert_response_round_trip::(Ok(vec![8])); + } + + #[test] + fn each_request_is_answered_by_its_named_response() { + fn response_name() -> &'static str { + let not_connected = <::Err as SsoError>::not_connected(); + R::Response::new(String::new(), Err(not_connected)) + .into_message() + .name() + } + assert_eq!(response_name::(), "SignResponse"); + assert_eq!( + response_name::(), + "GetAccountAliasResponse" + ); + assert_eq!( + response_name::(), + "ResourceAllocationResponse" + ); + assert_eq!( + response_name::(), + "CreateTransactionResponse" + ); + assert_eq!( + response_name::(), + "CreateTransactionResponse" + ); + assert_eq!( + response_name::(), + "SignRawWithLegacyAccountResponse" + ); + assert_eq!( + response_name::(), + "CreateAccountProofResponse" + ); + assert_eq!(response_name::(), "SignVrfResponse"); + assert_eq!( + response_name::(), + "ProductSubtreeResponse" + ); + assert_eq!( + response_name::(), + "RegisterRingVrfKeyResponse" + ); + assert_eq!( + response_name::(), + "ListRingVrfKeysResponse" + ); + assert_eq!(response_name::(), "RingVrfSignResponse"); + } + + #[test] + fn classify_separates_requests_responses_and_disconnect() { + let request = ProductSubtreeRequest { + product_id: "browse.dot".to_string(), + }; + assert_eq!( + classify(request.clone().into_message()), + Incoming::Request(AnyRequest::ProductSubtreeRequest(request)) + ); + let boxed = SignRequest::Raw(SigningRawRequest { + product_account_id: account(), + data: SigningRawPayload::Bytes(vec![]), + }); + assert_eq!( + classify(boxed.clone().into_message()), + Incoming::Request(AnyRequest::SignRequest(boxed)) + ); + assert_eq!( + classify(CreateTransactionResponse::new("m".to_string(), Ok(vec![])).into_message()), + Incoming::Response("CreateTransactionResponse") + ); + assert_eq!( + classify(v1::RemoteMessage::Disconnected), + Incoming::Disconnected + ); + } + + #[test] + fn shared_response_and_names_follow_the_enum() { + fn response_name(_: &R) -> &'static str { + core::any::type_name::() + } + let legacy = CreateTransactionWithLegacyAccountRequest { + payload: CreateTransactionLegacyPayload::V1(legacy_tx()), + }; + assert!(response_name(&legacy).ends_with("CreateTransactionResponse")); + assert_eq!( + RemoteMessage::request("m".to_string(), legacy).name(), + "create_transaction_with_legacy_account" + ); + assert_eq!( + ::NAME, + "create_transaction_with_legacy_account" + ); + assert_eq!(::NAME, "sign"); + assert_eq!( + ::NAME, + "get_account_alias" + ); + } +} diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 86fd42cd7..be9bcc885 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}; diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index 302473ea3..b3bf9055d 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -3,6 +3,8 @@ //! 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; @@ -13,7 +15,7 @@ use truapi::latest::{ HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, - ProductAccountId, ProductAccountTxPayload, ProductProofContext, RingLocation, + ProductAccountId, ProductAccountTxPayload, }; use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; @@ -21,7 +23,10 @@ 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::{ + CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, + RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, +}; use crate::host_logic::statement_store::statement_public_key_from_secret; /// Secret key allocated for Bulletin preimage submission. @@ -224,67 +229,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 +362,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: GetAccountAliasRequest, ) -> Result; /// Create a ring-VRF proof bound to a context and message. @@ -429,7 +373,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: CreateAccountProofRequest, ) -> Result; /// Register a ring-VRF key owned by the calling product. @@ -437,7 +381,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) -> Result; /// List registered ring-VRF keys. @@ -445,7 +389,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ListRingVrfKeysRequest, ) -> Result; /// Sign bytes directly with a registered ring-VRF key. @@ -453,7 +397,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: RingVrfSignRequest, ) -> 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..461afd709 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -25,9 +25,9 @@ use truapi_platform::{ normalize_product_identifier, }; -use crate::runtime::authority::{ - AccountAliasAuthorityRequest, CreateProofAuthorityRequest, ListRingVrfKeysAuthorityRequest, - RegisterRingVrfKeyAuthorityRequest, RingVrfSignAuthorityRequest, +use crate::host_logic::sso::messages::{ + CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, + RegisterRingVrfKeyRequest, RingVrfSignRequest, }; use crate::runtime::{ ProductRuntimeHost, account_access_authorization, account_get_authority_error, @@ -151,7 +151,7 @@ impl Account for ProductRuntimeHost { self.authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + GetAccountAliasRequest { calling_product_id, key_handle, context, @@ -202,7 +202,7 @@ impl Account for ProductRuntimeHost { self.authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + CreateAccountProofRequest { calling_product_id, key_handle, context, @@ -241,7 +241,7 @@ impl Account for ProductRuntimeHost { self.authority.register_ring_vrf_key( &cx, &session, - RegisterRingVrfKeyAuthorityRequest { + RegisterRingVrfKeyRequest { calling_product_id, index, ring, @@ -287,7 +287,7 @@ impl Account for ProductRuntimeHost { self.authority.list_ring_vrf_keys( &cx, &session, - ListRingVrfKeysAuthorityRequest { + ListRingVrfKeysRequest { calling_product_id, owner, disclosure, @@ -335,7 +335,7 @@ impl Account for ProductRuntimeHost { self.authority.ring_vrf_sign( &cx, &session, - RingVrfSignAuthorityRequest { + RingVrfSignRequest { calling_product_id, key_handle: request.key_handle, message: request.message, diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index df9453c1a..dd0f734d1 100644 --- a/rust/crates/truapi-server/src/runtime/pairing_host.rs +++ b/rust/crates/truapi-server/src/runtime/pairing_host.rs @@ -20,11 +20,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 +42,10 @@ 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::{ + CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, + RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, +}; use crate::subscription::Spawner; use futures::StreamExt; @@ -62,59 +64,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 +1973,7 @@ impl PairingHost { fn mirror_ring_vrf_registration( &self, session: SessionInfo, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) { let weak_self = self.weak_self.clone(); (self.spawner)(Box::pin(async move { @@ -2136,7 +2085,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: GetAccountAliasRequest, ) -> Result { let private_session = self.current_private_session(session)?; if request.calling_product_id == request.key_handle.dot_ns_identifier @@ -2165,7 +2114,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: CreateAccountProofRequest, ) -> Result { Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; let private_session = self.current_private_session(session)?; @@ -2203,7 +2152,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) -> Result { let private_session = self.current_private_session(session)?; let handle = v01::ProductAccountId { @@ -2252,7 +2201,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ListRingVrfKeysRequest, ) -> Result, RingVrfError> { let private_session = self.current_private_session(session)?; let owner = normalize_product_identifier(&request.owner).map_err(|error| { @@ -2294,7 +2243,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: RingVrfSignRequest, ) -> Result, RingVrfError> { Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; let private_session = self.current_private_session(session)?; @@ -2516,7 +2465,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: GetAccountAliasRequest, ) -> Result { PairingHost::account_alias(self, cx, session, request).await } @@ -2525,7 +2474,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: CreateAccountProofRequest, ) -> Result { PairingHost::create_proof(self, cx, session, request).await } @@ -2534,7 +2483,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) -> Result { PairingHost::register_ring_vrf_key(self, cx, session, request).await } @@ -2543,7 +2492,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ListRingVrfKeysRequest, ) -> Result, RingVrfError> { PairingHost::list_ring_vrf_keys(self, cx, session, request).await } @@ -2552,7 +2501,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: RingVrfSignRequest, ) -> 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..f6fbc446f 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,30 +1,27 @@ //! 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, + CreateAccountProofRequest, CreateTransactionLegacyPayload, CreateTransactionPayload, + CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, + ListRingVrfKeysRequest, OnExistingAllowancePolicy, ProductSubtreeRequest, + RegisterRingVrfKeyRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, + RingVrfError, RingVrfSignRequest, SignRawWithLegacyAccountRequest, SignRequest, SignVrfRequest, + SsoAllocatedResource, SsoAllocationOutcome, SsoSessionStatement, + build_outgoing_request_statement, decode_sso_session_statement, v1, }; +use crate::host_logic::sso::wire::{ResponsePayload, SsoRequest, SsoResponse}; use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; @@ -32,43 +29,6 @@ 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, -} - /// Active peer-disconnect watcher for one SSO session; aborts on drop. pub(super) struct SsoDisconnectMonitor { key: SsoSessionKey, @@ -82,32 +42,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 +141,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, SsoRemoteResponseError> { let sso = session .sso .as_ref() @@ -225,11 +161,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 +201,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 +230,7 @@ impl PairingHost { if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { self.handle_signing_host_disconnected(key).await; } - result + result.map(SsoResponse::into_payload) } /// Resolve a product's hard-subtree public key, asking the Account Holder @@ -308,24 +247,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) @@ -344,22 +269,17 @@ impl PairingHost { 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 { + self.call( + cx, + session, + SignVrfRequest { + calling_product_id, + payload: request, + }, + ) + .await + .map_err(remote_authority_error)? + .map_err(|err| match err { v01::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, v01::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, v01::HostAccountSignVrfError::Unknown { reason } => AuthorityError::Unknown { reason }, @@ -367,14 +287,16 @@ impl PairingHost { } /// 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 +307,116 @@ impl PairingHost { payload: request.payload, }, }; - let message = sign_payload_message(message_id, request); - self.submit_sign_request(cx, session, action, message) + let payload = self + .call(cx, session, SignRequest::Payload(Box::new(request.into()))) .await - .map_err(remote_authority_error) + .map_err(remote_authority_error)? + .map_err(remote_authority_error)?; + Ok(latest::HostSignPayloadResponse { + signature: payload.signature, + signed_transaction: payload.signed_transaction, + }) } /// 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 { + 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 { + let payload = self + .call(cx, session, SignRequest::Raw(request.into())) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error)?; + Ok(latest::HostSignPayloadResponse { signature: payload.signature, signed_transaction: payload.signed_transaction, }) - .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.into(), + }, + ) + .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 +426,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: AccountAliasAuthorityRequest, + request: GetAccountAliasRequest, ) -> 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 +438,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: CreateProofAuthorityRequest, + request: CreateAccountProofRequest, ) -> 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 +450,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) -> 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 +462,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: ListRingVrfKeysAuthorityRequest, + request: ListRingVrfKeysRequest, ) -> 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 +474,11 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RingVrfSignAuthorityRequest, + request: RingVrfSignRequest, ) -> 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 +491,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.into_iter().map(Into::into).collect(), + 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 +511,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.into()], + 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 +561,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 +580,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 +599,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 +618,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 +657,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 +725,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 +781,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 +812,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..98ee0c1b4 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -18,6 +18,7 @@ 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}; @@ -29,15 +30,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 +55,10 @@ 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::{ + CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, + OnExistingAllowancePolicy, RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, +}; use crate::host_logic::transaction::{extrinsic_payload_extensions, extrinsic_payload_preimage}; use crate::runtime::auth_state::AuthStateMachine; #[cfg(not(target_arch = "wasm32"))] @@ -843,7 +844,7 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: AccountAliasAuthorityRequest, + request: GetAccountAliasRequest, ) -> Result { self.require_current_session(session)?; match super::account_access_authorization( @@ -880,7 +881,7 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: CreateProofAuthorityRequest, + request: CreateAccountProofRequest, ) -> Result { self.require_current_session(session)?; Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; @@ -912,7 +913,7 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyAuthorityRequest, + request: RegisterRingVrfKeyRequest, ) -> Result { self.require_current_session(session)?; self.ring_resolver.validate(&request.ring).await?; @@ -937,7 +938,7 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysAuthorityRequest, + request: ListRingVrfKeysRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; let owner = @@ -981,7 +982,7 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignAuthorityRequest, + request: RingVrfSignRequest, ) -> Result, RingVrfError> { self.require_current_session(session)?; Self::require_owned_ring_vrf_key(&request.calling_product_id, &request.key_handle)?; @@ -1006,6 +1007,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_statement_store_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1016,6 +1018,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1026,6 +1029,7 @@ impl ProductAuthority for SigningHost { sso_responder::allocate_smart_contract_allowance( &self.services, self, + session, &product_id, index, OnExistingAllowancePolicy::Increase, @@ -1059,6 +1063,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_statement_store_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Ignore, ) @@ -1077,6 +1082,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Ignore, ) @@ -1095,6 +1101,7 @@ impl ProductAuthority for SigningHost { let secret = sso_responder::allocate_bulletin_allowance( &self.services, self, + session, &product_id, OnExistingAllowancePolicy::Increase, ) @@ -1287,9 +1294,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,6 +1309,10 @@ 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::{ + CreateAccountProofRequest, GetAccountAliasRequest, RegisterRingVrfKeyRequest, + RingVrfSignRequest, + }; use crate::host_logic::transaction::{ extrinsic_payload_extensions, extrinsic_payload_preimage, }; @@ -1470,7 +1479,7 @@ mod tests { futures::executor::block_on(authority.register_ring_vrf_key( &CallContext::default(), session, - RegisterRingVrfKeyAuthorityRequest { + RegisterRingVrfKeyRequest { calling_product_id: "peopl.dot".to_string(), index: v01::DerivationIndex::Index(0), ring: ring.clone(), @@ -1580,7 +1589,7 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + GetAccountAliasRequest { calling_product_id: "peopl.dot".to_string(), key_handle: full_person_key_handle(), context: context.clone(), @@ -1591,7 +1600,7 @@ mod tests { let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + CreateAccountProofRequest { calling_product_id: "peopl.dot".to_string(), key_handle: full_person_key_handle(), context, @@ -1621,7 +1630,7 @@ mod tests { let error = futures::executor::block_on(authority.account_alias( &CallContext::default(), &session, - AccountAliasAuthorityRequest { + GetAccountAliasRequest { calling_product_id: "peopl.dot".to_string(), key_handle: full_person_key_handle(), context: v01::ProductProofContext { @@ -1662,7 +1671,7 @@ mod tests { let error = futures::executor::block_on(authority.ring_vrf_sign( &CallContext::default(), &session, - RingVrfSignAuthorityRequest { + RingVrfSignRequest { calling_product_id: "myapp.dot".to_string(), key_handle: handle, message: b"reject mismatched registry state".to_vec(), @@ -1695,7 +1704,7 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - AccountAliasAuthorityRequest { + GetAccountAliasRequest { calling_product_id: "myapp.dot".to_string(), key_handle: full_person_key_handle(), context: context.clone(), @@ -1707,7 +1716,7 @@ mod tests { let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - CreateProofAuthorityRequest { + CreateAccountProofRequest { calling_product_id: "myapp.dot".to_string(), key_handle: full_person_key_handle(), context, @@ -1738,7 +1747,7 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); let cx = CallContext::default(); - let request = AccountAliasAuthorityRequest { + let request = GetAccountAliasRequest { calling_product_id: "myapp.dot".to_string(), key_handle: full_person_key_handle(), context: v01::ProductProofContext { 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..cce10cab1 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; @@ -364,6 +353,7 @@ async fn serve_session( session: SsoSessionInfo, replay_scope: SsoReplayScope, ) -> Result { + let service = SigningHostSsoService::new(services.clone(), 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 { @@ -457,32 +449,29 @@ async fn serve_session( /// Ack one inbound request statement and answer its batched messages. async fn serve_request( services: &Arc, - signing_host: &Arc, + 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, @@ -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, 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,6 +717,7 @@ 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()) } @@ -1168,6 +725,7 @@ pub(super) async fn allocate_statement_store_allowance( pub(super) async fn allocate_bulletin_allowance( services: &Arc, 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,6 +827,7 @@ 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()) } @@ -1276,6 +835,7 @@ pub(super) async fn allocate_bulletin_allowance( pub(super) async fn allocate_statement_store_allowance( _services: &Arc, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1299,6 +859,7 @@ pub(super) async fn allocate_statement_store_allowance( pub(super) async fn allocate_smart_contract_allowance( services: &Arc, 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,6 +948,7 @@ pub(super) async fn allocate_smart_contract_allowance( block = %outcome.block_hash, "claimed PGAS allowance" ); + signing_host.require_current_session(session)?; Ok(()) } @@ -1395,6 +957,7 @@ pub(super) async fn allocate_smart_contract_allowance( pub(super) async fn allocate_smart_contract_allowance( _services: &Arc, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _derivation_index: v01::DerivationIndex, _policy: OnExistingAllowancePolicy, @@ -1406,6 +969,7 @@ pub(super) async fn allocate_smart_contract_allowance( pub(super) async fn allocate_bulletin_allowance( _services: &Arc, _signing_host: &SigningHost, + _session: &AuthoritySession, _product_id: &str, _policy: OnExistingAllowancePolicy, ) -> Result, AllowanceAllocationError> { @@ -1422,284 +986,24 @@ 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, ResourceAllocationResponse, RingVrfError, + SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, + resource_allocation_outcome, + }; + use crate::host_logic::sso::wire::SsoResponse; 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 +1110,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 +1295,36 @@ mod tests { ); } - fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { - let RemoteMessageData::V1(data) = answer.response.data; + fn answer( + services: &Arc, + signing_host: &Arc, + message_id: &str, + request: v1::RemoteMessage, + ) -> v1::RemoteMessage { + let service = SigningHostSsoService::new(services.clone(), 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), - }, - ring_location: api::RingLocation { - chain_id: [0; 32], - junctions: vec![], - }, + fn response_summary_reports_protocol_errors_without_multiline_output() { + let response = GetAccountAliasResponse { + responding_to: "alias-1".to_string(), + payload: Err(RingVrfError::Unknown { + reason: "chain RPC\ntimed out".to_string(), }), - )) - .expect("response is emitted"); - - let v1::RemoteMessage::RingVrfAliasResponse(response) = response_payload(response) else { - panic!("expected alias response"); }; - assert_eq!(response.payload.unwrap_err(), RingVrfError::Rejected); - } - - #[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 result = remote_response_result(&response); + let result = response.outcome(); let summary = response_cli_summary( "SSO response sent", "get_account_alias", @@ -2062,62 +1349,181 @@ mod tests { #[test] fn resource_allocation_summary_reflects_per_resource_outcomes() { - let result = - resource_allocation_payload_result(&Ok(vec![SsoAllocationOutcome::Rejected]), &[]); - assert_eq!(result.outcome, "rejected"); + let result = resource_allocation_outcome(&Ok(vec![SsoAllocationOutcome::Rejected])); assert_eq!( - result.reason.as_deref(), - Some("Requested resource was rejected") + (result.outcome, result.reason.as_deref()), + ("rejected", Some("Requested resource was rejected")) ); - let result = resource_allocation_payload_result( - &Ok(vec![ - SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { - slot_account_key: vec![1; 64], - }), - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ]), - &[], - ); - assert_eq!(result.outcome, "partial"); + let result = resource_allocation_outcome(&Ok(vec![ + SsoAllocationOutcome::Allocated(SsoAllocatedResource::BulletinAllowance { + slot_account_key: vec![1; 64], + }), + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ])); assert_eq!( - result.reason.as_deref(), - Some("1 of 3 requested resources allocated; 1 rejected; 1 unavailable") + (result.outcome, result.reason.as_deref()), + ( + "partial", + 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()], + 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")) ); - assert_eq!(result.outcome, "not_available"); + } + + #[test] + fn response_summary_classifies_resource_allocation_batches() { + let response = ResourceAllocationResponse { + responding_to: "allocation-1".to_string(), + payload: Ok(vec![ + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ]), + }; + + let result = response.outcome(); + assert_eq!( - result.reason.as_deref(), - Some( - "Requested resource is not available: timed out waiting for Bulletin authorization" + (result.outcome, result.reason.as_deref()), + ( + "rejected", + Some("No resources allocated; 1 rejected; 1 unavailable") ) ); } + #[cfg(not(target_arch = "wasm32"))] #[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, + fn allocation_failure_details_reach_the_response_transcript() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + resource_allocation_confirmed: true, + chain_connect_error: Some("allocation node unavailable"), + ..StubPlatform::default() + })); + let auto_signing = SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { + product_root_private_key: signing_host.product_subtree_secret("myapp.dot").unwrap(), + ring_vrf_domain_entropy: derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot").unwrap(), + }); + let service = SigningHostSsoService::new(services, signing_host); + let cases = [ + ( + vec![SsoAllocatableResource::BulletinAllowance], + vec![SsoAllocationOutcome::NotAvailable], + "not_available", + "Requested resource is not available", + 1, + ), + ( + vec![ + SsoAllocatableResource::AutoSigning, + SsoAllocatableResource::BulletinAllowance, + SsoAllocatableResource::StatementStoreAllowance, + ], + vec![ + auto_signing.clone(), SsoAllocationOutcome::NotAvailable, - ]), - }, - )); + SsoAllocationOutcome::NotAvailable, + ], + "partial", + "1 of 3 requested resources allocated; 2 unavailable", + 2, + ), + ( + vec![SsoAllocatableResource::AutoSigning], + vec![auto_signing], + "ok", + "", + 0, + ), + ]; - let result = remote_response_result(&response); + for (index, (resources, outcomes, outcome, summary, failures)) in + cases.into_iter().enumerate() + { + let message_id = format!("allocation-{index}"); + let request = RemoteMessage::request( + message_id.clone(), + messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources, + on_existing: OnExistingAllowancePolicy::Ignore, + }, + ); + let Dispatch::Response(answer) = + futures::executor::block_on(service.dispatch(service.current_session(), request)) + else { + panic!("expected an allocation response"); + }; - assert_eq!(result.outcome, "rejected"); - assert_eq!( - result.reason.as_deref(), - Some("No resources allocated; 1 rejected; 1 unavailable") + let expected = RemoteMessage { + message_id: format!("{message_id}:response"), + data: RemoteMessageData::V1( + ResourceAllocationResponse::new(message_id.clone(), Ok(outcomes)) + .into_message(), + ), + }; + assert_eq!(answer.message.encode(), expected.encode()); + assert_eq!(answer.outcome.outcome, outcome); + if failures == 0 { + assert_eq!(answer.outcome.reason, None); + continue; + } + let reason = answer.outcome.reason.as_deref().unwrap(); + assert!(reason.starts_with(summary)); + assert_eq!( + reason.matches("allocation node unavailable").count(), + failures, + "{reason}" + ); + assert!(!reason.contains(['\r', '\n'])); + let cli = response_cli_summary( + "SSO response sent", + "resource_allocation", + &message_id, + &message_id, + &answer.message.message_id, + &answer.outcome, + 0, + ); + assert!(cli.contains(&format!("reason={reason}"))); + } + } + + #[test] + fn account_alias_requires_confirmation_for_cross_product_request() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let response = answer( + &services, + &signing_host, + "alias-1", + v1::RemoteMessage::GetAccountAliasRequest(messages::GetAccountAliasRequest { + 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), + }, + 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] @@ -2125,20 +1531,18 @@ mod tests { let platform = Arc::new(StubPlatform::default()); let (services, signing_host) = signing_fixture(platform.clone()); - let response = futures::executor::block_on(answer_remote_message( + let response = answer( &services, &signing_host, - "alloc-1".to_string(), + "alloc-1", v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { calling_product_id: "myapp.dot".to_string(), resources: vec![SsoAllocatableResource::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!( @@ -2170,20 +1574,18 @@ 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( + let response = answer( &services, &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], 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,6 +1599,73 @@ 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 (services, signing_host) = signing_fixture(platform.clone()); + let service = SigningHostSsoService::new(services, signing_host.clone()); + let message = RemoteMessage::request( + "alloc-stale".to_string(), + messages::ResourceAllocationRequest { + calling_product_id: "myapp.dot".to_string(), + resources: vec![SsoAllocatableResource::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 { @@ -2216,20 +1685,18 @@ mod tests { tx_ext_version: 0, }; - let response = futures::executor::block_on(answer_remote_message( + let response = answer( &services, &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 @@ -2250,17 +1717,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( + let response = answer( &services, &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 = 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..d39cccb97 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -0,0 +1,474 @@ +//! 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::v01; +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::{ + CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionLegacyPayload, + CreateTransactionPayload, CreateTransactionRequest, CreateTransactionResponse, + CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, GetAccountAliasResponse, + ListRingVrfKeysRequest, ListRingVrfKeysResponse, OnExistingAllowancePolicy, + ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, + RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, + SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, SignVrfResponse, + SigningPayloadResponseData, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, +}; +use crate::runtime::authority::{ + AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + SignPayloadAuthorityRequest, SignRawAuthorityRequest, +}; +use crate::runtime::services::RuntimeServices; +use crate::runtime::sso_service::{SsoReply, SsoRequestContext}; + +/// SSO handlers served by a locally activated [`SigningHost`]. +pub(crate) struct SigningHostSsoService { + services: Arc, + signing_host: Arc, +} + +impl SigningHostSsoService { + /// Serve requests with `signing_host`, prompting through `services`. + pub(crate) fn new(services: Arc, signing_host: Arc) -> Self { + Self { + services, + 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.services.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 { + let response = match request { + SignRequest::Payload(request) => { + let request: api::HostSignPayloadRequest = (*request).into(); + 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) => { + let request: api::HostSignRawRequest = request.into(); + 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())?; + Ok(SigningPayloadResponseData { + signature: response.signature, + signed_transaction: response.signed_transaction, + }) + } + + 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: SsoAllocatableResource, + on_existing: OnExistingAllowancePolicy, + ) -> Result { + let services = &self.services; + let signing_host = &self.signing_host; + match resource { + SsoAllocatableResource::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, + }) + }), + SsoAllocatableResource::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, + }) + }), + SsoAllocatableResource::SmartContractAllowance(index) => { + allocate_smart_contract_allowance( + services, + signing_host, + session, + calling_product_id, + index, + on_existing, + ) + .await + .map(|()| { + SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) + }) + } + SsoAllocatableResource::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, + }, + )) + } + } + } + + async fn serve_resource_allocation( + &self, + cx: &SsoRequestContext, + request: ResourceAllocationRequest, + ) -> SsoReply { + let mut failures = Vec::new(); + let payload = async { + let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { + calling_product_id: request.calling_product_id.clone(), + resources: request + .resources + .iter() + .map(public_allocatable_resource) + .collect(), + }); + match self.services.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) + } +} + +fn allocation_reply( + payload: Result, String>, + failures: Vec, +) -> SsoReply { + let mut outcome = crate::host_logic::sso::messages::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) +} + +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, + } +} + +#[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: GetAccountAliasRequest, + ) -> 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 { + self.serve_resource_allocation(cx, request).await + } + + /// 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.into(), + }; + 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: CreateAccountProofRequest, + ) -> 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: SignVrfRequest) -> SignVrfResponse { + self.signing_host + .sign_vrf( + &cx.call, + &cx.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 } + } + }) + } + + /// 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: RegisterRingVrfKeyRequest, + ) -> 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: ListRingVrfKeysRequest, + ) -> 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: RingVrfSignRequest, + ) -> RingVrfSignResponse { + self.signing_host + .ring_vrf_sign(&cx.call, &cx.session, request) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + + 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..ef21b03f7 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -8,9 +8,8 @@ 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, -}; +use crate::host_logic::sso::messages::{SsoSessionStatement, decode_sso_session_statement, v1}; +use crate::host_logic::sso::wire::{SsoRequest, SsoResponse}; use crate::host_logic::statement_store::{current_unix_secs, parse_new_statements_result}; use futures::channel::oneshot; @@ -244,12 +243,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> + '_ { + 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 +289,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 +317,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 +349,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 +367,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 +385,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 +405,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 +471,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, + SignResponse, 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] @@ -484,16 +520,19 @@ 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( + 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, + }, + |_| None::>, + )) .unwrap_err(); let SsoRemoteResponseError::Cancelled(err) = err else { @@ -508,19 +547,22 @@ 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 { + 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, + }, + |_| None::>, + )) .unwrap_err(); assert_eq!( @@ -536,16 +578,19 @@ 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), - })) + 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), + }, + |_| None::>, + )) .unwrap_err(); assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); @@ -556,16 +601,19 @@ 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), - })) + 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), + }, + |_| None::>, + )) .unwrap_err(); assert_eq!(err, SsoRemoteResponseError::LocalDisconnected); @@ -575,16 +623,19 @@ 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( + 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, + }, + |_| None::>, + ); cancel.cancel(); let err = futures::executor::block_on(wait).unwrap_err(); @@ -597,4 +648,157 @@ 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( + ProductSubtreeResponse { + responding_to: responding_to.to_string(), + product_public_key: Ok([7; 32]), + }, + )), + } + } + + fn wait_for_subtree( + host: &SsoSessionInfo, + pages: Vec, + ) -> Result { + let cancel = CancellationToken::default(); + futures::executor::block_on(wait_for_sso_remote_response( + RemoteResponseWait { + own_statements: stream::pending().boxed(), + peer_statements: stream::iter(pages.into_iter().map(Ok)) + .chain(stream::pending()) + .boxed(), + submit: futures::future::ready(Ok(())).boxed(), + session: host, + statement_request_id: "request-1", + remote_message_id: "request-1", + cancel: &cancel, + disconnect: None, + }, + 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(SignResponse { + 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, + ProductSubtreeResponse { + responding_to: "request-1".to_string(), + product_public_key: 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..b9dad7dc3 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/sso_service.rs @@ -0,0 +1,130 @@ +//! Context and reply types shared by SSO handlers and generated dispatch. + +use truapi::{CallContext, RequestId}; + +use super::authority::AuthoritySession; +use crate::host_logic::sso::messages::RemoteMessage; +use crate::host_logic::sso::wire::{ResponseOutcome, ResponsePayload, SsoResponse}; + +/// 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: ResponsePayload, + outcome: Option, +} + +impl From> for SsoReply { + fn from(payload: ResponsePayload) -> 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 + } + + /// Wrap the payload with correlation and use its derived or supplied outcome. + pub(crate) fn finish(self, message_id: &str) -> Answer { + let response = R::new(message_id.to_string(), self.payload); + let outcome = self.outcome.unwrap_or_else(|| response.outcome()); + Answer { + message: RemoteMessage { + message_id: format!("{message_id}:response"), + data: crate::host_logic::sso::messages::RemoteMessageData::V1( + response.into_message(), + ), + }, + outcome, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host_logic::sso::messages::{ProductSubtreeResponse, RemoteMessageData, v1}; + + #[test] + fn finish_addresses_the_response_to_the_request() { + let answer = + SsoReply::::from(Err("nope".to_string())).finish("m-1"); + + assert_eq!( + answer, + Answer { + message: RemoteMessage { + message_id: "m-1:response".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse( + ProductSubtreeResponse { + responding_to: "m-1".to_string(), + product_public_key: Err("nope".to_string()), + }, + )), + }, + outcome: ResponseOutcome { + outcome: "error", + reason: Some("nope".to_string()), + }, + } + ); + } + + #[test] + fn reply_outcome_does_not_change_the_wire_payload() { + let answer = SsoReply::::from(Err("denied".to_string())) + .with_outcome(ResponseOutcome { + outcome: "rejected", + reason: Some("local detail".to_string()), + }) + .finish("m-1"); + + assert_eq!(answer.outcome.outcome, "rejected"); + assert_eq!(answer.outcome.reason.as_deref(), Some("local detail")); + let RemoteMessageData::V1(data) = answer.message.data; + let response = ProductSubtreeResponse::from_message(data).unwrap(); + assert_eq!(response.product_public_key, Err("denied".to_string())); + } +} diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index f900e821b..59955adee 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -56,7 +56,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, + CreateAccountProofResponse, GetAccountAliasResponse, RemoteMessage, RemoteMessageData, v1, }; use crate::test_support::*; @@ -1196,8 +1196,8 @@ 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 { + data: RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse( + GetAccountAliasResponse { responding_to: "alias-1".to_string(), payload: Ok(v01::ContextualAlias { context: [9; 32], @@ -1224,7 +1224,7 @@ 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"); }; @@ -1258,8 +1258,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( + CreateAccountProofResponse { responding_to: "proof-1".to_string(), payload: Ok(v01::HostAccountCreateProofResponse { proof: vec![0xaa, 0xbb], @@ -1292,7 +1292,7 @@ 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"); }; @@ -1309,8 +1309,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( + CreateAccountProofResponse { responding_to: "proof-1".to_string(), payload: Err(RingVrfError::NotMember), }, @@ -1891,7 +1891,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 diff --git a/rust/crates/truapi-server/src/runtime/tests/signing.rs b/rust/crates/truapi-server/src/runtime/tests/signing.rs index ca00c6c45..e071b13c5 100644 --- a/rust/crates/truapi-server/src/runtime/tests/signing.rs +++ b/rust/crates/truapi-server/src/runtime/tests/signing.rs @@ -272,7 +272,7 @@ fn sign_raw_accepts_confirmation_then_returns_sso_response() { crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) ) if matches!( request.as_ref(), - crate::host_logic::sso::messages::SigningRequest::Raw(_) + crate::host_logic::sso::messages::SignRequest::Raw(_) ) )); let sent = platform.sent_rpc.lock().expect("rpc list mutex poisoned"); @@ -535,7 +535,7 @@ fn sign_payload_accepts_confirmation_then_returns_sso_response() { crate::host_logic::sso::messages::v1::RemoteMessage::SignRequest(request) ) if matches!( request.as_ref(), - crate::host_logic::sso::messages::SigningRequest::Payload(_) + crate::host_logic::sso::messages::SignRequest::Payload(_) ) )); } @@ -691,7 +691,7 @@ fn legacy_sign_raw_accepts_derived_ss58_then_returns_sso_response() { else { panic!("expected product raw signing request"); }; - let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + let crate::host_logic::sso::messages::SignRequest::Raw(request) = *request else { panic!("expected raw signing payload"); }; assert_eq!( @@ -744,7 +744,7 @@ fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { else { panic!("expected product raw signing request"); }; - let crate::host_logic::sso::messages::SigningRequest::Raw(request) = *request else { + let crate::host_logic::sso::messages::SignRequest::Raw(request) = *request else { panic!("expected raw signing payload"); }; assert_eq!( @@ -787,7 +787,8 @@ fn legacy_sign_raw_accepts_identity_ss58_then_routes_legacy_request() { let HostSignRawWithLegacyAccountResponse::V1(response) = response; assert_eq!(response.signature, vec![7, 7]); let message = submitted_remote_message(&platform, &session); - let RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyRequest(request)) = message.data + let RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountRequest(request)) = + message.data else { panic!("expected legacy raw signing request"); }; diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 62484a53d..e1ef63ea2 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -10,7 +10,7 @@ use std::time::Duration; #[cfg(target_arch = "wasm32")] use web_time::Duration; -use crate::host_logic::session::SessionInfo; +use crate::host_logic::session::{SessionInfo, SsoSessionInfo}; use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; use crate::host_logic::sso::pairing; use crate::subscription::Spawner; @@ -109,6 +109,9 @@ pub(crate) struct StubPlatform { pub(crate) create_transaction_error: Option<&'static str>, 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,7 +646,7 @@ 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::SignResponse { responding_to: message_id.to_string(), payload: Ok( crate::host_logic::sso::messages::SigningPayloadResponseData { @@ -615,8 +668,8 @@ 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::SignRawWithLegacyAccountResponse { responding_to: message_id.to_string(), signature: Ok(signature), }, @@ -1001,42 +1054,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 +1563,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, From 2540ca3191287f092deea5f4aedaf8d18d1bd3c5 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Tue, 8 Sep 2026 21:25:58 +0000 Subject: [PATCH 2/8] test(sso): trim redundant coverage and documentation --- CLAUDE.md | 26 +- README.md | 4 +- rust/crates/truapi-server/README.md | 86 ++---- .../truapi-server/src/host_logic/sso/wire.rs | 246 +----------------- .../truapi-server/src/runtime/sso_remote.rs | 89 ++----- .../truapi-server/src/runtime/sso_service.rs | 39 +-- 6 files changed, 67 insertions(+), 423 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 176443f27..afe74ced7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,27 +73,11 @@ 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 messages (`truapi-server/src/host_logic/sso/`) are an RPC - surface: the hand-written `v1::RemoteMessage` enum is the wire spec and - derives `SsoWire` for classification, request wrapping, and correlation - helpers (variant names equal payload type names). Response structs derive - `SsoResponse`. A dedicated inherent `impl SigningHostSsoService` in - `runtime/signing_host/sso_service.rs` carries `#[sso_service]`; every method - in that block is a handler naming its wire request and wire response. - The macro generates request/response pairing and an exhaustive `dispatch()` - method from those signatures. Constructors and helpers live in a separate, - unannotated impl. Shared responses are named directly by both handlers. - Bodies return ordinary `Result` payloads or explicit replies with a transcript - outcome; the service uses native async methods. - Method-specific diagnostics are collected and summarized by their handler; - other replies derive the outcome from the wire response payload. - The macro wraps handler results in `SsoReply`; dispatch adds - correlation ids. Request context carries only the call and signing session. - The pairing host sends through - `PairingHost::call(request)` using the same generated pairing. - Never add per-variant match lists for pairing, correlation, or transcript - outcome outside those derives; a new message is two payload structs, two - enum variants, and one handler; the client uses the typed `call` method. +- Inter-host SSO uses `SsoWire`, `SsoResponse`, and `#[sso_service]` as described + in the [macro guide](rust/crates/truapi-macros/README.md). Keep per-variant pairing, + dispatch, correlation, and transcript classification in those macros; + do not add manual per-variant catalogs. Method-specific diagnostics belong + in 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/README.md b/README.md index a37262ce1..b03b390a0 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,8 @@ 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. -SSO resource consent is bound to the signing session that received the request; -switching accounts or reconnecting while approval is pending invalidates it. See the core's [inter-host SSO design](rust/crates/truapi-server/README.md#inter-host-sso) -for the handler declarations and typed wire contracts. +for typed handlers and resource consent bound to the signing session. ### JS Host SDKs diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index c939fa354..084f1cc0a 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -226,24 +226,14 @@ role-specific lifecycle, so no method exists on a role that can't mean it: - **`PairingHost`** (seedless): the user's keys live in an external wallet, so signing/aliases/entropy relay over an encrypted SSO channel (statement store - on the People chain; the channel lives in `pairing_host/sso_channel.rs`, - whose one generic `call(request)` sends any `SsoRequest` and returns its - typed response payload). The v2 wire protocol uses raw X25519 keys, - HKDF-SHA256, and ChaCha20-Poly1305. It owns pairing/login state, persisted - auth-session reload, and remote signing-host liveness monitoring. + on the People chain; the channel lives in `pairing_host/sso_channel.rs`). The + v2 wire protocol uses raw X25519 keys, HKDF-SHA256, and + ChaCha20-Poly1305. It owns pairing/login state, persisted auth-session reload, + and remote signing-host liveness monitoring. - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session - from host-held secret material. Paired hosts' requests reach it through the - `SigningHostSsoService` handlers (`signing_host/sso_service.rs`, one method - per wire request in an inherent impl annotated with `#[sso_service]`). The - macro generates the service's dispatcher. Handlers own consent prompts and - revalidate the request's signing session after resource consent and allocation; - `signing_host/sso_responder.rs` runs the statement-store serve loop and holds - the shared allowance helpers. Those helpers use the caller's session through - chain reads and revalidate it before allocation and key return. - Its public identity is the RFC-0022 - `uid.` index-0 product account of the configured network. RFC-0024 - ring-VRF keys are explicit, + from host-held secret material. Its public identity is the RFC-0022 + `uid.` index-0 product account of the configured network. RFC-0024 ring-VRF keys are explicit, product-owned registry entries; aliases, proofs, direct signatures, and internal personhood flows use the requested or user-selected registered key without a compiled-in fallback. It resolves RFC-0004 `RingLocation` values @@ -257,54 +247,28 @@ role-specific lifecycle, so no method exists on a role that can't mean it: call. `host_logic` stays pure: the orchestrators above call into it for codecs, -session/SSO crypto, SSO wire types and traits (`sso/wire.rs`), key derivation, -and permission policy. The runtime service supplies request/response pairing; -all I/O (statement-store RPC, storage, prompts, chain RPC) stays in the layers -above. +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 -The hand-written `host_logic::sso::messages::v1::RemoteMessage` enum defines -the wire variants and their SCALE indices. `SsoWire` derives classification, -request wrapping, correlation helpers, and message names from that enum. -These helpers do not require a runtime service implementation. Response -structs derive `SsoResponse` to expose their `Result` payload and classify -its transcript outcome. - -Each method in the annotated `impl SigningHostSsoService` names its wire request -and wire response directly. `#[sso_service]` derives request/response pairing -and an exhaustive `dispatch` method from those handler signatures. It wraps -ordinary `Result` bodies in `SsoReply`, preserving `?` and early -returns. Constructors and helpers live in a separate, unannotated impl; service -methods use native async functions. Dispatch adds the correlation id and constructs -the wire response. Request context holds the call context and captured signing -session. The allocation handler collects -item failures locally and supplies a transcript outcome with those details; -other replies derive their outcome from the response payload. - -An additional SSO operation requires payload definitions, wire variants, one -handler in the annotated impl, and a typed client call. The macro's checked-in -compiler tests cover valid handler bodies and reject incomplete or incompatible -contracts. - -`PairingHost::call(request)` uses the generated pairing and rejects a response -of the wrong kind immediately. Alias, proof, and ring-VRF operations share -request types between the local authority and SSO service. Transcripts and the -client's `action` field use the service method name; forwarding spans distinguish -payload signing, raw signing, and transaction creation, with `account_kind` -identifying product, legacy, or identity accounts as applicable. - -The SSO macros share `truapi-macros`' proc-macro infrastructure. They are -server-specific: their generated `crate::host_logic` and `crate::runtime` -paths resolve only when invoked inside `truapi-server`. The canonical `truapi` -crate uses the other macros and has no dependency on the server runtime. - -Rust consumers of the public `host_logic::sso` module depend on its Rust API -as well as the wire format. Stable SCALE indices and payload layouts do not -make renamed types or removed helpers source-compatible. Construct outgoing -requests with `RemoteMessage::request(message_id, typed_request)`; decoded -`SsoSessionStatement::RemoteMessages` contains ordered wire messages, which can -be matched directly or unwrapped with `SsoResponse::from_message`. +`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. + +The `host_logic::sso::messages::v1::RemoteMessage` enum owns the SCALE wire +contract. 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 unwrap responses with `SsoResponse::from_message`. ## Wire envelope diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs index ea7932435..0d8a3a227 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -143,226 +143,15 @@ impl RemoteMessage { #[cfg(test)] mod tests { - use truapi::latest::{ - DerivationIndex, HostAccountGetAliasResponse, LegacyAccountTxPayload, ProductAccountId, - ProductAccountTxPayload, ProductProofContext, RingLocation, - }; - use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, RingVrfKeyDisclosure}; + use truapi::latest::{DerivationIndex, ProductAccountId}; use super::*; use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; use crate::host_logic::sso::messages::{ - CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionLegacyPayload, - CreateTransactionPayload, CreateTransactionRequest, CreateTransactionResponse, - CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, GetAccountAliasResponse, - ListRingVrfKeysRequest, ListRingVrfKeysResponse, OnExistingAllowancePolicy, - ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, - RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, - RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, - SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, - SignVrfResponse, SigningPayloadResponseData, SigningRawPayload, SigningRawRequest, - SsoAllocationOutcome, + CreateTransactionResponse, ProductSubtreeRequest, SignRequest, SigningRawPayload, + SigningRawRequest, }; - fn account() -> ProductAccountId { - ProductAccountId { - dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: DerivationIndex::Index(7), - } - } - - fn ring() -> RingLocation { - RingLocation { - chain_id: [0x11; 32], - junctions: vec![], - } - } - - fn context() -> ProductProofContext { - ProductProofContext { - product_id: "voting.dot".to_string(), - suffix: DerivationIndex::Index(0), - } - } - - fn product_tx() -> ProductAccountTxPayload { - ProductAccountTxPayload { - signer: account(), - genesis_hash: [2; 32], - call_data: vec![1, 2, 3], - extensions: vec![], - tx_ext_version: 0, - } - } - - fn legacy_tx() -> LegacyAccountTxPayload { - LegacyAccountTxPayload { - signer: [1; 32], - genesis_hash: [2; 32], - call_data: vec![4], - extensions: vec![], - tx_ext_version: 0, - } - } - - fn assert_request_round_trip(request: R) - where - R: SsoRequest + Clone + PartialEq + core::fmt::Debug, - { - let message = request.clone().into_message(); - assert_eq!(message.name(), R::NAME); - assert_eq!(R::from_message(message), Some(request)); - } - - fn assert_response_round_trip(payload: Result) - where - Q: SsoResponse + Clone + PartialEq + core::fmt::Debug, - Q::Ok: Clone + PartialEq + core::fmt::Debug, - Q::Err: Clone + PartialEq + core::fmt::Debug, - { - let response = Q::new("m-1".to_string(), payload.clone()); - assert_eq!(response.responding_to(), "m-1"); - assert_eq!(response.clone().into_payload(), payload); - assert_eq!( - Q::from_message(response.clone().into_message()), - Some(response) - ); - } - - #[test] - fn request_payloads_round_trip_through_their_variants() { - assert_request_round_trip(SignRequest::Raw(SigningRawRequest { - product_account_id: account(), - data: SigningRawPayload::Bytes(vec![0xde]), - })); - assert_request_round_trip(GetAccountAliasRequest { - calling_product_id: "caller.dot".to_string(), - key_handle: account(), - context: context(), - ring_location: ring(), - }); - assert_request_round_trip(ResourceAllocationRequest { - calling_product_id: "caller.dot".to_string(), - resources: vec![], - on_existing: OnExistingAllowancePolicy::Increase, - }); - assert_request_round_trip(CreateTransactionRequest { - payload: CreateTransactionPayload::V1(product_tx()), - }); - assert_request_round_trip(CreateTransactionWithLegacyAccountRequest { - payload: CreateTransactionLegacyPayload::V1(legacy_tx()), - }); - assert_request_round_trip(SignRawWithLegacyAccountRequest { - account: [1; 32], - data: SigningRawPayload::Payload("hi".to_string()), - }); - assert_request_round_trip(CreateAccountProofRequest { - calling_product_id: "caller.dot".to_string(), - key_handle: account(), - context: context(), - ring_location: ring(), - message: b"vote".to_vec(), - }); - assert_request_round_trip(SignVrfRequest { - calling_product_id: "caller.dot".to_string(), - payload: HostAccountSignVrfRequest { - account: account(), - transcript_label: b"label".to_vec(), - items: vec![], - }, - }); - assert_request_round_trip(ProductSubtreeRequest { - product_id: "browse.dot".to_string(), - }); - assert_request_round_trip(RegisterRingVrfKeyRequest { - calling_product_id: "game.dot".to_string(), - index: DerivationIndex::Index(4), - ring: ring(), - }); - assert_request_round_trip(ListRingVrfKeysRequest { - calling_product_id: "game.dot".to_string(), - owner: "peopl.dot".to_string(), - disclosure: RingVrfKeyDisclosure::PublicKey, - }); - assert_request_round_trip(RingVrfSignRequest { - calling_product_id: "game.dot".to_string(), - key_handle: account(), - message: vec![9], - }); - } - - #[test] - fn response_payloads_round_trip_through_their_variants() { - assert_response_round_trip::(Ok(SigningPayloadResponseData { - signature: vec![1], - signed_transaction: None, - })); - assert_response_round_trip::(Err("nope".to_string())); - assert_response_round_trip::(Err(HostAccountSignVrfError::Rejected)); - assert_response_round_trip::(Ok(HostAccountGetAliasResponse { - context: [0x22; 32], - alias: vec![0x33], - })); - assert_response_round_trip::(Err(RingVrfError::NotMember)); - assert_response_round_trip::(Ok([1; 32])); - assert_response_round_trip::(Ok(vec![])); - assert_response_round_trip::(Ok(vec![5])); - assert_response_round_trip::(Ok(vec![ - SsoAllocationOutcome::Rejected, - ])); - assert_response_round_trip::(Ok([7; 32])); - assert_response_round_trip::(Ok(vec![8])); - } - - #[test] - fn each_request_is_answered_by_its_named_response() { - fn response_name() -> &'static str { - let not_connected = <::Err as SsoError>::not_connected(); - R::Response::new(String::new(), Err(not_connected)) - .into_message() - .name() - } - assert_eq!(response_name::(), "SignResponse"); - assert_eq!( - response_name::(), - "GetAccountAliasResponse" - ); - assert_eq!( - response_name::(), - "ResourceAllocationResponse" - ); - assert_eq!( - response_name::(), - "CreateTransactionResponse" - ); - assert_eq!( - response_name::(), - "CreateTransactionResponse" - ); - assert_eq!( - response_name::(), - "SignRawWithLegacyAccountResponse" - ); - assert_eq!( - response_name::(), - "CreateAccountProofResponse" - ); - assert_eq!(response_name::(), "SignVrfResponse"); - assert_eq!( - response_name::(), - "ProductSubtreeResponse" - ); - assert_eq!( - response_name::(), - "RegisterRingVrfKeyResponse" - ); - assert_eq!( - response_name::(), - "ListRingVrfKeysResponse" - ); - assert_eq!(response_name::(), "RingVrfSignResponse"); - } - #[test] fn classify_separates_requests_responses_and_disconnect() { let request = ProductSubtreeRequest { @@ -373,7 +162,10 @@ mod tests { Incoming::Request(AnyRequest::ProductSubtreeRequest(request)) ); let boxed = SignRequest::Raw(SigningRawRequest { - product_account_id: account(), + product_account_id: ProductAccountId { + dot_ns_identifier: "myapp.dot".to_string(), + derivation_index: DerivationIndex::Index(7), + }, data: SigningRawPayload::Bytes(vec![]), }); assert_eq!( @@ -389,28 +181,4 @@ mod tests { Incoming::Disconnected ); } - - #[test] - fn shared_response_and_names_follow_the_enum() { - fn response_name(_: &R) -> &'static str { - core::any::type_name::() - } - let legacy = CreateTransactionWithLegacyAccountRequest { - payload: CreateTransactionLegacyPayload::V1(legacy_tx()), - }; - assert!(response_name(&legacy).ends_with("CreateTransactionResponse")); - assert_eq!( - RemoteMessage::request("m".to_string(), legacy).name(), - "create_transaction_with_legacy_account" - ); - assert_eq!( - ::NAME, - "create_transaction_with_legacy_account" - ); - assert_eq!(::NAME, "sign"); - assert_eq!( - ::NAME, - "get_account_alias" - ); - } } diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index ef21b03f7..33e2b9fac 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -513,6 +513,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(); @@ -521,16 +537,7 @@ mod tests { 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, - }, + pending_wait(session.sso.as_ref().unwrap(), &cancel), |_| None::>, )) .unwrap_err(); @@ -549,17 +556,11 @@ mod tests { 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, + ..pending_wait(session.sso.as_ref().unwrap(), &CancellationToken::default()) }, |_| None::>, )) @@ -580,37 +581,8 @@ mod tests { 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), - }, - |_| None::>, - )) - .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), + ..pending_wait(session.sso.as_ref().unwrap(), &CancellationToken::default()) }, |_| None::>, )) @@ -624,16 +596,7 @@ mod tests { 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, - }, + pending_wait(session.sso.as_ref().unwrap(), &cancel), |_| None::>, ); @@ -674,16 +637,10 @@ mod tests { let cancel = CancellationToken::default(); futures::executor::block_on(wait_for_sso_remote_response( RemoteResponseWait { - own_statements: stream::pending().boxed(), - peer_statements: stream::iter(pages.into_iter().map(Ok)) - .chain(stream::pending()) - .boxed(), + own_statements: stream::empty().boxed(), + peer_statements: stream::iter(pages.into_iter().map(Ok)).boxed(), submit: futures::future::ready(Ok(())).boxed(), - session: host, - statement_request_id: "request-1", - remote_message_id: "request-1", - cancel: &cancel, - disconnect: None, + ..pending_wait(host, &cancel) }, reply_matcher::("request-1"), )) diff --git a/rust/crates/truapi-server/src/runtime/sso_service.rs b/rust/crates/truapi-server/src/runtime/sso_service.rs index b9dad7dc3..5b4ab50db 100644 --- a/rust/crates/truapi-server/src/runtime/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/sso_service.rs @@ -85,46 +85,19 @@ impl SsoReply { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::{ProductSubtreeResponse, RemoteMessageData, v1}; + use crate::host_logic::sso::messages::{ProductSubtreeResponse, RemoteMessageData}; #[test] fn finish_addresses_the_response_to_the_request() { let answer = SsoReply::::from(Err("nope".to_string())).finish("m-1"); - assert_eq!( - answer, - Answer { - message: RemoteMessage { - message_id: "m-1:response".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse( - ProductSubtreeResponse { - responding_to: "m-1".to_string(), - product_public_key: Err("nope".to_string()), - }, - )), - }, - outcome: ResponseOutcome { - outcome: "error", - reason: Some("nope".to_string()), - }, - } - ); - } - - #[test] - fn reply_outcome_does_not_change_the_wire_payload() { - let answer = SsoReply::::from(Err("denied".to_string())) - .with_outcome(ResponseOutcome { - outcome: "rejected", - reason: Some("local detail".to_string()), - }) - .finish("m-1"); - - assert_eq!(answer.outcome.outcome, "rejected"); - assert_eq!(answer.outcome.reason.as_deref(), Some("local detail")); + assert_eq!(answer.message.message_id, "m-1:response"); let RemoteMessageData::V1(data) = answer.message.data; let response = ProductSubtreeResponse::from_message(data).unwrap(); - assert_eq!(response.product_public_key, Err("denied".to_string())); + assert_eq!(response.responding_to, "m-1"); + assert_eq!(response.product_public_key, Err("nope".to_string())); + assert_eq!(answer.outcome.outcome, "error"); + assert_eq!(answer.outcome.reason.as_deref(), Some("nope")); } } From ea3a0f191971a325f859bdb95c3971063acdd7fb Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 04:15:45 +0000 Subject: [PATCH 3/8] refactor(sso): share response envelopes and request conversions --- CLAUDE.md | 9 +- rust/crates/truapi-server/README.md | 6 +- rust/crates/truapi-server/src/host_core.rs | 10 +- .../src/host_logic/sso/messages.rs | 289 +++++------------- .../src/host_logic/sso/messages/v1.rs | 24 +- .../truapi-server/src/host_logic/sso/wire.rs | 76 +++-- rust/crates/truapi-server/src/native.rs | 2 +- .../src/runtime/pairing_host/sso_channel.rs | 6 +- .../src/runtime/signing_host/sso_responder.rs | 30 +- .../src/runtime/signing_host/sso_service.rs | 74 ++++- .../truapi-server/src/runtime/sso_remote.rs | 30 +- .../truapi-server/src/runtime/sso_service.rs | 47 +-- .../src/runtime/statement_store.rs | 2 +- .../crates/truapi-server/src/runtime/tests.rs | 40 ++- .../src/runtime/tests/signing.rs | 8 +- rust/crates/truapi-server/src/test_support.rs | 6 +- 16 files changed, 297 insertions(+), 362 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afe74ced7..f5faf3656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,11 +73,12 @@ 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 `SsoWire`, `SsoResponse`, and `#[sso_service]` as described +- Inter-host SSO uses `SsoWire` and `#[sso_service]` as described in the [macro guide](rust/crates/truapi-macros/README.md). Keep per-variant pairing, - dispatch, correlation, and transcript classification in those macros; - do not add manual per-variant catalogs. Method-specific diagnostics belong - in the handler; request context carries only the call and signing session. + dispatch, and correlation in those macros; do not add manual per-variant catalogs. + 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/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index 084f1cc0a..487ff6992 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -260,7 +260,9 @@ account changes, disconnects, and reactivation invalidate pending approval befor allocation or key return. Allocation failure details stay in local transcripts. The `host_logic::sso::messages::v1::RemoteMessage` enum owns the SCALE wire -contract. Macros generate request/response pairing and dispatch; see the +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. @@ -268,7 +270,7 @@ 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 unwrap responses with `SsoResponse::from_message`. +match variants directly or use the request's `SsoRequest::response_from_message`. ## Wire envelope diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index e26a3a8fd..dd491378f 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -2237,9 +2237,7 @@ mod tests { #[test] fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { - use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, SignRawWithLegacyAccountResponse, v1, - }; + use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1}; use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; const ENTROPY: [u8; 32] = [0xab; 32]; @@ -2272,9 +2270,9 @@ mod tests { let response_variant = RemoteMessage { message_id: "m2".to_string(), data: RemoteMessageData::V1(v1::RemoteMessage::SignRawWithLegacyAccountResponse( - SignRawWithLegacyAccountResponse { + Response { responding_to: "m2".to_string(), - signature: Ok(vec![]), + payload: Ok(vec![]), }, )), }; @@ -2328,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/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 9eba5541e..1f7ab0be6 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -2,9 +2,9 @@ //! //! These are the encrypted payloads carried inside statement-store //! `SsoStatementData::Request` / `Response` frames. A pairing host sends a -//! request with [`RemoteMessage::request`]; the signing host answers through -//! [`RemoteMessage::response`]. Which response answers which request is typed -//! by the annotated handler signatures (see `sso::wire`). +//! 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: //! @@ -29,7 +29,6 @@ use truapi::latest::{ ProductProofContext, RawPayload, RingLocation, }; use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; -use truapi_macros::SsoResponse; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -37,7 +36,6 @@ use crate::host_logic::sso::pairing::{ encrypt_session_statement_data, encrypt_session_statement_data_with_nonce, peer_response_channel, }; -use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::{ build_signed_session_request_statement, build_signed_statement, current_unix_secs, decode_verified_statement_data, statement_expiry_elapsed, @@ -91,6 +89,17 @@ pub enum RemoteMessageData { 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 @@ -286,13 +295,7 @@ impl From for truapi::v01::HostSignRawRequest { /// /// Decoded from [`v1::RemoteMessage::SignResponse`] while the runtime is waiting /// for a matching SSO remote message id. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -pub struct SignResponse { - /// `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, -} +pub type SignResponse = Result; /// Successful product-account signing result returned by the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -307,13 +310,7 @@ pub struct SigningPayloadResponseData { /// /// Decoded from [`v1::RemoteMessage::SignRawWithLegacyAccountResponse`] and mapped back to /// the public raw-signing response shape. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -pub struct SignRawWithLegacyAccountResponse { - /// `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>, -} +pub type SignRawWithLegacyAccountResponse = Result, String>; /// RFC-0023 VRF-signing request forwarded to the Account Holder. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -325,13 +322,7 @@ pub struct SignVrfRequest { } /// RFC-0023 VRF-signing response returned by the Account Holder. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -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)] @@ -372,13 +363,7 @@ pub struct GetAccountAliasRequest { } /// Response returned by the Account Holder for a ring-VRF alias request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -pub struct GetAccountAliasResponse { - /// `message_id` of the alias request being answered. - pub responding_to: String, - /// Derived alias, or the ring-VRF failure. - pub payload: Result, -} +pub type GetAccountAliasResponse = Result; /// Request sent when a product asks the Account Holder for a ring-VRF proof. /// @@ -411,13 +396,7 @@ pub struct RegisterRingVrfKeyRequest { } /// Response returned by the Account Holder for key registration. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -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>, -} +pub type RegisterRingVrfKeyResponse = Result<[u8; 32], RingVrfError>; /// Request to list registered ring-VRF keys. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -431,13 +410,7 @@ pub struct ListRingVrfKeysRequest { } /// Response returned by the Account Holder for registry listing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -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>, -} +pub type ListRingVrfKeysResponse = Result, RingVrfError>; /// Request to sign bytes with a ring-VRF key. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] @@ -451,22 +424,10 @@ pub struct RingVrfSignRequest { } /// Response returned by the Account Holder for direct ring-VRF signing. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -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, SsoResponse)] -pub struct CreateAccountProofResponse { - /// `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. @@ -521,14 +482,7 @@ pub enum OnExistingAllowancePolicy { } /// Response returned by the signing host for a resource-allocation request. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -#[sso(outcome = resource_allocation_outcome)] -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)] @@ -541,72 +495,6 @@ pub enum SsoAllocationOutcome { NotAvailable, } -/// Transcript outcome for an allocation batch: `ok` only when every requested -/// resource was allocated; otherwise `rejected`, `partial`, or `not_available` -/// with a count summary. -pub 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") - }), - } -} - /// Resource material allocated by the signing host. #[derive(Clone, PartialEq, Eq, Encode, Decode)] pub enum SsoAllocatedResource { @@ -657,13 +545,7 @@ pub struct ProductSubtreeRequest { } /// Account Holder response carrying a product subtree public key. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode, SsoResponse)] -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. @@ -695,16 +577,9 @@ 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, SsoResponse)] -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)] @@ -957,7 +832,7 @@ fn outgoing_request_data( mod tests { use super::*; use crate::host_logic::sso::pairing::decrypt_session_statement_data; - use crate::host_logic::sso::wire::SsoResponse; + use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::{ StatementField, build_signed_statement, decode_statement_data, }; @@ -1075,12 +950,10 @@ mod tests { .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 = RemoteMessage::request( @@ -1094,12 +967,10 @@ mod tests { .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 = RemoteMessage::request( @@ -1113,12 +984,10 @@ mod tests { .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(); @@ -1241,26 +1110,22 @@ mod tests { }; let alias_response = RemoteMessage { message_id: "r-alias".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::GetAccountAliasResponse( - GetAccountAliasResponse { - 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::CreateAccountProofResponse( - CreateAccountProofResponse { - 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( @@ -1299,12 +1164,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()), @@ -1315,10 +1178,10 @@ mod tests { ); let RemoteMessageData::V1(data) = response.data; assert_eq!( - ProductSubtreeResponse::from_message(data), - Some(ProductSubtreeResponse { + ProductSubtreeRequest::response_from_message(data), + Some(Response { responding_to: "request".to_string(), - product_public_key: Ok([0xAB; 32]), + payload: Ok([0xAB; 32]), }) ); } @@ -1350,7 +1213,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], @@ -1368,8 +1231,8 @@ mod tests { ); let RemoteMessageData::V1(data) = response.data; assert!(matches!( - SignVrfResponse::from_message(data), - Some(SignVrfResponse { + SignVrfRequest::response_from_message(data), + Some(Response { payload: Ok(VrfSignature { .. }), .. }) @@ -1380,17 +1243,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()), @@ -1444,7 +1305,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 { @@ -1808,7 +1669,7 @@ mod tests { let response = RemoteMessage { message_id: "resp-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SignResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(Response { responding_to: "remote-1".to_string(), payload: Ok(SigningPayloadResponseData { signature: vec![9; 64], @@ -1829,7 +1690,7 @@ mod tests { assert_eq!( decoded, Some(SsoSessionStatement::RemoteMessages(vec![Ok( - v1::RemoteMessage::SignResponse(SignResponse { + v1::RemoteMessage::SignResponse(Response { responding_to: "remote-1".to_string(), payload: Ok(SigningPayloadResponseData { signature: vec![9; 64], 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 47f9db53a..8b99e384e 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 @@ -13,7 +13,7 @@ use super::{ CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, GetAccountAliasResponse, ListRingVrfKeysRequest, ListRingVrfKeysResponse, ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, - RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, + RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, Response, RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, SignVrfResponse, }; @@ -29,57 +29,57 @@ pub enum RemoteMessage { /// Ask the signing host to sign a payload or raw data with a product account. SignRequest(Box), /// Signing host's answer to [`RemoteMessage::SignRequest`]. - SignResponse(SignResponse), + SignResponse(Response), /// Ask the Account Holder for a contextual alias. GetAccountAliasRequest(GetAccountAliasRequest), /// Account Holder's answer to [`RemoteMessage::GetAccountAliasRequest`]. - GetAccountAliasResponse(GetAccountAliasResponse), + GetAccountAliasResponse(Response), /// Ask the signing host to allocate SSO-backed resources. ResourceAllocationRequest(ResourceAllocationRequest), /// Signing host's answer to [`RemoteMessage::ResourceAllocationRequest`]. - ResourceAllocationResponse(ResourceAllocationResponse), + ResourceAllocationResponse(Response), /// Ask the signing host to create a signed product-account transaction. CreateTransactionRequest(CreateTransactionRequest), /// Signing host's answer to either transaction-creation request. - CreateTransactionResponse(CreateTransactionResponse), + CreateTransactionResponse(Response), /// Ask the signing host to create a signed legacy-account transaction. CreateTransactionWithLegacyAccountRequest(CreateTransactionWithLegacyAccountRequest), /// Ask the signing host to sign raw data with a legacy account. SignRawWithLegacyAccountRequest(SignRawWithLegacyAccountRequest), /// Signing host's answer to [`RemoteMessage::SignRawWithLegacyAccountRequest`]. - SignRawWithLegacyAccountResponse(SignRawWithLegacyAccountResponse), + SignRawWithLegacyAccountResponse(Response), /// Ask the Account Holder for a ring-VRF proof. CreateAccountProofRequest(CreateAccountProofRequest), /// Account Holder's answer to [`RemoteMessage::CreateAccountProofRequest`]. - CreateAccountProofResponse(CreateAccountProofResponse), + CreateAccountProofResponse(Response), /// Ask the Account Holder to sign an RFC-0023 sr25519 VRF transcript. #[codec(index = 14)] SignVrfRequest(SignVrfRequest), /// Account Holder's answer to [`RemoteMessage::SignVrfRequest`]. #[codec(index = 15)] - SignVrfResponse(SignVrfResponse), + SignVrfResponse(Response), /// Consent-free request for a product's hard-subtree public key. #[codec(index = 16)] ProductSubtreeRequest(ProductSubtreeRequest), /// Account Holder's answer to [`RemoteMessage::ProductSubtreeRequest`]. #[codec(index = 17)] - ProductSubtreeResponse(ProductSubtreeResponse), + ProductSubtreeResponse(Response), /// Register a ring-VRF key with the Account Holder. #[codec(index = 18)] RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. #[codec(index = 19)] - RegisterRingVrfKeyResponse(RegisterRingVrfKeyResponse), + RegisterRingVrfKeyResponse(Response), /// List registered ring-VRF keys. #[codec(index = 20)] ListRingVrfKeysRequest(ListRingVrfKeysRequest), /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. #[codec(index = 21)] - ListRingVrfKeysResponse(ListRingVrfKeysResponse), + ListRingVrfKeysResponse(Response), /// Sign bytes with a registered ring-VRF key. #[codec(index = 22)] RingVrfSignRequest(RingVrfSignRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. #[codec(index = 23)] - 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 index 0d8a3a227..6c89f13bf 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -1,53 +1,29 @@ -//! Typed pairing of SSO request and response payloads. +//! Typed SSO requests and their response variants. //! -//! [`SsoRequest`] is implemented by `#[sso_service]` from each handler's wire -//! request parameter and wire response return type. `#[derive(SsoWire)]` on -//! [`v1::RemoteMessage`] provides wire classification. [`SsoResponse`] is -//! derived on each response struct and exposes its payload without the -//! correlation id. +//! `#[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 truapi::v01::HostAccountSignVrfError; -use super::messages::{RemoteMessage, RemoteMessageData, RingVrfError, v1}; +use super::messages::{RemoteMessage, RemoteMessageData, Response, RingVrfError, v1}; -/// A request payload carried by one `v1::RemoteMessage` variant. +/// 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; - /// Response payload the signing host answers with. - type Response: SsoResponse; + /// The handler's result payload, without correlation metadata. + type Response; /// Wrap into the request variant. fn into_message(self) -> v1::RemoteMessage; /// Unwrap from the request variant; `None` for any other message. fn from_message(message: v1::RemoteMessage) -> Option; + /// 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>; } -/// A response payload carried by one `v1::RemoteMessage` variant. -pub trait SsoResponse: Sized { - /// Successful payload. - type Ok; - /// Failure payload. - type Err: SsoError; - /// Build the response for the request identified by `responding_to`. - fn new(responding_to: String, payload: Result) -> Self; - /// `message_id` of the request being answered. - fn responding_to(&self) -> &str; - /// Strip the correlation id. - fn into_payload(self) -> Result; - /// Wrap into the response variant. - fn into_message(self) -> v1::RemoteMessage; - /// Unwrap from the response variant; `None` for any other message. - fn from_message(message: v1::RemoteMessage) -> Option; - /// Transcript classification of the payload. - fn outcome(&self) -> ResponseOutcome; -} - -/// A wire response's `Result` payload, without its correlation id. -/// -/// The typed client returns this result. Server replies carry the same payload -/// alongside local diagnostics; dispatch adds the wire envelope. -pub type ResponsePayload = Result<::Ok, ::Err>; - /// Failure payload that can express "no signing session". pub trait SsoError { /// The signing host has no active session to serve the request with. @@ -148,10 +124,29 @@ mod tests { use super::*; use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; use crate::host_logic::sso::messages::{ - CreateTransactionResponse, ProductSubtreeRequest, SignRequest, SigningRawPayload, - SigningRawRequest, + CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, ProductSubtreeRequest, + SignRawWithLegacyAccountRequest, SignRequest, SigningRawPayload, SigningRawRequest, }; + #[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()); + } + #[test] fn classify_separates_requests_responses_and_disconnect() { let request = ProductSubtreeRequest { @@ -173,7 +168,10 @@ mod tests { Incoming::Request(AnyRequest::SignRequest(boxed)) ); assert_eq!( - classify(CreateTransactionResponse::new("m".to_string(), Ok(vec![])).into_message()), + classify(v1::RemoteMessage::CreateTransactionResponse(Response { + responding_to: "m".to_string(), + payload: Ok(vec![]), + })), Incoming::Response("CreateTransactionResponse") ); assert_eq!( 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/pairing_host/sso_channel.rs b/rust/crates/truapi-server/src/runtime/pairing_host/sso_channel.rs index f6fbc446f..690141caa 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 @@ -21,7 +21,7 @@ use crate::host_logic::sso::messages::{ SsoAllocatedResource, SsoAllocationOutcome, SsoSessionStatement, build_outgoing_request_statement, decode_sso_session_statement, v1, }; -use crate::host_logic::sso::wire::{ResponsePayload, SsoRequest, SsoResponse}; +use crate::host_logic::sso::wire::SsoRequest; use crate::host_logic::statement_store::parse_new_statements_result; use futures::FutureExt; @@ -151,7 +151,7 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, request: R, - ) -> Result, SsoRemoteResponseError> { + ) -> Result { let sso = session .sso .as_ref() @@ -230,7 +230,7 @@ impl PairingHost { if matches!(&result, Err(SsoRemoteResponseError::PeerDisconnected)) { self.handle_signing_host_disconnected(key).await; } - result.map(SsoResponse::into_payload) + result.map(|response| response.payload) } /// Resolve a product's hard-subtree public key, asking the Account Holder 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 cce10cab1..1add48f8d 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 @@ -989,15 +989,15 @@ pub(super) fn current_unix_secs() -> Result { #[cfg(test)] mod tests { use super::super::LocalActivation; + use super::super::sso_service::resource_allocation_outcome; 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, ResourceAllocationResponse, RingVrfError, + self, GetAccountAliasResponse, RemoteMessage, Response, RingVrfError, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, - resource_allocation_outcome, }; - use crate::host_logic::sso::wire::SsoResponse; + 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; @@ -1317,14 +1317,14 @@ mod tests { #[test] fn response_summary_reports_protocol_errors_without_multiline_output() { - let response = GetAccountAliasResponse { + let response: Response = Response { responding_to: "alias-1".to_string(), payload: Err(RingVrfError::Unknown { reason: "chain RPC\ntimed out".to_string(), }), }; - let result = response.outcome(); + let result = ResponseOutcome::from_payload(&response.payload); let summary = response_cli_summary( "SSO response sent", "get_account_alias", @@ -1379,7 +1379,7 @@ mod tests { #[test] fn response_summary_classifies_resource_allocation_batches() { - let response = ResourceAllocationResponse { + let response = Response { responding_to: "allocation-1".to_string(), payload: Ok(vec![ SsoAllocationOutcome::Rejected, @@ -1387,7 +1387,7 @@ mod tests { ]), }; - let result = response.outcome(); + let result = resource_allocation_outcome(&response.payload); assert_eq!( (result.outcome, result.reason.as_deref()), @@ -1463,10 +1463,12 @@ mod tests { let expected = RemoteMessage { message_id: format!("{message_id}:response"), - data: RemoteMessageData::V1( - ResourceAllocationResponse::new(message_id.clone(), Ok(outcomes)) - .into_message(), - ), + data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( + Response { + responding_to: message_id.clone(), + payload: Ok(outcomes), + }, + )), }; assert_eq!(answer.message.encode(), expected.encode()); assert_eq!(answer.outcome.outcome, outcome); @@ -1699,9 +1701,7 @@ mod tests { 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]); @@ -1737,7 +1737,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 index d39cccb97..8f66f7385 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -30,6 +30,7 @@ use crate::host_logic::sso::messages::{ SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, SignVrfResponse, SigningPayloadResponseData, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, }; +use crate::host_logic::sso::wire::ResponseOutcome; use crate::runtime::authority::{ AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, @@ -259,7 +260,7 @@ fn allocation_reply( payload: Result, String>, failures: Vec, ) -> SsoReply { - let mut outcome = crate::host_logic::sso::messages::resource_allocation_outcome(&payload); + let mut outcome = resource_allocation_outcome(&payload); if !failures.is_empty() { let details = failures.join("; ").replace(['\r', '\n'], " "); outcome.reason = Some(match outcome.reason { @@ -270,6 +271,72 @@ fn allocation_reply( 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. +pub(super) 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") + }), + } +} + fn public_allocatable_resource(resource: &SsoAllocatableResource) -> api::AllocatableResource { match resource { SsoAllocatableResource::StatementStoreAllowance => { @@ -463,7 +530,10 @@ mod tests { Ok(vec![SsoAllocationOutcome::NotAvailable]), vec!["rpc\nfailed".to_string(), "provider\rdown".to_string()], ) - .finish("allocation-1"); + .finish( + "allocation-1", + crate::host_logic::sso::messages::v1::RemoteMessage::ResourceAllocationResponse, + ); assert_eq!(answer.outcome.outcome, "not_available"); assert_eq!( diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 33e2b9fac..c444b6776 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -8,8 +8,10 @@ use std::sync::Mutex; use super::statement_store_rpc; use crate::host_logic::session::SsoSessionInfo; -use crate::host_logic::sso::messages::{SsoSessionStatement, decode_sso_session_statement, v1}; -use crate::host_logic::sso::wire::{SsoRequest, SsoResponse}; +use crate::host_logic::sso::messages::{ + 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; @@ -248,14 +250,14 @@ fn disconnect_error(reason: String) -> SsoRemoteResponseError { /// 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> + '_ { +) -> 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) + R::response_from_message(message) .ok_or_else(|| format!("Unexpected SSO response for {}: {kind}", R::NAME)), ) } @@ -473,7 +475,7 @@ mod tests { use super::*; use crate::host_logic::sso::messages::{ ProductSubtreeRequest, ProductSubtreeResponse, RemoteMessage, RemoteMessageData, - SignResponse, build_outgoing_request_statement, build_signed_session_response_statement, + 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; @@ -621,19 +623,17 @@ mod tests { fn subtree_response(responding_to: &str) -> RemoteMessage { RemoteMessage { message_id: "resp-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse( - ProductSubtreeResponse { - responding_to: responding_to.to_string(), - product_public_key: Ok([7; 32]), - }, - )), + 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 { + ) -> Result, SsoRemoteResponseError> { let cancel = CancellationToken::default(); futures::executor::block_on(wait_for_sso_remote_response( RemoteResponseWait { @@ -653,7 +653,7 @@ mod tests { let (host, responder) = sso_host_and_responder_sessions(); let wrong_kind = RemoteMessage { message_id: "resp-1".to_string(), - data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(SignResponse { + data: RemoteMessageData::V1(v1::RemoteMessage::SignResponse(Response { responding_to: "request-1".to_string(), payload: Err("nope".to_string()), })), @@ -704,9 +704,9 @@ mod tests { assert_eq!( response, - ProductSubtreeResponse { + Response { responding_to: "request-1".to_string(), - product_public_key: Ok([7; 32]), + payload: Ok([7; 32]), } ); } diff --git a/rust/crates/truapi-server/src/runtime/sso_service.rs b/rust/crates/truapi-server/src/runtime/sso_service.rs index 5b4ab50db..05a0b3a75 100644 --- a/rust/crates/truapi-server/src/runtime/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/sso_service.rs @@ -3,8 +3,8 @@ use truapi::{CallContext, RequestId}; use super::authority::AuthoritySession; -use crate::host_logic::sso::messages::RemoteMessage; -use crate::host_logic::sso::wire::{ResponseOutcome, ResponsePayload, SsoResponse}; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, Response, v1}; +use crate::host_logic::sso::wire::{ResponseOutcome, SsoError}; /// Per-request context handed to every service method. pub(crate) struct SsoRequestContext { @@ -45,13 +45,13 @@ pub(crate) struct Answer { } /// A handler's payload and optional transcript outcome, before wire wrapping. -pub(crate) struct SsoReply { - payload: ResponsePayload, +pub(crate) struct SsoReply

{ + payload: P, outcome: Option, } -impl From> for SsoReply { - fn from(payload: ResponsePayload) -> Self { +impl

From

for SsoReply

{ + fn from(payload: P) -> Self { Self { payload, outcome: None, @@ -59,23 +59,31 @@ impl From> for SsoReply { } } -impl SsoReply { +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 } +} - /// Wrap the payload with correlation and use its derived or supplied outcome. - pub(crate) fn finish(self, message_id: &str) -> Answer { - let response = R::new(message_id.to_string(), self.payload); - let outcome = self.outcome.unwrap_or_else(|| response.outcome()); +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: crate::host_logic::sso::messages::RemoteMessageData::V1( - response.into_message(), - ), + data: RemoteMessageData::V1(wrap(Response { + responding_to: message_id.to_string(), + payload: self.payload, + })), }, outcome, } @@ -85,18 +93,19 @@ impl SsoReply { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::{ProductSubtreeResponse, RemoteMessageData}; + 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"); + 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 = ProductSubtreeResponse::from_message(data).unwrap(); + let response = ProductSubtreeRequest::response_from_message(data).unwrap(); assert_eq!(response.responding_to, "m-1"); - assert_eq!(response.product_public_key, Err("nope".to_string())); + 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..006733e83 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( diff --git a/rust/crates/truapi-server/src/runtime/tests.rs b/rust/crates/truapi-server/src/runtime/tests.rs index 59955adee..e5b3b0155 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::{ - CreateAccountProofResponse, GetAccountAliasResponse, RemoteMessage, RemoteMessageData, 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::GetAccountAliasResponse( - GetAccountAliasResponse { - 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() @@ -1259,7 +1255,7 @@ fn create_account_proof_returns_sso_proof() { RemoteMessage { message_id: "wallet-proof-1".to_string(), data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse( - CreateAccountProofResponse { + Response { responding_to: "proof-1".to_string(), payload: Ok(v01::HostAccountCreateProofResponse { proof: vec![0xaa, 0xbb], @@ -1310,7 +1306,7 @@ fn create_account_proof_maps_not_member_error() { RemoteMessage { message_id: "wallet-proof-1".to_string(), data: RemoteMessageData::V1(v1::RemoteMessage::CreateAccountProofResponse( - 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]), }, ), ), @@ -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 Date: Wed, 9 Sep 2026 05:06:20 +0000 Subject: [PATCH 4/8] refactor(sso): reuse canonical payloads and simplify handlers --- CLAUDE.md | 4 +- README.md | 4 +- js/packages/truapi/src/wire-equality.test.ts | 38 ++ .../truapi-codegen/tests/golden/wire_table.rs | 2 +- rust/crates/truapi-server/README.md | 4 + .../src/host_logic/sso/messages.rs | 502 ++++-------------- .../src/host_logic/sso/messages/v1.rs | 29 +- .../truapi-server/src/host_logic/sso/wire.rs | 10 +- .../src/host_logic/transaction.rs | 2 +- .../truapi-server/src/runtime/authority.rs | 21 +- .../src/runtime/capabilities/account.rs | 45 +- .../truapi-server/src/runtime/pairing_host.rs | 88 +-- .../src/runtime/pairing_host/sso_channel.rs | 56 +- .../truapi-server/src/runtime/signing_host.rs | 177 +++--- .../src/runtime/signing_host/sso_responder.rs | 98 +--- .../src/runtime/signing_host/sso_service.rs | 260 +++++---- .../src/runtime/statement_store.rs | 2 +- .../crates/truapi-server/src/runtime/tests.rs | 8 +- .../src/runtime/tests/signing.rs | 8 +- rust/crates/truapi-server/src/test_support.rs | 12 +- rust/crates/truapi/src/lib.rs | 7 +- rust/crates/truapi/src/v01/signing.rs | 6 +- 22 files changed, 578 insertions(+), 805 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f5faf3656..a2e55f90a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,9 @@ scripts/truapi-host-installer.sh - Inter-host SSO uses `SsoWire` and `#[sso_service]` as described in the [macro guide](rust/crates/truapi-macros/README.md). Keep per-variant pairing, dispatch, and correlation in those macros; do not add manual per-variant catalogs. - Responses share `Response

`; handler signatures name their result payload + 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. diff --git a/README.md b/README.md index b03b390a0..7ec96f70d 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,9 @@ 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 and resource consent bound to the signing session. +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/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/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/README.md b/rust/crates/truapi-server/README.md index 487ff6992..1e391e642 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -258,6 +258,10 @@ consent and business logic; `sso_responder.rs` owns the transport loop and share 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

`, 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 1f7ab0be6..47d6c62b8 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -22,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, + HostSignPayloadRequest, HostSignPayloadResponse, HostSignRawRequest, LegacyAccountTxPayload, + ProductAccountTxPayload, RawPayload, }; -use truapi::v01::{HostAccountSignVrfError, HostAccountSignVrfRequest, VrfSignature}; +use truapi::v01::{HostAccountSignVrfError, VrfSignature}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -115,118 +115,24 @@ pub enum SsoRequestOutcome { Ignored, } -/// Signing request flavor sent to the signing host. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub enum SignRequest { - /// 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 From for SigningPayloadRequest { - fn from(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, +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 @@ -239,72 +145,14 @@ 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 SigningRawRequest { - fn from(value: truapi::v01::HostSignRawRequest) -> Self { - Self { - product_account_id: value.account, - data: value.payload.into(), - } - } -} - -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. -pub type SignResponse = 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. /// @@ -312,15 +160,6 @@ pub struct SigningPayloadResponseData { /// the public raw-signing response shape. pub type SignRawWithLegacyAccountResponse = 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, -} - /// RFC-0023 VRF-signing response returned by the Account Holder. pub type SignVrfResponse = Result; @@ -346,83 +185,15 @@ pub enum RingVrfError { }, } -/// Request sent when a product asks the Account Holder for a contextual alias. -/// -/// Used by `Account::get_account_alias`; `calling_product_id` names the caller, -/// `key_handle` selects a registered member key, and `context` binds the alias. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct GetAccountAliasRequest { - /// 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. pub type GetAccountAliasResponse = 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 CreateAccountProofRequest { - /// 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, -} - /// Response returned by the Account Holder for key registration. pub type RegisterRingVrfKeyResponse = Result<[u8; 32], RingVrfError>; -/// Request to list registered ring-VRF keys. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct ListRingVrfKeysRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Product whose registry entries should be listed. - pub owner: String, - /// Disclosure requested by the caller. - pub disclosure: truapi::v01::RingVrfKeyDisclosure, -} - /// Response returned by the Account Holder for registry listing. pub type ListRingVrfKeysResponse = Result, RingVrfError>; -/// Request to sign bytes with a ring-VRF key. -#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] -pub struct RingVrfSignRequest { - /// Product id of the calling product. - pub calling_product_id: String, - /// Registered key handle. - pub key_handle: ProductAccountId, - /// Message to sign. - pub message: Vec, -} - /// Response returned by the Account Holder for direct ring-VRF signing. pub type RingVrfSignResponse = Result, RingVrfError>; @@ -440,38 +211,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 { @@ -838,6 +582,10 @@ mod tests { }; 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}; @@ -891,14 +639,12 @@ mod tests { fn raw_sign_request_uses_remote_message_variant_indices() { let message = RemoteMessage::request( "m1".to_string(), - SignRequest::Raw(SigningRawRequest::from( - truapi::latest::HostSignRawRequest { - account: account(), - payload: RawPayload::Bytes { - bytes: vec![0xde, 0xad], - }, + SignRequest::Raw(truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Bytes { + bytes: vec![0xde, 0xad], }, - )), + }), ); let encoded = message.encode(); @@ -935,16 +681,18 @@ mod tests { String::new(), SignRawWithLegacyAccountRequest { account: [1; 32], - data: RawPayload::Bytes { bytes: vec![] }.into(), + data: RawPayload::Bytes { bytes: vec![] }, }, ) .encode(); let register = RemoteMessage::request( String::new(), - RegisterRingVrfKeyRequest { + ProductRequest { calling_product_id: "caller.dot".to_string(), - index: DerivationIndex::Index(0), - ring: ring_location.clone(), + payload: truapi::latest::HostAccountRegisterRingVrfKeyRequest { + index: DerivationIndex::Index(0), + ring: ring_location.clone(), + }, }, ) .encode(); @@ -958,10 +706,12 @@ mod tests { .encode(); let list = RemoteMessage::request( String::new(), - ListRingVrfKeysRequest { + ProductRequest { calling_product_id: "caller.dot".to_string(), - owner: "peopl.dot".to_string(), - disclosure: truapi::v01::RingVrfKeyDisclosure::Anonymized, + payload: truapi::latest::HostAccountListRingVrfKeysRequest { + owner: "peopl.dot".to_string(), + disclosure: truapi::v01::RingVrfKeyDisclosure::Anonymized, + }, }, ) .encode(); @@ -975,10 +725,12 @@ mod tests { .encode(); let sign = RemoteMessage::request( String::new(), - RingVrfSignRequest { + ProductRequest { calling_product_id: "caller.dot".to_string(), - key_handle, - message: vec![], + payload: truapi::latest::HostAccountRingVrfSignRequest { + key_handle, + message: vec![], + }, }, ) .encode(); @@ -1028,20 +780,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 = [ @@ -1074,21 +832,25 @@ mod tests { let alias = RemoteMessage::request( "m-alias".to_string(), - GetAccountAliasRequest { + ProductRequest { calling_product_id: "caller.dot".to_string(), - key_handle: key_handle.clone(), - context: context.clone(), - ring_location: ring_location.clone(), + payload: truapi::latest::HostAccountGetAliasRequest { + key_handle: key_handle.clone(), + context: context.clone(), + ring_location: ring_location.clone(), + }, }, ); let proof = RemoteMessage::request( "m-proof".to_string(), - CreateAccountProofRequest { + ProductRequest { calling_product_id: "caller.dot".to_string(), - key_handle, - context, - ring_location, - message: b"vote".to_vec(), + payload: truapi::latest::HostAccountCreateProofRequest { + key_handle, + context, + ring_location, + message: b"vote".to_vec(), + }, }, ); @@ -1201,7 +963,7 @@ mod tests { }; let request = RemoteMessage::request( "req".to_string(), - SignVrfRequest { + ProductRequest { calling_product_id: "browse.dot".to_string(), payload, }, @@ -1231,7 +993,9 @@ mod tests { ); let RemoteMessageData::V1(data) = response.data; assert!(matches!( - SignVrfRequest::response_from_message(data), + ProductRequest::::response_from_message( + data + ), Some(Response { payload: Ok(VrfSignature { .. }), .. @@ -1353,10 +1117,7 @@ mod tests { AllocatableResource::BulletinAllowance, AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), AllocatableResource::AutoSigning, - ] - .into_iter() - .map(Into::into) - .collect(), + ], on_existing: OnExistingAllowancePolicy::Increase, }, ); @@ -1457,8 +1218,7 @@ mod tests { account: sequential_bytes(0), data: RawPayload::Bytes { bytes: b"Hi".to_vec(), - } - .into(), + }, }, ), "0x306d2d6c65676163792d726177000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f00084869", @@ -1470,8 +1230,7 @@ mod tests { account: sequential_bytes(0), data: RawPayload::Payload { payload: "Hi".to_string(), - } - .into(), + }, }, ), "0x506d2d6c65676163792d7261772d7061796c6f6164000a000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f01443c42797465733e48693c2f42797465733e", @@ -1497,54 +1256,21 @@ mod tests { asset_id: None, metadata_hash: None, mode: None, - with_signed_transaction: Some(true), - }, - }; - let true_encoded = SigningPayloadRequest::from(request.clone()).encode(); - request.payload.with_signed_transaction = Some(false); - let false_encoded = SigningPayloadRequest::from(request.clone()).encode(); - request.payload.with_signed_transaction = None; - let none_encoded = SigningPayloadRequest::from(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 = RemoteMessage::request( - "alloc".to_string(), - ResourceAllocationRequest { - calling_product_id: "myapp.dot".to_string(), - resources: vec![ - AllocatableResource::StatementStoreAllowance, - AllocatableResource::BulletinAllowance, - AllocatableResource::SmartContractAllowance(DerivationIndex::Index(9)), - AllocatableResource::AutoSigning, - ] - .into_iter() - .map(Into::into) - .collect(), - on_existing: OnExistingAllowancePolicy::Increase, + with_signed_transaction: parity_scale_codec::OptionBool(Some(true)), }, - ); - 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] @@ -1552,14 +1278,12 @@ mod tests { let session = session(); let remote_message = RemoteMessage::request( "remote-1".to_string(), - SignRequest::Raw(SigningRawRequest::from( - truapi::latest::HostSignRawRequest { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), - }, + SignRequest::Raw(truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), }, - )), + }), ); let statement = build_outgoing_request_statement_with_nonce( @@ -1594,14 +1318,12 @@ mod tests { let session = session(); let remote_message = RemoteMessage::request( "remote-1".to_string(), - SignRequest::Raw(SigningRawRequest::from( - truapi::latest::HostSignRawRequest { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), - }, + SignRequest::Raw(truapi::latest::HostSignRawRequest { + account: account(), + payload: RawPayload::Payload { + payload: "hello".to_string(), }, - )), + }), ); let statement = build_outgoing_request_statement_with_nonce( &session, @@ -1625,14 +1347,12 @@ mod tests { let (host_session, responder_session) = sso_host_and_responder_sessions(); let request = RemoteMessage::request( "remote-1".to_string(), - SignRequest::Raw(SigningRawRequest::from( - truapi::latest::HostSignRawRequest { - account: account(), - payload: RawPayload::Payload { - payload: "hello".to_string(), - }, + 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( @@ -1671,7 +1391,7 @@ mod tests { message_id: "resp-1".to_string(), 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, }), @@ -1692,7 +1412,7 @@ mod tests { 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, }), 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 8b99e384e..73c6ad374 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,16 +6,19 @@ //! use parity_scale_codec::{Decode, Encode}; +use truapi::latest::{ + HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, HostAccountSignVrfRequest, +}; use truapi_macros::SsoWire; use super::{ - CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionRequest, - CreateTransactionResponse, CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, - GetAccountAliasResponse, ListRingVrfKeysRequest, ListRingVrfKeysResponse, - ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, - RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, Response, - RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, - SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, SignVrfResponse, + 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. @@ -31,7 +34,7 @@ pub enum RemoteMessage { /// Signing host's answer to [`RemoteMessage::SignRequest`]. SignResponse(Response), /// Ask the Account Holder for a contextual alias. - GetAccountAliasRequest(GetAccountAliasRequest), + GetAccountAliasRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::GetAccountAliasRequest`]. GetAccountAliasResponse(Response), /// Ask the signing host to allocate SSO-backed resources. @@ -49,12 +52,12 @@ pub enum RemoteMessage { /// Signing host's answer to [`RemoteMessage::SignRawWithLegacyAccountRequest`]. SignRawWithLegacyAccountResponse(Response), /// Ask the Account Holder for a ring-VRF proof. - CreateAccountProofRequest(CreateAccountProofRequest), + 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)] - SignVrfRequest(SignVrfRequest), + SignVrfRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::SignVrfRequest`]. #[codec(index = 15)] SignVrfResponse(Response), @@ -66,19 +69,19 @@ pub enum RemoteMessage { ProductSubtreeResponse(Response), /// Register a ring-VRF key with the Account Holder. #[codec(index = 18)] - RegisterRingVrfKeyRequest(RegisterRingVrfKeyRequest), + RegisterRingVrfKeyRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::RegisterRingVrfKeyRequest`]. #[codec(index = 19)] RegisterRingVrfKeyResponse(Response), /// List registered ring-VRF keys. #[codec(index = 20)] - ListRingVrfKeysRequest(ListRingVrfKeysRequest), + ListRingVrfKeysRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::ListRingVrfKeysRequest`]. #[codec(index = 21)] ListRingVrfKeysResponse(Response), /// Sign bytes with a registered ring-VRF key. #[codec(index = 22)] - RingVrfSignRequest(RingVrfSignRequest), + RingVrfSignRequest(ProductRequest), /// Account Holder's answer to [`RemoteMessage::RingVrfSignRequest`]. #[codec(index = 23)] 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 index 6c89f13bf..be27773ff 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -16,8 +16,6 @@ pub trait SsoRequest: Sized { type Response; /// Wrap into the request variant. fn into_message(self) -> v1::RemoteMessage; - /// Unwrap from the request variant; `None` for any other message. - fn from_message(message: v1::RemoteMessage) -> Option; /// 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. @@ -125,7 +123,7 @@ mod tests { use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; use crate::host_logic::sso::messages::{ CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, ProductSubtreeRequest, - SignRawWithLegacyAccountRequest, SignRequest, SigningRawPayload, SigningRawRequest, + SignRawWithLegacyAccountRequest, SignRequest, }; #[test] @@ -156,12 +154,12 @@ mod tests { classify(request.clone().into_message()), Incoming::Request(AnyRequest::ProductSubtreeRequest(request)) ); - let boxed = SignRequest::Raw(SigningRawRequest { - product_account_id: ProductAccountId { + let boxed = SignRequest::Raw(truapi::latest::HostSignRawRequest { + account: ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), derivation_index: DerivationIndex::Index(7), }, - data: SigningRawPayload::Bytes(vec![]), + payload: truapi::latest::RawPayload::Bytes { bytes: vec![] }, }); assert_eq!( classify(boxed.clone().into_message()), 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/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index b3bf9055d..dd32ebbb3 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -9,8 +9,10 @@ use async_trait::async_trait; use std::sync::Arc; use truapi::latest::{ - AccountId, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyResponse, + AccountId, HostAccountCreateProofRequest, HostAccountCreateProofResponse, + HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountListRingVrfKeysRequest, + HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, + HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, HostAccountRingVrfSignResponse, HostCreateTransactionResponse, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, @@ -23,10 +25,7 @@ use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; use crate::host_logic::session::{SessionInfo, SessionState}; -use crate::host_logic::sso::messages::{ - CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, - RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, -}; +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. @@ -362,7 +361,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: GetAccountAliasRequest, + request: ProductRequest, ) -> Result; /// Create a ring-VRF proof bound to a context and message. @@ -373,7 +372,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateAccountProofRequest, + request: ProductRequest, ) -> Result; /// Register a ring-VRF key owned by the calling product. @@ -381,7 +380,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) -> Result; /// List registered ring-VRF keys. @@ -389,7 +388,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysRequest, + request: ProductRequest, ) -> Result; /// Sign bytes directly with a registered ring-VRF key. @@ -397,7 +396,7 @@ pub(crate) trait ProductAuthority: Send + Sync { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignRequest, + 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 461afd709..ac76848a8 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::host_logic::sso::messages::{ - CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, - RegisterRingVrfKeyRequest, RingVrfSignRequest, -}; +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, @@ -151,11 +148,13 @@ impl Account for ProductRuntimeHost { self.authority.account_alias( &cx, &session, - GetAccountAliasRequest { + ProductRequest { calling_product_id, - key_handle, - context, - ring_location, + payload: latest::HostAccountGetAliasRequest { + key_handle, + context, + ring_location, + }, }, ), ) @@ -202,12 +201,14 @@ impl Account for ProductRuntimeHost { self.authority.create_proof( &cx, &session, - CreateAccountProofRequest { + ProductRequest { calling_product_id, - key_handle, - context, - ring_location, - message, + payload: latest::HostAccountCreateProofRequest { + key_handle, + context, + ring_location, + message, + }, }, ), ) @@ -241,10 +242,9 @@ impl Account for ProductRuntimeHost { self.authority.register_ring_vrf_key( &cx, &session, - RegisterRingVrfKeyRequest { + ProductRequest { calling_product_id, - index, - ring, + payload: latest::HostAccountRegisterRingVrfKeyRequest { index, ring }, }, ), ) @@ -287,10 +287,9 @@ impl Account for ProductRuntimeHost { self.authority.list_ring_vrf_keys( &cx, &session, - ListRingVrfKeysRequest { + ProductRequest { calling_product_id, - owner, - disclosure, + payload: latest::HostAccountListRingVrfKeysRequest { owner, disclosure }, }, ), ) @@ -335,10 +334,12 @@ impl Account for ProductRuntimeHost { self.authority.ring_vrf_sign( &cx, &session, - RingVrfSignRequest { + ProductRequest { calling_product_id, - key_handle: request.key_handle, - message: request.message, + payload: latest::HostAccountRingVrfSignRequest { + key_handle: request.key_handle, + message: request.message, + }, }, ), ) diff --git a/rust/crates/truapi-server/src/runtime/pairing_host.rs b/rust/crates/truapi-server/src/runtime/pairing_host.rs index dd0f734d1..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}; @@ -42,10 +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::{ - CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, - RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, -}; +use crate::host_logic::sso::messages::{ProductRequest, RingVrfError}; use crate::subscription::Spawner; use futures::StreamExt; @@ -1973,7 +1974,7 @@ impl PairingHost { fn mirror_ring_vrf_registration( &self, session: SessionInfo, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) { let weak_self = self.weak_self.clone(); (self.spawner)(Box::pin(async move { @@ -2085,21 +2086,23 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: GetAccountAliasRequest, + 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, @@ -2114,26 +2117,30 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateAccountProofRequest, + 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 { @@ -2152,7 +2159,7 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) -> Result { let private_session = self.current_private_session(session)?; let handle = v01::ProductAccountId { @@ -2161,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?; @@ -2191,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) @@ -2201,10 +2213,10 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysRequest, + 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(), } @@ -2216,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) @@ -2243,16 +2255,16 @@ impl PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignRequest, + 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 @@ -2465,7 +2477,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: GetAccountAliasRequest, + request: ProductRequest, ) -> Result { PairingHost::account_alias(self, cx, session, request).await } @@ -2474,7 +2486,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: CreateAccountProofRequest, + request: ProductRequest, ) -> Result { PairingHost::create_proof(self, cx, session, request).await } @@ -2483,7 +2495,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) -> Result { PairingHost::register_ring_vrf_key(self, cx, session, request).await } @@ -2492,7 +2504,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysRequest, + request: ProductRequest, ) -> Result, RingVrfError> { PairingHost::list_ring_vrf_keys(self, cx, session, request).await } @@ -2501,7 +2513,7 @@ impl ProductAuthority for PairingHost { &self, cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignRequest, + 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 690141caa..7a4e96c12 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 @@ -13,13 +13,12 @@ use super::super::statement_store_rpc::{self, StatementStoreRpc}; use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ - CreateAccountProofRequest, CreateTransactionLegacyPayload, CreateTransactionPayload, - CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, - ListRingVrfKeysRequest, OnExistingAllowancePolicy, ProductSubtreeRequest, - RegisterRingVrfKeyRequest, RemoteMessage, RemoteMessageData, ResourceAllocationRequest, - RingVrfError, RingVrfSignRequest, SignRawWithLegacyAccountRequest, SignRequest, SignVrfRequest, - SsoAllocatedResource, SsoAllocationOutcome, SsoSessionStatement, - build_outgoing_request_statement, decode_sso_session_statement, 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; @@ -272,7 +271,7 @@ impl PairingHost { self.call( cx, session, - SignVrfRequest { + ProductRequest { calling_product_id, payload: request, }, @@ -307,15 +306,10 @@ impl PairingHost { payload: request.payload, }, }; - let payload = self - .call(cx, session, SignRequest::Payload(Box::new(request.into()))) + self.call(cx, session, SignRequest::Payload(Box::new(request))) .await .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - Ok(latest::HostSignPayloadResponse { - signature: payload.signature, - signed_transaction: payload.signed_transaction, - }) + .map_err(remote_authority_error) } /// Forward a raw-signing request to the paired signing host. @@ -330,17 +324,11 @@ impl PairingHost { request: SignRawAuthorityRequest, ) -> Result { match request { - SignRawAuthorityRequest::Product(request) => { - let payload = self - .call(cx, session, SignRequest::Raw(request.into())) - .await - .map_err(remote_authority_error)? - .map_err(remote_authority_error)?; - Ok(latest::HostSignPayloadResponse { - signature: payload.signature, - signed_transaction: payload.signed_transaction, - }) - } + SignRawAuthorityRequest::Product(request) => self + .call(cx, session, SignRequest::Raw(request)) + .await + .map_err(remote_authority_error)? + .map_err(remote_authority_error), SignRawAuthorityRequest::LegacyAccount { account, request } => { let signature = self .call( @@ -348,7 +336,7 @@ impl PairingHost { session, SignRawWithLegacyAccountRequest { account, - data: request.payload.into(), + data: request.payload, }, ) .await @@ -426,7 +414,7 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: GetAccountAliasRequest, + request: ProductRequest, ) -> Result { self.call(cx, session, request) .await @@ -438,7 +426,7 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: CreateAccountProofRequest, + request: ProductRequest, ) -> Result { self.call(cx, session, request) .await @@ -450,7 +438,7 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) -> Result { self.call(cx, session, request) .await @@ -462,7 +450,7 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: ListRingVrfKeysRequest, + request: ProductRequest, ) -> Result, RingVrfError> { self.call(cx, session, request) .await @@ -474,7 +462,7 @@ impl PairingHost { &self, cx: &CallContext, session: &SessionInfo, - request: RingVrfSignRequest, + request: ProductRequest, ) -> Result, RingVrfError> { self.call(cx, session, request) .await @@ -497,7 +485,7 @@ impl PairingHost { session, ResourceAllocationRequest { calling_product_id: product_id.clone(), - resources: request.resources.into_iter().map(Into::into).collect(), + resources: request.resources, on_existing: OnExistingAllowancePolicy::Increase, }, ) @@ -527,7 +515,7 @@ impl PairingHost { session, ResourceAllocationRequest { calling_product_id: product_id.to_string(), - resources: vec![resource.into()], + resources: vec![resource], on_existing, }, ) diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 98ee0c1b4..ece69f522 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -22,6 +22,10 @@ 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}; @@ -55,10 +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::{ - CreateAccountProofRequest, GetAccountAliasRequest, ListRingVrfKeysRequest, - OnExistingAllowancePolicy, RegisterRingVrfKeyRequest, RingVrfError, RingVrfSignRequest, -}; +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"))] @@ -844,13 +845,13 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: GetAccountAliasRequest, + 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 { @@ -866,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.payload.ring_location) .await?; - self.ring_resolver.validate(&request.ring_location).await?; - let context = development_context_bytes(&request.context); + let context = development_context_bytes(&request.payload.context); let alias = alias_from_entropy(&entropy, &context)?; Ok(v01::ContextualAlias { context, @@ -881,23 +888,27 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: CreateAccountProofRequest, + 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 { @@ -913,10 +924,10 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RegisterRingVrfKeyRequest, + 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( @@ -924,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) } @@ -938,13 +949,14 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: ListRingVrfKeysRequest, + 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(), @@ -970,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; } @@ -982,14 +994,14 @@ impl ProductAuthority for SigningHost { &self, _cx: &CallContext, session: &AuthoritySession, - request: RingVrfSignRequest, + 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( @@ -1168,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( @@ -1309,16 +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::{ - CreateAccountProofRequest, GetAccountAliasRequest, RegisterRingVrfKeyRequest, - RingVrfSignRequest, - }; + 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::{ @@ -1479,10 +1492,12 @@ mod tests { futures::executor::block_on(authority.register_ring_vrf_key( &CallContext::default(), session, - RegisterRingVrfKeyRequest { + 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"); @@ -1589,23 +1604,27 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - GetAccountAliasRequest { + 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, - CreateAccountProofRequest { + 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"); @@ -1630,16 +1649,18 @@ mod tests { let error = futures::executor::block_on(authority.account_alias( &CallContext::default(), &session, - GetAccountAliasRequest { + 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![], + }, }, }, )) @@ -1671,10 +1692,12 @@ mod tests { let error = futures::executor::block_on(authority.ring_vrf_sign( &CallContext::default(), &session, - RingVrfSignRequest { + 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(); @@ -1704,11 +1727,13 @@ mod tests { let alias = futures::executor::block_on(authority.account_alias( &cx, &session, - GetAccountAliasRequest { + 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)); @@ -1716,12 +1741,14 @@ mod tests { let proof = futures::executor::block_on(authority.create_proof( &cx, &session, - CreateAccountProofRequest { + 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)); @@ -1747,16 +1774,18 @@ mod tests { .expect("activation succeeds"); let session = authority.current_session().expect("active session"); let cx = CallContext::default(); - let request = GetAccountAliasRequest { + 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"); @@ -2152,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 1add48f8d..68d9a8064 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 @@ -989,13 +989,12 @@ pub(super) fn current_unix_secs() -> Result { #[cfg(test)] mod tests { use super::super::LocalActivation; - use super::super::sso_service::resource_allocation_outcome; 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, Response, RingVrfError, - SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, + self, GetAccountAliasResponse, RemoteMessage, Response, RingVrfError, SsoAllocatedResource, + SsoAllocationOutcome, }; use crate::host_logic::sso::wire::ResponseOutcome; use crate::host_logic::statement_store::decode_verified_statement_data; @@ -1347,57 +1346,6 @@ mod tests { ); } - #[test] - fn resource_allocation_summary_reflects_per_resource_outcomes() { - 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 response_summary_classifies_resource_allocation_batches() { - let response = Response { - responding_to: "allocation-1".to_string(), - payload: Ok(vec![ - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ]), - }; - - let result = resource_allocation_outcome(&response.payload); - - assert_eq!( - (result.outcome, result.reason.as_deref()), - ( - "rejected", - Some("No resources allocated; 1 rejected; 1 unavailable") - ) - ); - } - #[cfg(not(target_arch = "wasm32"))] #[test] fn allocation_failure_details_reach_the_response_transcript() { @@ -1413,7 +1361,7 @@ mod tests { let service = SigningHostSsoService::new(services, signing_host); let cases = [ ( - vec![SsoAllocatableResource::BulletinAllowance], + vec![api::AllocatableResource::BulletinAllowance], vec![SsoAllocationOutcome::NotAvailable], "not_available", "Requested resource is not available", @@ -1421,9 +1369,9 @@ mod tests { ), ( vec![ - SsoAllocatableResource::AutoSigning, - SsoAllocatableResource::BulletinAllowance, - SsoAllocatableResource::StatementStoreAllowance, + api::AllocatableResource::AutoSigning, + api::AllocatableResource::BulletinAllowance, + api::AllocatableResource::StatementStoreAllowance, ], vec![ auto_signing.clone(), @@ -1435,7 +1383,7 @@ mod tests { 2, ), ( - vec![SsoAllocatableResource::AutoSigning], + vec![api::AllocatableResource::AutoSigning], vec![auto_signing], "ok", "", @@ -1505,19 +1453,21 @@ mod tests { &services, &signing_host, "alias-1", - v1::RemoteMessage::GetAccountAliasRequest(messages::GetAccountAliasRequest { + v1::RemoteMessage::GetAccountAliasRequest(messages::ProductRequest { 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), - }, - ring_location: api::RingLocation { - chain_id: [0; 32], - junctions: vec![], + 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![], + }, }, }), ); @@ -1539,7 +1489,7 @@ mod tests { "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, }), ); @@ -1582,7 +1532,7 @@ mod tests { "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, }), ); @@ -1616,7 +1566,7 @@ mod tests { "alloc-stale".to_string(), messages::ResourceAllocationRequest { calling_product_id: "myapp.dot".to_string(), - resources: vec![SsoAllocatableResource::AutoSigning], + resources: vec![api::AllocatableResource::AutoSigning], on_existing: OnExistingAllowancePolicy::Ignore, }, ); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 8f66f7385..e6a46b89c 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -20,15 +20,13 @@ use crate::host_logic::product_account::{ derive_ring_vrf_domain_entropy, product_public_key_to_address, }; use crate::host_logic::sso::messages::{ - CreateAccountProofRequest, CreateAccountProofResponse, CreateTransactionLegacyPayload, - CreateTransactionPayload, CreateTransactionRequest, CreateTransactionResponse, - CreateTransactionWithLegacyAccountRequest, GetAccountAliasRequest, GetAccountAliasResponse, - ListRingVrfKeysRequest, ListRingVrfKeysResponse, OnExistingAllowancePolicy, - ProductSubtreeRequest, ProductSubtreeResponse, RegisterRingVrfKeyRequest, - RegisterRingVrfKeyResponse, ResourceAllocationRequest, ResourceAllocationResponse, - RingVrfSignRequest, RingVrfSignResponse, SignRawWithLegacyAccountRequest, - SignRawWithLegacyAccountResponse, SignRequest, SignResponse, SignVrfRequest, SignVrfResponse, - SigningPayloadResponseData, SsoAllocatableResource, SsoAllocatedResource, SsoAllocationOutcome, + 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::{ @@ -72,10 +70,10 @@ impl SigningHostSsoService { &self, cx: &SsoRequestContext, request: SignRequest, - ) -> Result { - let response = match request { + ) -> Result { + match request { SignRequest::Payload(request) => { - let request: api::HostSignPayloadRequest = (*request).into(); + let request = *request; self.confirm(UserConfirmationReview::SignPayload( SignPayloadReview::Product(request.clone()), )) @@ -89,7 +87,6 @@ impl SigningHostSsoService { .await } SignRequest::Raw(request) => { - let request: api::HostSignRawRequest = request.into(); self.confirm(UserConfirmationReview::SignRaw(SignRawReview::Product( request.clone(), ))) @@ -103,11 +100,7 @@ impl SigningHostSsoService { .await } } - .map_err(|err| err.to_string())?; - Ok(SigningPayloadResponseData { - signature: response.signature, - signed_transaction: response.signed_transaction, - }) + .map_err(|err| err.to_string()) } async fn serve_create_transaction( @@ -129,26 +122,28 @@ impl SigningHostSsoService { &self, session: &AuthoritySession, calling_product_id: &str, - resource: SsoAllocatableResource, + resource: api::AllocatableResource, on_existing: OnExistingAllowancePolicy, ) -> Result { let services = &self.services; let signing_host = &self.signing_host; match resource { - SsoAllocatableResource::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::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, + }) }) - }), - SsoAllocatableResource::BulletinAllowance => allocate_bulletin_allowance( + } + api::AllocatableResource::BulletinAllowance => allocate_bulletin_allowance( services, signing_host, session, @@ -161,7 +156,7 @@ impl SigningHostSsoService { slot_account_key, }) }), - SsoAllocatableResource::SmartContractAllowance(index) => { + api::AllocatableResource::SmartContractAllowance(index) => { allocate_smart_contract_allowance( services, signing_host, @@ -175,7 +170,7 @@ impl SigningHostSsoService { SsoAllocationOutcome::Allocated(SsoAllocatedResource::SmartContractAllowance) }) } - SsoAllocatableResource::AutoSigning => { + api::AllocatableResource::AutoSigning => { let product_root_private_key = signing_host .product_subtree_secret(calling_product_id) .map_err(AllowanceAllocationError::Authority)?; @@ -193,67 +188,6 @@ impl SigningHostSsoService { } } } - - async fn serve_resource_allocation( - &self, - cx: &SsoRequestContext, - request: ResourceAllocationRequest, - ) -> SsoReply { - let mut failures = Vec::new(); - let payload = async { - let review = UserConfirmationReview::ResourceAllocation(ResourceAllocationReview { - calling_product_id: request.calling_product_id.clone(), - resources: request - .resources - .iter() - .map(public_allocatable_resource) - .collect(), - }); - match self.services.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) - } } fn allocation_reply( @@ -274,7 +208,7 @@ fn allocation_reply( /// Transcript outcome for an allocation batch: `ok` only when every requested /// resource was allocated; otherwise `rejected`, `partial`, or `not_available` /// with a count summary. -pub(super) fn resource_allocation_outcome( +fn resource_allocation_outcome( payload: &Result, String>, ) -> ResponseOutcome { let outcomes = match payload { @@ -337,19 +271,6 @@ pub(super) fn resource_allocation_outcome( } } -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, - } -} - #[truapi_macros::sso_service] impl SigningHostSsoService { /// Sign a payload or raw bytes with a product account. @@ -365,7 +286,7 @@ impl SigningHostSsoService { async fn get_account_alias( &self, cx: &SsoRequestContext, - request: GetAccountAliasRequest, + request: ProductRequest, ) -> GetAccountAliasResponse { self.signing_host .account_alias(&cx.call, &cx.session, request) @@ -378,7 +299,56 @@ impl SigningHostSsoService { cx: &SsoRequestContext, request: ResourceAllocationRequest, ) -> ResourceAllocationResponse { - self.serve_resource_allocation(cx, request).await + 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.services.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. @@ -419,7 +389,7 @@ impl SigningHostSsoService { ) -> SignRawWithLegacyAccountResponse { let public_request = api::HostSignRawWithLegacyAccountRequest { signer: product_public_key_to_address(request.account), - payload: request.data.into(), + payload: request.data, }; self.confirm(UserConfirmationReview::SignRaw( SignRawReview::LegacyAccount(public_request.clone()), @@ -443,7 +413,7 @@ impl SigningHostSsoService { async fn create_account_proof( &self, cx: &SsoRequestContext, - request: CreateAccountProofRequest, + request: ProductRequest, ) -> CreateAccountProofResponse { self.signing_host .create_proof(&cx.call, &cx.session, request) @@ -451,7 +421,11 @@ impl SigningHostSsoService { } /// Sign an RFC-0023 VRF transcript. - async fn sign_vrf(&self, cx: &SsoRequestContext, request: SignVrfRequest) -> SignVrfResponse { + async fn sign_vrf( + &self, + cx: &SsoRequestContext, + request: ProductRequest, + ) -> SignVrfResponse { self.signing_host .sign_vrf( &cx.call, @@ -490,7 +464,7 @@ impl SigningHostSsoService { async fn register_ring_vrf_key( &self, cx: &SsoRequestContext, - request: RegisterRingVrfKeyRequest, + request: ProductRequest, ) -> RegisterRingVrfKeyResponse { self.signing_host .register_ring_vrf_key(&cx.call, &cx.session, request) @@ -501,7 +475,7 @@ impl SigningHostSsoService { async fn list_ring_vrf_keys( &self, cx: &SsoRequestContext, - request: ListRingVrfKeysRequest, + request: ProductRequest, ) -> ListRingVrfKeysResponse { self.signing_host .list_ring_vrf_keys(&cx.call, &cx.session, request) @@ -512,7 +486,7 @@ impl SigningHostSsoService { async fn ring_vrf_sign( &self, cx: &SsoRequestContext, - request: RingVrfSignRequest, + request: ProductRequest, ) -> RingVrfSignResponse { self.signing_host .ring_vrf_sign(&cx.call, &cx.session, request) @@ -523,6 +497,58 @@ impl SigningHostSsoService { #[cfg(test)] mod tests { use super::*; + use crate::host_logic::sso::messages::Response; + + #[test] + fn resource_allocation_summary_reflects_per_resource_outcomes() { + 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 response_summary_classifies_resource_allocation_batches() { + let response = Response { + responding_to: "allocation-1".to_string(), + payload: Ok(vec![ + SsoAllocationOutcome::Rejected, + SsoAllocationOutcome::NotAvailable, + ]), + }; + + let result = resource_allocation_outcome(&response.payload); + + 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() { diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index 006733e83..560614e8b 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -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 e5b3b0155..9c6b54c58 100644 --- a/rust/crates/truapi-server/src/runtime/tests.rs +++ b/rust/crates/truapi-server/src/runtime/tests.rs @@ -1225,8 +1225,8 @@ fn get_account_alias_forwards_without_pairing_host_confirmation() { 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] @@ -1293,8 +1293,8 @@ fn create_account_proof_returns_sso_proof() { 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] diff --git a/rust/crates/truapi-server/src/runtime/tests/signing.rs b/rust/crates/truapi-server/src/runtime/tests/signing.rs index 1dfabe3df..535338a31 100644 --- a/rust/crates/truapi-server/src/runtime/tests/signing.rs +++ b/rust/crates/truapi-server/src/runtime/tests/signing.rs @@ -695,15 +695,15 @@ fn legacy_sign_raw_accepts_derived_ss58_then_returns_sso_response() { panic!("expected raw signing payload"); }; assert_eq!( - request.product_account_id, + request.account, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), derivation_index: v01::DerivationIndex::Index(0), } ); assert!(matches!( - &request.data, - crate::host_logic::sso::messages::SigningRawPayload::Bytes(bytes) + &request.payload, + truapi::latest::RawPayload::Bytes { bytes } if bytes == b"hello" )); } @@ -748,7 +748,7 @@ fn legacy_sign_raw_accepts_derived_hex_then_returns_sso_response() { panic!("expected raw signing payload"); }; assert_eq!( - request.product_account_id, + request.account, v01::ProductAccountId { dot_ns_identifier: "myapp.dot".to_string(), derivation_index: v01::DerivationIndex::Index(0), diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index f5a6ae054..85c4a2adf 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -648,12 +648,10 @@ pub(crate) fn sign_response_message( crate::host_logic::sso::messages::v1::RemoteMessage::SignResponse( 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, + }), }, ), ), @@ -752,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), } } diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index 06d3557f4..c893361bf 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -54,8 +54,11 @@ pub mod latest { AccountId, AllocatableResource, AllocationOutcome, ChainIdentifier, ChatAction, ChatActionLayout, ChatActions, ChatBotRegistrationStatus, ChatCustomMessage, ChatFile, ChatMedia, ChatMessageContent, ChatReaction, ChatRichText, ChatRoomRegistrationStatus, - ContextualAlias, DerivationIndex, GenericError, HostPlatform, HostSignPayloadData, - NotificationId, OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, + ContextualAlias, DerivationIndex, GenericError, HostAccountCreateProofRequest, + HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, + HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, + HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, NotificationId, + OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, RegisteredRingVrfKey, RemotePermission, RemoteStatementStoreCreateProofError, RemoteStatementStoreCreateProofRequest, RemoteStatementStoreCreateProofResponse, RemoteStatementStoreSubscribeItem, RemoteStatementStoreSubscribeRequest, RingLocation, 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. From ad82d90336b91748c7d49003490b568c2d5056a1 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 08:47:04 +0000 Subject: [PATCH 5/8] refactor(sso): use Display and forward canonical requests --- .../src/host_logic/sso/messages.rs | 11 +-- .../truapi-server/src/host_logic/sso/wire.rs | 40 ++-------- .../truapi-server/src/runtime/authority.rs | 5 +- .../src/runtime/capabilities/account.rs | 79 +++++++------------ .../src/runtime/pairing_host/sso_channel.rs | 14 ++-- .../src/runtime/signing_host/sso_service.rs | 9 +-- .../truapi-server/src/runtime/sso_service.rs | 6 +- rust/crates/truapi/src/lib.rs | 6 +- rust/crates/truapi/src/v01/account.rs | 3 +- 9 files changed, 64 insertions(+), 109 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/sso/messages.rs b/rust/crates/truapi-server/src/host_logic/sso/messages.rs index 47d6c62b8..bf66c958c 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -25,10 +25,10 @@ use core::fmt; use parity_scale_codec::{Decode, Encode}; use truapi::latest::{ AccountId, AllocatableResource, HostAccountCreateProofResponse, HostAccountGetAliasResponse, - HostSignPayloadRequest, HostSignPayloadResponse, HostSignRawRequest, LegacyAccountTxPayload, - ProductAccountTxPayload, RawPayload, + HostAccountSignVrfError, HostSignPayloadRequest, HostSignPayloadResponse, HostSignRawRequest, + LegacyAccountTxPayload, ProductAccountTxPayload, RawPayload, RegisteredRingVrfKey, + VrfSignature, }; -use truapi::v01::{HostAccountSignVrfError, VrfSignature}; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::pairing::{ @@ -164,7 +164,7 @@ pub type SignRawWithLegacyAccountResponse = Result, String>; 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, @@ -179,6 +179,7 @@ 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, @@ -192,7 +193,7 @@ pub type GetAccountAliasResponse = Result; /// Response returned by the Account Holder for registry listing. -pub type ListRingVrfKeysResponse = Result, RingVrfError>; +pub type ListRingVrfKeysResponse = Result, RingVrfError>; /// Response returned by the Account Holder for direct ring-VRF signing. pub type RingVrfSignResponse = Result, RingVrfError>; diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs index be27773ff..85294eca7 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -4,7 +4,9 @@ //! signature. Responses share the [`Response`] envelope; the request identifies //! the response variant even when different operations have identical payload types. -use truapi::v01::HostAccountSignVrfError; +use core::fmt::Display; + +use truapi::latest::HostAccountSignVrfError; use super::messages::{RemoteMessage, RemoteMessageData, Response, RingVrfError, v1}; @@ -23,54 +25,28 @@ pub trait SsoRequest: Sized { } /// Failure payload that can express "no signing session". -pub trait SsoError { +pub trait SsoError: Display { /// The signing host has no active session to serve the request with. fn not_connected() -> Self; - /// Single-line description for transcripts. - fn reason(&self) -> String; } impl SsoError for String { fn not_connected() -> Self { "signing host session is not active".to_string() } - - fn reason(&self) -> String { - self.clone() - } } impl SsoError for RingVrfError { fn not_connected() -> Self { - RingVrfError::Unknown { + Self::Unknown { reason: String::not_connected(), } } - - fn reason(&self) -> String { - match self { - 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}"), - } - } } impl SsoError for HostAccountSignVrfError { fn not_connected() -> Self { - HostAccountSignVrfError::NotConnected - } - - fn reason(&self) -> String { - match self { - HostAccountSignVrfError::NotConnected => "NotConnected".to_string(), - HostAccountSignVrfError::Rejected => "Rejected".to_string(), - HostAccountSignVrfError::Unknown { reason } => reason.clone(), - } + Self::NotConnected } } @@ -85,7 +61,7 @@ pub struct ResponseOutcome { impl ResponseOutcome { /// Classify a plain payload: `ok`, or `error` with the failure's reason. - pub fn from_payload(payload: &Result) -> Self { + pub fn from_payload(payload: &Result) -> Self { match payload { Ok(_) => Self { outcome: "ok", @@ -93,7 +69,7 @@ impl ResponseOutcome { }, Err(err) => Self { outcome: "error", - reason: Some(err.reason()), + reason: Some(err.to_string()), }, } } diff --git a/rust/crates/truapi-server/src/runtime/authority.rs b/rust/crates/truapi-server/src/runtime/authority.rs index dd32ebbb3..02e1b764f 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -13,13 +13,12 @@ use truapi::latest::{ HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, - HostAccountRingVrfSignResponse, HostCreateTransactionResponse, + HostAccountRingVrfSignResponse, HostAccountSignVrfRequest, HostCreateTransactionResponse, HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, - ProductAccountId, ProductAccountTxPayload, + ProductAccountId, ProductAccountTxPayload, VrfSignature, }; -use truapi::v01::{HostAccountSignVrfRequest, VrfSignature}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; use truapi_platform::ProductContext; diff --git a/rust/crates/truapi-server/src/runtime/capabilities/account.rs b/rust/crates/truapi-server/src/runtime/capabilities/account.rs index ac76848a8..c60cb57fe 100644 --- a/rust/crates/truapi-server/src/runtime/capabilities/account.rs +++ b/rust/crates/truapi-server/src/runtime/capabilities/account.rs @@ -123,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, @@ -150,11 +147,7 @@ impl Account for ProductRuntimeHost { &session, ProductRequest { calling_product_id, - payload: latest::HostAccountGetAliasRequest { - key_handle, - context, - ring_location, - }, + payload: request, }, ), ) @@ -169,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, ))); @@ -203,12 +192,7 @@ impl Account for ProductRuntimeHost { &session, ProductRequest { calling_product_id, - payload: latest::HostAccountCreateProofRequest { - key_handle, - context, - ring_location, - message, - }, + payload: request, }, ), ) @@ -226,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, @@ -244,7 +225,7 @@ impl Account for ProductRuntimeHost { &session, ProductRequest { calling_product_id, - payload: latest::HostAccountRegisterRingVrfKeyRequest { 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(), @@ -289,7 +267,7 @@ impl Account for ProductRuntimeHost { &session, ProductRequest { calling_product_id, - payload: latest::HostAccountListRingVrfKeysRequest { owner, disclosure }, + payload: request, }, ), ) @@ -336,10 +314,7 @@ impl Account for ProductRuntimeHost { &session, ProductRequest { calling_product_id, - payload: latest::HostAccountRingVrfSignRequest { - key_handle: request.key_handle, - message: request.message, - }, + payload: request, }, ), ) 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 7a4e96c12..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 @@ -26,7 +26,7 @@ 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}; +use truapi::{CallContext, latest}; /// Active peer-disconnect watcher for one SSO session; aborts on drop. pub(super) struct SsoDisconnectMonitor { @@ -266,8 +266,8 @@ impl PairingHost { cx: &CallContext, session: &SessionInfo, calling_product_id: String, - request: v01::HostAccountSignVrfRequest, - ) -> Result { + request: latest::HostAccountSignVrfRequest, + ) -> Result { self.call( cx, session, @@ -279,9 +279,11 @@ impl PairingHost { .await .map_err(remote_authority_error)? .map_err(|err| match err { - v01::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, - v01::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, - v01::HostAccountSignVrfError::Unknown { reason } => AuthorityError::Unknown { reason }, + latest::HostAccountSignVrfError::NotConnected => AuthorityError::Disconnected, + latest::HostAccountSignVrfError::Rejected => AuthorityError::Rejected, + latest::HostAccountSignVrfError::Unknown { reason } => { + AuthorityError::Unknown { reason } + } }) } diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index e6a46b89c..33eecf6e8 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -5,7 +5,6 @@ use std::sync::Arc; use tracing::warn; use truapi::latest as api; -use truapi::v01; use truapi_platform::{ CreateTransactionReview, ResourceAllocationReview, SignPayloadReview, SignRawReview, UserConfirmationReview, @@ -435,15 +434,15 @@ impl SigningHostSsoService { ) .await .map_err(|err| match err { - AuthorityError::Disconnected => v01::HostAccountSignVrfError::NotConnected, - AuthorityError::Rejected => v01::HostAccountSignVrfError::Rejected, - AuthorityError::Cancelled(err) => v01::HostAccountSignVrfError::Unknown { + AuthorityError::Disconnected => api::HostAccountSignVrfError::NotConnected, + AuthorityError::Rejected => api::HostAccountSignVrfError::Rejected, + AuthorityError::Cancelled(err) => api::HostAccountSignVrfError::Unknown { reason: err.to_string(), }, AuthorityError::Unavailable { reason } | AuthorityError::NotSupported { reason } | AuthorityError::Unknown { reason } => { - v01::HostAccountSignVrfError::Unknown { reason } + api::HostAccountSignVrfError::Unknown { reason } } }) } diff --git a/rust/crates/truapi-server/src/runtime/sso_service.rs b/rust/crates/truapi-server/src/runtime/sso_service.rs index 05a0b3a75..4e0daf1ff 100644 --- a/rust/crates/truapi-server/src/runtime/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/sso_service.rs @@ -1,10 +1,12 @@ //! 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, SsoError}; +use crate::host_logic::sso::wire::ResponseOutcome; /// Per-request context handed to every service method. pub(crate) struct SsoRequestContext { @@ -67,7 +69,7 @@ impl

SsoReply

{ } } -impl SsoReply> { +impl SsoReply> { /// Address the reply and wrap it in the response variant selected by the request. pub(crate) fn finish( self, diff --git a/rust/crates/truapi/src/lib.rs b/rust/crates/truapi/src/lib.rs index c893361bf..9b13fb6fd 100644 --- a/rust/crates/truapi/src/lib.rs +++ b/rust/crates/truapi/src/lib.rs @@ -57,14 +57,14 @@ pub mod latest { ContextualAlias, DerivationIndex, GenericError, HostAccountCreateProofRequest, HostAccountGetAliasRequest, HostAccountListRingVrfKeysRequest, HostAccountRegisterRingVrfKeyRequest, HostAccountRingVrfSignRequest, - HostAccountSignVrfRequest, HostPlatform, HostSignPayloadData, NotificationId, - OperationStartedResult, ProductAccountId, ProductProofContext, RawPayload, + 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, From 45f74dccbd7c42765281ad88fbb6a816e844be15 Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 09:01:18 +0000 Subject: [PATCH 6/8] refactor(sso): remove redundant service state and error mappings --- rust/crates/truapi-server/src/host_core.rs | 2 +- rust/crates/truapi-server/src/runtime.rs | 12 +--- .../truapi-server/src/runtime/authority.rs | 26 ++++++-- .../src/runtime/signing_host/sso_responder.rs | 59 ++++++++----------- .../src/runtime/signing_host/sso_service.rs | 48 ++++----------- 5 files changed, 61 insertions(+), 86 deletions(-) diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index dd491378f..b5b79c34a 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -781,7 +781,7 @@ impl SigningHostRuntime { &self, message: RemoteMessage, ) -> SsoRequestOutcome { - let service = SigningHostSsoService::new(self.services.clone(), self.signing_host.clone()); + 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, diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index be9bcc885..bc7ba5dd3 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -752,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 02e1b764f..256fa5501 100644 --- a/rust/crates/truapi-server/src/runtime/authority.rs +++ b/rust/crates/truapi-server/src/runtime/authority.rs @@ -13,11 +13,12 @@ use truapi::latest::{ HostAccountGetAliasRequest, HostAccountGetAliasResponse, HostAccountListRingVrfKeysRequest, HostAccountListRingVrfKeysResponse, HostAccountRegisterRingVrfKeyRequest, HostAccountRegisterRingVrfKeyResponse, HostAccountRingVrfSignRequest, - HostAccountRingVrfSignResponse, HostAccountSignVrfRequest, HostCreateTransactionResponse, - HostRequestResourceAllocationRequest, HostRequestResourceAllocationResponse, - HostSignPayloadRequest, HostSignPayloadResponse, HostSignPayloadWithLegacyAccountRequest, - HostSignRawRequest, HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, - ProductAccountId, ProductAccountTxPayload, VrfSignature, + HostAccountRingVrfSignResponse, HostAccountSignVrfError, HostAccountSignVrfRequest, + HostCreateTransactionResponse, HostRequestResourceAllocationRequest, + HostRequestResourceAllocationResponse, HostSignPayloadRequest, HostSignPayloadResponse, + HostSignPayloadWithLegacyAccountRequest, HostSignRawRequest, + HostSignRawWithLegacyAccountRequest, LegacyAccountTxPayload, ProductAccountId, + ProductAccountTxPayload, VrfSignature, }; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, CancellationReason}; @@ -162,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( 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 68d9a8064..943e37b30 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 @@ -247,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)?; @@ -353,7 +353,7 @@ async fn serve_session( session: SsoSessionInfo, replay_scope: SsoReplayScope, ) -> Result { - let service = SigningHostSsoService::new(services.clone(), signing_host.clone()); + let service = SigningHostSsoService::new(signing_host.clone()); let rpc_client = services .statement_store .client("sso-responder session") @@ -448,7 +448,7 @@ async fn serve_session( /// Ack one inbound request statement and answer its batched messages. async fn serve_request( - services: &Arc, + services: &RuntimeServices, service: &SigningHostSsoService, session: &SsoSessionInfo, incoming: IncomingSsoRequest, @@ -543,7 +543,7 @@ async fn serve_request( } async fn acknowledge_request( - services: &Arc, + services: &RuntimeServices, session: &SsoSessionInfo, request_id: &str, ) -> Result<(), String> { @@ -594,7 +594,7 @@ fn response_cli_summary( #[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, @@ -723,7 +723,7 @@ pub(super) async fn allocate_statement_store_allowance( #[cfg(not(target_arch = "wasm32"))] pub(super) async fn allocate_bulletin_allowance( - services: &Arc, + services: &RuntimeServices, signing_host: &SigningHost, session: &AuthoritySession, product_id: &str, @@ -833,7 +833,7 @@ pub(super) async fn allocate_bulletin_allowance( #[cfg(target_arch = "wasm32")] pub(super) async fn allocate_statement_store_allowance( - _services: &Arc, + _services: &RuntimeServices, _signing_host: &SigningHost, _session: &AuthoritySession, _product_id: &str, @@ -857,7 +857,7 @@ 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, @@ -955,7 +955,7 @@ pub(super) async fn allocate_smart_contract_allowance( /// 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, @@ -967,7 +967,7 @@ 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, @@ -1295,12 +1295,11 @@ mod tests { } fn answer( - services: &Arc, signing_host: &Arc, message_id: &str, request: v1::RemoteMessage, ) -> v1::RemoteMessage { - let service = SigningHostSsoService::new(services.clone(), signing_host.clone()); + let service = SigningHostSsoService::new(signing_host.clone()); let message = RemoteMessage { message_id: message_id.to_string(), data: RemoteMessageData::V1(request), @@ -1316,14 +1315,11 @@ mod tests { #[test] fn response_summary_reports_protocol_errors_without_multiline_output() { - let response: Response = Response { - 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 = ResponseOutcome::from_payload(&response.payload); + let result = ResponseOutcome::from_payload(&payload); let summary = response_cli_summary( "SSO response sent", "get_account_alias", @@ -1349,7 +1345,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn allocation_failure_details_reach_the_response_transcript() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform { resource_allocation_confirmed: true, chain_connect_error: Some("allocation node unavailable"), ..StubPlatform::default() @@ -1358,7 +1354,7 @@ mod tests { product_root_private_key: signing_host.product_subtree_secret("myapp.dot").unwrap(), ring_vrf_domain_entropy: derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot").unwrap(), }); - let service = SigningHostSsoService::new(services, signing_host); + let service = SigningHostSsoService::new(signing_host); let cases = [ ( vec![api::AllocatableResource::BulletinAllowance], @@ -1447,10 +1443,9 @@ mod tests { #[test] fn account_alias_requires_confirmation_for_cross_product_request() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); let response = answer( - &services, &signing_host, "alias-1", v1::RemoteMessage::GetAccountAliasRequest(messages::ProductRequest { @@ -1481,10 +1476,9 @@ mod tests { #[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 = answer( - &services, &signing_host, "alloc-1", v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { @@ -1518,7 +1512,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"); @@ -1527,7 +1521,6 @@ mod tests { .expect("ring-VRF domain entropy derives"); let response = answer( - &services, &signing_host, "alloc-auto-signing", v1::RemoteMessage::ResourceAllocationRequest(messages::ResourceAllocationRequest { @@ -1560,8 +1553,8 @@ mod tests { resource_allocation_confirmation_gate: std::sync::Mutex::new(Some(gate)), ..StubPlatform::default() }); - let (services, signing_host) = signing_fixture(platform.clone()); - let service = SigningHostSsoService::new(services, signing_host.clone()); + let (_, signing_host) = signing_fixture(platform.clone()); + let service = SigningHostSsoService::new(signing_host.clone()); let message = RemoteMessage::request( "alloc-stale".to_string(), messages::ResourceAllocationRequest { @@ -1620,7 +1613,7 @@ mod tests { #[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() })); @@ -1638,7 +1631,6 @@ mod tests { }; let response = answer( - &services, &signing_host, "legacy-tx-1", v1::RemoteMessage::CreateTransactionWithLegacyAccountRequest( @@ -1666,9 +1658,8 @@ mod tests { #[test] fn product_subtree_request_is_consent_free_and_hard_derived() { - let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + let (_, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); let response = answer( - &services, &signing_host, "subtree-1", v1::RemoteMessage::ProductSubtreeRequest(messages::ProductSubtreeRequest { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index 33eecf6e8..e7dd91030 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -29,25 +29,20 @@ use crate::host_logic::sso::messages::{ }; use crate::host_logic::sso::wire::ResponseOutcome; use crate::runtime::authority::{ - AuthorityError, AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, + AuthoritySession, CreateTransactionAuthorityRequest, ProductAuthority, SignPayloadAuthorityRequest, SignRawAuthorityRequest, }; -use crate::runtime::services::RuntimeServices; use crate::runtime::sso_service::{SsoReply, SsoRequestContext}; /// SSO handlers served by a locally activated [`SigningHost`]. pub(crate) struct SigningHostSsoService { - services: Arc, signing_host: Arc, } impl SigningHostSsoService { - /// Serve requests with `signing_host`, prompting through `services`. - pub(crate) fn new(services: Arc, signing_host: Arc) -> Self { - Self { - services, - signing_host, - } + /// 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. @@ -58,7 +53,7 @@ impl SigningHostSsoService { /// 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.services.platform.confirm_user_action(review).await { + 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)), @@ -124,8 +119,8 @@ impl SigningHostSsoService { resource: api::AllocatableResource, on_existing: OnExistingAllowancePolicy, ) -> Result { - let services = &self.services; let signing_host = &self.signing_host; + let services = &signing_host.services; match resource { api::AllocatableResource::StatementStoreAllowance => { allocate_statement_store_allowance( @@ -304,7 +299,7 @@ impl SigningHostSsoService { calling_product_id: request.calling_product_id.clone(), resources: request.resources.clone(), }); - match self.services.platform.confirm_user_action(review).await { + match self.signing_host.platform.confirm_user_action(review).await { Ok(true) => {} Ok(false) => { return Ok(vec![ @@ -433,18 +428,7 @@ impl SigningHostSsoService { request.payload, ) .await - .map_err(|err| match err { - AuthorityError::Disconnected => api::HostAccountSignVrfError::NotConnected, - AuthorityError::Rejected => api::HostAccountSignVrfError::Rejected, - AuthorityError::Cancelled(err) => api::HostAccountSignVrfError::Unknown { - reason: err.to_string(), - }, - AuthorityError::Unavailable { reason } - | AuthorityError::NotSupported { reason } - | AuthorityError::Unknown { reason } => { - api::HostAccountSignVrfError::Unknown { reason } - } - }) + .map_err(api::HostAccountSignVrfError::from) } /// Consent-free product hard-subtree public key. @@ -496,7 +480,6 @@ impl SigningHostSsoService { #[cfg(test)] mod tests { use super::*; - use crate::host_logic::sso::messages::Response; #[test] fn resource_allocation_summary_reflects_per_resource_outcomes() { @@ -529,16 +512,11 @@ mod tests { } #[test] - fn response_summary_classifies_resource_allocation_batches() { - let response = Response { - responding_to: "allocation-1".to_string(), - payload: Ok(vec![ - SsoAllocationOutcome::Rejected, - SsoAllocationOutcome::NotAvailable, - ]), - }; - - let result = resource_allocation_outcome(&response.payload); + 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()), From 634b25300ec3824d7f427aa75398a5beddda79de Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 09:13:27 +0000 Subject: [PATCH 7/8] test(sso): remove overlapping classification and allocation scenarios --- .../truapi-server/src/host_logic/sso/wire.rs | 40 +----- .../src/runtime/signing_host/sso_responder.rs | 131 ++++++------------ .../src/runtime/signing_host/sso_service.rs | 5 + 3 files changed, 53 insertions(+), 123 deletions(-) diff --git a/rust/crates/truapi-server/src/host_logic/sso/wire.rs b/rust/crates/truapi-server/src/host_logic/sso/wire.rs index 85294eca7..1f9fb98a4 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/wire.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/wire.rs @@ -93,13 +93,10 @@ impl RemoteMessage { #[cfg(test)] mod tests { - use truapi::latest::{DerivationIndex, ProductAccountId}; - use super::*; - use crate::host_logic::sso::messages::v1::{AnyRequest, Incoming, classify}; use crate::host_logic::sso::messages::{ - CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, ProductSubtreeRequest, - SignRawWithLegacyAccountRequest, SignRequest, + CreateTransactionRequest, CreateTransactionWithLegacyAccountRequest, + SignRawWithLegacyAccountRequest, }; #[test] @@ -120,37 +117,4 @@ mod tests { assert_eq!(signature.name(), "SignRawWithLegacyAccountResponse"); assert!(CreateTransactionRequest::response_from_message(signature).is_none()); } - - #[test] - fn classify_separates_requests_responses_and_disconnect() { - let request = ProductSubtreeRequest { - product_id: "browse.dot".to_string(), - }; - assert_eq!( - classify(request.clone().into_message()), - Incoming::Request(AnyRequest::ProductSubtreeRequest(request)) - ); - let boxed = SignRequest::Raw(truapi::latest::HostSignRawRequest { - account: ProductAccountId { - dot_ns_identifier: "myapp.dot".to_string(), - derivation_index: DerivationIndex::Index(7), - }, - payload: truapi::latest::RawPayload::Bytes { bytes: vec![] }, - }); - assert_eq!( - classify(boxed.clone().into_message()), - Incoming::Request(AnyRequest::SignRequest(boxed)) - ); - assert_eq!( - classify(v1::RemoteMessage::CreateTransactionResponse(Response { - responding_to: "m".to_string(), - payload: Ok(vec![]), - })), - Incoming::Response("CreateTransactionResponse") - ); - assert_eq!( - classify(v1::RemoteMessage::Disconnected), - Incoming::Disconnected - ); - } } 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 943e37b30..172038f62 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 @@ -993,7 +993,7 @@ mod tests { 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, Response, RingVrfError, SsoAllocatedResource, + self, GetAccountAliasResponse, RemoteMessage, RingVrfError, SsoAllocatedResource, SsoAllocationOutcome, }; use crate::host_logic::sso::wire::ResponseOutcome; @@ -1350,95 +1350,56 @@ mod tests { chain_connect_error: Some("allocation node unavailable"), ..StubPlatform::default() })); - let auto_signing = SsoAllocationOutcome::Allocated(SsoAllocatedResource::AutoSigning { - product_root_private_key: signing_host.product_subtree_secret("myapp.dot").unwrap(), - ring_vrf_domain_entropy: derive_ring_vrf_domain_entropy(&ENTROPY, "myapp.dot").unwrap(), - }); let service = SigningHostSsoService::new(signing_host); - let cases = [ - ( - vec![api::AllocatableResource::BulletinAllowance], - vec![SsoAllocationOutcome::NotAvailable], - "not_available", - "Requested resource is not available", - 1, - ), - ( - vec![ + 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, ], - vec![ - auto_signing.clone(), - SsoAllocationOutcome::NotAvailable, - SsoAllocationOutcome::NotAvailable, - ], - "partial", - "1 of 3 requested resources allocated; 2 unavailable", - 2, - ), - ( - vec![api::AllocatableResource::AutoSigning], - vec![auto_signing], - "ok", - "", - 0, - ), - ]; - - for (index, (resources, outcomes, outcome, summary, failures)) in - cases.into_iter().enumerate() - { - let message_id = format!("allocation-{index}"); - let request = RemoteMessage::request( - message_id.clone(), - messages::ResourceAllocationRequest { - calling_product_id: "myapp.dot".to_string(), - resources, - on_existing: OnExistingAllowancePolicy::Ignore, - }, - ); - let Dispatch::Response(answer) = - futures::executor::block_on(service.dispatch(service.current_session(), request)) - else { - panic!("expected an allocation response"); - }; - - let expected = RemoteMessage { - message_id: format!("{message_id}:response"), - data: RemoteMessageData::V1(v1::RemoteMessage::ResourceAllocationResponse( - Response { - responding_to: message_id.clone(), - payload: Ok(outcomes), - }, - )), - }; - assert_eq!(answer.message.encode(), expected.encode()); - assert_eq!(answer.outcome.outcome, outcome); - if failures == 0 { - assert_eq!(answer.outcome.reason, None); - continue; - } - let reason = answer.outcome.reason.as_deref().unwrap(); - assert!(reason.starts_with(summary)); - assert_eq!( - reason.matches("allocation node unavailable").count(), - failures, - "{reason}" - ); - assert!(!reason.contains(['\r', '\n'])); - let cli = response_cli_summary( - "SSO response sent", - "resource_allocation", - &message_id, - &message_id, - &answer.message.message_id, - &answer.outcome, - 0, - ); - assert!(cli.contains(&format!("reason={reason}"))); - } + on_existing: OnExistingAllowancePolicy::Ignore, + }, + ); + 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, + 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!( + reason.matches("allocation node unavailable").count(), + 2, + "{reason}" + ); + 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] diff --git a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs index e7dd91030..f47cfba98 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/sso_service.rs @@ -483,6 +483,11 @@ mod tests { #[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()), From 281eae4e5e5fc95d7a03537e23a7ff1d46ff2c0f Mon Sep 17 00:00:00 2001 From: pgherveou Date: Wed, 9 Sep 2026 13:29:18 +0000 Subject: [PATCH 8/8] fix(codegen): keep OptionBool fields optional in TypeScript --- rust/crates/truapi-codegen/src/ts.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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