From c3612663cd9559392a5099510ab6a34874047a8a Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 14 Aug 2026 17:32:15 +0200 Subject: [PATCH 1/2] feat(server,platform): retain and expose session identity material The v2 pairing handshake carries three values the core consumed and then discarded: the identity chat X25519 private key, the answering device's encryption public key, and the peer's statement account id. A host adopting core pairing had no way to reach them, so it had to keep a second duplicate pairing alive purely to harvest them. Retain rather than derive. The wallet chooses the chat key and every peer already addresses that identity by the matching public key, so a value derived host-side would name an identity nobody can reach. The pairing host provably cannot recompute it either: the derivation needs raw BIP-39 entropy, which only a signing host holds. Split the read paths by sensitivity. Public material rides SessionUiInfo (chat_public_key, device_enc_public_key, peer_statement_account_id) so a host that only addresses the identity never touches a secret. The secrets sit behind explicit CoreAdmin calls, because SessionUiInfo rides every AuthState broadcast to all registered AuthPresenters and would push them to hosts that never asked. Both secrets are reachable over wasm and UniFFI. Give the responder its own device encryption key. It previously advertised the SSO channel key as device_enc_pub_key, which makes every device sharing an identity indistinguishable. The key is now random and persisted under CoreStorageKey::DeviceEncryptionKey, matching the provider, and the CLI host anchors that slot to its bootstrap directory so a user switch cannot regenerate the identity peers address this install by. Verified field-for-field against the Android provider and the host-papp consumer: the handshake layout, the X25519 basepoint derivation of the chat public key, and the statement-signer origin of the peer account id all match. SessionInfo gains fields mid-struct, so its SCALE layout changes. The blob is unversioned and decode rejects short input, so sessions persisted by an earlier build no longer decode and those users re-pair once. Refs #384 Co-Authored-By: Claude Opus 5 (1M context) --- .../Sources/TrUAPIHost/truapi_platform.swift | 40 +++++ .../Sources/TrUAPIHost/truapi_server.swift | 68 ++++++++ .../include/truapi_serverFFI.h | 22 +++ js/packages/truapi-host/src/wasm-module.ts | 2 + .../src/web/create-worker-host-runtime.ts | 82 +++++++++- .../src/web/worker-provider.test.ts | 39 +++++ .../truapi-host/src/worker-protocol.ts | 26 +++ js/packages/truapi-host/src/worker-runtime.ts | 60 +++++++ .../tests/golden/host-callbacks.ts | 60 ++++++- rust/crates/truapi-host-cli/src/platform.rs | 149 +++++++++++++++++- rust/crates/truapi-platform/README.md | 4 + rust/crates/truapi-platform/src/lib.rs | 46 ++++++ rust/crates/truapi-server/src/host_core.rs | 34 ++++ rust/crates/truapi-server/src/host_logic.rs | 1 + .../src/host_logic/device_key.rs | 65 ++++++++ .../truapi-server/src/host_logic/session.rs | 20 +++ .../src/host_logic/sso/pairing.rs | 27 +++- rust/crates/truapi-server/src/lib.rs | 6 + rust/crates/truapi-server/src/native.rs | 28 +++- rust/crates/truapi-server/src/runtime.rs | 9 ++ .../truapi-server/src/runtime/signing_host.rs | 12 ++ .../runtime/signing_host/local_activation.rs | 5 + .../src/runtime/signing_host/sso_responder.rs | 34 +++- .../truapi-server/src/runtime/sso_pairing.rs | 26 ++- rust/crates/truapi-server/src/test_support.rs | 11 +- rust/crates/truapi-server/src/wasm.rs | 20 +++ .../truapi-server/tests/wire_result_shape.rs | 2 + 27 files changed, 869 insertions(+), 29 deletions(-) create mode 100644 rust/crates/truapi-server/src/host_logic/device_key.rs diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift index 08ecbf22d..a8a0dd421 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_platform.swift @@ -1121,6 +1121,23 @@ public struct SessionUiInfo: Equatable, Hashable { * Wallet identity account id used for People-chain username lookup. */ public var identityAccountId: Bytes32? + /** + * X25519 public key addressing this identity in chat. Public counterpart + * of the key [`CoreAdmin::get_session_chat_identity_key`] serves. + */ + public var chatPublicKey: Bytes32? + /** + * X25519 public key of the wallet device that answered pairing. Hosts + * running their own encrypted device-sync channel key it against this. + */ + public var deviceEncPublicKey: Bytes32? + /** + * Statement-store account id the paired wallet signs every session-channel + * statement with. Whether it is scoped to the wallet device or to the + * wallet identity is the wallet's choice, so hosts must not treat it as a + * device discriminator; use [`Self::device_enc_public_key`] for that. + */ + public var peerStatementAccountId: Bytes32? /** * Short username from the People-chain identity record. */ @@ -1139,6 +1156,20 @@ public struct SessionUiInfo: Equatable, Hashable { /** * Wallet identity account id used for People-chain username lookup. */identityAccountId: Bytes32?, + /** + * X25519 public key addressing this identity in chat. Public counterpart + * of the key [`CoreAdmin::get_session_chat_identity_key`] serves. + */chatPublicKey: Bytes32?, + /** + * X25519 public key of the wallet device that answered pairing. Hosts + * running their own encrypted device-sync channel key it against this. + */deviceEncPublicKey: Bytes32?, + /** + * Statement-store account id the paired wallet signs every session-channel + * statement with. Whether it is scoped to the wallet device or to the + * wallet identity is the wallet's choice, so hosts must not treat it as a + * device discriminator; use [`Self::device_enc_public_key`] for that. + */peerStatementAccountId: Bytes32?, /** * Short username from the People-chain identity record. */liteUsername: String?, @@ -1147,6 +1178,9 @@ public struct SessionUiInfo: Equatable, Hashable { */fullUsername: String?) { self.publicKey = publicKey self.identityAccountId = identityAccountId + self.chatPublicKey = chatPublicKey + self.deviceEncPublicKey = deviceEncPublicKey + self.peerStatementAccountId = peerStatementAccountId self.liteUsername = liteUsername self.fullUsername = fullUsername } @@ -1169,6 +1203,9 @@ public struct FfiConverterTypeSessionUiInfo: FfiConverterRustBuffer { try SessionUiInfo( publicKey: FfiConverterTypeBytes32.read(from: &buf), identityAccountId: FfiConverterOptionTypeBytes32.read(from: &buf), + chatPublicKey: FfiConverterOptionTypeBytes32.read(from: &buf), + deviceEncPublicKey: FfiConverterOptionTypeBytes32.read(from: &buf), + peerStatementAccountId: FfiConverterOptionTypeBytes32.read(from: &buf), liteUsername: FfiConverterOptionString.read(from: &buf), fullUsername: FfiConverterOptionString.read(from: &buf) ) @@ -1177,6 +1214,9 @@ public struct FfiConverterTypeSessionUiInfo: FfiConverterRustBuffer { public static func write(_ value: SessionUiInfo, into buf: inout [UInt8]) { FfiConverterTypeBytes32.write(value.publicKey, into: &buf) FfiConverterOptionTypeBytes32.write(value.identityAccountId, into: &buf) + FfiConverterOptionTypeBytes32.write(value.chatPublicKey, into: &buf) + FfiConverterOptionTypeBytes32.write(value.deviceEncPublicKey, into: &buf) + FfiConverterOptionTypeBytes32.write(value.peerStatementAccountId, into: &buf) FfiConverterOptionString.write(value.liteUsername, into: &buf) FfiConverterOptionString.write(value.fullUsername, into: &buf) } diff --git a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift index 9bef8fdc8..a7928126e 100644 --- a/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +++ b/ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift @@ -2390,6 +2390,12 @@ public func FfiConverterTypeNativeCustomRendererSubscription_lower(_ value: Nati */ public protocol NativeProductExecutionProtocol: AnyObject, Sendable { + /** + * Read this device's X25519 encryption secret, for device sync against a + * peer's `deviceEncPublicKey`. Generated and persisted on first read. + */ + func deviceEncryptionKey() throws -> Bytes32 + /** * Notify this execution's chain adapter that a connection closed. */ @@ -2431,6 +2437,12 @@ public protocol NativeProductExecutionProtocol: AnyObject, Sendable { */ func renderCustomMessage(messageId: String, messageType: String, payload: Data, observer: NativeCustomRendererObserver) throws -> NativeCustomRendererSubscription + /** + * Read the active session's X25519 chat identity private key, or `None` + * when no session is active. + */ + func sessionChatIdentityKey() throws -> Bytes32? + /** * Update a product-scoped permission authorization. */ @@ -2512,6 +2524,19 @@ open class NativeProductExecution: NativeProductExecutionProtocol, @unchecked Se + /** + * Read this device's X25519 encryption secret, for device sync against a + * peer's `deviceEncPublicKey`. Generated and persisted on first read. + */ +open func deviceEncryptionKey()throws -> Bytes32 { + return try FfiConverterTypeBytes32_lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativeproductexecution_device_encryption_key( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) +} + /** * Notify this execution's chain adapter that a connection closed. */ @@ -2614,6 +2639,19 @@ open func renderCustomMessage(messageId: String, messageType: String, payload: D FfiConverterCallbackInterfaceNativeCustomRendererObserver_lower(observer),uniffiCallStatus ) }) +} + + /** + * Read the active session's X25519 chat identity private key, or `None` + * when no session is active. + */ +open func sessionChatIdentityKey()throws -> Bytes32? { + return try FfiConverterOptionTypeBytes32.lift(try rustCallWithError(FfiConverterTypeHostRejection_lift) { + uniffiCallStatus in + uniffi_truapi_server_fn_method_nativeproductexecution_session_chat_identity_key( + self.uniffiCloneHandle(),uniffiCallStatus + ) +}) } /** @@ -5037,6 +5075,30 @@ fileprivate struct FfiConverterOptionTypeProductAccountId: FfiConverterRustBuffe } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionTypeBytes32: FfiConverterRustBuffer { + typealias SwiftType = Bytes32? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeBytes32.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeBytes32.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -5348,6 +5410,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms() != 21374) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativeproductexecution_device_encryption_key() != 18707) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativeproductexecution_notify_chain_closed() != 59343) { return InitializationResult.apiChecksumMismatch } @@ -5372,6 +5437,9 @@ private let initializationResult: InitializationResult = { if (uniffi_truapi_server_checksum_method_nativeproductexecution_render_custom_message() != 17716) { return InitializationResult.apiChecksumMismatch } + if (uniffi_truapi_server_checksum_method_nativeproductexecution_session_chat_identity_key() != 3903) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_truapi_server_checksum_method_nativeproductexecution_set_permission_authorization_status() != 14164) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h index 2d0c3b221..d2bb84735 100644 --- a/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h +++ b/ios/truapi-host/Sources/truapi_serverFFI/include/truapi_serverFFI.h @@ -641,6 +641,11 @@ uint64_t uniffi_truapi_server_fn_clone_nativeproductexecution(uint64_t handle, R void uniffi_truapi_server_fn_free_nativeproductexecution(uint64_t handle, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_DEVICE_ENCRYPTION_KEY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_DEVICE_ENCRYPTION_KEY +RustBuffer uniffi_truapi_server_fn_method_nativeproductexecution_device_encryption_key(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_NOTIFY_CHAIN_CLOSED #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_NOTIFY_CHAIN_CLOSED void uniffi_truapi_server_fn_method_nativeproductexecution_notify_chain_closed(uint64_t ptr, uint32_t connection_id, RustCallStatus *_Nonnull out_status @@ -681,6 +686,11 @@ void uniffi_truapi_server_fn_method_nativeproductexecution_publish_chat_action(u uint64_t uniffi_truapi_server_fn_method_nativeproductexecution_render_custom_message(uint64_t ptr, RustBuffer message_id, RustBuffer message_type, RustBuffer payload, uint64_t observer, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_SESSION_CHAT_IDENTITY_KEY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_SESSION_CHAT_IDENTITY_KEY +RustBuffer uniffi_truapi_server_fn_method_nativeproductexecution_session_chat_identity_key(uint64_t ptr, RustCallStatus *_Nonnull out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_SET_PERMISSION_AUTHORIZATION_STATUS #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_FN_METHOD_NATIVEPRODUCTEXECUTION_SET_PERMISSION_AUTHORIZATION_STATUS void uniffi_truapi_server_fn_method_nativeproductexecution_set_permission_authorization_status(uint64_t ptr, RustBuffer request, RustBuffer status, RustCallStatus *_Nonnull out_status @@ -1281,6 +1291,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_post_custom_me #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVECHATCALLBACKS_LIST_ROOMS uint16_t uniffi_truapi_server_checksum_method_nativechatcallbacks_list_rooms(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_DEVICE_ENCRYPTION_KEY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_DEVICE_ENCRYPTION_KEY +uint16_t uniffi_truapi_server_checksum_method_nativeproductexecution_device_encryption_key(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_NOTIFY_CHAIN_CLOSED @@ -1329,6 +1345,12 @@ uint16_t uniffi_truapi_server_checksum_method_nativeproductexecution_publish_cha #define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_RENDER_CUSTOM_MESSAGE uint16_t uniffi_truapi_server_checksum_method_nativeproductexecution_render_custom_message(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_SESSION_CHAT_IDENTITY_KEY +#define UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_SESSION_CHAT_IDENTITY_KEY +uint16_t uniffi_truapi_server_checksum_method_nativeproductexecution_session_chat_identity_key(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_TRUAPI_SERVER_CHECKSUM_METHOD_NATIVEPRODUCTEXECUTION_SET_PERMISSION_AUTHORIZATION_STATUS diff --git a/js/packages/truapi-host/src/wasm-module.ts b/js/packages/truapi-host/src/wasm-module.ts index 1e1e8242c..4b7b05c06 100644 --- a/js/packages/truapi-host/src/wasm-module.ts +++ b/js/packages/truapi-host/src/wasm-module.ts @@ -21,6 +21,8 @@ export interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime disconnectSession(): Promise; cancelPairing(): void; notifySessionStoreChanged(): void; + sessionChatIdentityKey(): Uint8Array | undefined; + deviceEncryptionKey(): Promise; free(): void; } diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 4aa1dd046..67617806c 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -8,7 +8,7 @@ import type { RequiredHostCallbacks, TrUApiProductProvider, } from "../index.js"; -import type { GenericError } from "@parity/truapi"; +import type { Bytes32, GenericError } from "@parity/truapi"; import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; @@ -48,6 +48,8 @@ export interface WorkerPairingHostRuntime { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ): Promise; + getSessionChatIdentityKey(): Promise; + getDeviceEncryptionKey(): Promise; setLogLevel(level: LogLevel): void; dispose(): void; } @@ -97,6 +99,17 @@ interface RuntimeState { number, { resolve: () => void; reject: (error: Error) => void } >; + pendingSessionChatIdentityKeys: Map< + number, + { + resolve: (key: Uint8Array | undefined) => void; + reject: (error: Error) => void; + } + >; + pendingDeviceEncryptionKeys: Map< + number, + { resolve: (key: Uint8Array) => void; reject: (error: Error) => void } + >; closedError: Error | null; logLevel: LogLevel; disposed: boolean; @@ -109,6 +122,8 @@ function debugLoggingEnabled(state: RuntimeState): boolean { let nextDisconnectRequestId = 0; let nextPermissionAuthorizationRequestId = 0; +let nextSessionChatIdentityKeyRequestId = 0; +let nextDeviceEncryptionKeyRequestId = 0; function encodePermissionAuthorizationRequest( request: PermissionAuthorizationRequest, ): Uint8Array { @@ -388,11 +403,39 @@ function handleSetPermissionAuthorizationStatusResponse( ); } +function handleSessionChatIdentityKeyResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true; key: Uint8Array | undefined } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingSessionChatIdentityKeys, + msg.requestId, + msg.ok ? { ok: true, value: msg.key } : { ok: false, error: msg.error }, + ); +} + +function handleDeviceEncryptionKeyResponse( + state: RuntimeState, + msg: + | { requestId: number; ok: true; key: Uint8Array } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingDeviceEncryptionKeys, + msg.requestId, + msg.ok ? { ok: true, value: msg.key } : { ok: false, error: msg.error }, + ); +} + function rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { rejectAll(state.pendingDisconnects, error); rejectAll(state.pendingPermissionAuthorizationStatuses, error); rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); + rejectAll(state.pendingSessionChatIdentityKeys, error); + rejectAll(state.pendingDeviceEncryptionKeys, error); for (const pending of state.pendingCores.values()) { pending.reject(error); } @@ -492,6 +535,8 @@ export function createWebWorkerPairingHostRuntime( pendingPermissionAuthorizationStatuses: new Map(), pendingPermissionAuthorizationStatusBatches: new Map(), pendingSetPermissionAuthorizationStatuses: new Map(), + pendingSessionChatIdentityKeys: new Map(), + pendingDeviceEncryptionKeys: new Map(), closedError: null, logLevel: devLogLevelOverride ?? options.logLevel ?? "off", disposed: false, @@ -547,6 +592,12 @@ export function createWebWorkerPairingHostRuntime( case "setPermissionAuthorizationStatusResponse": handleSetPermissionAuthorizationStatusResponse(state, msg); break; + case "sessionChatIdentityKeyResponse": + handleSessionChatIdentityKeyResponse(state, msg); + break; + case "deviceEncryptionKeyResponse": + handleDeviceEncryptionKeyResponse(state, msg); + break; case "callbackRequest": if (debugLoggingEnabled(state)) { console.debug("[truapi worker] callbackRequest", msg.name); @@ -737,6 +788,24 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { kind: "cancelPairing", } satisfies MainToWorker); }, + getSessionChatIdentityKey(): Promise { + return sendWorkerRequest( + state, + state.pendingSessionChatIdentityKeys, + () => ++nextSessionChatIdentityKeyRequestId, + undefined, + (requestId) => ({ kind: "getSessionChatIdentityKey", requestId }), + ); + }, + getDeviceEncryptionKey(): Promise { + return sendWorkerRequest( + state, + state.pendingDeviceEncryptionKeys, + () => ++nextDeviceEncryptionKeyRequestId, + new Uint8Array(), + (requestId) => ({ kind: "getDeviceEncryptionKey", requestId }), + ); + }, notifySessionStoreChanged(): void { if (state.disposed) return; state.worker.postMessage({ @@ -840,6 +909,17 @@ function buildProvider( if (core.disposed) return Promise.resolve(); return runtime.disconnectSession(); }, + async getSessionChatIdentityKey(): Promise { + if (core.disposed) return undefined; + const key = await runtime.getSessionChatIdentityKey(); + return key && bytesToHex(key); + }, + async getDeviceEncryptionKey(): Promise { + if (core.disposed) { + throw new Error("product connection is closed"); + } + return bytesToHex(await runtime.getDeviceEncryptionKey()); + }, getPermissionAuthorizationStatus(request) { if (core.disposed) return Promise.resolve("NotDetermined"); return runtime.getPermissionAuthorizationStatus(core.productId, request); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 9ac00565e..7b8934a1a 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -432,6 +432,45 @@ describe("createWebWorkerPairingHostRuntime", () => { provider.dispose(); }); + it("returns the session chat identity key as hex, and undefined when absent", async () => { + const worker = new FakeWorker(); + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + const keyBytes = new Uint8Array(32).fill(0x77); + const pending = provider.getSessionChatIdentityKey(); + const msg = worker.messages.at(-1)!; + expect(msg.kind).toBe("getSessionChatIdentityKey"); + + worker.emit({ + kind: "sessionChatIdentityKeyResponse", + requestId: msg.requestId, + ok: true, + key: keyBytes, + }); + expect(await pending).toBe(bytesToHex(keyBytes)); + + // A session that predates retention reports absence rather than an error. + const missing = provider.getSessionChatIdentityKey(); + worker.emit({ + kind: "sessionChatIdentityKeyResponse", + requestId: worker.messages.at(-1)!.requestId, + ok: true, + key: undefined, + }); + expect(await missing).toBeUndefined(); + + provider.dispose(); + }); + it("dispatches callback requests to host hooks", async () => { const worker = new FakeWorker(); let clears = 0; diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index cdb3ea586..64c777d9a 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -82,6 +82,8 @@ export type MainToWorker = request: Uint8Array; status: PermissionAuthorizationStatus; } + | { kind: "getSessionChatIdentityKey"; requestId: number } + | { kind: "getDeviceEncryptionKey"; requestId: number } | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } | { kind: "callbackResponse"; requestId: number; ok: false; error: string } | { kind: "subscriptionItem"; subId: number; value: unknown } @@ -147,6 +149,30 @@ export type WorkerToMain = ok: false; error: string; } + | { + kind: "sessionChatIdentityKeyResponse"; + requestId: number; + ok: true; + key: Uint8Array | undefined; + } + | { + kind: "sessionChatIdentityKeyResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "deviceEncryptionKeyResponse"; + requestId: number; + ok: true; + key: Uint8Array; + } + | { + kind: "deviceEncryptionKeyResponse"; + requestId: number; + ok: false; + error: string; + } | { kind: "callbackRequest"; requestId: number; diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index f5223689e..f62a049b4 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -249,6 +249,12 @@ ctx.addEventListener("message", (ev: MessageEvent) => { case "cancelPairing": runtime?.cancelPairing(); break; + case "getSessionChatIdentityKey": + handleGetSessionChatIdentityKey(msg.requestId); + break; + case "getDeviceEncryptionKey": + void handleGetDeviceEncryptionKey(msg.requestId); + break; case "notifySessionStoreChanged": runtime?.notifySessionStoreChanged(); break; @@ -385,6 +391,60 @@ async function handleDisconnectSession(requestId: number): Promise { } } +function handleGetSessionChatIdentityKey(requestId: number): void { + if (!runtime) { + postToMain({ + kind: "sessionChatIdentityKeyResponse", + requestId, + ok: false, + error: "getSessionChatIdentityKey received before runtime is ready", + }); + return; + } + try { + postToMain({ + kind: "sessionChatIdentityKeyResponse", + requestId, + ok: true, + key: runtime.sessionChatIdentityKey(), + }); + } catch (err) { + postToMain({ + kind: "sessionChatIdentityKeyResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetDeviceEncryptionKey(requestId: number): Promise { + if (!runtime) { + postToMain({ + kind: "deviceEncryptionKeyResponse", + requestId, + ok: false, + error: "getDeviceEncryptionKey received before runtime is ready", + }); + return; + } + try { + postToMain({ + kind: "deviceEncryptionKeyResponse", + requestId, + ok: true, + key: await runtime.deviceEncryptionKey(), + }); + } catch (err) { + postToMain({ + kind: "deviceEncryptionKeyResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + async function handleFrame(coreId: number, bytes: Uint8Array): Promise { const core = cores.get(coreId); if (!core) { diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts index 9d24ac67b..079295ca5 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks.ts @@ -155,7 +155,17 @@ export type CoreStorageKey = /** * Statement-store allowance targets the signing host keeps renewed. */ - | { tag: "StatementRenewalTargets"; value?: undefined }; + | { tag: "StatementRenewalTargets"; value?: undefined } + /** + * This device's long-lived X25519 encryption secret, advertised to peers + * as the device encryption public key. Random rather than identity-derived + * so devices restoring one identity stay individually addressable. + * + * Hosts must back this slot with storage scoped to the install, outliving + * logout and any per-user namespacing: once it changes, peers addressing + * the previous key can no longer reach this device. + */ + | { tag: "DeviceEncryptionKey"; value?: undefined }; /** * Review shown before a product creates a ring-VRF proof (RFC 0004). @@ -338,6 +348,26 @@ export interface SessionUiInfo { */ identityAccountId?: Bytes32; + /** + * X25519 public key addressing this identity in chat. Public counterpart + * of the key `CoreAdmin::get_session_chat_identity_key` serves. + */ + chatPublicKey?: Bytes32; + + /** + * X25519 public key of the wallet device that answered pairing. Hosts + * running their own encrypted device-sync channel key it against this. + */ + deviceEncPublicKey?: Bytes32; + + /** + * Statement-store account id the paired wallet signs every session-channel + * statement with. Whether it is scoped to the wallet device or to the + * wallet identity is the wallet's choice, so hosts must not treat it as a + * device discriminator; use `Self::device_enc_public_key` for that. + */ + peerStatementAccountId?: Bytes32; + /** * Short username from the People-chain identity record. */ @@ -527,6 +557,7 @@ export const CoreStorageKey: S.Codec = S.lazy( rootPublicKey: Uint8Array; }>, StatementRenewalTargets: S._void, + DeviceEncryptionKey: S._void, }), ); @@ -667,6 +698,9 @@ export const SessionUiInfo: S.Codec = S.lazy( S.Struct({ publicKey: Bytes32, identityAccountId: S.Option(Bytes32), + chatPublicKey: S.Option(Bytes32), + deviceEncPublicKey: S.Option(Bytes32), + peerStatementAccountId: S.Option(Bytes32), liteUsername: S.Option(S.str), fullUsername: S.Option(S.str), }) as S.Codec, @@ -835,6 +869,30 @@ export interface CoreAdmin { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ): Promise; + + /** + * Read the active session's X25519 chat identity private key, for hosts + * that run their own P2P chat channel for the paired identity. + * + * The wallet derives this key from the identity root and shares it during + * pairing; the core retains it verbatim, because a value derived + * host-side would address an identity no existing peer can reach. ``undefined`` + * when no session is active. + * + * Deliberately not on `SessionUiInfo`: that projection rides every + * `AuthState` broadcast to all registered `AuthPresenter`s, so a + * secret placed there would reach hosts that never asked for it. + */ + getSessionChatIdentityKey(): Promise; + + /** + * Read this device's X25519 encryption secret, for hosts that run device + * sync against the peer's `SessionUiInfo::device_enc_public_key`. + * + * Generated and persisted on first read, so the returned key is stable for + * the install and matches the public key peers were told to address. + */ + getDeviceEncryptionKey(): Promise; } /** diff --git a/rust/crates/truapi-host-cli/src/platform.rs b/rust/crates/truapi-host-cli/src/platform.rs index 07c42aa8a..5cfceb22a 100644 --- a/rust/crates/truapi-host-cli/src/platform.rs +++ b/rust/crates/truapi-host-cli/src/platform.rs @@ -97,8 +97,13 @@ pub struct CliPlatform { chains: truapi_platform::HostChainSet, product_storage: Mutex>>>, core_storage: Mutex, Vec>>, + /// Device-scoped core slots, kept outside the per-user namespaces that + /// [`Self::switch_pairing_user_storage`] swaps. Peers address this install + /// by the key held here, so a user switch must not regenerate it. + device_storage: Mutex, Vec>>, product_storage_dir: Mutex>, core_storage_path: Mutex>, + device_storage_path: Option, state_dir: Mutex>, pairing_scope: Option, preimages: Mutex, Vec>>, @@ -150,14 +155,37 @@ impl CliPlatform { .as_deref() .map(load_hex_key_map) .unwrap_or_default(); + // Anchored to the role-level bootstrap directory rather than the active + // user's, so switching users keeps this install's device identity. + let device_storage_path = storage.as_ref().map(|paths| { + let directory = paths + .pairing_scope + .as_ref() + .map(|scope| scope.bootstrap_dir.clone()) + .unwrap_or_else(|| paths.state_dir.clone()); + if let Err(err) = fs::create_dir_all(&directory) { + tracing::warn!( + path = %directory.display(), + %err, + "could not create CLI device storage dir" + ); + } + directory.join("device-storage.json") + }); + let device_storage = device_storage_path + .as_deref() + .map(load_hex_key_map) + .unwrap_or_default(); Arc::new(Self { chain: WsChainProvider::new(network.people_ws, network.live_chain_endpoints), chains: network.host_chain_set(), product_storage: Mutex::new(product_storage), core_storage: Mutex::new(core_storage), + device_storage: Mutex::new(device_storage), product_storage_dir: Mutex::new(product_storage_dir), core_storage_path: Mutex::new(core_storage_path), + device_storage_path, state_dir: Mutex::new(storage.as_ref().map(|paths| paths.state_dir.clone())), pairing_scope: storage.and_then(|paths| paths.pairing_scope), preimages: Mutex::new(HashMap::new()), @@ -191,6 +219,22 @@ impl CliPlatform { save_product_storage(&directory, product_id, values) } + /// Whether a slot belongs to the install rather than the signed-in user. + fn is_device_scoped(key: &CoreStorageKey) -> bool { + matches!(key, CoreStorageKey::DeviceEncryptionKey) + } + + fn persist_device_storage(&self) -> Result<(), String> { + let Some(path) = self.device_storage_path.as_deref() else { + return Ok(()); + }; + let storage = self + .device_storage + .lock() + .expect("device storage mutex poisoned"); + save_hex_key_map(path, &storage) + } + fn persist_core_storage(&self) -> Result<(), String> { let Some(path) = self .core_storage_path @@ -437,8 +481,12 @@ impl CoreStorage for CliPlatform { &self, key: CoreStorageKey, ) -> Result>, api::GenericError> { - Ok(self - .core_storage + let store = if Self::is_device_scoped(&key) { + &self.device_storage + } else { + &self.core_storage + }; + Ok(store .lock() .expect("core storage mutex poisoned") .get(&Self::core_key(&key)) @@ -450,25 +498,45 @@ impl CoreStorage for CliPlatform { key: CoreStorageKey, value: Vec, ) -> Result<(), api::GenericError> { + let device_scoped = Self::is_device_scoped(&key); { - self.core_storage + let store = if device_scoped { + &self.device_storage + } else { + &self.core_storage + }; + store .lock() .expect("core storage mutex poisoned") .insert(Self::core_key(&key), value); } - self.persist_core_storage() - .map_err(|reason| api::GenericError { reason }) + if device_scoped { + self.persist_device_storage() + } else { + self.persist_core_storage() + } + .map_err(|reason| api::GenericError { reason }) } async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), api::GenericError> { + let device_scoped = Self::is_device_scoped(&key); { - self.core_storage + let store = if device_scoped { + &self.device_storage + } else { + &self.core_storage + }; + store .lock() .expect("core storage mutex poisoned") .remove(&Self::core_key(&key)); } - self.persist_core_storage() - .map_err(|reason| api::GenericError { reason }) + if device_scoped { + self.persist_device_storage() + } else { + self.persist_core_storage() + } + .map_err(|reason| api::GenericError { reason }) } } @@ -1575,6 +1643,71 @@ mod tests { ); } + #[test] + fn device_encryption_key_outlives_pairing_user_switches() { + let temporary = tempdir().expect("create pairing storage root"); + let network_dir = temporary.path().join("testnet"); + let platform = CliPlatform::new( + test_network(), + Some(CliStoragePaths::pairing(network_dir.clone())), + ApprovalPolicy::AutoAccept, + None, + ); + + platform + .switch_pairing_user_storage("alice.dot") + .expect("select alice"); + futures::executor::block_on( + platform.write_core_storage(CoreStorageKey::DeviceEncryptionKey, vec![7; 32]), + ) + .expect("write device key"); + + // Peers address this install by the matching public key, so switching + // users must not strand them on a regenerated one. + platform + .switch_pairing_user_storage("bob.dot") + .expect("select bob"); + assert_eq!( + futures::executor::block_on( + platform.read_core_storage(CoreStorageKey::DeviceEncryptionKey) + ) + .expect("read device key as bob"), + Some(vec![7; 32]) + ); + + // A user-scoped slot stays isolated, so the routing is not simply + // making every slot global. + futures::executor::block_on( + platform.write_core_storage(CoreStorageKey::AutoSigningKeys, vec![1, 2, 3]), + ) + .expect("write bob auto-signing keys"); + platform + .switch_pairing_user_storage("alice.dot") + .expect("restore alice"); + assert_eq!( + futures::executor::block_on( + platform.read_core_storage(CoreStorageKey::AutoSigningKeys) + ) + .expect("read alice auto-signing keys"), + None + ); + + // It also survives a fresh process reading the same directories. + let restarted = CliPlatform::new( + test_network(), + Some(CliStoragePaths::pairing(network_dir)), + ApprovalPolicy::AutoAccept, + None, + ); + assert_eq!( + futures::executor::block_on( + restarted.read_core_storage(CoreStorageKey::DeviceEncryptionKey) + ) + .expect("read device key after restart"), + Some(vec![7; 32]) + ); + } + #[test] fn legacy_pairing_storage_moves_to_the_first_resolved_user() { let temporary = tempdir().expect("create pairing storage root"); diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index c22db4c7a..3c348aeb7 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -45,3 +45,7 @@ when it provides the Chat modality. `CoreAdmin` is not part of the host-provided `Platform` callback surface. It is the core-owned control API exposed to host UI for logout, pairing cancellation, session-store refresh, and permission administration. + +It also serves the session's X25519 chat identity private key. Public session +material a host needs to address the identity or the paired device travels on +`SessionUiInfo` instead; only the secret requires this deliberate call. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 77a6147ca..631ea57af 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -518,6 +518,26 @@ pub trait CoreAdmin: Send + Sync { request: PermissionAuthorizationRequest, status: PermissionAuthorizationStatus, ) -> Result<(), GenericError>; + + /// Read the active session's X25519 chat identity private key, for hosts + /// that run their own P2P chat channel for the paired identity. + /// + /// The wallet derives this key from the identity root and shares it during + /// pairing; the core retains it verbatim, because a value derived + /// host-side would address an identity no existing peer can reach. `None` + /// when no session is active. + /// + /// Deliberately not on [`SessionUiInfo`]: that projection rides every + /// [`AuthState`] broadcast to all registered [`AuthPresenter`]s, so a + /// secret placed there would reach hosts that never asked for it. + async fn get_session_chat_identity_key(&self) -> Result, GenericError>; + + /// Read this device's X25519 encryption secret, for hosts that run device + /// sync against the peer's [`SessionUiInfo::device_enc_public_key`]. + /// + /// Generated and persisted on first read, so the returned key is stable for + /// the install and matches the public key peers were told to address. + async fn get_device_encryption_key(&self) -> Result; } /// Pairing-host-only administration API exposed to host UI. @@ -644,6 +664,15 @@ pub enum CoreStorageKey { /// Statement-store allowance targets the signing host keeps renewed. #[codec(index = 8)] StatementRenewalTargets, + /// This device's long-lived X25519 encryption secret, advertised to peers + /// as the device encryption public key. Random rather than identity-derived + /// so devices restoring one identity stay individually addressable. + /// + /// Hosts must back this slot with storage scoped to the install, outliving + /// logout and any per-user namespacing: once it changes, peers addressing + /// the previous key can no longer reach this device. + #[codec(index = 9)] + DeviceEncryptionKey, } /// Stable metadata describing one strictly decoded [`CoreStorageKey`]. @@ -693,6 +722,7 @@ pub fn describe_core_storage_key( CoreStorageKey::AutoSigningKeys => ("AutoSigningKeys", None), CoreStorageKey::RingVrfRegistry { .. } => ("RingVrfRegistry", None), CoreStorageKey::StatementRenewalTargets => ("StatementRenewalTargets", None), + CoreStorageKey::DeviceEncryptionKey => ("DeviceEncryptionKey", None), }; Ok(CoreStorageKeyDescription { kind, product_id }) } @@ -874,6 +904,11 @@ mod tests { "StatementRenewalTargets", None, ), + ( + CoreStorageKey::DeviceEncryptionKey, + "DeviceEncryptionKey", + None, + ), ] { let description = describe_core_storage_key(&key.encode()).expect("valid key"); assert_eq!(description.kind, kind); @@ -1025,6 +1060,17 @@ pub struct SessionUiInfo { pub public_key: Bytes32, /// Wallet identity account id used for People-chain username lookup. pub identity_account_id: Option, + /// X25519 public key addressing this identity in chat. Public counterpart + /// of the key [`CoreAdmin::get_session_chat_identity_key`] serves. + pub chat_public_key: Option, + /// X25519 public key of the wallet device that answered pairing. Hosts + /// running their own encrypted device-sync channel key it against this. + pub device_enc_public_key: Option, + /// Statement-store account id the paired wallet signs every session-channel + /// statement with. Whether it is scoped to the wallet device or to the + /// wallet identity is the wallet's choice, so hosts must not treat it as a + /// device discriminator; use [`Self::device_enc_public_key`] for that. + pub peer_statement_account_id: Option, /// Short username from the People-chain identity record. pub lite_username: Option, /// Fully qualified username from the People-chain identity record. diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 94f2bc7ea..6b94918b0 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -27,6 +27,7 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; +use crate::host_logic::device_key::read_or_create_device_encryption_secret; use crate::runtime::{ ChatConnection, LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, respond_to_pairing, @@ -198,6 +199,25 @@ impl PairingHostRuntime { .map_err(ring_vrf_admin_error) } + /// Read the active session's X25519 chat identity private key, for hosts + /// running their own P2P chat channel for the paired identity. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.session_chat_identity_key"))] + pub fn session_chat_identity_key(&self) -> Option<[u8; 32]> { + self.pairing_host + .session_state() + .current()? + .identity_chat_private_key + } + + /// Read this device's X25519 encryption secret, for hosts running device + /// sync. Generated and persisted on first read. + #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.device_encryption_key"))] + pub async fn device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { + read_or_create_device_encryption_secret(self.services.platform.as_ref()) + .await + .map_err(|reason| v01::GenericError { reason }) + } + /// Clear the canonical paired session and all capability caches/storage /// without sending a peer-disconnect notice. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.reset_session_state"))] @@ -693,6 +713,20 @@ impl CoreAdmin for HostAdmin { ) -> Result<(), v01::GenericError> { HostAdmin::set_permission_authorization_status(self, request, status).await } + + async fn get_session_chat_identity_key(&self) -> Result, v01::GenericError> { + Ok(self + .authority + .session_state() + .current() + .and_then(|session| session.identity_chat_private_key)) + } + + async fn get_device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { + read_or_create_device_encryption_secret(self.product_runtime.platform().as_ref()) + .await + .map_err(|reason| v01::GenericError { reason }) + } } /// Target-neutral host runtime wrapper. diff --git a/rust/crates/truapi-server/src/host_logic.rs b/rust/crates/truapi-server/src/host_logic.rs index e687fafa1..3a65c9f79 100644 --- a/rust/crates/truapi-server/src/host_logic.rs +++ b/rust/crates/truapi-server/src/host_logic.rs @@ -6,6 +6,7 @@ pub mod attestation; pub mod bulletin; +pub mod device_key; pub mod dotns; pub mod entropy; pub mod extrinsic; diff --git a/rust/crates/truapi-server/src/host_logic/device_key.rs b/rust/crates/truapi-server/src/host_logic/device_key.rs new file mode 100644 index 000000000..511021605 --- /dev/null +++ b/rust/crates/truapi-server/src/host_logic/device_key.rs @@ -0,0 +1,65 @@ +//! This install's long-lived X25519 encryption identity. +//! +//! Peers address one device by this key, so it is random rather than derived +//! from the identity entropy: two devices restoring the same identity must not +//! collapse onto the same key. It is created on first use and then persisted, +//! because a regenerated key silently strands peers still addressing the old +//! one. + +use tracing::{debug, instrument}; +use truapi_platform::{CoreStorage, CoreStorageKey}; + +use crate::host_logic::sso::pairing::generate_x25519_keypair; + +/// Read this device's persisted X25519 encryption secret, generating and +/// storing one on first use. +/// +/// Callers that answer pairing must serialize this against themselves; two +/// concurrent generations would each persist and advertise a different key. +#[instrument(skip_all, fields(runtime.method = "device_key.read_or_create"))] +pub async fn read_or_create_device_encryption_secret( + storage: &(impl CoreStorage + ?Sized), +) -> Result<[u8; 32], String> { + let stored = storage + .read_core_storage(CoreStorageKey::DeviceEncryptionKey) + .await + .map_err(|err| format!("device encryption key read failed: {err:?}"))?; + if let Some(stored) = stored { + match <[u8; 32]>::try_from(stored.as_slice()) { + Ok(secret) => return Ok(secret), + Err(_) => debug!("discarding malformed stored device encryption key"), + } + } + + let (secret, _) = + generate_x25519_keypair().map_err(|err| format!("device encryption key failed: {err}"))?; + storage + .write_core_storage(CoreStorageKey::DeviceEncryptionKey, secret.to_vec()) + .await + .map_err(|err| format!("device encryption key write failed: {err:?}"))?; + Ok(secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::StubPlatform; + use std::sync::Arc; + use truapi_platform::Platform; + + #[test] + fn secret_is_generated_once_and_then_reused() { + let storage: Arc = Arc::new(StubPlatform::default()); + + let first = + futures::executor::block_on(read_or_create_device_encryption_secret(storage.as_ref())) + .unwrap(); + let second = + futures::executor::block_on(read_or_create_device_encryption_secret(storage.as_ref())) + .unwrap(); + + // Peers address this device by the matching public key, so regenerating + // it would strand everyone still holding the old one. + assert_eq!(first, second); + } +} diff --git a/rust/crates/truapi-server/src/host_logic/session.rs b/rust/crates/truapi-server/src/host_logic/session.rs index be9f8ad16..be590c05a 100644 --- a/rust/crates/truapi-server/src/host_logic/session.rs +++ b/rust/crates/truapi-server/src/host_logic/session.rs @@ -32,6 +32,12 @@ pub struct SessionInfo { pub root_entropy_source: Option<[u8; 32]>, /// Wallet identity account id used for People-chain username lookup. pub identity_account_id: Option<[u8; 32]>, + /// X25519 private key addressing this identity in chat. A pairing host + /// retains what the handshake shares and cannot recompute it. + pub identity_chat_private_key: Option<[u8; 32]>, + /// X25519 public key of the wallet device that answered pairing. Distinct + /// from [`SsoSessionInfo::peer_enc_pubkey`], which keys the SSO channels. + pub device_enc_public_key: Option<[u8; 32]>, /// Short username (e.g. `alice`). pub lite_username: Option, /// Fully qualified username (e.g. `Alice Smith`). @@ -102,6 +108,12 @@ pub struct ExternalPairedSession { pub root_entropy_source: [u8; 32], /// Wallet identity account id used for People-chain username lookup. pub identity_account_id: [u8; 32], + /// Wallet-supplied X25519 private key addressing this identity in chat, + /// when the external runtime captured it during its own handshake. + pub identity_chat_private_key: Option<[u8; 32]>, + /// X25519 public key of the wallet device that answered pairing, when the + /// external runtime captured it during its own handshake. + pub device_enc_public_key: Option<[u8; 32]>, } /// Encode an already-paired external host session as the canonical opaque @@ -115,6 +127,8 @@ pub fn encode_external_paired_session(info: ExternalPairedSession) -> Vec { sso: Some(info.sso), root_entropy_source: Some(info.root_entropy_source), identity_account_id: Some(info.identity_account_id), + identity_chat_private_key: info.identity_chat_private_key, + device_enc_public_key: info.device_enc_public_key, lite_username: None, full_username: None, }) @@ -251,6 +265,8 @@ mod tests { sso: None, root_entropy_source: None, identity_account_id: None, + identity_chat_private_key: None, + device_enc_public_key: None, lite_username: Some("alice".to_string()), full_username: None, } @@ -365,6 +381,8 @@ mod tests { }, root_entropy_source: [12; 32], identity_account_id: [5; 32], + identity_chat_private_key: Some([13; 32]), + device_enc_public_key: Some([14; 32]), }; let blob = encode_external_paired_session(external); @@ -388,6 +406,8 @@ mod tests { }), root_entropy_source: Some([12; 32]), identity_account_id: Some([5; 32]), + identity_chat_private_key: Some([13; 32]), + device_enc_public_key: Some([14; 32]), lite_username: None, full_username: None, } 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 974a490f5..84b5cedb3 100644 --- a/rust/crates/truapi-server/src/host_logic/sso/pairing.rs +++ b/rust/crates/truapi-server/src/host_logic/sso/pairing.rs @@ -318,9 +318,23 @@ pub fn derive_x25519_keypair_from_entropy(entropy: &[u8], domain: &[u8]) -> ([u8 let root = blake2b256_keyed(entropy, b"ecdh"); let chain_code = normalize_chain_code(domain.encode()); let secret_bytes = blake2b256_keyed(&root, &chain_code); - let secret = StaticSecret::from(secret_bytes); - let public = PublicKey::from(&secret).to_bytes(); - (secret_bytes, public) + (secret_bytes, x25519_public_key(secret_bytes)) +} + +/// X25519 public key matching a raw 32-byte secret scalar. +pub fn x25519_public_key(secret_bytes: [u8; 32]) -> [u8; 32] { + PublicKey::from(&StaticSecret::from(secret_bytes)).to_bytes() +} + +/// RFC-0022 domain for the identity chat X25519 key. +pub const CHAT_ENCRYPTION_DOMAIN: &[u8] = b"chat"; + +/// Derive the X25519 private key addressing an identity in chat. +/// +/// Every host holding the identity entropy derives the same key, which is what +/// lets a wallet share it with a paired host that has no entropy of its own. +pub fn derive_identity_chat_private_key(entropy: &[u8]) -> [u8; 32] { + derive_x25519_keypair_from_entropy(entropy, CHAT_ENCRYPTION_DOMAIN).0 } /// Encrypt session-channel statement data with a random nonce. @@ -587,12 +601,11 @@ fn generate_statement_store_keypair() -> Result<([u8; 64], [u8; 32]), PairingBoo Ok((keypair.secret.to_bytes(), keypair.public.to_bytes())) } -fn generate_x25519_keypair() -> Result<([u8; 32], [u8; 32]), PairingBootstrapError> { +/// Generate a random X25519 keypair as `(secret, public)`. +pub fn generate_x25519_keypair() -> Result<([u8; 32], [u8; 32]), PairingBootstrapError> { let mut secret_bytes = [0u8; 32]; getrandom::getrandom(&mut secret_bytes).map_err(PairingBootstrapError::Random)?; - let secret = StaticSecret::from(secret_bytes); - let public = PublicKey::from(&secret).to_bytes(); - Ok((secret_bytes, public)) + Ok((secret_bytes, x25519_public_key(secret_bytes))) } fn normalize_chain_code(encoded: Vec) -> [u8; 32] { diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index d4dc3f8e9..30a2b7f8f 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -79,6 +79,12 @@ pub use wasm::*; #[cfg(not(target_arch = "wasm32"))] uniffi::setup_scaffolding!(); +#[cfg(not(target_arch = "wasm32"))] +uniffi::use_remote_type!(truapi::Bytes32); + +#[cfg(not(target_arch = "wasm32"))] +use truapi::Bytes32; + #[cfg(not(target_arch = "wasm32"))] truapi::uniffi_reexport_scaffolding!(); diff --git a/rust/crates/truapi-server/src/native.rs b/rust/crates/truapi-server/src/native.rs index 99623ce7b..fa9bc6985 100644 --- a/rust/crates/truapi-server/src/native.rs +++ b/rust/crates/truapi-server/src/native.rs @@ -18,10 +18,10 @@ use futures::future::BoxFuture; use futures::stream::{self, BoxStream, StreamExt}; use futures::task::SpawnExt; use parity_scale_codec::Encode; -use truapi::v01; +use truapi::{Bytes32, v01}; use truapi_platform::{ - AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, HostInfo, - JsonRpcConnection, Navigation, Notifications, PermissionAuthorizationRequest, + AuthPresenter, AuthState, ChainProvider, CoreAdmin, CoreStorage, CoreStorageKey, Features, + HostInfo, JsonRpcConnection, Navigation, Notifications, PermissionAuthorizationRequest, PermissionAuthorizationStatus, Permissions, PlatformInfo, PreimageHost, ProductContext, ProductExecutionKind, ProductStorage, RuntimeConfigValidationError, SigningHostConfig, ThemeHost, UserConfirmation, UserConfirmationReview, async_trait, normalize_product_identifier, @@ -763,6 +763,22 @@ impl NativeProductExecution { Ok(()) } + /// Read the active session's X25519 chat identity private key, or `None` + /// when no session is active. + pub fn session_chat_identity_key(&self) -> Result, HostRejection> { + Ok(futures::executor::block_on( + self.admin().get_session_chat_identity_key(), + )?) + } + + /// Read this device's X25519 encryption secret, for device sync against a + /// peer's `deviceEncPublicKey`. Generated and persisted on first read. + pub fn device_encryption_key(&self) -> Result { + Ok(futures::executor::block_on( + self.admin().get_device_encryption_key(), + )?) + } + /// Push a host theme replacement to this execution's subscriptions. pub fn notify_theme_changed(&self, theme: v01::ThemeVariant) { self.events.notify_theme_changed(theme); @@ -1983,6 +1999,9 @@ mod tests { truapi_platform::SessionUiInfo { public_key: [7; 32], identity_account_id: None, + chat_public_key: None, + device_enc_public_key: None, + peer_statement_account_id: None, lite_username: Some("alice".to_string()), full_username: None, }, @@ -2002,6 +2021,9 @@ mod tests { AuthState::Connected(truapi_platform::SessionUiInfo { public_key: [7; 32], identity_account_id: None, + chat_public_key: None, + device_enc_public_key: None, + peer_statement_account_id: None, lite_username: Some("alice".to_string()), full_username: None, }), diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 8b983d619..cf2cb31e2 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -53,6 +53,7 @@ use crate::host_logic::session::SessionInfo; #[cfg(test)] use crate::host_logic::session::SessionState; use crate::host_logic::sso::messages::RingVrfError; +use crate::host_logic::sso::pairing::x25519_public_key; use crate::runtime::bulletin_rpc::BulletinSubmitError; #[cfg(test)] use crate::subscription::Spawner; @@ -340,6 +341,11 @@ impl ProductRuntimeHost { } /// Trusted executable kind attached to this product connection. + /// Host platform backing this runtime's callbacks and core storage. + pub(crate) fn platform(&self) -> &Arc { + &self.platform + } + pub(crate) fn execution_kind(&self) -> truapi_platform::ProductExecutionKind { self.product.execution_kind } @@ -1314,6 +1320,9 @@ fn connected_session_ui_info(session: &SessionInfo) -> SessionUiInfo { SessionUiInfo { public_key: session.public_key, identity_account_id: session.identity_account_id, + chat_public_key: session.identity_chat_private_key.map(x25519_public_key), + device_enc_public_key: session.device_enc_public_key, + peer_statement_account_id: session.sso.as_ref().map(|sso| sso.identity_account_id), lite_username: session.lite_username.clone(), full_username: session.full_username.clone(), } diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 50048ccb3..eaccc145b 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -111,6 +111,10 @@ pub(crate) struct SigningHost { local_grants: Mutex, /// Durable RFC-0024 registry, scoped by the active wallet root. ring_vrf_registry: Arc, + /// Serializes the read-or-create of the persisted device encryption key. + /// Concurrent pairing answers would otherwise each generate a key and + /// advertise it, leaving the overwritten peer unable to reach this device. + device_encryption_key: futures::lock::Mutex<()>, #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState, } @@ -129,6 +133,7 @@ impl SigningHost { root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), ring_vrf_registry: RingVrfRegistryStore::new(platform), + device_encryption_key: futures::lock::Mutex::new(()), #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState::default(), }) @@ -154,6 +159,7 @@ impl SigningHost { root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), ring_vrf_registry: RingVrfRegistryStore::new(platform), + device_encryption_key: futures::lock::Mutex::new(()), #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState::default(), }) @@ -164,6 +170,12 @@ impl SigningHost { self.session_state.clone() } + /// Hold while reading-or-creating the persisted device encryption key, so + /// concurrent pairing answers agree on the key they advertise. + pub(super) async fn device_encryption_key_guard(&self) -> futures::lock::MutexGuard<'_, ()> { + self.device_encryption_key.lock().await + } + /// Current root entropy, or [`AuthorityError::Disconnected`] when no local /// session is active. fn root_entropy(&self) -> Result>, AuthorityError> { diff --git a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs index e90e5d8c8..df9a0c922 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host/local_activation.rs @@ -3,6 +3,7 @@ use crate::host_logic::product_account::{ derive_identity_keypair, derive_root_keypair_from_entropy, }; use crate::host_logic::session::SessionInfo; +use crate::host_logic::sso::pairing::derive_identity_chat_private_key; use crate::runtime::authority::AuthorityError; use crate::runtime::connected_session_ui_info; @@ -47,11 +48,15 @@ impl LocalActivation for SigningHost { .map_err(product_authority_error)? .public .to_bytes(); + let identity_chat_private_key = derive_identity_chat_private_key(&secret); let session = SessionInfo { public_key, sso: None, root_entropy_source: None, identity_account_id: Some(identity_account_id), + identity_chat_private_key: Some(identity_chat_private_key), + // A local session has no answering remote device to address. + device_enc_public_key: None, lite_username, full_username: None, }; 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 1aa5cef97..d78f2d1c1 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 @@ -26,6 +26,7 @@ use truapi_platform::{ use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; +use crate::host_logic::device_key::read_or_create_device_encryption_secret; use crate::host_logic::entropy::root_entropy_source; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::derive_sr25519_hard_path; @@ -44,8 +45,8 @@ use crate::host_logic::sso::messages::{ }; use crate::host_logic::sso::pairing::{ ResponderIdentity, VersionedHandshakeProposal, bootstrap_topic, decode_pairing_deeplink, - derive_x25519_keypair_from_entropy, encrypt_v2_handshake_response, - establish_responder_session_info, v2, + derive_identity_chat_private_key, derive_x25519_keypair_from_entropy, + encrypt_v2_handshake_response, establish_responder_session_info, v2, x25519_public_key, }; use crate::host_logic::statement_store::{build_signed_statement, parse_new_statements_result}; use crate::runtime::authority::{ @@ -64,8 +65,6 @@ use crate::runtime::statement_store_rpc::StatementStoreRpcClientError; /// RFC-0022 domain for the responder's persistent SSO X25519 key. const SSO_ENCRYPTION_DOMAIN: &[u8] = b"sso"; -/// RFC-0022 domain for the identity chat X25519 key shared in the handshake. -const CHAT_ENCRYPTION_DOMAIN: &[u8] = b"chat"; /// Leave the product runtime one minute to receive and process the SSO response /// before its 300-second remote-authority deadline expires. #[cfg(not(target_arch = "wasm32"))] @@ -83,8 +82,7 @@ fn derive_responder_identity( let statement = derive_identity_keypair(entropy)?; let (encryption_secret_key, encryption_public_key) = derive_x25519_keypair_from_entropy(entropy, SSO_ENCRYPTION_DOMAIN); - let (identity_chat_private_key, _) = - derive_x25519_keypair_from_entropy(entropy, CHAT_ENCRYPTION_DOMAIN); + let identity_chat_private_key = derive_identity_chat_private_key(entropy); Ok(( ResponderIdentity { statement_secret: statement.secret.to_bytes(), @@ -238,6 +236,12 @@ pub(crate) async fn respond_to_pairing( .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 device_enc_pub_key = { + let _guard = signing_host.device_encryption_key_guard().await; + x25519_public_key( + read_or_create_device_encryption_secret(services.platform.as_ref()).await?, + ) + }; let session = establish_responder_session_info( &identity, proposal.device.statement_account_id, @@ -249,7 +253,7 @@ pub(crate) async fn respond_to_pairing( 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, + device_enc_pub_key, root_entropy_source: root_entropy_source(&entropy), })); let handshake = encrypt_v2_handshake_response(proposal.device.encryption_public_key, &success)?; @@ -1729,6 +1733,22 @@ mod tests { assert_eq!(verified.signer, local_identity); } + #[test] + fn advertised_device_key_is_independent_of_the_identity() { + let platform = Arc::new(StubPlatform::default()); + let storage: Arc = platform.clone(); + + let advertised = x25519_public_key( + futures::executor::block_on(read_or_create_device_encryption_secret(storage.as_ref())) + .unwrap(), + ); + + // The regression this guards: advertising the SSO channel key as the + // device key makes every device sharing an identity indistinguishable. + let (_, sso_public) = derive_x25519_keypair_from_entropy(&ENTROPY, SSO_ENCRYPTION_DOMAIN); + assert_ne!(advertised, sso_public); + } + fn response_payload(answer: AnsweredRemoteMessage) -> v1::RemoteMessage { let RemoteMessageData::V1(data) = answer.response.data; data diff --git a/rust/crates/truapi-server/src/runtime/sso_pairing.rs b/rust/crates/truapi-server/src/runtime/sso_pairing.rs index ad08608d5..7552923d1 100644 --- a/rust/crates/truapi-server/src/runtime/sso_pairing.rs +++ b/rust/crates/truapi-server/src/runtime/sso_pairing.rs @@ -235,6 +235,8 @@ impl<'a> SsoPairingFlow<'a> { sso: Some(sso), root_entropy_source: Some(response.success.root_entropy_source), identity_account_id: Some(response.success.identity_account_id), + identity_chat_private_key: Some(response.success.identity_chat_private_key), + device_enc_public_key: Some(response.success.device_enc_pub_key), lite_username: None, full_username: None, }; @@ -545,7 +547,7 @@ mod tests { use crate::test_support::{ StubPlatform, core_storage_test_key, pairing_device_from_deeplink, peer_statement_keypair, runtime_config, session_info, signed_test_statement, stub_platform, subscribe_ack_frame, - test_spawner, wallet_handshake_statement, + test_spawner, wallet_device_encryption_public_key, wallet_handshake_statement, }; use truapi::CallContext; use truapi::api::Account; @@ -819,6 +821,28 @@ mod tests { peer_statement_keypair().1 ); + // The handshake shares both values once; nothing can recompute them + // afterwards, so pairing must retain them verbatim. + assert_eq!(session.identity_chat_private_key, Some([0x77; 32])); + // The fixture's device key differs from its SSO channel key, so this + // also pins that the two are not conflated. + assert_eq!( + session.device_enc_public_key, + Some(wallet_device_encryption_public_key()) + ); + + // Public values project to host UI; the chat secret deliberately does not. + let ui_info = connected_session_ui_info(&session); + assert_eq!( + ui_info.chat_public_key, + Some(X25519PublicKey::from(&X25519SecretKey::from([0x77; 32])).to_bytes()), + "the projected chat public key must match the retained secret" + ); + assert_eq!( + ui_info.peer_statement_account_id, + Some(peer_statement_keypair().1) + ); + let writes = session_writes .lock() .expect("session write list mutex poisoned"); diff --git a/rust/crates/truapi-server/src/test_support.rs b/rust/crates/truapi-server/src/test_support.rs index 9fd8193bb..72394ed51 100644 --- a/rust/crates/truapi-server/src/test_support.rs +++ b/rust/crates/truapi-server/src/test_support.rs @@ -228,6 +228,8 @@ pub(crate) fn session_info() -> crate::host_logic::session::SessionInfo { 0xb8, 0xf5, 0x81, 0xaa, 0x99, 0xe3, 0x49, 0x3b, 0xf4, 0x96, 0xed, 0xf1, 0x51, 0xab, 0xc1, 0xd7, 0x20, 0x23, ]), + identity_chat_private_key: None, + device_enc_public_key: None, lite_username: Some("alice".to_string()), full_username: Some("Alice Smith".to_string()), } @@ -525,6 +527,13 @@ pub(crate) fn failed_wallet_handshake_statement(deeplink: &str, reason: &str) -> ) } +/// Wallet device X25519 public key distinct from the persistent SSO key, so +/// tests catch a session that keys device-scoped material off the SSO channel +/// key by mistake. +pub(crate) fn wallet_device_encryption_public_key() -> [u8; 32] { + pairing::x25519_public_key([9; 32]) +} + fn wallet_handshake_success() -> pairing::v2::Success { let wallet_persistent_secret = X25519SecretKey::from([2; 32]); let wallet_persistent_public = X25519PublicKey::from(&wallet_persistent_secret).to_bytes(); @@ -533,7 +542,7 @@ fn wallet_handshake_success() -> pairing::v2::Success { root_account_id: session_info().public_key, identity_chat_private_key: [0x77; 32], sso_enc_pub_key: wallet_persistent_public, - device_enc_pub_key: wallet_persistent_public, + device_enc_pub_key: wallet_device_encryption_public_key(), root_entropy_source: [0x66; 32], } } diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 95da42029..08a04fe2e 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -776,6 +776,26 @@ impl WasmPairingHostRuntime { self.runtime.cancel_pairing(); } + /// Read the active session's X25519 chat identity private key, or + /// `undefined` when no session is active. + #[wasm_bindgen(js_name = sessionChatIdentityKey)] + pub fn session_chat_identity_key(&self) -> Option> { + self.runtime + .session_chat_identity_key() + .map(|key| key.to_vec()) + } + + /// Read this device's X25519 encryption secret, generating and persisting + /// it on first read. + #[wasm_bindgen(js_name = deviceEncryptionKey)] + pub async fn device_encryption_key(&self) -> Result, JsValue> { + self.runtime + .device_encryption_key() + .await + .map(|key| key.to_vec()) + .map_err(generic_error_to_js) + } + /// Activate an externally persisted canonical session without writing it /// to core storage; resolves only after product frames may use it. #[wasm_bindgen(js_name = activateExternalSession)] diff --git a/rust/crates/truapi-server/tests/wire_result_shape.rs b/rust/crates/truapi-server/tests/wire_result_shape.rs index 6b496a9ab..348459911 100644 --- a/rust/crates/truapi-server/tests/wire_result_shape.rs +++ b/rust/crates/truapi-server/tests/wire_result_shape.rs @@ -484,6 +484,8 @@ fn subscription_start_receive_stop_through_wire_boundary() { sso: None, root_entropy_source: None, identity_account_id: None, + identity_chat_private_key: None, + device_enc_public_key: None, lite_username: None, full_username: None, }); From 8b8d33318cf37b87155bd7152dcebef34e8d48d2 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Fri, 14 Aug 2026 17:50:34 +0200 Subject: [PATCH 2/2] fix(server,host): close the device-key race and the empty-key fallback Move the device-key serialization guard from SigningHost onto RuntimeServices and bundle it with the read. The key is reachable from both roles now, so a guard owned by one of them left the CoreAdmin and pairing-runtime paths free to race a first-time read against pairing, generate a second secret, and overwrite the one peers were told to address. `device_encryption_secret` is the only way to reach the key, so no caller can forget to hold it. Reject instead of resolving with an empty key when the worker host runtime is disposed. Callers encrypt with whatever comes back, so a zero-length secret is the one answer that must never be handed out. --- .../src/web/create-worker-host-runtime.ts | 6 ++++++ rust/crates/truapi-server/src/host_core.rs | 8 +++++--- rust/crates/truapi-server/src/runtime.rs | 6 +++--- .../truapi-server/src/runtime/services.rs | 18 ++++++++++++++++++ .../truapi-server/src/runtime/signing_host.rs | 12 ------------ .../src/runtime/signing_host/sso_responder.rs | 14 +++----------- 6 files changed, 35 insertions(+), 29 deletions(-) diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 67617806c..1dcb021d3 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -798,6 +798,12 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { ); }, getDeviceEncryptionKey(): Promise { + // A key has no safe empty value: callers encrypt with what they get back, + // so a disposed runtime must fail rather than hand out a zero-length one. + // The check is synchronous with the send, so the fallback is unreachable. + if (state.disposed) { + return Promise.reject(new Error("worker host runtime is disposed")); + } return sendWorkerRequest( state, state.pendingDeviceEncryptionKeys, diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 6b94918b0..b14716cca 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -27,7 +27,6 @@ use truapi_platform::{ use crate::core::TrUApiCore; use crate::frame::ProtocolMessage; -use crate::host_logic::device_key::read_or_create_device_encryption_secret; use crate::runtime::{ ChatConnection, LocalActivation, PairingHostRole, ProductAuthority, ProductRuntimeHost, ResponderExit, RuntimeServices, SigningHostRole, respond_to_pairing, @@ -213,7 +212,8 @@ impl PairingHostRuntime { /// sync. Generated and persisted on first read. #[instrument(skip_all, fields(runtime.method = "pairing_host_runtime.device_encryption_key"))] pub async fn device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { - read_or_create_device_encryption_secret(self.services.platform.as_ref()) + self.services + .device_encryption_secret() .await .map_err(|reason| v01::GenericError { reason }) } @@ -723,7 +723,9 @@ impl CoreAdmin for HostAdmin { } async fn get_device_encryption_key(&self) -> Result<[u8; 32], v01::GenericError> { - read_or_create_device_encryption_secret(self.product_runtime.platform().as_ref()) + self.product_runtime + .services() + .device_encryption_secret() .await .map_err(|reason| v01::GenericError { reason }) } diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index cf2cb31e2..71f03d8df 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -341,9 +341,9 @@ impl ProductRuntimeHost { } /// Trusted executable kind attached to this product connection. - /// Host platform backing this runtime's callbacks and core storage. - pub(crate) fn platform(&self) -> &Arc { - &self.platform + /// Role-neutral services shared with the owning host runtime. + pub(crate) fn services(&self) -> &Arc { + &self.services } pub(crate) fn execution_kind(&self) -> truapi_platform::ProductExecutionKind { diff --git a/rust/crates/truapi-server/src/runtime/services.rs b/rust/crates/truapi-server/src/runtime/services.rs index 7288c2582..2b969bdbf 100644 --- a/rust/crates/truapi-server/src/runtime/services.rs +++ b/rust/crates/truapi-server/src/runtime/services.rs @@ -46,6 +46,10 @@ pub(crate) struct RuntimeServices { statement_cache: Mutex, /// Task spawner for background runtime work. pub(crate) spawner: Spawner, + /// Serializes the read-or-create of the persisted device encryption key. + /// Concurrent first-time readers would otherwise each generate a secret and + /// persist it, leaving peers addressing an overwritten key. + device_encryption_key: futures::lock::Mutex<()>, next_core_instance: AtomicU64, } @@ -76,10 +80,24 @@ impl RuntimeServices { preimage_cache: Mutex::new(PreimageCache::default()), statement_cache: Mutex::new(StatementCache::default()), spawner, + device_encryption_key: futures::lock::Mutex::new(()), next_core_instance: AtomicU64::new(1), }) } + /// This device's persisted X25519 encryption secret, created on first use. + /// + /// The only supported way to reach the key: it bundles the serialization + /// guard with the read, so no caller can race another into generating a + /// second secret and overwriting the one peers were told to address. + pub(crate) async fn device_encryption_secret(&self) -> Result<[u8; 32], String> { + let _guard = self.device_encryption_key.lock().await; + crate::host_logic::device_key::read_or_create_device_encryption_secret( + self.platform.as_ref(), + ) + .await + } + /// Allocate the next per-product-runtime id, used to scope chain follow /// operation ids within the shared host runtime. pub(crate) fn next_core_instance(&self) -> u64 { diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index eaccc145b..50048ccb3 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -111,10 +111,6 @@ pub(crate) struct SigningHost { local_grants: Mutex, /// Durable RFC-0024 registry, scoped by the active wallet root. ring_vrf_registry: Arc, - /// Serializes the read-or-create of the persisted device encryption key. - /// Concurrent pairing answers would otherwise each generate a key and - /// advertise it, leaving the overwritten peer unable to reach this device. - device_encryption_key: futures::lock::Mutex<()>, #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState, } @@ -133,7 +129,6 @@ impl SigningHost { root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), ring_vrf_registry: RingVrfRegistryStore::new(platform), - device_encryption_key: futures::lock::Mutex::new(()), #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState::default(), }) @@ -159,7 +154,6 @@ impl SigningHost { root_entropy: Mutex::new(None), local_grants: Mutex::new(LocalGrantState::default()), ring_vrf_registry: RingVrfRegistryStore::new(platform), - device_encryption_key: futures::lock::Mutex::new(()), #[cfg(not(target_arch = "wasm32"))] renewal: allowance_renewal::RenewalState::default(), }) @@ -170,12 +164,6 @@ impl SigningHost { self.session_state.clone() } - /// Hold while reading-or-creating the persisted device encryption key, so - /// concurrent pairing answers agree on the key they advertise. - pub(super) async fn device_encryption_key_guard(&self) -> futures::lock::MutexGuard<'_, ()> { - self.device_encryption_key.lock().await - } - /// Current root entropy, or [`AuthorityError::Disconnected`] when no local /// session is active. fn root_entropy(&self) -> Result>, AuthorityError> { 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 d78f2d1c1..312d5c053 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 @@ -26,7 +26,6 @@ use truapi_platform::{ use super::SigningHost; #[cfg(not(target_arch = "wasm32"))] use crate::chain_runtime::RuntimeFailure; -use crate::host_logic::device_key::read_or_create_device_encryption_secret; use crate::host_logic::entropy::root_entropy_source; #[cfg(not(target_arch = "wasm32"))] use crate::host_logic::product_account::derive_sr25519_hard_path; @@ -236,12 +235,7 @@ pub(crate) async fn respond_to_pairing( .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 device_enc_pub_key = { - let _guard = signing_host.device_encryption_key_guard().await; - x25519_public_key( - read_or_create_device_encryption_secret(services.platform.as_ref()).await?, - ) - }; + let device_enc_pub_key = x25519_public_key(services.device_encryption_secret().await?); let session = establish_responder_session_info( &identity, proposal.device.statement_account_id, @@ -1735,12 +1729,10 @@ mod tests { #[test] fn advertised_device_key_is_independent_of_the_identity() { - let platform = Arc::new(StubPlatform::default()); - let storage: Arc = platform.clone(); + let (services, _signing_host) = signing_fixture(Arc::new(StubPlatform::default())); let advertised = x25519_public_key( - futures::executor::block_on(read_or_create_device_encryption_secret(storage.as_ref())) - .unwrap(), + futures::executor::block_on(services.device_encryption_secret()).unwrap(), ); // The regression this guards: advertising the SSO channel key as the