diff --git a/README.md b/README.md index 90385c1ae..8e9ee8c3b 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ container bundle (`make xcframework` + `make uniffi`); see [`ios/truapi-host/README.md`](ios/truapi-host/README.md). Native bindings expose the canonical Rust domain and protocol value types; native-only adapter types are limited to lifecycle and callback behavior. +Both platforms expose `TrUAPIHostRuntime`, the process-owned native runtime; wallet +hosts that manage their own statement-store SSO session can additionally call +`handleSsoRequest` (routes one decrypted remote message through the core, +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). Response posting and session-record cleanup +remain on the wallet side. ### JS Host SDKs diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 7cc95faae..8bc478736 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -96,6 +96,25 @@ The core's `Permissions` platform trait has two methods, and so does `HostCallba Both return a `Bool` granted flag; the host renders the typed request in its own prompt UI. The same typed values drive the `TrUAPIHostCore` permission admin API (`permissionAuthorizationStatus`, `setPermissionAuthorizationStatus`), which reads and updates the persisted decisions without prompting. +## SSO session handling + +`TrUAPIHostRuntime` exposes two methods for wallet-owned SSO sessions. Meaningful request answering requires `activateLocalSession` to have been called first; `prepareDisconnectRequest` needs no session. + +```swift +func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome +func prepareDisconnectRequest() -> Data +``` + +`handleSsoRequest(message:)` takes one SCALE-encoded `RemoteMessage` exactly as decrypted from the statement-store session and routes it through the Rust core. The returned `SsoRequestOutcome` is the generated UniFFI enum (no Swift mirror): + +- `.response(message:)` — SCALE-encoded reply; post it back over the same session. +- `.disconnected` — the peer ended the session; tear down the transport and records on the wallet side. +- `.ignored` — the message was not a request; nothing to post. + +Confirmation-gated requests suspend on `confirmUserAction`, so `handleSsoRequest` can take arbitrarily long. Always call it from a `Task`, never the main thread. + +`prepareDisconnectRequest()` returns the SCALE-encoded `Disconnected` message to post when the wallet is ending the session. Posting and record cleanup (host entry, device record, device-removed broadcast) stay with the wallet. + ## Statement-store allowance renewal Statement-store allowances are granted per period, so a host has to re-register the accounts it wants to keep writing. They are not revoked the moment the period ends: `Resources.StmtStoreGraceWindow` keeps an ended period's allowances active until cleanup catches up, 48 hours on `paseo-next-v2`. The runtime owns the ledger and the registration; the app owns only the schedule. diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 6075a8e98..f88e98104 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -747,6 +747,24 @@ public final class TrUAPIHostRuntime: @unchecked Sendable { try inner.activateLocalSession(secret: secret, liteUsername: liteUsername) } + /// Answer one decrypted SSO remote message from the wallet-managed + /// statement-store session. `message` is one SCALE-encoded + /// `RemoteMessage` exactly as decrypted. `.response` carries the + /// SCALE-encoded reply to post back over the same session; + /// `.disconnected` means the peer ended the session (perform native + /// teardown); `.ignored` means the message was not a request. + /// Confirmation-gated requests await `confirmUserAction`, so this can + /// take arbitrarily long — call from a `Task`, never the main thread. + public func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome { + try await inner.handleSsoRequest(message: message) + } + + /// Build the SCALE-encoded `Disconnected` message to post over a + /// session the wallet is ending; record cleanup stays with the wallet. + public func prepareDisconnectRequest() -> Data { + inner.prepareDisconnectRequest() + } + public func notifyChainResponse(connectionId: UInt32, json: String) { inner.notifyChainResponse(connectionId: connectionId, json: json) } diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index adfd4324d..44fcb6db5 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3398,6 +3398,19 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { */ func disconnect() + /** + * Answer one decrypted SSO remote message from a wallet-managed + * statement-store session. + * + * `message` is one SCALE-encoded `RemoteMessage` exactly as decrypted from + * the session statement. The bytes are deliberately opaque at this + * boundary: the wallet forwards wire encodings verbatim and never + * constructs them. Session control and transport stay with the wallet — + * `Disconnected` is reported, never handled here. Confirmation-gated + * requests await `confirm_user_action`, so this can take arbitrarily long. + */ + func handleSsoRequest(message: Data) async throws -> SsoRequestOutcome + /** * The in-process loop's own cadence: at most an hour, tightening to land * just after the next period boundary. @@ -3428,6 +3441,15 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { */ func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeChatCallbacks?, executionConfig: NativeProductExecutionConfig) throws -> NativeProductExecution + /** + * Build the SCALE-encoded `Disconnected` message a wallet posts over a + * session it is ending. Each call carries a fresh opaque message id, + * like every outgoing SSO message; receivers detect disconnect by + * message variant, not id. Posting and record cleanup stay with the + * wallet. + */ + func prepareDisconnectRequest() -> Data + /** * Run one renewal pass now and report what each tracked target got. * @@ -3555,6 +3577,33 @@ open func disconnect() {try! rustCall() { } } + /** + * Answer one decrypted SSO remote message from a wallet-managed + * statement-store session. + * + * `message` is one SCALE-encoded `RemoteMessage` exactly as decrypted from + * the session statement. The bytes are deliberately opaque at this + * boundary: the wallet forwards wire encodings verbatim and never + * constructs them. Session control and transport stay with the wallet — + * `Disconnected` is reported, never handled here. Confirmation-gated + * requests await `confirm_user_action`, so this can take arbitrarily long. + */ +open func handleSsoRequest(message: Data)async throws -> SsoRequestOutcome { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_truapi_server_fn_method_nativetruapihostruntime_handle_sso_request( + self.uniffiCloneHandle(),FfiConverterData.lower(message) + ) + }, + pollFunc: ffi_truapi_server_rust_future_poll_rust_buffer, + completeFunc: ffi_truapi_server_rust_future_complete_rust_buffer, + freeFunc: ffi_truapi_server_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeSsoRequestOutcome_lift, + errorHandler: FfiConverterTypeHostRejection_lift + ) +} + /** * The in-process loop's own cadence: at most an hour, tightening to land * just after the next period boundary. @@ -3615,6 +3664,22 @@ open func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeCh FfiConverterTypeNativeProductExecutionConfig_lower(executionConfig),uniffiCallStatus ) }) +} + + /** + * Build the SCALE-encoded `Disconnected` message a wallet posts over a + * session it is ending. Each call carries a fresh opaque message id, + * like every outgoing SSO message; receivers detect disconnect by + * message variant, not id. Posting and record cleanup stay with the + * wallet. + */ +open func prepareDisconnectRequest() -> Data { + return try! FfiConverterData.lift(try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapihostruntime_prepare_disconnect_request( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } /** @@ -5373,6 +5438,105 @@ public func FfiConverterTypeProductRuntimeError_lower(_ value: ProductRuntimeErr } +/** + * FFI projection of the canonical + * [`SsoRequestOutcome`](crate::host_logic::sso::messages::SsoRequestOutcome), + * concrete because UniFFI cannot export generics. + * + * Variants carry SCALE-encoded wire bytes rather than decoded Rust types because + * the wallet forwards encodings verbatim and never constructs them — the opaque + * bytes are the correct boundary representation here. + */ + +public enum SsoRequestOutcome: Equatable, Hashable { + + /** + * SCALE-encoded response to post back over the session. + */ + case response( + /** + * SCALE-encoded `RemoteMessage` response ready to submit over the + * session statement store. + */message: Data + ) + /** + * The peer ended the session; the wallet tears down its transport and + * records (host entry, device record, device-removed broadcast). + */ + case disconnected + /** + * Not a request; nothing to post. + */ + case ignored + + + + + +} + +#if compiler(>=6) +extension SsoRequestOutcome: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeSsoRequestOutcome: FfiConverterRustBuffer { + typealias SwiftType = SsoRequestOutcome + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SsoRequestOutcome { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .response(message: try FfiConverterData.read(from: &buf) + ) + + case 2: return .disconnected + + case 3: return .ignored + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: SsoRequestOutcome, into buf: inout [UInt8]) { + switch value { + + + case let .response(message): + writeInt(&buf, Int32(1)) + FfiConverterData.write(message, into: &buf) + + + case .disconnected: + writeInt(&buf, Int32(2)) + + + case .ignored: + writeInt(&buf, Int32(3)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSsoRequestOutcome_lift(_ buf: RustBuffer) throws -> SsoRequestOutcome { + return try FfiConverterTypeSsoRequestOutcome.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeSsoRequestOutcome_lower(_ value: SsoRequestOutcome) -> RustBuffer { + return FfiConverterTypeSsoRequestOutcome.lower(value) +} + + + /** * Outcome of renewing one target. */ @@ -6384,6 +6548,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect() != 38487) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_handle_sso_request() != 21060) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_next_statement_renewal_delay() != 33452) { return InitializationResult.apiChecksumMismatch } @@ -6396,6 +6563,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_open_product_execution() != 49537) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_prepare_disconnect_request() != 17252) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_renew_statement_allowances() != 11225) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h index ae36d3eaa..642e2e8dd 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -846,6 +846,11 @@ void uniffi_truapi_server_fn_method_nativetruapihostruntime_activate_local_sessi void uniffi_truapi_server_fn_method_nativetruapihostruntime_disconnect(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_HANDLE_SSO_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_HANDLE_SSO_REQUEST +uint64_t uniffi_truapi_server_fn_method_nativetruapihostruntime_handle_sso_request(uint64_t ptr, RustBuffer message +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY RustBuffer uniffi_truapi_server_fn_method_nativetruapihostruntime_next_statement_renewal_delay(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -866,6 +871,11 @@ void uniffi_truapi_server_fn_method_nativetruapihostruntime_notify_chain_respons uint64_t uniffi_truapi_server_fn_method_nativetruapihostruntime_open_product_execution(uint64_t ptr, uint64_t callbacks, RustBuffer chat_callbacks, RustBuffer execution_config, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_PREPARE_DISCONNECT_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_PREPARE_DISCONNECT_REQUEST +RustBuffer uniffi_truapi_server_fn_method_nativetruapihostruntime_prepare_disconnect_request(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES RustBuffer uniffi_truapi_server_fn_method_nativetruapihostruntime_renew_statement_allowances(uint64_t ptr, RustCallStatus *_Nonnull out_status @@ -1541,6 +1551,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_activate_l #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_DISCONNECT uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_HANDLE_SSO_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_HANDLE_SSO_REQUEST +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_handle_sso_request(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_NEXT_STATEMENT_RENEWAL_DELAY @@ -1565,6 +1581,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_notify_cha #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_OPEN_PRODUCT_EXECUTION uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_open_product_execution(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_PREPARE_DISCONNECT_REQUEST +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_PREPARE_DISCONNECT_REQUEST +uint16_t uniffi_truapi_server_checksum_method_nativetruapihostruntime_prepare_disconnect_request(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPIHOSTRUNTIME_RENEW_STATEMENT_ALLOWANCES diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index e0e438d9b..7f28ba061 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -82,4 +82,12 @@ final class StubHostBridge: HostBridge { func devicePermission(request _: HostDevicePermissionRequest) async throws -> Bool { false } func remotePermission(request _: RemotePermission) async throws -> Bool { false } func featureSupported(request _: HostFeatureSupportedRequest) async throws -> Bool { true } + func supportedChains() throws -> HostChainSet { HostChainSet(network: "", chains: []) } + func localStorageRead(key: String) throws -> Data? { try storage.read(key: key) } + + func localStorageWrite(key: String, value: Data) throws { + try storage.write(key: key, value: value) + } + + func localStorageClear(key: String) throws { try storage.clear(key: key) } } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index b14716cca..c720d42f6 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -27,9 +27,10 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequestOutcome, v1}; use crate::runtime::{ ChatConnection, LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, - ResponderExit, RuntimeServices, SigningHostRole, respond_to_pairing, + ResponderExit, RuntimeServices, SigningHostRole, answer_remote_message, respond_to_pairing, }; use crate::subscription::{HostInitiatedSubscriptionManager, Spawner}; use crate::transport::Transport; @@ -532,6 +533,32 @@ impl SigningHostRuntime { .await .map_err(|reason| v01::GenericError { reason }) } + + /// Answer one decrypted SSO remote message with this signing host. + /// + /// Session control stays with the caller: `Disconnected` is reported as an + /// outcome, never handled here. + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] + pub async fn answer_sso_request( + &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, + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -1142,4 +1169,96 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(5)); } } + + #[test] + fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { + use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1, + }; + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 32] = [0xab; 32]; + + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let runtime = + SigningHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()); + futures::executor::block_on(runtime.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + + let disconnected = RemoteMessage { + message_id: "m1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let outcome = futures::executor::block_on(runtime.answer_sso_request(disconnected)); + assert!(matches!(outcome, SsoRequestOutcome::Disconnected)); + + let response_variant = RemoteMessage { + message_id: "m2".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::SignRawLegacyResponse( + SignRawLegacyResponse { + responding_to: "m2".to_string(), + signature: Ok(vec![]), + }, + )), + }; + let outcome = futures::executor::block_on(runtime.answer_sso_request(response_variant)); + assert!(matches!(outcome, SsoRequestOutcome::Ignored)); + } + + #[test] + fn answer_sso_request_returns_a_correlated_response() { + use crate::host_logic::sso::messages::{ + ProductSubtreeRequest, RemoteMessage, RemoteMessageData, v1, + }; + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 32] = [0xab; 32]; + + let config = SigningHostConfig::new( + HostInfo { + name: "Polkadot Mobile".to_string(), + icon: None, + version: None, + }, + PlatformInfo::default(), + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + let runtime = + SigningHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()); + futures::executor::block_on(runtime.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + + let request = RemoteMessage { + message_id: "m3".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeRequest( + ProductSubtreeRequest { + product_id: "browse.dot".to_string(), + }, + )), + }; + let outcome = futures::executor::block_on(runtime.answer_sso_request(request)); + let SsoRequestOutcome::Response(response) = outcome else { + panic!("expected a response outcome"); + }; + assert_eq!(response.message_id, "m3:response"); + let RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(payload)) = + response.data + else { + panic!("expected a product subtree response payload"); + }; + assert_eq!(payload.responding_to, "m3"); + assert!(payload.product_public_key.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 42312bf8a..3731eae57 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -92,6 +92,21 @@ pub enum RemoteMessageData { V1(v1::RemoteMessage), } +/// 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 +/// boundary carries its SCALE encoding. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SsoRequestOutcome { + /// Response to post back over the session. + Response(T), + /// The peer ended the session; the caller tears down its transport and + /// records. The core holds no per-peer state to clear. + Disconnected, + /// Not a request (a `*Response` variant); nothing to do. + Ignored, +} + /// Signing request flavor sent to the signing host. #[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)] pub enum SigningRequest { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 81847bf3d..e2af9f464 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -17,7 +17,7 @@ use futures::executor::ThreadPool; use futures::future::BoxFuture; use futures::stream::{self, BoxStream, StreamExt}; use futures::task::SpawnExt; -use parity_scale_codec::Encode; +use parity_scale_codec::{Decode, Encode}; use truapi::{Bytes32, v01}; use truapi_platform::{ AuthPresenter, AuthState, ChainProvider, CoreAdmin, CoreStorage, CoreStorageKey, Features, @@ -30,9 +30,13 @@ use truapi_platform::{ use crate::SigningHostRuntime; use crate::host_logic::dotns; pub use crate::host_logic::dotns::NavigateDecision; +use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, SsoRequestOutcome as CoreSsoRequestOutcome, v1, +}; #[cfg(feature = "ws-bridge")] use crate::native_renderer::observe_renderer; use crate::native_renderer::{NativeCustomRendererObserver, NativeCustomRendererSubscription}; +use crate::runtime::sso_remote::sso_message_id; use crate::subscription::Spawner; #[cfg(feature = "ws-bridge")] use crate::ws_bridge::{BridgeLogger, WsBridge, WsBridgeEndpoint, WsBridgeStartError}; @@ -118,6 +122,28 @@ pub enum NativePairingDeeplinkScheme { PolkadotAppDev, } +/// FFI projection of the canonical +/// [`SsoRequestOutcome`](crate::host_logic::sso::messages::SsoRequestOutcome), +/// concrete because UniFFI cannot export generics. +/// +/// Variants carry SCALE-encoded wire bytes rather than decoded Rust types because +/// the wallet forwards encodings verbatim and never constructs them — the opaque +/// bytes are the correct boundary representation here. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)] +pub enum SsoRequestOutcome { + /// SCALE-encoded response to post back over the session. + Response { + /// SCALE-encoded `RemoteMessage` response ready to submit over the + /// session statement store. + message: Vec, + }, + /// The peer ended the session; the wallet tears down its transport and + /// records (host entry, device record, device-removed broadcast). + Disconnected, + /// Not a request; nothing to post. + Ignored, +} + /// Native runtime configuration supplied before product calls are handled. #[derive(Debug, Clone, uniffi::Record)] pub struct NativeRuntimeConfig { @@ -795,6 +821,46 @@ impl NativeTrUApiHostRuntime { .map_err(Into::into) } + /// Answer one decrypted SSO remote message from a wallet-managed + /// statement-store session. + /// + /// `message` is one SCALE-encoded `RemoteMessage` exactly as decrypted from + /// the session statement. The bytes are deliberately opaque at this + /// boundary: the wallet forwards wire encodings verbatim and never + /// constructs them. Session control and transport stay with the wallet — + /// `Disconnected` is reported, never handled here. Confirmation-gated + /// requests await `confirm_user_action`, so this can take arbitrarily long. + pub async fn handle_sso_request( + &self, + message: Vec, + ) -> Result { + let message = RemoteMessage::decode(&mut message.as_slice()).map_err(|err| { + HostRejection::Rejected { + reason: format!("undecodable RemoteMessage: {err}"), + } + })?; + Ok(match self.runtime.answer_sso_request(message).await { + CoreSsoRequestOutcome::Response(response) => SsoRequestOutcome::Response { + message: response.encode(), + }, + CoreSsoRequestOutcome::Disconnected => SsoRequestOutcome::Disconnected, + CoreSsoRequestOutcome::Ignored => SsoRequestOutcome::Ignored, + }) + } + + /// Build the SCALE-encoded `Disconnected` message a wallet posts over a + /// session it is ending. Each call carries a fresh opaque message id, + /// like every outgoing SSO message; receivers detect disconnect by + /// message variant, not id. Posting and record cleanup stay with the + /// wallet. + pub fn prepare_disconnect_request(&self) -> Vec { + RemoteMessage { + message_id: sso_message_id(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + } + .encode() + } + /// Notify the shared chain adapter of one JSON-RPC response. pub fn notify_chain_response(&self, connection_id: u32, json: String) { self.events.notify_chain_response(connection_id, json); @@ -2967,6 +3033,95 @@ mod tests { core.stop_ws_bridge(); } + fn native_host_runtime_no_session() -> Arc { + let mut config = native_host_runtime_config(); + config.local_session_secret = None; + config.local_session_lite_username = None; + NativeTrUApiHostRuntime::with_runtime_config(Arc::new(EventCallbacks::new()), config) + .expect("host runtime config should be valid") + } + + #[test] + fn handle_sso_request_rejects_undecodable_bytes() { + let runtime = native_host_runtime_no_session(); + let result = + futures::executor::block_on(runtime.handle_sso_request(vec![0xFF, 0xFF, 0xFF])); + assert!(result.is_err(), "garbage bytes must be a decode error"); + } + + #[test] + fn handle_sso_request_reports_disconnect_as_marker() { + use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; + use parity_scale_codec::Encode; + let runtime = native_host_runtime_no_session(); + let disconnected = RemoteMessage { + message_id: "m1".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }; + let outcome = + futures::executor::block_on(runtime.handle_sso_request(disconnected.encode())) + .expect("decodable message"); + assert!(matches!(outcome, SsoRequestOutcome::Disconnected)); + } + + #[test] + fn handle_sso_request_reencodes_a_request_response() { + use crate::host_logic::sso::messages::{ + ProductSubtreeRequest, RemoteMessage, RemoteMessageData, v1, + }; + use parity_scale_codec::{Decode, Encode}; + // The default test config carries a local session secret, so the + // runtime is activated at construction. + let runtime = NativeTrUApiHostRuntime::with_runtime_config( + Arc::new(EventCallbacks::new()), + native_host_runtime_config(), + ) + .expect("host runtime config should be valid"); + let request = RemoteMessage { + message_id: "m9".to_string(), + data: RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeRequest( + ProductSubtreeRequest { + product_id: "browse.dot".to_string(), + }, + )), + }; + let outcome = futures::executor::block_on(runtime.handle_sso_request(request.encode())) + .expect("decodable message"); + let SsoRequestOutcome::Response { message } = outcome else { + panic!("expected a response outcome"); + }; + let response = + RemoteMessage::decode(&mut message.as_slice()).expect("valid response encoding"); + assert_eq!(response.message_id, "m9:response"); + let RemoteMessageData::V1(v1::RemoteMessage::ProductSubtreeResponse(payload)) = + response.data + else { + panic!("expected a product subtree response payload"); + }; + assert_eq!(payload.responding_to, "m9"); + assert!(payload.product_public_key.is_ok()); + } + + #[test] + fn prepare_disconnect_request_round_trips_with_fresh_ids() { + use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; + use parity_scale_codec::Decode; + let runtime = native_host_runtime_no_session(); + let bytes = runtime.prepare_disconnect_request(); + let message = RemoteMessage::decode(&mut bytes.as_slice()).expect("valid encoding"); + assert_eq!(message.message_id.len(), 8, "opaque nanoid message id"); + assert!(matches!( + message.data, + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) + )); + let second = RemoteMessage::decode(&mut runtime.prepare_disconnect_request().as_slice()) + .expect("valid encoding"); + assert_ne!( + message.message_id, second.message_id, + "each disconnect message carries its own id" + ); + } + #[test] fn bytes32_widens_to_plain_bytes_on_the_wire() { let mut buf = Vec::new(); diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index ee4e48617..9bf15966d 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -68,7 +68,7 @@ pub use signing_host::ResponderExit; #[cfg(not(target_arch = "wasm32"))] pub use signing_host::StatementRenewalTarget; pub(crate) use signing_host::{ - LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, + LocalActivation, SigningHost as SigningHostRole, answer_remote_message, respond_to_pairing, }; use authority::{ @@ -6125,7 +6125,7 @@ mod tests { 1 ); let message = submitted_remote_message(&platform, &session); - assert_eq!(message.message_id, "truapi:sso:disconnect"); + assert_eq!(message.message_id.len(), 8, "opaque nanoid message id"); assert!(matches!( message.data, RemoteMessageData::V1(v1::RemoteMessage::Disconnected) 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 13494c0cb..4df2f9ff9 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 @@ -189,7 +189,7 @@ impl PairingHost { .sso .as_ref() .ok_or_else(|| "No SSO session state".to_string())?; - let message_id = "truapi:sso:disconnect".to_string(); + let message_id = sso_message_id(); let message = RemoteMessage { message_id: message_id.clone(), data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index c321060a4..ecb312be4 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -28,7 +28,7 @@ use subxt::utils::{AccountId32, MultiSignature}; pub use allowance_renewal::StatementRenewalTarget; pub(crate) use local_activation::LocalActivation; pub use sso_responder::ResponderExit; -pub(crate) use sso_responder::respond_to_pairing; +pub(crate) use sso_responder::{answer_remote_message, respond_to_pairing}; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, 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 95136c579..afb299dd2 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 @@ -464,8 +464,12 @@ struct ResponseResult { reason: Option, } -struct AnsweredRemoteMessage { - response: RemoteMessage, +/// 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, } @@ -649,7 +653,7 @@ fn response_cli_summary( /// Answer one application-level request message; `None` for message kinds /// that take no response (responses echoed by the peer, unknown variants). -async fn answer_remote_message( +pub(crate) async fn answer_remote_message( services: &Arc, signing_host: &Arc, message_id: String, diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index e1f06ea3e..d6db1df2b 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -420,7 +420,7 @@ pub(super) fn statement_subscription_stream( } /// Fresh opaque message id for one SSO request. -pub(super) fn sso_message_id() -> String { +pub(crate) fn sso_message_id() -> String { nanoid::nanoid!(8) }