diff --git a/docs/ios-sso-simulator.md b/docs/ios-sso-simulator.md new file mode 100644 index 00000000..32c6fb84 --- /dev/null +++ b/docs/ios-sso-simulator.md @@ -0,0 +1,195 @@ +# iOS SSO simulator runbook + +Use this runbook to validate the shared Rust SSO responder inside +`polkadot-app-ios-v2` against `truapi-host pairing-host`. These details were +confirmed with an iOS 18.3.1 simulator and the Paseo Next v2 test network. + +## Build the Rust library first + +The Xcode project links the already-built simulator archive. Rebuild it after +every Rust core change or Xcode can succeed while embedding stale Rust code. + +```bash +# repository root +cargo build -p truapi-server \ + --release \ + --features ws-bridge \ + --target aarch64-apple-ios-sim +``` + +## Build the correct iOS flavor + +Use an arm64 iOS 18.3 simulator and the Nightly/Paseo flags. A plain Debug +build selects PreviewNet and cannot pair with a CLI using `paseo-next-v2`. +The iOS 26 simulator exposed unrelated keychain/onboarding failures during +this flow, so it is not the reference test device yet. + +```bash +IOS_SSO_SIMULATOR_ID= + +cd hosts/ios +xcodebuild \ + -project polkadot-app.xcodeproj \ + -scheme polkadot-app \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=${IOS_SSO_SIMULATOR_ID}" \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + TRUAPI_SWIFT_FLAGS='-DF_DEV -DNIGHTLY -DTESTNET_FEATURE -DIOS_PASEO_E2E' \ + RUN_IN_CI=true \ + build +``` + +Do not set `CODE_SIGNING_ALLOWED=NO`: that produces an app the simulator will +not launch. Without `RUN_IN_CI=true`, the Xcode pre-actions run SwiftFormat +across the checkout; inspect `git status` afterward if the safeguard was +omitted. Do not pipe `xcodebuild` through `tail` or a similar filter while +automating this run: the wrapper can return while the underlying build still +owns `XCBuildData/build.db`, making the next invocation fail with “database is +locked”. Use the unpiped command (optionally with `-quiet`) and wait for its +exit status. + +Keep `RUN_IN_CI=true` even for the local simulator build. It skips the +format/lint build phases, which otherwise recurse into a local +`source_packages` checkout: formatting roughly 9,500 dependency and project +files took almost ten minutes, rewrote unrelated tracked files, and SwiftLint +then failed on dependency-owned violations. Run formatting and linting as +separate, intentionally scoped checks instead. + +If that formatter has already touched an ignored `source_packages` directory, +do not reuse it: generated bridge sources in dependencies can become invalid +Swift (for example, a `get(index:)` call can be reformatted as an accessor). +Move the tainted cache aside for recovery and give Xcode a clean package-cache +path outside the checkout together with fresh DerivedData: + +```bash +mv source_packages "/tmp/truapi-ios-source-packages-formatted-$(date +%s)" + +xcodebuild \ + -project polkadot-app.xcodeproj \ + -scheme polkadot-app \ + -configuration Debug \ + -destination "platform=iOS Simulator,id=${IOS_SSO_SIMULATOR_ID}" \ + -derivedDataPath /tmp/truapi-ios-sso-clean-dd \ + -clonedSourcePackagesDirPath /tmp/truapi-ios-source-packages-clean \ + ARCHS=arm64 \ + ONLY_ACTIVE_ARCH=YES \ + TRUAPI_SWIFT_FLAGS='-DF_DEV -DNIGHTLY -DTESTNET_FEATURE -DIOS_PASEO_E2E' \ + RUN_IN_CI=true \ + build +``` + +After merging iOS `main` or updating binary dependencies, Xcode can fail with +“header has been modified since the module file was built” for a framework +such as WebRTC (`RTCPeerConnection.h`). The copied framework header is newer +than the explicit precompiled module cached in DerivedData. Run the same +project, scheme, and simulator destination with `xcodebuild clean`, then +rebuild; deleting the simulator or changing Rust code does not address this +cache mismatch. + +For integration tests that use `@testable import Products`, do not reuse +DerivedData from a normal app build. The package module in that cache was +compiled without testability and Xcode reports it as incompatible. Use a +dedicated test DerivedData directory, pass `ENABLE_TESTABILITY=YES` for the +`DevCI` configuration, and give the integration-test target the Rust archive +search path explicitly: + +```bash +xcodebuild test \ + -project polkadot-app.xcodeproj \ + -scheme polkadot-appIntegrationTests \ + -configuration DevCI \ + -destination "platform=iOS Simulator,id=${IOS_SSO_SIMULATOR_ID}" \ + -derivedDataPath /tmp/truapi-ios-integration-tests \ + ENABLE_TESTABILITY=YES \ + LIBRARY_SEARCH_PATHS="$(pwd)/../../target/aarch64-apple-ios-sim/release" +``` + +Without the explicit library path the app target can compile while the test +bundle still fails to link with `library 'truapi_server' not found`. + +Install the resulting signed Debug app: + +```bash +IOS_SSO_APP_PATH=/Build/Products/Debug-iphonesimulator/polkadot-app.app +xcrun simctl install "$IOS_SSO_SIMULATOR_ID" "$IOS_SSO_APP_PATH" +xcrun simctl launch "$IOS_SSO_SIMULATOR_ID" io.pcf.polkadotapp.develop +``` + +## Prepare a real iOS identity + +Recover or create a disposable RFC-0022 test wallet and make sure its `uid.dot` +identity plus `peopl.dot` LitePeople membership are registered on Paseo Next +v2 before importing the same mnemonic into the app. A wallet claimed through +the older native `//wallet` flow is not an RFC-0022 test identity and will fail +alias, proof, allowance, and legacy-identity signing checks even when the +shared core is working correctly. + +All hosts use the same RFC-0022 derivations. `platformType` is metadata only; +it must never select account, ring-VRF, allowance, or ECDH key material. + +On a fresh simulator, the app can remain on “Waiting for network connection” +until Safari has made the simulator's first network request. Open any HTTPS +page once, then relaunch the app. + +## Pair the CLI + +The Debug app registers `polkadotappdev://`, while the CLI prints the +production `polkadotapp://` deeplink. Replace only that scheme before opening +the deeplink in the Debug simulator. The app accepts both schemes when parsing +the handshake. + +Use `truapi-playground.dot` for the generated battery. Using +`headless-playground.dot` makes the signing examples request the wrong product +accounts and produces misleading permission failures. + +```bash +./target/debug/truapi-host pairing-host \ + --base-path /tmp/truapi-ios-pairing-host-e2e \ + --network paseo-next-v2 \ + --product-id truapi-playground.dot \ + --auto-accept \ + --log-level info \ + --script rust/crates/truapi-host-cli/js/scripts/battery.ts +``` + +Approve the sensitive operations in the simulator. The supported baseline is +46 passing examples. The remaining 19 examples are the currently unwired Chat +(6), Coin Payment (9), and Payment (4) service families. + +## Recover a simulator without erasing it + +If temporary onboarding defaults were injected, remove them through the +simulator's `cfprefsd` domain before relaunching. Editing or inspecting the +preferences plist directly is not authoritative while `cfprefsd` is running. + +```bash +xcrun simctl terminate "$IOS_SSO_SIMULATOR_ID" io.pcf.polkadotapp.develop +xcrun simctl spawn "$IOS_SSO_SIMULATOR_ID" \ + defaults delete io.pcf.polkadotapp.develop username +xcrun simctl spawn "$IOS_SSO_SIMULATOR_ID" \ + defaults delete io.pcf.polkadotapp.develop usernameClaimed +xcrun simctl spawn "$IOS_SSO_SIMULATOR_ID" \ + defaults delete io.pcf.polkadotapp.develop isPerson +xcrun simctl launch "$IOS_SSO_SIMULATOR_ID" io.pcf.polkadotapp.develop +``` + +Use `xcrun simctl spawn "$IOS_SSO_SIMULATOR_ID" defaults read +io.pcf.polkadotapp.develop` when diagnosing those values. If the app reports +“Environment has been reset”, clear injected values and use the app's Start +Over/recovery flow rather than adding more defaults. + +## Failure signatures + +- `BlockHeaderNotFound` during alias/proof means the native JSON-RPC engine + advertised ChainHead but did not return the finalized header. The Rust ring + resolver falls back to legacy `chain_*`/`state_*` RPC for this snapshot. +- `channelPriorityTooLow` on the second rapid SSO request means two statements + reused an expiry priority. Rust statement priorities are process-locally + monotonic so calls created in the same second remain strictly ordered. +- A legacy signer “not available in this CLI wallet” means the requested + account is not the RFC-0022 `uid.dot` identity derived from the active root + entropy. Check that the simulator imported the RFC-provisioned mnemonic. +- A ten-second timeout after approving VRF is a CLI diagnosis timeout, not a + cryptographic failure. Interactive SSO methods use the remote-response + timeout in the battery runner. diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index 1547c917..cb471c6e 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -45,6 +45,11 @@ job. The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. +For the Rust-core SSO flow in the native iOS app, use the dedicated +[iOS SSO simulator runbook](ios-sso-simulator.md). It records the build +configuration and simulator recovery details that are easy to miss when the +app is rebuilt. + ``` Rust crates → codegen → @parity/truapi → playground → dotli iframe ``` diff --git a/ios/truapi-host/README.md b/ios/truapi-host/README.md index 389e161f..a5a698c7 100644 --- a/ios/truapi-host/README.md +++ b/ios/truapi-host/README.md @@ -29,7 +29,7 @@ Run `rebuild.sh` after changing anything host-visible — the `NativeTrUApiCore` For local iteration without publishing, flip `useLocalBinary = true` in the root `Package.swift` to build against `Binaries/` directly; flip it back before committing. -The embedding app implements the UniFFI-generated `HostCallbacks` protocol directly (defined in `truapi_server.swift`): navigation, push, permissions, auth state, scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, and feature support. UI-decision callbacks are `async` and awaited by the Rust core. +The embedding app implements the UniFFI-generated `HostCallbacks` protocol directly (defined in `truapi_server.swift`): navigation, push, permissions, auth state, paired-peer disconnects, scoped + core storage, chain JSON-RPC, confirmations, preimage, theme, and feature support. UI-decision callbacks are `async` and awaited by the Rust core. ## Integrating in an iOS app @@ -127,13 +127,18 @@ final class MyCallbacks: HostCallbacks, @unchecked Sendable { // Core-owned auth state stream: render `.connected`/`.disconnected` as the // account badge and `.loginFailed` as a retryable error. This core is a - // signing host — it owns the signer and never pairs — so `.pairing` and + // signing host, so it does not enter the remote-login flow: `.pairing` and // `.authenticating` are not emitted and `core.cancelLogin()` is inert. - // Activate the session with `core.activateLocalSession(secret:...)`. + // Activate the signer with `core.activateLocalSession(secret:...)`; serving + // paired product hosts uses the separate responder-pairing methods. func authStateChanged(state: AuthState) { DispatchQueue.main.async { /* render the state */ } } + func pairingPeerDisconnected(peer: NativePairingPeer) { + DispatchQueue.main.async { /* remove the persisted peer and update UI */ } + } + func coreStorageRead(key: Data) throws -> Data? { coreStorage[key] } func coreStorageWrite(key: Data, value: Data) throws { coreStorage[key] = value } func coreStorageClear(key: Data) throws { coreStorage.removeValue(forKey: key) } diff --git a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift index 712cc434..28f49cd6 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/TrUAPIHost.swift @@ -39,6 +39,31 @@ public enum PairingDeeplinkScheme: Sendable { } } +/// Stable identity for a paired product host. Persist these two public keys so +/// the Rust responder can restore its encrypted Statement Store subscription +/// after the native app restarts. +public struct PairingPeer: Sendable, Hashable { + public let statementAccountId: Data + public let encryptionPublicKey: Data + + public init(statementAccountId: Data, encryptionPublicKey: Data) { + self.statementAccountId = statementAccountId + self.encryptionPublicKey = encryptionPublicKey + } + + fileprivate init(native: NativePairingPeer) { + statementAccountId = native.statementAccountId + encryptionPublicKey = native.encryptionPublicKey + } + + fileprivate var native: NativePairingPeer { + NativePairingPeer( + statementAccountId: statementAccountId, + encryptionPublicKey: encryptionPublicKey + ) + } +} + /// Static product and pairing config supplied before the Rust core handles /// product calls. One core instance represents one product identity. /// @@ -215,6 +240,11 @@ public protocol TrUAPIHostCoreProtocol: AnyObject { func disconnect() func cancelLogin() func activateLocalSession(secret: Data, liteUsername: String?) throws + func respondToPairing(deeplink: String) throws -> PairingPeer + func resumePairing(peer: PairingPeer) throws + func disconnectPairing(peer: PairingPeer) throws + func suspendPairing(peer: PairingPeer) throws + func suspendAllPairings() func permissionAuthorizationStatus( request: PermissionAuthorizationRequest ) throws -> PermissionAuthorizationStatus @@ -287,6 +317,34 @@ public final class TrUAPIHostCore: TrUAPIHostCoreProtocol { try inner.activateLocalSession(secret: secret, liteUsername: liteUsername) } + /// Answer a pairing deeplink and start serving the session in Rust. This + /// returns once the handshake statement has been accepted; session traffic + /// continues on the core's background pool. + public func respondToPairing(deeplink: String) throws -> PairingPeer { + PairingPeer(native: try inner.respondToPairing(deeplink: deeplink)) + } + + /// Restore the Rust responder for a persisted pairing host. + public func resumePairing(peer: PairingPeer) throws { + try inner.resumePairing(peer: peer.native) + } + + /// Notify one pairing host of a local disconnect and stop serving it. + public func disconnectPairing(peer: PairingPeer) throws { + try inner.disconnectPairing(peer: peer.native) + } + + /// Stop one responder without sending a disconnect. The peer can be + /// resumed later from its persisted public keys. + public func suspendPairing(peer: PairingPeer) throws { + try inner.suspendPairing(peer: peer.native) + } + + /// Stop every responder without changing persisted pairings. + public func suspendAllPairings() { + inner.suspendAllPairings() + } + /// Read a stored permission authorization status without prompting. public func permissionAuthorizationStatus( request: PermissionAuthorizationRequest diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 8bff1238..21fa292b 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -667,6 +667,13 @@ public protocol HostCallbacks: AnyObject, Sendable { */ func authStateChanged(state: AuthState) + /** + * A paired host explicitly ended its SSO session. Native shells should + * remove the matching persisted host/device and update their UI. Ordinary + * transport interruptions are retried by the core and do not emit this. + */ + func pairingPeerDisconnected(peer: NativePairingPeer) + /** * Read a core-owned host-private storage slot. `key` is a SCALE-encoded * [`CoreStorageKey`]. @@ -926,6 +933,20 @@ open func authStateChanged(state: AuthState) {try! rustCall() { FfiConverterTypeAuthState_lower(state),uniffiCallStatus ) } +} + + /** + * A paired host explicitly ended its SSO session. Native shells should + * remove the matching persisted host/device and update their UI. Ordinary + * transport interruptions are retried by the core and do not emit this. + */ +open func pairingPeerDisconnected(peer: NativePairingPeer) {try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_hostcallbacks_pairing_peer_disconnected( + self.uniffiCloneHandle(), + FfiConverterTypeNativePairingPeer_lower(peer),uniffiCallStatus + ) +} } /** @@ -1383,6 +1404,30 @@ fileprivate struct UniffiCallbackInterfaceHostCallbacks { } + let writeReturn = { () } + uniffiTraitInterfaceCall( + callStatus: uniffiCallStatus, + makeCall: makeCall, + writeReturn: writeReturn + ) + }, + pairingPeerDisconnected: { ( + uniffiHandle: UInt64, + peer: RustBuffer, + uniffiOutReturn: UnsafeMutableRawPointer, + uniffiCallStatus: UnsafeMutablePointer + ) in + let makeCall = { + () throws -> () in + guard let uniffiObj = try? FfiConverterTypeHostCallbacks.handleMap.get(handle: uniffiHandle) else { + throw UniffiInternalError.unexpectedStaleHandle + } + return uniffiObj.pairingPeerDisconnected( + peer: try FfiConverterTypeNativePairingPeer_lift(peer) + ) + } + + let writeReturn = { () } uniffiTraitInterfaceCall( callStatus: uniffiCallStatus, @@ -1887,6 +1932,14 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func disconnect() + /** + * Notify one paired host of a local disconnect and stop its responder. + * + * Blocks on the best-effort Statement Store submission, so call it off + * the host's main/UI thread. + */ + func disconnectPairing(peer: NativePairingPeer) throws + /** * Notify the core that a native chain connection closed externally. */ @@ -1928,6 +1981,22 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) throws -> PermissionAuthorizationStatus + /** + * Answer a pairing deeplink and start serving the resulting SSO session + * in the core's background pool. Returns after the handshake statement is + * accepted, not when the long-lived session eventually ends. + * + * Blocks the calling thread on the handshake submission, so call it off + * the host's main/UI thread. + */ + func respondToPairing(deeplink: String) throws -> NativePairingPeer + + /** + * Restore the background responder for a previously persisted pairing. + * Repeated calls replace the old subscription for the same peer. + */ + func resumePairing(peer: NativePairingPeer) throws + /** * Update a stored permission authorization status. Passing * `.notDetermined` clears the stored value so the next product request @@ -1949,6 +2018,17 @@ public protocol NativeTrUApiCoreProtocol: AnyObject, Sendable { */ func stopWsBridge() + /** + * Stop all responder subscriptions without disconnecting their peers. + */ + func suspendAllPairings() + + /** + * Stop one responder subscription without notifying the peer. Used when + * the native app suspends and will restore sessions later. + */ + func suspendPairing(peer: NativePairingPeer) throws + } /** * UniFFI object exposing the TrUAPI core to native hosts. @@ -2072,6 +2152,21 @@ open func disconnect() {try! rustCall() { self.uniffiCloneHandle(),uniffiCallStatus ) } +} + + /** + * Notify one paired host of a local disconnect and stop its responder. + * + * Blocks on the best-effort Statement Store submission, so call it off + * the host's main/UI thread. + */ +open func disconnectPairing(peer: NativePairingPeer)throws {try rustCallWithError(FfiConverterTypeNativePairingError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_disconnect_pairing( + self.uniffiCloneHandle(), + FfiConverterTypeNativePairingPeer_lower(peer),uniffiCallStatus + ) +} } /** @@ -2157,6 +2252,37 @@ open func permissionAuthorizationStatus(request: PermissionAuthorizationRequest) FfiConverterTypePermissionAuthorizationRequest_lower(request),uniffiCallStatus ) }) +} + + /** + * Answer a pairing deeplink and start serving the resulting SSO session + * in the core's background pool. Returns after the handshake statement is + * accepted, not when the long-lived session eventually ends. + * + * Blocks the calling thread on the handshake submission, so call it off + * the host's main/UI thread. + */ +open func respondToPairing(deeplink: String)throws -> NativePairingPeer { + return try FfiConverterTypeNativePairingPeer_lift(try rustCallWithError(FfiConverterTypeNativePairingError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_respond_to_pairing( + self.uniffiCloneHandle(), + FfiConverterString.lower(deeplink),uniffiCallStatus + ) +}) +} + + /** + * Restore the background responder for a previously persisted pairing. + * Repeated calls replace the old subscription for the same peer. + */ +open func resumePairing(peer: NativePairingPeer)throws {try rustCallWithError(FfiConverterTypeNativePairingError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_resume_pairing( + self.uniffiCloneHandle(), + FfiConverterTypeNativePairingPeer_lower(peer),uniffiCallStatus + ) +} } /** @@ -2202,6 +2328,30 @@ open func stopWsBridge() {try! rustCall() { } } + /** + * Stop all responder subscriptions without disconnecting their peers. + */ +open func suspendAllPairings() {try! rustCall() { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_suspend_all_pairings( + self.uniffiCloneHandle(),uniffiCallStatus + ) +} +} + + /** + * Stop one responder subscription without notifying the peer. Used when + * the native app suspends and will restore sessions later. + */ +open func suspendPairing(peer: NativePairingPeer)throws {try rustCallWithError(FfiConverterTypeNativePairingError_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativetruapicore_suspend_pairing( + self.uniffiCloneHandle(), + FfiConverterTypeNativePairingPeer_lower(peer),uniffiCallStatus + ) +} +} + } @@ -2250,6 +2400,76 @@ public func FfiConverterTypeNativeTrUApiCore_lower(_ value: NativeTrUApiCore) -> +/** + * Pairing-host identity persisted by a native signing host so its responder + * subscription can be restored after an app restart. + */ +public struct NativePairingPeer: Equatable, Hashable { + /** + * Pairing host's 32-byte sr25519 Statement Store account id. + */ + public var statementAccountId: Data + /** + * Pairing host's 32-byte raw X25519 public key. + */ + public var encryptionPublicKey: Data + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + /** + * Pairing host's 32-byte sr25519 Statement Store account id. + */statementAccountId: Data, + /** + * Pairing host's 32-byte raw X25519 public key. + */encryptionPublicKey: Data) { + self.statementAccountId = statementAccountId + self.encryptionPublicKey = encryptionPublicKey + } + + + + +} + +#if compiler(>=6) +extension NativePairingPeer: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNativePairingPeer: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NativePairingPeer { + return + try NativePairingPeer( + statementAccountId: FfiConverterData.read(from: &buf), + encryptionPublicKey: FfiConverterData.read(from: &buf) + ) + } + + public static func write(_ value: NativePairingPeer, into buf: inout [UInt8]) { + FfiConverterData.write(value.statementAccountId, into: &buf) + FfiConverterData.write(value.encryptionPublicKey, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativePairingPeer_lift(_ buf: RustBuffer) throws -> NativePairingPeer { + return try FfiConverterTypeNativePairingPeer.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativePairingPeer_lower(_ value: NativePairingPeer) -> RustBuffer { + return FfiConverterTypeNativePairingPeer.lower(value) +} + + /** * Native runtime configuration supplied before product calls are handled. */ @@ -2827,6 +3047,122 @@ public func FfiConverterTypeNativePairingDeeplinkScheme_lower(_ value: NativePai +/** + * Invalid persisted peer data or an SSO responder failure. + */ +public +enum NativePairingError: Swift.Error, Equatable, Hashable, Foundation.LocalizedError { + + + + /** + * Statement Store account id was not exactly 32 bytes. + */ + case InvalidStatementAccountId( + /** + * Supplied byte length. + */actual: UInt64 + ) + /** + * X25519 public key was not exactly 32 bytes. + */ + case InvalidEncryptionPublicKey( + /** + * Supplied byte length. + */actual: UInt64 + ) + /** + * Pairing or responder startup failed. + */ + case Failed( + /** + * Human-readable failure reason. + */reason: String + ) + + + + + + + public var errorDescription: String? { + String(reflecting: self) + } + +} + +#if compiler(>=6) +extension NativePairingError: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeNativePairingError: FfiConverterRustBuffer { + typealias SwiftType = NativePairingError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> NativePairingError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .InvalidStatementAccountId( + actual: try FfiConverterUInt64.read(from: &buf) + ) + case 2: return .InvalidEncryptionPublicKey( + actual: try FfiConverterUInt64.read(from: &buf) + ) + case 3: return .Failed( + reason: try FfiConverterString.read(from: &buf) + ) + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: NativePairingError, into buf: inout [UInt8]) { + switch value { + + + + + + case let .InvalidStatementAccountId(actual): + writeInt(&buf, Int32(1)) + FfiConverterUInt64.write(actual, into: &buf) + + + case let .InvalidEncryptionPublicKey(actual): + writeInt(&buf, Int32(2)) + FfiConverterUInt64.write(actual, into: &buf) + + + case let .Failed(reason): + writeInt(&buf, Int32(3)) + FfiConverterString.write(reason, into: &buf) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativePairingError_lift(_ buf: RustBuffer) throws -> NativePairingError { + return try FfiConverterTypeNativePairingError.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeNativePairingError_lower(_ value: NativePairingError) -> RustBuffer { + return FfiConverterTypeNativePairingError.lower(value) +} + + /** * Native runtime config validation error. */ @@ -3535,43 +3871,46 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed() != 48975) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 59238) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_pairing_peer_disconnected() != 22344) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_read() != 61703) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_write() != 35684) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_write() != 4428) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_clear() != 61002) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_core_storage_clear() != 43717) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_connect() != 36320) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_connect() != 30923) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_send() != 10194) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_send() != 6042) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_close() != 54867) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_chain_close() != 51970) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_confirm_user_action() != 23589) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_confirm_user_action() != 20260) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_lookup_preimage() != 33694) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_lookup_preimage() != 59647) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_current_theme() != 20227) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_current_theme() != 63562) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_feature_supported() != 46490) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_feature_supported() != 28665) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_read() != 54709) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_read() != 32804) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_write() != 33044) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_write() != 62222) { return InitializationResult.apiChecksumMismatch } - if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_clear() != 6971) { + if (uniffi_truapi_server_checksum_method_hostcallbacks_local_storage_clear() != 61208) { return InitializationResult.apiChecksumMismatch } if (uniffi_truapi_server_checksum_method_nativetruapicore_activate_local_session() != 19215) { @@ -3583,6 +3922,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_disconnect() != 18254) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_disconnect_pairing() != 173) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_notify_chain_closed() != 25320) { return InitializationResult.apiChecksumMismatch } @@ -3601,6 +3943,12 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_permission_authorization_status() != 21962) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_respond_to_pairing() != 17059) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_nativetruapicore_resume_pairing() != 58346) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativetruapicore_set_permission_authorization_status() != 37317) { return InitializationResult.apiChecksumMismatch } @@ -3610,6 +3958,12 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativetruapicore_stop_ws_bridge() != 13438) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativetruapicore_suspend_all_pairings() != 64494) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_truapi_server_checksum_method_nativetruapicore_suspend_pairing() != 43595) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_constructor_nativetruapicore_with_runtime_config() != 54861) { return InitializationResult.apiChecksumMismatch } @@ -3633,4 +3987,4 @@ public func uniffiEnsureTruapiServerInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file 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 d7c7db79..4061ca25 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -290,88 +290,95 @@ typedef void (*UniffiCallbackInterfaceHostCallbacksMethod6)(uint64_t, RustBuffer #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD7 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD7 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod7)(uint64_t, RustBuffer, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod7)(uint64_t, RustBuffer, void* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD8 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD8 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod8)(uint64_t, RustBuffer, RustBuffer, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod8)(uint64_t, RustBuffer, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD9 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD9 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod9)(uint64_t, RustBuffer, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod9)(uint64_t, RustBuffer, RustBuffer, void* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD10 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD10 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod10)(uint64_t, RustBuffer, RustBuffer* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod10)(uint64_t, RustBuffer, void* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD11 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD11 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod11)(uint64_t, uint32_t, RustBuffer, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod11)(uint64_t, RustBuffer, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD12 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD12 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod12)(uint64_t, uint32_t, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod12)(uint64_t, uint32_t, RustBuffer, void* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD13 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD13 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod13)(uint64_t, RustBuffer, UniffiForeignFutureCompleteI8 _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod13)(uint64_t, uint32_t, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD14 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD14 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod14)(uint64_t, RustBuffer, UniffiForeignFutureCompleteRustBuffer _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod14)(uint64_t, RustBuffer, UniffiForeignFutureCompleteI8 _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD15 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD15 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod15)(uint64_t, RustBuffer* _Nonnull, - RustCallStatus *_Nonnull uniffiCallStatus +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod15)(uint64_t, RustBuffer, UniffiForeignFutureCompleteRustBuffer _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD16 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD16 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod16)(uint64_t, RustBuffer, UniffiForeignFutureCompleteI8 _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod16)(uint64_t, RustBuffer* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD17 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD17 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod17)(uint64_t, RustBuffer, RustBuffer* _Nonnull, - RustCallStatus *_Nonnull uniffiCallStatus +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod17)(uint64_t, RustBuffer, UniffiForeignFutureCompleteI8 _Nonnull, uint64_t, UniffiForeignFutureDroppedCallbackStruct* _Nonnull ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD18 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD18 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod18)(uint64_t, RustBuffer, RustBuffer, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod18)(uint64_t, RustBuffer, RustBuffer* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); #endif #ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD19 #define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD19 -typedef void (*UniffiCallbackInterfaceHostCallbacksMethod19)(uint64_t, RustBuffer, void* _Nonnull, +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod19)(uint64_t, RustBuffer, RustBuffer, void* _Nonnull, + RustCallStatus *_Nonnull uniffiCallStatus + ); + +#endif +#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD20 +#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_HOST_CALLBACKS_METHOD20 +typedef void (*UniffiCallbackInterfaceHostCallbacksMethod20)(uint64_t, RustBuffer, void* _Nonnull, RustCallStatus *_Nonnull uniffiCallStatus ); @@ -388,19 +395,20 @@ typedef struct UniffiVTableCallbackInterfaceHostCallbacks { UniffiCallbackInterfaceHostCallbacksMethod4 _Nonnull devicePermission; UniffiCallbackInterfaceHostCallbacksMethod5 _Nonnull remotePermission; UniffiCallbackInterfaceHostCallbacksMethod6 _Nonnull authStateChanged; - UniffiCallbackInterfaceHostCallbacksMethod7 _Nonnull coreStorageRead; - UniffiCallbackInterfaceHostCallbacksMethod8 _Nonnull coreStorageWrite; - UniffiCallbackInterfaceHostCallbacksMethod9 _Nonnull coreStorageClear; - UniffiCallbackInterfaceHostCallbacksMethod10 _Nonnull chainConnect; - UniffiCallbackInterfaceHostCallbacksMethod11 _Nonnull chainSend; - UniffiCallbackInterfaceHostCallbacksMethod12 _Nonnull chainClose; - UniffiCallbackInterfaceHostCallbacksMethod13 _Nonnull confirmUserAction; - UniffiCallbackInterfaceHostCallbacksMethod14 _Nonnull lookupPreimage; - UniffiCallbackInterfaceHostCallbacksMethod15 _Nonnull currentTheme; - UniffiCallbackInterfaceHostCallbacksMethod16 _Nonnull featureSupported; - UniffiCallbackInterfaceHostCallbacksMethod17 _Nonnull localStorageRead; - UniffiCallbackInterfaceHostCallbacksMethod18 _Nonnull localStorageWrite; - UniffiCallbackInterfaceHostCallbacksMethod19 _Nonnull localStorageClear; + UniffiCallbackInterfaceHostCallbacksMethod7 _Nonnull pairingPeerDisconnected; + UniffiCallbackInterfaceHostCallbacksMethod8 _Nonnull coreStorageRead; + UniffiCallbackInterfaceHostCallbacksMethod9 _Nonnull coreStorageWrite; + UniffiCallbackInterfaceHostCallbacksMethod10 _Nonnull coreStorageClear; + UniffiCallbackInterfaceHostCallbacksMethod11 _Nonnull chainConnect; + UniffiCallbackInterfaceHostCallbacksMethod12 _Nonnull chainSend; + UniffiCallbackInterfaceHostCallbacksMethod13 _Nonnull chainClose; + UniffiCallbackInterfaceHostCallbacksMethod14 _Nonnull confirmUserAction; + UniffiCallbackInterfaceHostCallbacksMethod15 _Nonnull lookupPreimage; + UniffiCallbackInterfaceHostCallbacksMethod16 _Nonnull currentTheme; + UniffiCallbackInterfaceHostCallbacksMethod17 _Nonnull featureSupported; + UniffiCallbackInterfaceHostCallbacksMethod18 _Nonnull localStorageRead; + UniffiCallbackInterfaceHostCallbacksMethod19 _Nonnull localStorageWrite; + UniffiCallbackInterfaceHostCallbacksMethod20 _Nonnull localStorageClear; } UniffiVTableCallbackInterfaceHostCallbacks; #endif @@ -454,6 +462,11 @@ uint64_t uniffi_truapi_server_fn_method_hostcallbacks_remote_permission(uint64_t void uniffi_truapi_server_fn_method_hostcallbacks_auth_state_changed(uint64_t ptr, RustBuffer state, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_HOSTCALLBACKS_PAIRING_PEER_DISCONNECTED +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_HOSTCALLBACKS_PAIRING_PEER_DISCONNECTED +void uniffi_truapi_server_fn_method_hostcallbacks_pairing_peer_disconnected(uint64_t ptr, RustBuffer peer, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_HOSTCALLBACKS_CORE_STORAGE_READ #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_HOSTCALLBACKS_CORE_STORAGE_READ RustBuffer uniffi_truapi_server_fn_method_hostcallbacks_core_storage_read(uint64_t ptr, RustBuffer key, RustCallStatus *_Nonnull out_status @@ -549,6 +562,11 @@ void uniffi_truapi_server_fn_method_nativetruapicore_cancel_login(uint64_t ptr, void uniffi_truapi_server_fn_method_nativetruapicore_disconnect(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_DISCONNECT_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_DISCONNECT_PAIRING +void uniffi_truapi_server_fn_method_nativetruapicore_disconnect_pairing(uint64_t ptr, RustBuffer peer, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED void uniffi_truapi_server_fn_method_nativetruapicore_notify_chain_closed(uint64_t ptr, uint32_t connection_id, RustCallStatus *_Nonnull out_status @@ -579,6 +597,16 @@ void uniffi_truapi_server_fn_method_nativetruapicore_notify_theme_changed(uint64 RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_permission_authorization_status(uint64_t ptr, RustBuffer request, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RESPOND_TO_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RESPOND_TO_PAIRING +RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_respond_to_pairing(uint64_t ptr, RustBuffer deeplink, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RESUME_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_RESUME_PAIRING +void uniffi_truapi_server_fn_method_nativetruapicore_resume_pairing(uint64_t ptr, RustBuffer peer, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SET_PERMISSION_AUTHORIZATION_STATUS #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SET_PERMISSION_AUTHORIZATION_STATUS void uniffi_truapi_server_fn_method_nativetruapicore_set_permission_authorization_status(uint64_t ptr, RustBuffer request, RustBuffer status, RustCallStatus *_Nonnull out_status @@ -594,6 +622,16 @@ RustBuffer uniffi_truapi_server_fn_method_nativetruapicore_start_ws_bridge(uint6 void uniffi_truapi_server_fn_method_nativetruapicore_stop_ws_bridge(uint64_t ptr, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SUSPEND_ALL_PAIRINGS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SUSPEND_ALL_PAIRINGS +void uniffi_truapi_server_fn_method_nativetruapicore_suspend_all_pairings(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SUSPEND_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVETRUAPICORE_SUSPEND_PAIRING +void uniffi_truapi_server_fn_method_nativetruapicore_suspend_pairing(uint64_t ptr, RustBuffer peer, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_FUNC_PARSE_NAVIGATE #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_FUNC_PARSE_NAVIGATE RustBuffer uniffi_truapi_server_fn_func_parse_navigate(RustBuffer input, RustCallStatus *_Nonnull out_status @@ -916,6 +954,12 @@ uint16_t uniffi_truapi_server_checksum_method_hostcallbacks_remote_permission(vo #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_HOSTCALLBACKS_AUTH_STATE_CHANGED uint16_t uniffi_truapi_server_checksum_method_hostcallbacks_auth_state_changed(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_HOSTCALLBACKS_PAIRING_PEER_DISCONNECTED +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_HOSTCALLBACKS_PAIRING_PEER_DISCONNECTED +uint16_t uniffi_truapi_server_checksum_method_hostcallbacks_pairing_peer_disconnected(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_HOSTCALLBACKS_CORE_STORAGE_READ @@ -1012,6 +1056,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_cancel_login(void #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_DISCONNECT uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_disconnect(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_DISCONNECT_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_DISCONNECT_PAIRING +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_disconnect_pairing(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_NOTIFY_CHAIN_CLOSED @@ -1048,6 +1098,18 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_notify_theme_chan #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_PERMISSION_AUTHORIZATION_STATUS uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_permission_authorization_status(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RESPOND_TO_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RESPOND_TO_PAIRING +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_respond_to_pairing(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RESUME_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_RESUME_PAIRING +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_resume_pairing(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SET_PERMISSION_AUTHORIZATION_STATUS @@ -1066,6 +1128,18 @@ uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_start_ws_bridge(v #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_STOP_WS_BRIDGE uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_stop_ws_bridge(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SUSPEND_ALL_PAIRINGS +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SUSPEND_ALL_PAIRINGS +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_suspend_all_pairings(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SUSPEND_PAIRING +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVETRUAPICORE_SUSPEND_PAIRING +uint16_t uniffi_truapi_server_checksum_method_nativetruapicore_suspend_pairing(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_CONSTRUCTOR_NATIVETRUAPICORE_WITH_RUNTIME_CONFIG diff --git a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift index 9c78e3cb..47e3cc3c 100644 --- a/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift +++ b/ios/truapi-host/Tests/TrUAPIWsBridgeTests.swift @@ -65,6 +65,7 @@ final class StubHostCallbacks: HostCallbacks, @unchecked Sendable { func devicePermission(request _: HostDevicePermissionRequest) async throws -> Bool { false } func remotePermission(request _: RemotePermission) async throws -> Bool { false } func authStateChanged(state _: AuthState) {} + func pairingPeerDisconnected(peer _: NativePairingPeer) {} func coreStorageRead(key: Data) throws -> Data? { coreStore[key] } func coreStorageWrite(key: Data, value: Data) throws { coreStore[key] = value } func coreStorageClear(key: Data) throws { coreStore[key] = nil } diff --git a/rust/crates/truapi-host-cli/js/diagnosis.test.ts b/rust/crates/truapi-host-cli/js/diagnosis.test.ts index e9adc87f..93c72bad 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.test.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.test.ts @@ -7,6 +7,7 @@ import { } from "./diagnosis-report.ts"; import { createDiagnosisPlan, + diagnosisTimeoutMs, expectedCliBatteryFailureReason, knownUnsupportedReason, type DiagnosisCase, @@ -47,6 +48,11 @@ describe("generated-example battery", () => { ); }); + test("allows interactive VRF approval to outlive the unary timeout", () => { + expect(diagnosisTimeoutMs("Account/sign_vrf")).toBe(190_000); + expect(diagnosisTimeoutMs("System/handshake")).toBe(10_000); + }); + test("classifies only the committed unsupported CLI battery failures as expected", () => { expect(expectedCliBatteryFailureReason("Chat")).toBe( "Chat service not yet wired up by hosts", diff --git a/rust/crates/truapi-host-cli/js/diagnosis.ts b/rust/crates/truapi-host-cli/js/diagnosis.ts index b4dff38d..bfc21bc3 100644 --- a/rust/crates/truapi-host-cli/js/diagnosis.ts +++ b/rust/crates/truapi-host-cli/js/diagnosis.ts @@ -22,6 +22,7 @@ const LONG_TIMEOUT_METHODS = new Set([ "Account/get_account", "Account/get_account_alias", "Account/create_account_proof", + "Account/sign_vrf", "Resource Allocation/request", "Signing/sign_payload", "Signing/sign_raw", @@ -126,11 +127,7 @@ async function runOne( if (!test.exampleSource) { return finish("fail", "no runnable example"); } - const timeoutMs = - METHOD_TIMEOUT_MS.get(test.id) ?? - (LONG_TIMEOUT_METHODS.has(test.id) - ? REMOTE_RESPONSE_TIMEOUT_MS - : UNARY_TIMEOUT_MS); + const timeoutMs = diagnosisTimeoutMs(test.id); const logs: LogEntry[] = []; let timer: ReturnType | undefined; @@ -162,6 +159,15 @@ async function runOne( } } +export function diagnosisTimeoutMs(id: string): number { + return ( + METHOD_TIMEOUT_MS.get(id) ?? + (LONG_TIMEOUT_METHODS.has(id) + ? REMOTE_RESPONSE_TIMEOUT_MS + : UNARY_TIMEOUT_MS) + ); +} + function joinLogs(logs: LogEntry[]): string | undefined { return logs.length === 0 ? undefined : logs.map((l) => l.text).join("\n"); } diff --git a/rust/crates/truapi-server/src/chain_runtime.rs b/rust/crates/truapi-server/src/chain_runtime.rs index 28aa4e8b..186d7d6e 100644 --- a/rust/crates/truapi-server/src/chain_runtime.rs +++ b/rust/crates/truapi-server/src/chain_runtime.rs @@ -39,7 +39,7 @@ use parity_scale_codec::{Decode, Error as ScaleError, Input}; use serde::de::{Deserializer, Error as DeError}; use serde_json::Value; use subxt::OnlineClient; -use subxt::backend::ChainHeadBackend; +use subxt::backend::{ChainHeadBackend, LegacyBackend}; use subxt::config::substrate::{SubstrateConfig, SubstrateConfigBuilder}; use subxt::utils::H256; use subxt_rpcs::client::RpcClient; @@ -621,6 +621,35 @@ impl ChainRuntime { Ok(self.subxt_connection(genesis_hash).await?.client) } + /// Genesis-pinned Subxt client backed exclusively by legacy RPC methods. + /// + /// Native mobile chain engines can advertise ChainHead v1 while failing + /// to return a header for its finalized block. Internal snapshot readers + /// use this client as a narrow fallback so storage remains available via + /// `chain_*`/`state_*` without changing the public ChainHead transport. + #[instrument(skip_all, fields(runtime.method = "chain_runtime.legacy_online_client"))] + pub(crate) async fn legacy_online_client( + &self, + genesis_hash: &[u8], + ) -> Result, RuntimeFailure> { + const METHOD: &str = "legacy_subxt_connection"; + let connection = self.connection_for(METHOD, genesis_hash).await?; + let genesis_hash: [u8; 32] = genesis_hash.try_into().map_err(|_| { + RuntimeFailure::host_failure( + METHOD, + format!("expected 32-byte genesis hash, got {}", genesis_hash.len()), + ) + })?; + let backend = LegacyBackend::::builder() + .build(RpcClient::new(connection.rpc_client.clone())); + let config = SubstrateConfigBuilder::new() + .set_genesis_hash(H256(genesis_hash)) + .build(); + OnlineClient::from_backend_with_config(config, Arc::new(backend)) + .await + .map_err(|error| RuntimeFailure::host_failure(METHOD, error.to_string())) + } + /// Raw JSON-RPC client for the chain identified by `genesis_hash`. #[cfg(not(target_arch = "wasm32"))] pub(crate) async fn rpc_client( diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d226ea5e..9c46018f 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -25,10 +25,17 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; +#[cfg(not(target_arch = "wasm32"))] +use crate::host_logic::session::SsoSessionInfo; use crate::runtime::{ LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, respond_to_pairing, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::runtime::{ + ResponderPeer, answer_pairing, responder_session_for_peer, serve_responder_session, + submit_responder_disconnected, +}; use crate::subscription::Spawner; use crate::transport::Transport; @@ -375,6 +382,51 @@ impl SigningHostRuntime { .await .map_err(|reason| v01::GenericError { reason }) } + + /// Submit a pairing response and return immediately-servable session + /// material. Used by native shells that own the background task lifecycle. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn answer_pairing( + &self, + deeplink: &str, + ) -> Result<(ResponderPeer, SsoSessionInfo), v01::GenericError> { + answer_pairing(self.services.clone(), self.signing_host.clone(), deeplink) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Rebuild session channels for a persisted pairing host. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn responder_session_for_peer( + &self, + peer: &ResponderPeer, + ) -> Result { + responder_session_for_peer(&self.signing_host, peer) + .map_err(|reason| v01::GenericError { reason }) + } + + /// Drive one responder subscription until the peer disconnects or the + /// underlying subscription ends. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn serve_responder_session( + &self, + session: SsoSessionInfo, + ) -> Result { + serve_responder_session(self.services.clone(), self.signing_host.clone(), session) + .await + .map_err(|reason| v01::GenericError { reason }) + } + + /// Notify one paired host that this signing host ended its session. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) async fn disconnect_responder_session( + &self, + session: &SsoSessionInfo, + ) -> Result<(), v01::GenericError> { + submit_responder_disconnected(&self.services, session) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Product-scoped administration handle for host UI. @@ -694,6 +746,54 @@ mod tests { assert_send(runtime.receive_frame(Vec::new())); } + #[test] + fn all_platforms_activate_the_same_rfc0022_identity() { + const ENTROPY: [u8; 16] = [0xab; 16]; + let runtime_for = |kind: &str| { + let config = SigningHostConfig::new( + truapi_platform::HostInfo { + name: format!("{kind} signing host"), + icon: None, + version: None, + }, + truapi_platform::PlatformInfo { + kind: Some(kind.to_string()), + version: None, + }, + [0; 32], + [0xbb; 32], + ) + .expect("signing host config is valid"); + SigningHostRuntime::new(Arc::new(StubPlatform::default()), config, test_spawner()) + }; + let ios = runtime_for("iOS"); + let cli = runtime_for("CLI"); + + futures::executor::block_on(ios.signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("iOS activation succeeds"); + futures::executor::block_on(cli.signing_host.activate_local_session(ENTROPY.to_vec())) + .expect("CLI activation succeeds"); + let expected = crate::host_logic::product_account::derive_identity_keypair(&ENTROPY) + .expect("RFC-0022 identity derives") + .public + .to_bytes(); + + assert_eq!( + ios.signing_host + .current_session() + .expect("iOS session") + .identity_account_id, + Some(expected) + ); + assert_eq!( + cli.signing_host + .current_session() + .expect("CLI session") + .identity_account_id, + Some(expected) + ); + } + #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs index 974a490f..6da7f38a 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -27,6 +27,7 @@ use x25519_dalek::{PublicKey, StaticSecret}; use crate::host_logic::session::SsoSessionInfo; const HANDSHAKE_TOPIC_SUFFIX: &[u8] = b"topic"; +const HANDSHAKE_CHANNEL_SUFFIX: &[u8] = b"channel"; /// Byte length of the ChaCha20-Poly1305 nonce prepended to encrypted payloads. pub const AEAD_NONCE_LEN: usize = 12; @@ -578,6 +579,21 @@ pub fn bootstrap_topic( keyed_hash(statement_store_public_key, &message) } +/// Derive the statement channel for pairing answer statements from advertised +/// host keys. Distinct from [`bootstrap_topic`] so a `Success` answer replaces +/// an earlier `Pending` status in the store. +pub fn bootstrap_channel( + statement_store_public_key: [u8; 32], + encryption_public_key: [u8; 32], +) -> [u8; 32] { + let mut message = + Vec::with_capacity(encryption_public_key.len() + HANDSHAKE_CHANNEL_SUFFIX.len()); + message.extend_from_slice(&encryption_public_key); + message.extend_from_slice(HANDSHAKE_CHANNEL_SUFFIX); + + keyed_hash(statement_store_public_key, &message) +} + fn generate_statement_store_keypair() -> Result<([u8; 64], [u8; 32]), PairingBootstrapError> { let mut seed = [0u8; 32]; getrandom::getrandom(&mut seed).map_err(PairingBootstrapError::Random)?; @@ -770,6 +786,19 @@ mod tests { ); } + #[test] + fn derives_pinned_topic_and_channel_vectors() { + let (_, public) = x25519_keypair(1); + assert_eq!( + hex::encode(bootstrap_topic(SS_PUBLIC, public)), + "ec8c8d7993ef1b367a704f34cec0fa1fe01d0a060a918688f26b23e88452a6af" + ); + assert_eq!( + hex::encode(bootstrap_channel(SS_PUBLIC, public)), + "f7df2ba8c948e35c35edfaf8bad6cb4a8c4e0373f64bab787f68620a88f7c51f" + ); + } + #[test] fn responder_session_mirrors_pairing_session() { let host = create_pairing_bootstrap(&runtime_config()).unwrap(); diff --git a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs index e4ffebfe..bf34ccd4 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing/v2.rs @@ -43,6 +43,8 @@ pub enum MetadataKey { PlatformType, /// Platform version. PlatformVersion, + /// Host location in `latitude;longitude` format. + Location, } /// Plaintext wallet response after decrypting the pairing statement. diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index df65b925..3ab317ed 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -5,16 +5,17 @@ //! [`truapi_platform::Platform`] trait to a corresponding callback. The //! resulting platform is fed into [`SigningHostRuntime`] so the rest of the //! dispatcher pipeline behaves identically to the WS-bridge and wasm flavors. -//! A native host therefore owns the signer: there is no pairing flow here, and -//! the pairing-host-only entry points are inert. +//! A native host owns the signer and can also serve responder sessions for +//! paired product hosts. use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use futures::FutureExt; use futures::channel::mpsc; use futures::executor::ThreadPool; -use futures::future::BoxFuture; +use futures::future::{AbortHandle, Abortable, BoxFuture}; use futures::stream::{self, BoxStream, StreamExt}; use futures::task::SpawnExt; use parity_scale_codec::Encode; @@ -27,12 +28,13 @@ use truapi_platform::{ UserConfirmationReview, async_trait, }; -use crate::SigningHostRuntime; -use crate::host_logic::dotns; pub use crate::host_logic::dotns::NavigateDecision; +use crate::host_logic::{dotns, session::SsoSessionInfo}; +use crate::runtime::ResponderPeer; use crate::subscription::Spawner; #[cfg(feature = "ws-bridge")] use crate::ws_bridge::{BridgeLogger, WsBridge, WsBridgeEndpoint, WsBridgeStartError}; +use crate::{ResponderExit, SigningHostRuntime}; /// Host-thrown storage failure wrapping the canonical error payload, so its /// variants remain defined once in `truapi`. @@ -115,6 +117,75 @@ pub enum NativePairingDeeplinkScheme { PolkadotAppDev, } +/// Pairing-host identity persisted by a native signing host so its responder +/// subscription can be restored after an app restart. +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct NativePairingPeer { + /// Pairing host's 32-byte sr25519 Statement Store account id. + pub statement_account_id: Vec, + /// Pairing host's 32-byte raw X25519 public key. + pub encryption_public_key: Vec, +} + +impl From for NativePairingPeer { + fn from(peer: ResponderPeer) -> Self { + Self { + statement_account_id: peer.statement_account_id.to_vec(), + encryption_public_key: peer.encryption_public_key.to_vec(), + } + } +} + +/// Invalid persisted peer data or an SSO responder failure. +#[derive(Debug, Clone, thiserror::Error, uniffi::Error)] +pub enum NativePairingError { + /// Statement Store account id was not exactly 32 bytes. + #[error("statement_account_id must be exactly 32 bytes, got {actual}")] + InvalidStatementAccountId { + /// Supplied byte length. + actual: u64, + }, + /// X25519 public key was not exactly 32 bytes. + #[error("encryption_public_key must be exactly 32 bytes, got {actual}")] + InvalidEncryptionPublicKey { + /// Supplied byte length. + actual: u64, + }, + /// Pairing or responder startup failed. + #[error("{reason}")] + Failed { + /// Human-readable failure reason. + reason: String, + }, +} + +impl TryFrom for ResponderPeer { + type Error = NativePairingError; + + fn try_from(peer: NativePairingPeer) -> Result { + let statement_account_id = + peer.statement_account_id + .try_into() + .map_err( + |value: Vec| NativePairingError::InvalidStatementAccountId { + actual: value.len() as u64, + }, + )?; + let encryption_public_key = + peer.encryption_public_key + .try_into() + .map_err( + |value: Vec| NativePairingError::InvalidEncryptionPublicKey { + actual: value.len() as u64, + }, + )?; + Ok(Self { + statement_account_id, + encryption_public_key, + }) + } +} + /// Native runtime configuration supplied before product calls are handled. #[derive(Debug, Clone, uniffi::Record)] pub struct NativeRuntimeConfig { @@ -337,6 +408,11 @@ pub trait HostCallbacks: Send + Sync { /// `NativeTrUApiCore.cancel_login()`. fn auth_state_changed(&self, state: AuthState); + /// A paired host explicitly ended its SSO session. Native shells should + /// remove the matching persisted host/device and update their UI. Ordinary + /// transport interruptions are retried by the core and do not emit this. + fn pairing_peer_disconnected(&self, peer: NativePairingPeer); + /// Read a core-owned host-private storage slot. `key` is a SCALE-encoded /// [`CoreStorageKey`]. fn core_storage_read(&self, key: Vec) -> Result>, HostRejection>; @@ -393,12 +469,20 @@ pub struct NativeTrUApiCore { runtime: Arc, product: ProductContext, events: Arc, - #[cfg(feature = "ws-bridge")] callbacks: Arc, + spawner: Spawner, + pairing_tasks: Arc>>, + next_pairing_generation: AtomicU64, #[cfg(feature = "ws-bridge")] bridge: std::sync::Mutex>, } +struct NativePairingTask { + generation: u64, + abort: AbortHandle, + session: SsoSessionInfo, +} + #[uniffi::export] impl NativeTrUApiCore { /// Construct the core with explicit product and pairing runtime config. @@ -494,6 +578,76 @@ impl NativeTrUApiCore { .map_err(Into::into) } + /// Answer a pairing deeplink and start serving the resulting SSO session + /// in the core's background pool. Returns after the handshake statement is + /// accepted, not when the long-lived session eventually ends. + /// + /// Blocks the calling thread on the handshake submission, so call it off + /// the host's main/UI thread. + pub fn respond_to_pairing( + &self, + deeplink: String, + ) -> Result { + let (peer, session) = futures::executor::block_on(self.runtime.answer_pairing(&deeplink)) + .map_err(|err| NativePairingError::Failed { reason: err.reason })?; + self.start_pairing_task(peer.clone(), session); + Ok(peer.into()) + } + + /// Restore the background responder for a previously persisted pairing. + /// Repeated calls replace the old subscription for the same peer. + pub fn resume_pairing(&self, peer: NativePairingPeer) -> Result<(), NativePairingError> { + let peer = ResponderPeer::try_from(peer)?; + let session = self + .runtime + .responder_session_for_peer(&peer) + .map_err(|err| NativePairingError::Failed { reason: err.reason })?; + self.start_pairing_task(peer, session); + Ok(()) + } + + /// Notify one paired host of a local disconnect and stop its responder. + /// + /// Blocks on the best-effort Statement Store submission, so call it off + /// the host's main/UI thread. + pub fn disconnect_pairing(&self, peer: NativePairingPeer) -> Result<(), NativePairingError> { + let peer = ResponderPeer::try_from(peer)?; + let session = self + .stop_pairing_task(&peer) + .map(|task| task.session) + .map(Ok) + .unwrap_or_else(|| self.runtime.responder_session_for_peer(&peer)) + .map_err(|err| NativePairingError::Failed { reason: err.reason })?; + match futures::executor::block_on(self.runtime.disconnect_responder_session(&session)) { + Ok(()) => Ok(()), + Err(err) => { + self.start_pairing_task(peer, session); + Err(NativePairingError::Failed { reason: err.reason }) + } + } + } + + /// Stop one responder subscription without notifying the peer. Used when + /// the native app suspends and will restore sessions later. + pub fn suspend_pairing(&self, peer: NativePairingPeer) -> Result<(), NativePairingError> { + let peer = ResponderPeer::try_from(peer)?; + self.stop_pairing_task(&peer); + Ok(()) + } + + /// Stop all responder subscriptions without disconnecting their peers. + pub fn suspend_all_pairings(&self) { + let tasks = std::mem::take( + &mut *self + .pairing_tasks + .lock() + .expect("native pairing tasks mutex poisoned"), + ); + for (_, task) in tasks { + task.abort.abort(); + } + } + /// Push a host theme update to active TrUAPI theme subscriptions. pub fn notify_theme_changed(&self, theme: v01::ThemeVariant) { self.events.notify_theme_changed(theme); @@ -518,6 +672,101 @@ impl NativeTrUApiCore { } } +impl NativeTrUApiCore { + fn start_pairing_task(&self, peer: ResponderPeer, session: SsoSessionInfo) { + let generation = self.next_pairing_generation.fetch_add(1, Ordering::Relaxed); + let (abort, registration) = AbortHandle::new_pair(); + let task = NativePairingTask { + generation, + abort, + session: session.clone(), + }; + if let Some(previous) = self + .pairing_tasks + .lock() + .expect("native pairing tasks mutex poisoned") + .insert(peer.statement_account_id, task) + { + previous.abort.abort(); + } + + let runtime = self.runtime.clone(); + let callbacks = self.callbacks.clone(); + let tasks = self.pairing_tasks.clone(); + let peer_for_callback: NativePairingPeer = peer.clone().into(); + let peer_key = peer.statement_account_id; + let future = async move { + loop { + match runtime.serve_responder_session(session.clone()).await { + Ok(ResponderExit::PeerDisconnected) => { + let is_current = tasks + .lock() + .expect("native pairing tasks mutex poisoned") + .get(&peer_key) + .is_some_and(|task| task.generation == generation); + if !is_current { + break; + } + callbacks.pairing_peer_disconnected(peer_for_callback.clone()); + break; + } + Ok(ResponderExit::SubscriptionEnded) => callbacks.on_core_log( + "truapi.native.sso.subscription_ended".to_string(), + format!( + "peer={}; retrying", + hex::encode(peer_for_callback.statement_account_id.as_slice()) + ), + ), + Err(err) => callbacks.on_core_log( + "truapi.native.sso.subscription_failed".to_string(), + format!( + "peer={}; {}; retrying", + hex::encode(peer_for_callback.statement_account_id.as_slice()), + err.reason + ), + ), + } + futures_timer::Delay::new(std::time::Duration::from_secs(1)).await; + } + + let mut active = tasks.lock().expect("native pairing tasks mutex poisoned"); + if active + .get(&peer_key) + .is_some_and(|task| task.generation == generation) + { + active.remove(&peer_key); + } + }; + (self.spawner)(Box::pin(Abortable::new(future, registration).map(|_| ()))); + } + + fn stop_pairing_task(&self, peer: &ResponderPeer) -> Option { + let task = self + .pairing_tasks + .lock() + .expect("native pairing tasks mutex poisoned") + .remove(&peer.statement_account_id); + if let Some(task) = &task { + task.abort.abort(); + } + task + } +} + +impl Drop for NativeTrUApiCore { + fn drop(&mut self) { + let tasks = std::mem::take( + &mut *self + .pairing_tasks + .lock() + .expect("native pairing tasks mutex poisoned"), + ); + for (_, task) in tasks { + task.abort.abort(); + } + } +} + /// Set the live log level (`off`/`error`/`warn`/`info`/`debug`/`trace`) for /// the `tracing` output, which on native routes to stderr (system logs on /// iOS/Android). Most native diagnostics flow through `on_core_log` instead; @@ -546,7 +795,7 @@ fn native_core_from_platform_config( let runtime = Arc::new(SigningHostRuntime::new( platform, runtime_config.signing, - spawner, + spawner.clone(), )); if let Some(secret) = runtime_config.local_session_secret { @@ -561,8 +810,10 @@ fn native_core_from_platform_config( runtime, product: runtime_config.product, events, - #[cfg(feature = "ws-bridge")] callbacks, + spawner, + pairing_tasks: Arc::new(Mutex::new(HashMap::new())), + next_pairing_generation: AtomicU64::new(1), #[cfg(feature = "ws-bridge")] bridge: std::sync::Mutex::new(None), })) @@ -1068,6 +1319,7 @@ mod tests { .expect("auth state mutex poisoned") .push(state); } + fn pairing_peer_disconnected(&self, _peer: NativePairingPeer) {} fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } @@ -1375,6 +1627,29 @@ mod tests { )); } + #[test] + fn native_pairing_peer_validates_persisted_key_lengths() { + let err = ResponderPeer::try_from(NativePairingPeer { + statement_account_id: vec![0; 31], + encryption_public_key: vec![0; 32], + }) + .unwrap_err(); + assert!(matches!( + err, + NativePairingError::InvalidStatementAccountId { actual: 31 } + )); + + let err = ResponderPeer::try_from(NativePairingPeer { + statement_account_id: vec![0; 32], + encryption_public_key: vec![0; 31], + }) + .unwrap_err(); + assert!(matches!( + err, + NativePairingError::InvalidEncryptionPublicKey { actual: 31 } + )); + } + /// Calling `start_ws_bridge` twice on the same `NativeTrUApiCore` /// without an intervening `stop_ws_bridge` is a hard error. The bridge /// is single-instance per core, so the second start must surface @@ -1411,6 +1686,7 @@ mod tests { Ok(false) } fn auth_state_changed(&self, _state: AuthState) {} + fn pairing_peer_disconnected(&self, _peer: NativePairingPeer) {} fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } @@ -1550,6 +1826,7 @@ mod tests { Ok(false) } fn auth_state_changed(&self, _state: AuthState) {} + fn pairing_peer_disconnected(&self, _peer: NativePairingPeer) {} fn core_storage_read(&self, _key: Vec) -> Result>, HostRejection> { Ok(None) } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index b2ad043b..f4658313 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -60,9 +60,17 @@ use pairing_host::PairingHost; pub(crate) use pairing_host::PairingHost as PairingHostRole; pub(crate) use services::RuntimeServices; pub use signing_host::ResponderExit; +#[cfg(not(target_arch = "wasm32"))] +pub use signing_host::ResponderPeer; pub(crate) use signing_host::{ LocalActivation, SigningHost as SigningHostRole, respond_to_pairing, }; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use signing_host::{ + answer_pairing, serve_session as serve_responder_session, + session_for_peer as responder_session_for_peer, + submit_disconnected as submit_responder_disconnected, +}; use authority::{ AccountAliasAuthorityRequest, AuthorityCancelError, AuthorityError, AuthoritySession, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index b7300633..ab4d53b0 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -24,7 +24,13 @@ use subxt::utils::{AccountId32, MultiSignature}; pub(crate) use local_activation::LocalActivation; pub use sso_responder::ResponderExit; +#[cfg(not(target_arch = "wasm32"))] +pub use sso_responder::ResponderPeer; pub(crate) use sso_responder::respond_to_pairing; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use sso_responder::{ + answer_pairing, serve_session, session_for_peer, submit_disconnected, +}; use super::authority::{ AccountAliasAuthorityRequest, AuthorityError, AuthoritySession, BulletinAllowanceKey, @@ -366,6 +372,163 @@ impl SigningHost { ]) } + async fn account_alias_inner( + &self, + session: &AuthoritySession, + request: AccountAliasAuthorityRequest, + ) -> Result { + self.require_current_session(session)?; + match super::account_access_authorization( + &self.services, + &request.calling_product_id, + &request.context.product_id, + ) + .await + { + Ok(PermissionAuthorizationStatus::Authorized) => {} + Ok( + PermissionAuthorizationStatus::Denied + | PermissionAuthorizationStatus::NotDetermined, + ) => return Err(RingVrfError::Rejected), + Err(err) => { + return Err(RingVrfError::Unknown { + reason: err.to_string(), + }); + } + } + let collection = self.ring_resolver.validate(&request.ring_location).await?; + let context = context_bytes(&request.context); + let entropy = self.person_entropy(session, key_for_collection(&collection))?; + let alias = alias_from_entropy(&entropy, &context)?; + Ok(v01::ContextualAlias { + context, + alias: alias.to_vec(), + }) + } + + async fn create_proof_inner( + &self, + session: &AuthoritySession, + request: CreateProofAuthorityRequest, + ) -> Result { + self.require_current_session(session)?; + self.confirm_ring_vrf_if_cross_product( + &request.calling_product_id, + &request.context.product_id, + UserConfirmationReview::CreateProof(CreateProofReview { + calling_product_id: request.calling_product_id.clone(), + context: request.context.clone(), + ring_location: request.ring_location.clone(), + message: request.message.clone(), + }), + ) + .await?; + let candidates = self.member_candidates(session)?; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &candidates) + .await?; + // Reject a stale request if the local session disconnected or changed + // while its chain snapshot was being resolved. + let entropy = self.person_entropy(session, resolved.selected.key)?; + let context = context_bytes(&request.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + Ok(v01::HostAccountCreateProofResponse { + proof, + contextual_alias: v01::ContextualAlias { + context, + alias: alias.to_vec(), + }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, + }) + } + + async fn sso_account_alias( + &self, + session: &AuthoritySession, + request: AccountAliasAuthorityRequest, + ) -> Result { + self.account_alias_inner(session, request).await + } + + async fn sso_create_proof( + &self, + session: &AuthoritySession, + request: CreateProofAuthorityRequest, + ) -> Result { + self.create_proof_inner(session, request).await + } + + fn sign_raw_with_identity( + &self, + session: &AuthoritySession, + account: [u8; 32], + payload: v01::RawPayload, + ) -> Result { + self.require_current_session(session)?; + let keypair = self.identity_keypair()?; + if keypair.public.to_bytes() != account { + return Err(AuthorityError::Unavailable { + reason: + "signing host: the requested legacy account is not available in this CLI wallet" + .to_string(), + }); + } + let message = raw_payload_bytes(payload)?; + let signature = keypair + .secret + .sign_simple(SR25519_SIGNING_CONTEXT, &message, &keypair.public) + .to_bytes(); + Ok(v01::HostSignPayloadResponse { + signature: signature.to_vec(), + signed_transaction: None, + }) + } + + fn sign_sso_raw_identity( + &self, + session: &AuthoritySession, + account: [u8; 32], + payload: v01::RawPayload, + ) -> Result { + self.sign_raw_with_identity(session, account, payload) + } + + async fn create_identity_transaction_inner( + &self, + session: &AuthoritySession, + request: v01::LegacyAccountTxPayload, + ) -> Result { + self.require_current_session(session)?; + let keypair = self.identity_keypair()?; + if keypair.public.to_bytes() != request.signer { + return Err(AuthorityError::Unavailable { + reason: "signing host: the requested identity account is not available in \ + this CLI wallet" + .to_string(), + }); + } + build_local_transaction( + &self.services, + &keypair, + request.genesis_hash, + &request.call_data, + &request.extensions, + request.tx_ext_version, + ) + .await + } + + async fn create_sso_identity_transaction( + &self, + session: &AuthoritySession, + request: v01::LegacyAccountTxPayload, + ) -> Result { + self.create_identity_transaction_inner(session, request) + .await + } + async fn confirm_ring_vrf_if_cross_product( &self, calling_product_id: &str, @@ -509,15 +672,7 @@ impl ProductAuthority for SigningHost { (self.product_keypair(&request.account)?, request.payload) } SignRawAuthorityRequest::LegacyAccount { account, request } => { - let keypair = self.identity_keypair()?; - if keypair.public.to_bytes() != account { - return Err(AuthorityError::Unavailable { - reason: "signing host: the requested legacy account is not available in \ - this CLI wallet" - .to_string(), - }); - } - (keypair, request.payload) + return self.sign_raw_with_identity(session, account, request.payload); } }; self.require_current_session(session)?; @@ -580,23 +735,8 @@ impl ProductAuthority for SigningHost { .await } CreateTransactionAuthorityRequest::IdentityAccount(request) => { - let keypair = self.identity_keypair()?; - if keypair.public.to_bytes() != request.signer { - return Err(AuthorityError::Unavailable { - reason: "signing host: the requested identity account is not available in \ - this CLI wallet" - .to_string(), - }); - } - build_local_transaction( - &self.services, - &keypair, - request.genesis_hash, - &request.call_data, - &request.extensions, - request.tx_ext_version, - ) - .await + self.create_identity_transaction_inner(session, request) + .await } } } @@ -607,33 +747,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: AccountAliasAuthorityRequest, ) -> Result { - self.require_current_session(session)?; - match super::account_access_authorization( - &self.services, - &request.calling_product_id, - &request.context.product_id, - ) - .await - { - Ok(PermissionAuthorizationStatus::Authorized) => {} - Ok( - PermissionAuthorizationStatus::Denied - | PermissionAuthorizationStatus::NotDetermined, - ) => return Err(RingVrfError::Rejected), - Err(err) => { - return Err(RingVrfError::Unknown { - reason: err.to_string(), - }); - } - } - let collection = self.ring_resolver.validate(&request.ring_location).await?; - let context = context_bytes(&request.context); - let entropy = self.person_entropy(session, key_for_collection(&collection))?; - let alias = alias_from_entropy(&entropy, &context)?; - Ok(v01::ContextualAlias { - context, - alias: alias.to_vec(), - }) + self.account_alias_inner(session, request).await } async fn create_proof( @@ -642,37 +756,7 @@ impl ProductAuthority for SigningHost { session: &AuthoritySession, request: CreateProofAuthorityRequest, ) -> Result { - self.require_current_session(session)?; - self.confirm_ring_vrf_if_cross_product( - &request.calling_product_id, - &request.context.product_id, - UserConfirmationReview::CreateProof(CreateProofReview { - calling_product_id: request.calling_product_id.clone(), - context: request.context.clone(), - ring_location: request.ring_location.clone(), - message: request.message.clone(), - }), - ) - .await?; - let candidates = self.member_candidates(session)?; - let resolved = self - .ring_resolver - .resolve(&request.ring_location, &candidates) - .await?; - // Reject a stale request if the local session disconnected or changed - // while its chain snapshot was being resolved. - let entropy = self.person_entropy(session, resolved.selected.key)?; - let context = context_bytes(&request.context); - let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; - Ok(v01::HostAccountCreateProofResponse { - proof, - contextual_alias: v01::ContextualAlias { - context, - alias: alias.to_vec(), - }, - ring_index: resolved.ring_index, - ring_revision: resolved.ring_revision, - }) + self.create_proof_inner(session, request).await } async fn allocate_resources( diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs index e001f0e5..634ebe32 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -86,7 +86,22 @@ impl ChainRingResolver { .online_client(&location.chain_id) .await .map_err(unknown)?; - let at_block = client.at_current_block().await.map_err(unknown)?; + let at_block = match client.at_current_block().await { + Ok(at_block) => at_block, + Err(chain_head_error) => { + tracing::warn!( + %chain_head_error, + "ChainHead could not open the finalized ring snapshot; retrying with legacy RPC" + ); + self.chain + .legacy_online_client(&location.chain_id) + .await + .map_err(unknown)? + .at_current_block() + .await + .map_err(unknown)? + } + }; let Some(pallet) = at_block.metadata_ref().pallet_by_name(MEMBERS_PALLET) else { return Err(RingVrfError::RingNotFound); }; 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 0e9fa8b9..3035f0b6 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 @@ -27,13 +27,12 @@ use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; use crate::host_logic::entropy::root_entropy_source; +#[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::{ - ProductAccountError, derive_identity_keypair, derive_root_keypair_from_entropy, - product_public_key_to_address, + ProductAccountError, derive_lite_person_ring_vrf_entropy, derive_sr25519_hard_path, }; -#[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::{ - derive_lite_person_ring_vrf_entropy, derive_sr25519_hard_path, + derive_identity_keypair, derive_root_keypair_from_entropy, product_public_key_to_address, }; use crate::host_logic::session::SsoSessionInfo; use crate::host_logic::sso::messages::{ @@ -45,8 +44,8 @@ use crate::host_logic::sso::messages::{ build_signed_session_response_statement, decode_incoming_sso_request, v1, }; use crate::host_logic::sso::pairing::{ - ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, - derive_x25519_keypair_from_entropy, encrypt_v2_handshake_response, + ResponderIdentity, VersionedHandshakeProposal, bootstrap_channel, bootstrap_topic, + decode_pairing_deeplink, derive_x25519_keypair_from_entropy, encrypt_v2_handshake_response, establish_responder_session_info, v2, }; use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; @@ -78,10 +77,9 @@ const BULLETIN_AUTHORIZATION_WAIT: std::time::Duration = std::time::Duration::fr /// longer be validly replayed. const MAX_SERVED_REQUEST_IDS: usize = 1024; -fn derive_responder_identity( - entropy: &[u8], -) -> Result<(ResponderIdentity, [u8; 32]), ProductAccountError> { - let statement = derive_identity_keypair(entropy)?; +fn derive_responder_identity(entropy: &[u8]) -> Result<(ResponderIdentity, [u8; 32]), String> { + let statement = derive_identity_keypair(entropy) + .map_err(|err| format!("identity account derivation failed: {err}"))?; let (encryption_secret_key, encryption_public_key) = derive_x25519_keypair_from_entropy(entropy, SSO_ENCRYPTION_DOMAIN); let (identity_chat_private_key, _) = @@ -138,6 +136,22 @@ pub enum ResponderExit { SubscriptionEnded, } +/// Pairing-host identity needed to restore an SSO responder session. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResponderPeer { + /// Pairing host's sr25519 Statement Store account id. + pub statement_account_id: [u8; 32], + /// Pairing host's raw X25519 public key. + pub encryption_public_key: [u8; 32], +} + +struct ResponderMaterial { + identity: ResponderIdentity, + identity_chat_private_key: [u8; 32], + root_account_id: [u8; 32], + root_entropy_source: [u8; 32], +} + /// Failure while deriving or allocating a Statement Store/Bulletin allowance. #[derive(Debug, thiserror::Error)] pub(super) enum AllowanceAllocationError { @@ -217,55 +231,171 @@ pub(crate) async fn respond_to_pairing( signing_host: Arc, deeplink: &str, ) -> Result { + let (_, session) = answer_pairing(services.clone(), signing_host.clone(), deeplink).await?; + serve_session(services, signing_host, session).await +} + +/// Answer one pairing handshake and return the session material used by the +/// long-running responder loop. +/// +/// Native shells use this split form so their approval UI can complete once +/// the handshake is actually on the Statement Store, while the core keeps the +/// session subscription alive in a background task. +pub(crate) async fn answer_pairing( + services: Arc, + signing_host: Arc, + deeplink: &str, +) -> Result<(ResponderPeer, SsoSessionInfo), String> { let VersionedHandshakeProposal::V2(proposal) = decode_pairing_deeplink(deeplink).map_err(|err| err.to_string())?; - let entropy = signing_host - .root_entropy() - .map_err(|err| format!("signing host has no active local session: {err}"))?; + let peer = ResponderPeer { + statement_account_id: proposal.device.statement_account_id, + encryption_public_key: proposal.device.encryption_public_key, + }; + let material = responder_material(&signing_host)?; // Product accounts and the SSO statement identity derive from the // canonical root key; the identity is the RFC-0022 uid.dot default account. - let root = derive_root_keypair_from_entropy(&entropy) - .map_err(|err| format!("root account derivation failed: {err}"))?; - let (identity, identity_chat_private_key) = derive_responder_identity(&entropy) - .map_err(|err| format!("responder identity derivation failed: {err}"))?; let session = establish_responder_session_info( - &identity, - proposal.device.statement_account_id, - proposal.device.encryption_public_key, + &material.identity, + peer.statement_account_id, + peer.encryption_public_key, )?; + match submit_pairing_answer(&services, &material, &session, &peer).await { + Ok(()) => Ok((peer, session)), + Err(reason) => { + let failed = v2::EncryptedResponse::Failed(reason.clone()); + if let Err(status_error) = submit_handshake_response( + &services, + &session, + &peer, + &failed, + "sso-responder failed status", + ) + .await + { + warn!(%status_error, "failed to post pairing Failed status"); + } + Err(reason) + } + } +} + +/// Post the wallet-side pairing answer sequence: a `Pending` status while the +/// session is being prepared, then the `Success` payload. +async fn submit_pairing_answer( + services: &Arc, + material: &ResponderMaterial, + session: &SsoSessionInfo, + peer: &ResponderPeer, +) -> Result<(), String> { + let pending = v2::EncryptedResponse::Pending(v2::Status::AllowanceAllocation); + if let Err(status_error) = submit_handshake_response( + services, + session, + peer, + &pending, + "sso-responder pending status", + ) + .await + { + warn!(%status_error, "failed to post pairing Pending status"); + } + let success = v2::EncryptedResponse::Success(Box::new(v2::Success { - identity_account_id: identity.statement_public_key, - root_account_id: root.public.to_bytes(), - identity_chat_private_key, - sso_enc_pub_key: identity.encryption_public_key, - device_enc_pub_key: identity.encryption_public_key, - root_entropy_source: root_entropy_source(&entropy), + identity_account_id: material.identity.statement_public_key, + root_account_id: material.root_account_id, + identity_chat_private_key: material.identity_chat_private_key, + sso_enc_pub_key: material.identity.encryption_public_key, + // The headless responder has no separate device chat key; chat + // envelopes addressed to the device land on the session key. + device_enc_pub_key: material.identity.encryption_public_key, + root_entropy_source: material.root_entropy_source, })); - let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; - let topic = bootstrap_topic( - proposal.device.statement_account_id, - proposal.device.encryption_public_key, - ); + submit_handshake_response(services, session, peer, &success, "sso-responder handshake").await?; + debug!("answered pairing handshake"); + Ok(()) +} + +/// Encrypt one handshake response to the peer and post it on the pairing +/// topic/channel. Each statement uses fresh ephemeral encryption; the shared +/// channel makes later responses replace earlier statuses in the store. +async fn submit_handshake_response( + services: &Arc, + session: &SsoSessionInfo, + peer: &ResponderPeer, + response: &v2::EncryptedResponse, + label: &'static str, +) -> Result<(), String> { + let handshake = encrypt_v2_handshake_response(peer.encryption_public_key, response)?; + let topic = bootstrap_topic(peer.statement_account_id, peer.encryption_public_key); + let channel = bootstrap_channel(peer.statement_account_id, peer.encryption_public_key); let statement = build_signed_statement( - &session, - topic, + session, + channel, topic, handshake.encode(), fresh_statement_expiry(), )?; + services.statement_store.submit(statement, label).await +} + +/// Reconstruct responder session material for a previously persisted peer. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn session_for_peer( + signing_host: &Arc, + peer: &ResponderPeer, +) -> Result { + let material = responder_material(signing_host)?; + establish_responder_session_info( + &material.identity, + peer.statement_account_id, + peer.encryption_public_key, + ) +} + +fn responder_material(signing_host: &Arc) -> Result { + let entropy = signing_host + .root_entropy() + .map_err(|err| format!("signing host has no active local session: {err}"))?; + let root = derive_root_keypair_from_entropy(&entropy) + .map_err(|err| format!("root account derivation failed: {err}"))?; + let (identity, identity_chat_private_key) = derive_responder_identity(&entropy) + .map_err(|err| format!("responder identity derivation failed: {err}"))?; + Ok(ResponderMaterial { + identity, + identity_chat_private_key, + root_account_id: root.public.to_bytes(), + root_entropy_source: root_entropy_source(&entropy), + }) +} + +/// Best-effort notification that the signing host ended one paired session. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn submit_disconnected( + services: &Arc, + session: &SsoSessionInfo, +) -> Result<(), String> { + let message_id = "truapi:sso:disconnect".to_string(); + let statement = build_outgoing_request_statement( + session, + message_id.clone(), + vec![RemoteMessage { + message_id, + data: RemoteMessageData::V1(v1::RemoteMessage::Disconnected), + }], + fresh_statement_expiry(), + )?; services .statement_store - .submit(statement, "sso-responder handshake") - .await?; - debug!("answered pairing handshake, serving SSO session"); - - serve_session(services, signing_host, session).await + .submit_fire_and_forget(statement, "sso-responder disconnect") + .await + .map_err(|err| format!("SSO disconnect submit failed: {err}")) } /// Serve inbound session statements until the session ends. #[instrument(skip_all, fields(runtime.method = "sso_responder.serve_session"))] -async fn serve_session( +pub(crate) async fn serve_session( services: Arc, signing_host: Arc, session: SsoSessionInfo, @@ -685,13 +815,8 @@ async fn answer_remote_message( } v1::RemoteMessage::CreateTransactionLegacyRequest(request) => { let messages::CreateTransactionLegacyPayload::V1(payload) = request.payload; - let signed_transaction = create_transaction_response( - services, - signing_host, - CreateTransactionReview::LegacyAccount(payload.clone()), - CreateTransactionAuthorityRequest::IdentityAccount(payload), - ) - .await; + let signed_transaction = + create_sso_identity_transaction_response(services, signing_host, payload).await; v1::RemoteMessage::CreateTransactionResponse(messages::CreateTransactionResponse { responding_to: message_id, signed_transaction, @@ -1117,15 +1242,7 @@ async fn sign_raw_legacy_response( .current_session() .ok_or_else(|| "signing host session is not active".to_string())?; signing_host - .sign_raw( - &CallContext::default(), - &session, - SignRawAuthorityRequest::LegacyAccount { - account: request.account, - request: public_request, - }, - ) - .await + .sign_sso_raw_identity(&session, request.account, public_request.payload) .map(|response| response.signature) .map_err(|err| err.to_string()) } @@ -1187,6 +1304,28 @@ async fn create_transaction_response( .map_err(|err| err.to_string()) } +async fn create_sso_identity_transaction_response( + services: &Arc, + signing_host: &Arc, + request: api::LegacyAccountTxPayload, +) -> Result, String> { + let session = signing_host + .current_session() + .ok_or_else(|| "signing host session is not active".to_string())?; + confirm( + services, + UserConfirmationReview::CreateTransaction(CreateTransactionReview::LegacyAccount( + request.clone(), + )), + ) + .await?; + signing_host + .create_sso_identity_transaction(&session, request) + .await + .map(|response| response.transaction) + .map_err(|err| err.to_string()) +} + async fn account_alias_response( signing_host: &Arc, request: messages::RingVrfAliasRequest, @@ -1194,10 +1333,8 @@ async fn account_alias_response( let session = signing_host .current_session() .ok_or_else(disconnected_ring_vrf)?; - let cx = CallContext::default(); signing_host - .account_alias( - &cx, + .sso_account_alias( &session, AccountAliasAuthorityRequest { calling_product_id: request.calling_product_id, @@ -1215,10 +1352,8 @@ async fn create_proof_response( let session = signing_host .current_session() .ok_or_else(disconnected_ring_vrf)?; - let cx = CallContext::default(); signing_host - .create_proof( - &cx, + .sso_create_proof( &session, CreateProofAuthorityRequest { calling_product_id: request.calling_product_id, @@ -1288,15 +1423,15 @@ mod tests { } #[test] - fn responder_advertises_and_signs_with_the_local_uid_identity() { + fn responder_advertises_and_signs_with_the_rfc0022_identity() { let (_services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); - let local_identity = signing_host + let rfc_identity = signing_host .current_session() .unwrap() .identity_account_id .unwrap(); let (identity, _) = derive_responder_identity(&ENTROPY).unwrap(); - assert_eq!(identity.statement_public_key, local_identity); + assert_eq!(identity.statement_public_key, rfc_identity); let (_, host_encryption_public_key) = derive_x25519_keypair_from_entropy(&[0x42; 16], b"sso"); @@ -1314,7 +1449,7 @@ mod tests { let verified = decode_verified_statement_data(&statement, Some(identity.statement_public_key)) .unwrap(); - assert_eq!(verified.signer, local_identity); + assert_eq!(verified.signer, rfc_identity); } fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { @@ -1567,6 +1702,40 @@ mod tests { ); } + #[test] + fn legacy_raw_request_signs_with_the_rfc0022_identity() { + let (services, signing_host) = signing_fixture(Arc::new(StubPlatform { + sign_raw_confirmed: true, + ..StubPlatform::default() + })); + let identity = derive_identity_keypair(&ENTROPY).unwrap(); + + let response = futures::executor::block_on(answer_remote_message( + &services, + &signing_host, + "legacy-raw-1".to_string(), + v1::RemoteMessage::SignRawLegacyRequest(messages::SignRawLegacyRequest { + account: identity.public.to_bytes(), + data: messages::SigningRawPayload::Bytes(b"hello".to_vec()), + }), + )) + .expect("response is emitted"); + + let v1::RemoteMessage::SignRawLegacyResponse(response) = response_payload(response) else { + panic!("expected raw-signing response"); + }; + let signature = schnorrkel::Signature::from_bytes( + &response.signature.expect("identity raw signing succeeds"), + ) + .unwrap(); + assert!( + identity + .public + .verify_simple(b"substrate", b"hello", &signature) + .is_ok() + ); + } + #[test] fn product_subtree_request_is_consent_free_and_hard_derived() { let (services, signing_host) = signing_fixture(Arc::new(StubPlatform::default())); diff --git a/rust/crates/truapi-server/src/runtime/sso_remote.rs b/rust/crates/truapi-server/src/runtime/sso_remote.rs index 37dfe6f0..5bff41da 100644 --- a/rust/crates/truapi-server/src/runtime/sso_remote.rs +++ b/rust/crates/truapi-server/src/runtime/sso_remote.rs @@ -26,6 +26,10 @@ use truapi::{CancellationReason, CancellationToken}; /// Host-spec B.3.3 recommends seven-day statement expiry for session traffic: /// const DEFAULT_SSO_STATEMENT_EXPIRY_SECS: u64 = 7 * 24 * 60 * 60; +/// Last process-local SSO statement priority. Statement Store requires a +/// replacement on one channel to have a strictly greater `Expiry`, so two +/// requests produced in the same second must not reuse the same value. +static LAST_SSO_STATEMENT_EXPIRY: Mutex = Mutex::new(0); /// Disconnect reason reported when the local session logs out mid-request. pub(super) const SSO_LOCAL_DISCONNECT_REASON: &str = "SSO session disconnected"; /// Disconnect reason reported when the paired signing host announces a disconnect. @@ -424,7 +428,17 @@ pub(super) fn sso_message_id() -> String { /// high 32 bits, seven days from now. pub(super) fn fresh_statement_expiry() -> u64 { let timestamp = current_unix_secs().saturating_add(DEFAULT_SSO_STATEMENT_EXPIRY_SECS); - timestamp << 32 + let mut last = LAST_SSO_STATEMENT_EXPIRY + .lock() + .expect("SSO statement expiry mutex poisoned"); + next_statement_expiry(timestamp, &mut last) +} + +fn next_statement_expiry(expiry_unix_secs: u64, last: &mut u64) -> u64 { + let timestamp_priority = expiry_unix_secs << 32; + let expiry = timestamp_priority.max(last.saturating_add(1)); + *last = expiry; + expiry } #[cfg(test)] @@ -448,6 +462,16 @@ mod tests { assert!(second.bytes().all(is_nanoid_safe_byte)); } + #[test] + fn statement_expiry_is_strictly_monotonic_within_one_second() { + let mut last = 0; + let first = next_statement_expiry(123, &mut last); + let second = next_statement_expiry(123, &mut last); + + assert_eq!(first, 123 << 32); + assert_eq!(second, first + 1); + } + fn is_nanoid_safe_byte(value: u8) -> bool { value.is_ascii_alphanumeric() || value == b'_' || value == b'-' } diff --git a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs index 2f1756b6..fcecec3e 100644 --- a/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs +++ b/rust/crates/truapi-server/tests/wasm_crypto_vectors.rs @@ -5,9 +5,8 @@ use parity_scale_codec::{Decode, Encode}; use schnorrkel::{ExpansionMode, MiniSecretKey}; -use truapi_platform::{ - CoreStorageKey, HostDevicePermissionRequest, HostInfo, PairingHostConfig, PlatformInfo, -}; +use truapi::latest::HostDevicePermissionRequest; +use truapi_platform::{CoreStorageKey, HostInfo, PairingHostConfig, PlatformInfo}; use truapi_server::host_logic::entropy::derive_product_entropy; use truapi_server::host_logic::product_account::{ derive_product_public_key, derive_product_subtree_keypair, derive_root_keypair_from_entropy, @@ -16,8 +15,8 @@ use truapi_server::host_logic::product_account::{ use truapi_server::host_logic::session::SsoSessionInfo; use truapi_server::host_logic::sso::pairing::{ self, AEAD_NONCE_LEN, PairingBootstrap, SsoStatementData, VersionedHandshakeProposal, - VersionedHandshakeResponse, bootstrap_topic, build_pairing_deeplink, decode_app_handshake_data, - decrypt_session_statement_data, decrypt_v2_handshake_response, + VersionedHandshakeResponse, bootstrap_channel, bootstrap_topic, build_pairing_deeplink, + decode_app_handshake_data, decrypt_session_statement_data, decrypt_v2_handshake_response, encrypt_session_statement_data_with_nonce, encrypt_v2_handshake_response, establish_sso_session_info, }; @@ -128,6 +127,10 @@ fn pairing_deeplink_topic_and_scale_vectors_match_mobile() { hex::encode(bootstrap_topic(SS_PUBLIC, encryption_public)), "ec8c8d7993ef1b367a704f34cec0fa1fe01d0a060a918688f26b23e88452a6af" ); + assert_eq!( + hex::encode(bootstrap_channel(SS_PUBLIC, encryption_public)), + "f7df2ba8c948e35c35edfaf8bad6cb4a8c4e0373f64bab787f68620a88f7c51f" + ); let answer = VersionedHandshakeResponse::V2 { encrypted_message: vec![0xde, 0xad],