From fbc9455041752ff7c2bc97b2200afcbfca16c4ac Mon Sep 17 00:00:00 2001 From: ERussel Date: Thu, 13 Aug 2026 10:36:08 +0200 Subject: [PATCH 1/8] [wip] sso api --- README.md | 7 + android/truapi-host/README.md | 1 + .../kotlin/io/parity/truapi/TrUAPIHost.kt | 118 ++++++++++++ ios/truapi-host/README.md | 19 ++ .../Sources/TrUAPIHost/TrUAPIHost.swift | 18 ++ .../Sources/TrUAPIHost/truapi_server.swift | 168 ++++++++++++++++++ .../include/truapi_serverFFI.h | 22 +++ .../Tests/TrUAPIWsBridgeTests.swift | 1 + rust/crates/truapi-server/src/host_core.rs | 82 ++++++++- .../src/host_logic/sso/messages.rs | 5 + rust/crates/truapi-server/src/native.rs | 111 +++++++++++- rust/crates/truapi-server/src/runtime.rs | 2 +- .../src/runtime/pairing_host/sso_channel.rs | 12 +- .../truapi-server/src/runtime/signing_host.rs | 2 +- .../src/runtime/signing_host/sso_responder.rs | 10 +- 15 files changed, 565 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e5a4f4d2c..2ab77d229 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,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/android/truapi-host/README.md b/android/truapi-host/README.md index 7cfd51b69..43d3ecce7 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -49,6 +49,7 @@ The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](s - `HostStorage` - product-scoped read/write/clear interface the host backs with its own persistence. - `HostCoreStorage` - core-owned read/write/clear interface for auth session, pairing identity, and persisted permission decisions (`key` is a SCALE-encoded `CoreStorageKey`). - `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, local-session activation, permission-authorization status, and native change notifications for session storage, theme, and preimage updates. +- `TrUAPIHostRuntime` - thin wrapper around the UniFFI-generated `NativeTrUApiHostRuntime` for wallet hosts that manage their own statement-store SSO session. `handleSsoRequest(message)` routes one decrypted remote message through the Rust core and returns a `SsoRequestOutcome` (response bytes to post back, a disconnect marker, or ignored). `prepareDisconnectRequest()` builds the SCALE-encoded wire message when the wallet initiates the disconnect. Posting the response and session-record cleanup remain with the wallet. - `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge endpoint (`window.__truapi_localhost`) to the product page so it can dial back in. ## Architecture diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 91ac870fe..2bcb55f39 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -15,6 +15,9 @@ // * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated // `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core // and exposes session + WS-bridge controls plus native change notifications. +// * `TrUAPIHostRuntime` - process-owned runtime for wallet hosts that manage +// their own statement-store SSO session; exposes `handleSsoRequest` and +// `prepareDisconnectRequest`. // * `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge // endpoint to the product page so it can dial back in. // @@ -41,8 +44,11 @@ import uniffi.truapi_server.HostNavigateRejection import uniffi.truapi_server.HostRejection import uniffi.truapi_server.HostStorageException import uniffi.truapi_platform.ProductExecutionKind as UniFfiProductExecutionKind +import uniffi.truapi_server.NativeHostRuntimeConfig as UniFfiNativeHostRuntimeConfig import uniffi.truapi_server.NativeRuntimeConfigException import uniffi.truapi_server.NativeTrUApiCore +import uniffi.truapi_server.NativeTrUApiHostRuntime +import uniffi.truapi_server.SsoRequestOutcome import uniffi.truapi_server.WsBridgeEndpoint import uniffi.truapi_server.WsBridgeStartException import uniffi.truapi_server.NativePairingDeeplinkScheme as UniFfiNativePairingDeeplinkScheme @@ -629,3 +635,115 @@ class TrUAPIHostCore private constructor( inner.close() } } + +/** + * Static config supplied to [TrUAPIHostRuntime]. Fields mirror the Rust + * `NativeHostRuntimeConfig`; [toNative] converts for the generated boundary. + * + * [hostName], [hostIcon], [hostVersion], [platformType], and [platformVersion] + * describe this host to the wallet during SSO pairing. + * [peopleChainGenesisHash] and [bulletinChainGenesisHash] must each be exactly + * 32 bytes. [localSessionSecret] optionally activates a local signing session + * from host-held BIP-39 entropy without SSO pairing. + */ +data class HostRuntimeConfig( + val hostName: String, + val hostIcon: String? = null, + val hostVersion: String? = null, + val platformType: String? = null, + val platformVersion: String? = null, + val peopleChainGenesisHash: ByteArray, + val bulletinChainGenesisHash: ByteArray, + val localSessionSecret: ByteArray? = null, + val localSessionLiteUsername: String? = null, +) { + internal fun toNative(): UniFfiNativeHostRuntimeConfig = + UniFfiNativeHostRuntimeConfig( + hostName = hostName, + hostIcon = hostIcon, + hostVersion = hostVersion, + platformType = platformType, + platformVersion = platformVersion, + peopleChainGenesisHash = peopleChainGenesisHash, + bulletinChainGenesisHash = bulletinChainGenesisHash, + localSessionSecret = localSessionSecret, + localSessionLiteUsername = localSessionLiteUsername, + ) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is HostRuntimeConfig) return false + return hostName == other.hostName && + hostIcon == other.hostIcon && + hostVersion == other.hostVersion && + platformType == other.platformType && + platformVersion == other.platformVersion && + peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && + bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && + localSessionSecret.contentEquals(other.localSessionSecret) && + localSessionLiteUsername == other.localSessionLiteUsername + } + + override fun hashCode(): Int { + var result = hostName.hashCode() + result = 31 * result + (hostIcon?.hashCode() ?: 0) + result = 31 * result + (hostVersion?.hashCode() ?: 0) + result = 31 * result + (platformType?.hashCode() ?: 0) + result = 31 * result + (platformVersion?.hashCode() ?: 0) + result = 31 * result + peopleChainGenesisHash.contentHashCode() + result = 31 * result + bulletinChainGenesisHash.contentHashCode() + result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) + result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) + return result + } +} + +/** + * Process-owned Rust host runtime. Exposes SSO request handling over a + * wallet-managed statement-store session. Retain across the application lifetime + * to maintain session and authentication state. + */ +class TrUAPIHostRuntime private constructor( + bridge: HostBridge, + runtimeConfig: UniFfiNativeHostRuntimeConfig, +) : AutoCloseable { + @Throws(NativeRuntimeConfigException::class) + constructor(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) : this( + bridge, + runtimeConfig.toNative(), + ) + + private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge) + private val inner: NativeTrUApiHostRuntime = + NativeTrUApiHostRuntime.withRuntimeConfig(callbackRetainer, runtimeConfig) + + /** + * Activate or replace the local signing-host session from host-held secret + * material (raw BIP-39 entropy). Lets the host run without SSO pairing. + * Blocks on key derivation — call from a coroutine on a background + * dispatcher, never the main thread. + */ + @Throws(HostRejection::class) + fun activateLocalSession(secret: ByteArray, liteUsername: String? = null) { + inner.activateLocalSession(secret, liteUsername) + } + + /** + * Answer one decrypted SSO remote message from the wallet-managed + * statement-store session. Response carries the SCALE-encoded reply to + * post back; Disconnected means the peer ended the session (perform + * native teardown); Ignored means the message was not a request. + * Confirmation-gated requests await confirmUserAction — call from a + * coroutine on a background dispatcher, never the main thread. + */ + @Throws(HostRejection::class) + suspend fun handleSsoRequest(message: ByteArray): SsoRequestOutcome = + inner.handleSsoRequest(message) + + /** SCALE-encoded Disconnected message to post over a session being ended. */ + fun prepareDisconnectRequest(): ByteArray = inner.prepareDisconnectRequest() + + override fun close() { + inner.close() + } +} diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 43292fbec..6bf7b4a05 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -90,6 +90,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. + ## Example > **Threading:** the Rust core invokes every `HostCallbacks` method on a diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 0add372c7..950c4bdf3 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -735,6 +735,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 171ea91ff..c18b0f13d 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3139,6 +3139,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 + /** * Notify the shared chain adapter that a connection closed. */ @@ -3156,6 +3169,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. Uses the core's fixed disconnect id + * (`truapi:sso:disconnect`, matching the pairing host's convention); + * receivers detect disconnect by message variant, not id. Posting and + * record cleanup stay with the wallet. + */ + func prepareDisconnectRequest() -> Data + } /** * Process-owned native TrUAPI runtime shared by all executable connections. @@ -3250,6 +3272,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 + ) +} + /** * Notify the shared chain adapter that a connection closed. */ @@ -3292,6 +3341,22 @@ open func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeCh }) } + /** + * Build the SCALE-encoded `Disconnected` message a wallet posts over a + * session it is ending. Uses the core's fixed disconnect id + * (`truapi:sso:disconnect`, matching the pairing host's convention); + * 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 + ) +}) +} + } @@ -4602,6 +4667,103 @@ public func FfiConverterTypeProductRuntimeError_lower(_ value: ProductRuntimeErr } +/** + * Outcome of answering one wallet-supplied SSO request at the FFI boundary. + * + * 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) +} + + + /** * Failure modes returned from host-facing `start_ws_bridge` wrappers. */ @@ -5321,6 +5483,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_notify_chain_closed() != 55360) { return InitializationResult.apiChecksumMismatch } @@ -5330,6 +5495,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() != 48286) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativecustomrenderersubscription_cancel() != 26593) { 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 40224ac8a..1e2d39a81 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -801,6 +801,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_NOTIFY_CHAIN_CLOSED #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPIHOSTRUNTIME_NOTIFY_CHAIN_CLOSED void uniffi_truapi_server_fn_method_nativetruapihostruntime_notify_chain_closed(uint64_t ptr, uint32_t connection_id, RustCallStatus *_Nonnull out_status @@ -816,6 +821,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_CLONE_NATIVECUSTOMRENDERERSUBSCRIPTION #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_CLONE_NATIVECUSTOMRENDERERSUBSCRIPTION uint64_t uniffi_truapi_server_fn_clone_nativecustomrenderersubscription(uint64_t handle, RustCallStatus *_Nonnull out_status @@ -1422,6 +1432,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_NOTIFY_CHAIN_CLOSED @@ -1440,6 +1456,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_NATIVECUSTOMRENDERERSUBSCRIPTION_CANCEL diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index 9c78e3cba..c4825bc60 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -75,6 +75,7 @@ final class StubHostCallbacks: HostCallbacks, @unchecked Sendable { func lookupPreimage(key _: Data) async throws -> Data? { nil } func currentTheme() throws -> ThemeVariant { .dark } func featureSupported(request _: HostFeatureSupportedRequest) async throws -> Bool { true } + func supportedChains() throws -> HostChainSet { HostChainSet(network: "", chains: []) } func localStorageRead(key: String) throws -> Data? { localStore[key] } func localStorageWrite(key: String, value: Data) throws { localStore[key] = value } func localStorageClear(key: String) throws { localStore[key] = nil } diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index c967b51c2..0e4995e11 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, 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; @@ -288,6 +289,17 @@ impl PairingHostAdmin for PairingHostRuntime { } } +/// Outcome of answering one wallet-supplied SSO remote message. +pub enum SsoMessageOutcome { + /// Response message to post back over the session. + Response(RemoteMessage), + /// The peer ended the session; the wallet 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, +} + /// A wallet-local signing host: the user's keys are held on this device. /// /// Owns the shared services plus signing-host state. There is no pairing flow, @@ -444,6 +456,29 @@ 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_message"))] + pub async fn answer_sso_message(&self, message: RemoteMessage) -> SsoMessageOutcome { + let RemoteMessageData::V1(request) = message.data; + if matches!(request, v1::RemoteMessage::Disconnected) { + return SsoMessageOutcome::Disconnected; + } + match answer_remote_message( + &self.services, + &self.signing_host, + message.message_id, + request, + ) + .await + { + Some(answer) => SsoMessageOutcome::Response(answer.response), + None => SsoMessageOutcome::Ignored, + } + } } /// Adapters scoped to one product connection: the platform serving its @@ -979,4 +1014,49 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(5)); } } + + #[test] + fn answer_sso_message_distinguishes_disconnect_from_ignorable_messages() { + use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1, + }; + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xab; 16]; + + 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_message(disconnected)); + assert!(matches!(outcome, SsoMessageOutcome::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_message(response_variant)); + assert!(matches!(outcome, SsoMessageOutcome::Ignored)); + } } 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 f6672de01..836d7f3b1 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -44,6 +44,11 @@ use crate::host_logic::statement_store::{ pub mod v1; +/// Fixed correlation id used by both sides of the SSO channel to signal session +/// end. Receivers detect disconnect by message variant, not by this id; the id +/// is stable so session logs remain correlated across implementations. +pub const SSO_DISCONNECT_MESSAGE_ID: &str = "truapi:sso:disconnect"; + /// Transport-level acknowledgement code for an SSO session statement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum SsoResponseCode { diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 4352ec71e..ebf555366 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::v01; use truapi_platform::{ AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, @@ -28,8 +28,12 @@ use truapi_platform::{ }; use crate::SigningHostRuntime; +use crate::host_core::SsoMessageOutcome; use crate::host_logic::dotns; pub use crate::host_logic::dotns::NavigateDecision; +use crate::host_logic::sso::messages::{ + RemoteMessage, RemoteMessageData, SSO_DISCONNECT_MESSAGE_ID, v1, +}; #[cfg(feature = "ws-bridge")] use crate::native_renderer::observe_renderer; use crate::native_renderer::{NativeCustomRendererObserver, NativeCustomRendererSubscription}; @@ -118,6 +122,26 @@ pub enum NativePairingDeeplinkScheme { PolkadotAppDev, } +/// Outcome of answering one wallet-supplied SSO request at the FFI boundary. +/// +/// 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 { @@ -659,6 +683,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_message(message).await { + SsoMessageOutcome::Response(response) => SsoRequestOutcome::Response { + message: response.encode(), + }, + SsoMessageOutcome::Disconnected => SsoRequestOutcome::Disconnected, + SsoMessageOutcome::Ignored => SsoRequestOutcome::Ignored, + }) + } + + /// Build the SCALE-encoded `Disconnected` message a wallet posts over a + /// session it is ending. Uses the core's fixed disconnect id + /// (`truapi:sso:disconnect`, matching the pairing host's convention); + /// 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_DISCONNECT_MESSAGE_ID.to_string(), + 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); @@ -2616,6 +2680,51 @@ 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 prepare_disconnect_request_round_trips() { + 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, "truapi:sso:disconnect"); + assert!(matches!( + message.data, + RemoteMessageData::V1(v1::RemoteMessage::Disconnected) + )); + } + #[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 e1704aa34..93b873143 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -63,7 +63,7 @@ pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; pub use signing_host::ResponderExit; pub(crate) use signing_host::{ - LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, + LocalActivation, SigningHost as SigningHostRole, answer_remote_message, respond_to_pairing, }; use authority::{ 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 b928eacd9..bcd0c6e11 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 @@ -16,11 +16,11 @@ 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, product_subtree_request_message, - proof_request_message, resource_allocation_message, sign_payload_message, - sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, + SSO_DISCONNECT_MESSAGE_ID, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, + SsoSessionStatement, alias_request_message, build_outgoing_request_statement, + create_transaction_legacy_message, create_transaction_message, decode_sso_session_statement, + product_subtree_request_message, proof_request_message, resource_allocation_message, + sign_payload_message, sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -174,7 +174,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_DISCONNECT_MESSAGE_ID.to_string(); 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 a0fde9e03..e815d428a 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -24,7 +24,7 @@ use subxt::utils::{AccountId32, MultiSignature}; 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 0e9fa8b9d..585839d83 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 @@ -456,8 +456,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, } @@ -629,7 +633,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, From e97752e83170952d6c0b76b68fb6c22007430268 Mon Sep 17 00:00:00 2001 From: ERussel Date: Thu, 13 Aug 2026 11:53:45 +0200 Subject: [PATCH 2/8] refactoring --- .../Sources/TrUAPIHost/truapi_server.swift | 22 ++++++------ rust/crates/truapi-server/src/host_core.rs | 28 ++++++--------- .../src/host_logic/sso/messages.rs | 20 ++++++++--- rust/crates/truapi-server/src/native.rs | 34 ++++++++++++------- rust/crates/truapi-server/src/runtime.rs | 2 +- .../src/runtime/pairing_host/sso_channel.rs | 14 ++++---- .../truapi-server/src/runtime/sso_remote.rs | 2 +- 7 files changed, 67 insertions(+), 55 deletions(-) diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 676b0df03..beaeeeef7 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -3229,10 +3229,10 @@ public protocol NativeTrUApiHostRuntimeProtocol: AnyObject, Sendable { /** * Build the SCALE-encoded `Disconnected` message a wallet posts over a - * session it is ending. Uses the core's fixed disconnect id - * (`truapi:sso:disconnect`, matching the pairing host's convention); - * receivers detect disconnect by message variant, not id. Posting and - * record cleanup stay with the wallet. + * 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 @@ -3401,10 +3401,10 @@ open func openProductExecution(callbacks: HostCallbacks, chatCallbacks: NativeCh /** * Build the SCALE-encoded `Disconnected` message a wallet posts over a - * session it is ending. Uses the core's fixed disconnect id - * (`truapi:sso:disconnect`, matching the pairing host's convention); - * receivers detect disconnect by message variant, not id. Posting and - * record cleanup stay with the wallet. + * 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() { @@ -4726,7 +4726,9 @@ public func FfiConverterTypeProductRuntimeError_lower(_ value: ProductRuntimeErr /** - * Outcome of answering one wallet-supplied SSO request at the FFI boundary. + * 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 @@ -5611,7 +5613,7 @@ 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() != 48286) { + if (uniffi_truapi_server_checksum_method_nativetruapihostruntime_prepare_disconnect_request() != 17252) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativecustomrenderersubscription_cancel() != 26593) { diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index cd006cd66..e459f3b4e 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -27,7 +27,7 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; -use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, v1}; +use crate::host_logic::sso::messages::{RemoteMessage, RemoteMessageData, SsoRequestOutcome, v1}; use crate::runtime::{ ChatConnection, LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, answer_remote_message, respond_to_pairing, @@ -323,17 +323,6 @@ impl PairingHostAdmin for PairingHostRuntime { } } -/// Outcome of answering one wallet-supplied SSO remote message. -pub enum SsoMessageOutcome { - /// Response message to post back over the session. - Response(RemoteMessage), - /// The peer ended the session; the wallet 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, -} - /// A wallet-local signing host: the user's keys are held on this device. /// /// Owns the shared services plus signing-host state. There is no pairing flow, @@ -530,10 +519,13 @@ impl SigningHostRuntime { /// 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_message"))] - pub async fn answer_sso_message(&self, message: RemoteMessage) -> SsoMessageOutcome { + pub async fn answer_sso_message( + &self, + message: RemoteMessage, + ) -> SsoRequestOutcome { let RemoteMessageData::V1(request) = message.data; if matches!(request, v1::RemoteMessage::Disconnected) { - return SsoMessageOutcome::Disconnected; + return SsoRequestOutcome::Disconnected; } match answer_remote_message( &self.services, @@ -543,8 +535,8 @@ impl SigningHostRuntime { ) .await { - Some(answer) => SsoMessageOutcome::Response(answer.response), - None => SsoMessageOutcome::Ignored, + Some(answer) => SsoRequestOutcome::Response(answer.response), + None => SsoRequestOutcome::Ignored, } } } @@ -1172,7 +1164,7 @@ mod tests { data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), }; let outcome = futures::executor::block_on(runtime.answer_sso_message(disconnected)); - assert!(matches!(outcome, SsoMessageOutcome::Disconnected)); + assert!(matches!(outcome, SsoRequestOutcome::Disconnected)); let response_variant = RemoteMessage { message_id: "m2".to_string(), @@ -1184,6 +1176,6 @@ mod tests { )), }; let outcome = futures::executor::block_on(runtime.answer_sso_message(response_variant)); - assert!(matches!(outcome, SsoMessageOutcome::Ignored)); + assert!(matches!(outcome, SsoRequestOutcome::Ignored)); } } 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 1dfa6ff8b..b141bb241 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/messages.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/messages.rs @@ -44,11 +44,6 @@ use crate::host_logic::statement_store::{ pub mod v1; -/// Fixed correlation id used by both sides of the SSO channel to signal session -/// end. Receivers detect disconnect by message variant, not by this id; the id -/// is stable so session logs remain correlated across implementations. -pub const SSO_DISCONNECT_MESSAGE_ID: &str = "truapi:sso:disconnect"; - /// Transport-level acknowledgement code for an SSO session statement. #[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode, derive_more::Display)] pub enum SsoResponseCode { @@ -97,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 64adf754c..5baf6321f 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -28,15 +28,15 @@ use truapi_platform::{ }; use crate::SigningHostRuntime; -use crate::host_core::SsoMessageOutcome; use crate::host_logic::dotns; pub use crate::host_logic::dotns::NavigateDecision; use crate::host_logic::sso::messages::{ - RemoteMessage, RemoteMessageData, SSO_DISCONNECT_MESSAGE_ID, v1, + 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}; @@ -122,7 +122,9 @@ pub enum NativePairingDeeplinkScheme { PolkadotAppDev, } -/// Outcome of answering one wallet-supplied SSO request at the FFI boundary. +/// 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 @@ -702,22 +704,22 @@ impl NativeTrUApiHostRuntime { } })?; Ok(match self.runtime.answer_sso_message(message).await { - SsoMessageOutcome::Response(response) => SsoRequestOutcome::Response { + CoreSsoRequestOutcome::Response(response) => SsoRequestOutcome::Response { message: response.encode(), }, - SsoMessageOutcome::Disconnected => SsoRequestOutcome::Disconnected, - SsoMessageOutcome::Ignored => SsoRequestOutcome::Ignored, + CoreSsoRequestOutcome::Disconnected => SsoRequestOutcome::Disconnected, + CoreSsoRequestOutcome::Ignored => SsoRequestOutcome::Ignored, }) } /// Build the SCALE-encoded `Disconnected` message a wallet posts over a - /// session it is ending. Uses the core's fixed disconnect id - /// (`truapi:sso:disconnect`, matching the pairing host's convention); - /// receivers detect disconnect by message variant, not id. Posting and - /// record cleanup stay with the wallet. + /// 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_DISCONNECT_MESSAGE_ID.to_string(), + message_id: sso_message_id(), data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), } .encode() @@ -2747,17 +2749,23 @@ mod tests { } #[test] - fn prepare_disconnect_request_round_trips() { + 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, "truapi:sso:disconnect"); + 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] diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 2779701fd..7d1fba91a 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -6075,7 +6075,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 aa72b59cc..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 @@ -18,12 +18,12 @@ use super::PairingHost; use crate::host_logic::session::{SessionInfo, SessionState, SsoSessionInfo}; use crate::host_logic::sso::messages::{ OnExistingAllowancePolicy, RemoteMessage, RemoteMessageData, RingVrfError, - SSO_DISCONNECT_MESSAGE_ID, SsoAllocatedResource, SsoAllocationOutcome, SsoRemoteResponse, - SsoSessionStatement, alias_request_message, build_outgoing_request_statement, - create_transaction_legacy_message, create_transaction_message, decode_sso_session_statement, - list_ring_vrf_keys_message, product_subtree_request_message, proof_request_message, - register_ring_vrf_key_message, resource_allocation_message, ring_vrf_sign_message, - sign_payload_message, sign_raw_legacy_message, sign_raw_message, sign_vrf_message, v1, + SsoAllocatedResource, SsoAllocationOutcome, 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, }; use crate::host_logic::statement_store::parse_new_statements_result; @@ -189,7 +189,7 @@ impl PairingHost { .sso .as_ref() .ok_or_else(|| "No SSO session state".to_string())?; - let message_id = SSO_DISCONNECT_MESSAGE_ID.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/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) } From 51e94bd7a96c21c212dc5c41214179e3a07b09ad Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 06:59:04 +0200 Subject: [PATCH 3/8] refactor --- android/truapi-host/README.md | 1 - .../kotlin/io/parity/truapi/TrUAPIHost.kt | 118 ------------------ 2 files changed, 119 deletions(-) diff --git a/android/truapi-host/README.md b/android/truapi-host/README.md index 6b27408a4..52a382340 100644 --- a/android/truapi-host/README.md +++ b/android/truapi-host/README.md @@ -49,7 +49,6 @@ The public surface lives in [`src/main/kotlin/io/parity/truapi/TrUAPIHost.kt`](s - `HostStorage` - product-scoped read/write/clear interface the host backs with its own persistence. - `HostCoreStorage` - core-owned read/write/clear interface for auth session, pairing identity, and persisted permission decisions (`key` is a SCALE-encoded `CoreStorageKey`). - `TrUAPIHostCore` - owning wrapper around the UniFFI-generated `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core and exposes the localhost WebSocket bridge, core-owned disconnect, local-session activation, permission-authorization status, and native change notifications for session storage, theme, and preimage updates. -- `TrUAPIHostRuntime` - thin wrapper around the UniFFI-generated `NativeTrUApiHostRuntime` for wallet hosts that manage their own statement-store SSO session. `handleSsoRequest(message)` routes one decrypted remote message through the Rust core and returns a `SsoRequestOutcome` (response bytes to post back, a disconnect marker, or ignored). `prepareDisconnectRequest()` builds the SCALE-encoded wire message when the wallet initiates the disconnect. Posting the response and session-record cleanup remain with the wallet. - `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge endpoint (`window.__truapi_localhost`) to the product page so it can dial back in. ## Architecture diff --git a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt index 062439965..8b9716a84 100644 --- a/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt +++ b/android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt @@ -15,9 +15,6 @@ // * `TrUAPIHostCore` - owning wrapper around the UniFFI-generated // `NativeTrUApiCore`. Holds the bridge alive for the lifetime of the core // and exposes session + WS-bridge controls plus native change notifications. -// * `TrUAPIHostRuntime` - process-owned runtime for wallet hosts that manage -// their own statement-store SSO session; exposes `handleSsoRequest` and -// `prepareDisconnectRequest`. // * `LocalhostBridgeBootstrap` - JS snippet that publishes the WS bridge // endpoint to the product page so it can dial back in. // @@ -46,13 +43,10 @@ import uniffi.truapi_server.HostNavigateRejection import uniffi.truapi_server.HostRejection import uniffi.truapi_server.HostStorageException import uniffi.truapi_platform.ProductExecutionKind as UniFfiProductExecutionKind -import uniffi.truapi_server.NativeHostRuntimeConfig as UniFfiNativeHostRuntimeConfig import uniffi.truapi_server.NativeRenewalTargetException import uniffi.truapi_server.NativeRuntimeConfigException import uniffi.truapi_server.NativeStatementRenewalTarget import uniffi.truapi_server.NativeTrUApiCore -import uniffi.truapi_server.NativeTrUApiHostRuntime -import uniffi.truapi_server.SsoRequestOutcome import uniffi.truapi_server.StatementRenewalReport import uniffi.truapi_server.WsBridgeEndpoint import uniffi.truapi_server.WsBridgeStartException @@ -690,115 +684,3 @@ class TrUAPIHostCore private constructor( inner.close() } } - -/** - * Static config supplied to [TrUAPIHostRuntime]. Fields mirror the Rust - * `NativeHostRuntimeConfig`; [toNative] converts for the generated boundary. - * - * [hostName], [hostIcon], [hostVersion], [platformType], and [platformVersion] - * describe this host to the wallet during SSO pairing. - * [peopleChainGenesisHash] and [bulletinChainGenesisHash] must each be exactly - * 32 bytes. [localSessionSecret] optionally activates a local signing session - * from host-held BIP-39 entropy without SSO pairing. - */ -data class HostRuntimeConfig( - val hostName: String, - val hostIcon: String? = null, - val hostVersion: String? = null, - val platformType: String? = null, - val platformVersion: String? = null, - val peopleChainGenesisHash: ByteArray, - val bulletinChainGenesisHash: ByteArray, - val localSessionSecret: ByteArray? = null, - val localSessionLiteUsername: String? = null, -) { - internal fun toNative(): UniFfiNativeHostRuntimeConfig = - UniFfiNativeHostRuntimeConfig( - hostName = hostName, - hostIcon = hostIcon, - hostVersion = hostVersion, - platformType = platformType, - platformVersion = platformVersion, - peopleChainGenesisHash = peopleChainGenesisHash, - bulletinChainGenesisHash = bulletinChainGenesisHash, - localSessionSecret = localSessionSecret, - localSessionLiteUsername = localSessionLiteUsername, - ) - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is HostRuntimeConfig) return false - return hostName == other.hostName && - hostIcon == other.hostIcon && - hostVersion == other.hostVersion && - platformType == other.platformType && - platformVersion == other.platformVersion && - peopleChainGenesisHash.contentEquals(other.peopleChainGenesisHash) && - bulletinChainGenesisHash.contentEquals(other.bulletinChainGenesisHash) && - localSessionSecret.contentEquals(other.localSessionSecret) && - localSessionLiteUsername == other.localSessionLiteUsername - } - - override fun hashCode(): Int { - var result = hostName.hashCode() - result = 31 * result + (hostIcon?.hashCode() ?: 0) - result = 31 * result + (hostVersion?.hashCode() ?: 0) - result = 31 * result + (platformType?.hashCode() ?: 0) - result = 31 * result + (platformVersion?.hashCode() ?: 0) - result = 31 * result + peopleChainGenesisHash.contentHashCode() - result = 31 * result + bulletinChainGenesisHash.contentHashCode() - result = 31 * result + (localSessionSecret?.contentHashCode() ?: 0) - result = 31 * result + (localSessionLiteUsername?.hashCode() ?: 0) - return result - } -} - -/** - * Process-owned Rust host runtime. Exposes SSO request handling over a - * wallet-managed statement-store session. Retain across the application lifetime - * to maintain session and authentication state. - */ -class TrUAPIHostRuntime private constructor( - bridge: HostBridge, - runtimeConfig: UniFfiNativeHostRuntimeConfig, -) : AutoCloseable { - @Throws(NativeRuntimeConfigException::class) - constructor(bridge: HostBridge, runtimeConfig: HostRuntimeConfig) : this( - bridge, - runtimeConfig.toNative(), - ) - - private val callbackRetainer: HostCallbacks = HostCallbackAdapter(bridge) - private val inner: NativeTrUApiHostRuntime = - NativeTrUApiHostRuntime.withRuntimeConfig(callbackRetainer, runtimeConfig) - - /** - * Activate or replace the local signing-host session from host-held secret - * material (raw BIP-39 entropy). Lets the host run without SSO pairing. - * Blocks on key derivation — call from a coroutine on a background - * dispatcher, never the main thread. - */ - @Throws(HostRejection::class) - fun activateLocalSession(secret: ByteArray, liteUsername: String? = null) { - inner.activateLocalSession(secret, liteUsername) - } - - /** - * Answer one decrypted SSO remote message from the wallet-managed - * statement-store session. Response carries the SCALE-encoded reply to - * post back; Disconnected means the peer ended the session (perform - * native teardown); Ignored means the message was not a request. - * Confirmation-gated requests await confirmUserAction — call from a - * coroutine on a background dispatcher, never the main thread. - */ - @Throws(HostRejection::class) - suspend fun handleSsoRequest(message: ByteArray): SsoRequestOutcome = - inner.handleSsoRequest(message) - - /** SCALE-encoded Disconnected message to post over a session being ended. */ - fun prepareDisconnectRequest(): ByteArray = inner.prepareDisconnectRequest() - - override fun close() { - inner.close() - } -} From a3a48be6ad51b585b6099a229c4b3b9afe9f3ea0 Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 07:14:17 +0200 Subject: [PATCH 4/8] fix tests --- ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index d925066c9..39596b1ca 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -82,11 +82,8 @@ 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 } -<<<<<<< HEAD func supportedChains() throws -> HostChainSet { HostChainSet(network: "", chains: []) } func localStorageRead(key: String) throws -> Data? { localStore[key] } func localStorageWrite(key: String, value: Data) throws { localStore[key] = value } func localStorageClear(key: String) throws { localStore[key] = nil } -======= ->>>>>>> main } From d82d9cca928d3c36cb3edaade440fd1abc6c0826 Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 07:25:28 +0200 Subject: [PATCH 5/8] fix tests --- ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index 39596b1ca..7f28ba061 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -83,7 +83,11 @@ final class StubHostBridge: HostBridge { 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? { localStore[key] } - func localStorageWrite(key: String, value: Data) throws { localStore[key] = value } - func localStorageClear(key: String) throws { localStore[key] = nil } + 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) } } From 46e412f4eb042683537f785ea7a54c29fc3e0910 Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 10:11:21 +0200 Subject: [PATCH 6/8] add missing tests --- rust/crates/truapi-server/src/host_core.rs | 47 ++++++++++++++++++++++ rust/crates/truapi-server/src/native.rs | 38 +++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index e459f3b4e..b05e46436 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1178,4 +1178,51 @@ mod tests { let outcome = futures::executor::block_on(runtime.answer_sso_message(response_variant)); assert!(matches!(outcome, SsoRequestOutcome::Ignored)); } + + #[test] + fn answer_sso_message_answers_a_request_with_a_correlated_response() { + use crate::host_logic::sso::messages::{ + ProductSubtreeRequest, RemoteMessage, RemoteMessageData, v1, + }; + use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; + + const ENTROPY: [u8; 16] = [0xab; 16]; + + 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_message(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/native.rs b/rust/crates/truapi-server/src/native.rs index fb33eea12..dc91a9181 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -3039,6 +3039,44 @@ mod tests { 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}; From 1e53c01bda06db0e6c377578a0c758145be2ecef Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 10:15:43 +0200 Subject: [PATCH 7/8] more tests --- rust/crates/truapi-server/src/host_core.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index b05e46436..7f2759c51 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -1141,7 +1141,7 @@ mod tests { }; use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; - const ENTROPY: [u8; 16] = [0xab; 16]; + const ENTROPY: [u8; 32] = [0xab; 32]; let config = SigningHostConfig::new( HostInfo { @@ -1186,7 +1186,7 @@ mod tests { }; use truapi_platform::{HostInfo, PlatformInfo, SigningHostConfig}; - const ENTROPY: [u8; 16] = [0xab; 16]; + const ENTROPY: [u8; 32] = [0xab; 32]; let config = SigningHostConfig::new( HostInfo { From 2fade0d8ee07dbd7b44f0669f425465e2ba434d4 Mon Sep 17 00:00:00 2001 From: ERussel Date: Tue, 18 Aug 2026 10:21:36 +0200 Subject: [PATCH 8/8] refactoring --- rust/crates/truapi-server/src/host_core.rs | 14 +++++++------- rust/crates/truapi-server/src/native.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 7f2759c51..cea249d74 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -518,8 +518,8 @@ impl SigningHostRuntime { /// /// 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_message"))] - pub async fn answer_sso_message( + #[instrument(skip_all, fields(runtime.method = "signing_host_runtime.answer_sso_request"))] + pub async fn answer_sso_request( &self, message: RemoteMessage, ) -> SsoRequestOutcome { @@ -1135,7 +1135,7 @@ mod tests { } #[test] - fn answer_sso_message_distinguishes_disconnect_from_ignorable_messages() { + fn answer_sso_request_distinguishes_disconnect_from_ignorable_messages() { use crate::host_logic::sso::messages::{ RemoteMessage, RemoteMessageData, SignRawLegacyResponse, v1, }; @@ -1163,7 +1163,7 @@ mod tests { message_id: "m1".to_string(), data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), }; - let outcome = futures::executor::block_on(runtime.answer_sso_message(disconnected)); + let outcome = futures::executor::block_on(runtime.answer_sso_request(disconnected)); assert!(matches!(outcome, SsoRequestOutcome::Disconnected)); let response_variant = RemoteMessage { @@ -1175,12 +1175,12 @@ mod tests { }, )), }; - let outcome = futures::executor::block_on(runtime.answer_sso_message(response_variant)); + let outcome = futures::executor::block_on(runtime.answer_sso_request(response_variant)); assert!(matches!(outcome, SsoRequestOutcome::Ignored)); } #[test] - fn answer_sso_message_answers_a_request_with_a_correlated_response() { + fn answer_sso_request_returns_a_correlated_response() { use crate::host_logic::sso::messages::{ ProductSubtreeRequest, RemoteMessage, RemoteMessageData, v1, }; @@ -1212,7 +1212,7 @@ mod tests { }, )), }; - let outcome = futures::executor::block_on(runtime.answer_sso_message(request)); + let outcome = futures::executor::block_on(runtime.answer_sso_request(request)); let SsoRequestOutcome::Response(response) = outcome else { panic!("expected a response outcome"); }; diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index dc91a9181..3eb76c6e6 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -836,7 +836,7 @@ impl NativeTrUApiHostRuntime { reason: format!("undecodable RemoteMessage: {err}"), } })?; - Ok(match self.runtime.answer_sso_message(message).await { + Ok(match self.runtime.answer_sso_request(message).await { CoreSsoRequestOutcome::Response(response) => SsoRequestOutcome::Response { message: response.encode(), },