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 c8b141592..e78084867 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..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 @@ -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,30 @@ function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { kind: "cancelPairing", } satisfies MainToWorker); }, + getSessionChatIdentityKey(): Promise { + return sendWorkerRequest( + state, + state.pendingSessionChatIdentityKeys, + () => ++nextSessionChatIdentityKeyRequestId, + undefined, + (requestId) => ({ kind: "getSessionChatIdentityKey", requestId }), + ); + }, + 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, + () => ++nextDeviceEncryptionKeyRequestId, + new Uint8Array(), + (requestId) => ({ kind: "getDeviceEncryptionKey", requestId }), + ); + }, notifySessionStoreChanged(): void { if (state.disposed) return; state.worker.postMessage({ @@ -840,6 +915,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 00710aca5..823cd9b8e 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 1ea295ca0..38dc4eae9 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 }) } } @@ -1598,6 +1666,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 701e4f0ef..d92f185d7 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..b14716cca 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -198,6 +198,26 @@ 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> { + self.services + .device_encryption_secret() + .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,22 @@ 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> { + self.product_runtime + .services() + .device_encryption_secret() + .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 f2f2a6941..8cec1da54 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::HostThemeSubscribeItem) { self.events.notify_theme_changed(theme); @@ -1989,6 +2005,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, }, @@ -2008,6 +2027,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 fc08a8113..92561459c 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. + /// 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 { 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/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/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..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 @@ -44,8 +44,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 +64,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 +81,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 +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 = x25519_public_key(services.device_encryption_secret().await?); let session = establish_responder_session_info( &identity, proposal.device.statement_account_id, @@ -249,7 +247,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 +1727,20 @@ mod tests { assert_eq!(verified.signer, local_identity); } + #[test] + fn advertised_device_key_is_independent_of_the_identity() { + let (services, _signing_host) = signing_fixture(Arc::new(StubPlatform::default())); + + let advertised = x25519_public_key( + futures::executor::block_on(services.device_encryption_secret()).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 e8ac1cca8..43fad6e07 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, });