From b2c61b7fd0659c9b836631e3245910501d69e4ea Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 13 Jul 2026 14:34:48 -0400 Subject: [PATCH] feat(mobile): upgrade direct pairings to relay Co-authored-by: Orca --- mobile/src/transport/host-logical-client.ts | 42 +--- mobile/src/transport/host-store.test.ts | 13 ++ mobile/src/transport/host-store.ts | 16 ++ .../transport/mobile-endpoint-lifecycle.ts | 98 +++++++++ ...le-relay-direct-upgrade-controller.test.ts | 118 +++++++++++ .../mobile-relay-direct-upgrade-controller.ts | 69 +++++++ .../mobile-relay-direct-upgrade-journal.ts | 95 +++++++++ .../mobile-relay-direct-upgrade.test.ts | 193 ++++++++++++++++++ .../transport/mobile-relay-direct-upgrade.ts | 175 ++++++++++++++++ 9 files changed, 784 insertions(+), 35 deletions(-) create mode 100644 mobile/src/transport/mobile-endpoint-lifecycle.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade-controller.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade-journal.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade.test.ts create mode 100644 mobile/src/transport/mobile-relay-direct-upgrade.ts diff --git a/mobile/src/transport/host-logical-client.ts b/mobile/src/transport/host-logical-client.ts index 3175cb23de5..2d10cd0ca2e 100644 --- a/mobile/src/transport/host-logical-client.ts +++ b/mobile/src/transport/host-logical-client.ts @@ -1,17 +1,9 @@ -import * as ExpoCrypto from 'expo-crypto' import { AppState, Platform } from 'react-native' import { connect, type RpcClient } from './rpc-client' import { createStableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionLogSink, HostProfile } from './types' -import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' import { directPathForEndpoint } from './mobile-endpoint-supervisor-support' -import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' -import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' -import { - readMobileRelayCredentialBundle, - writeMobileRelayCredentialBundle -} from './mobile-relay-credential-bundle' -import { saveHost } from './host-store' +import { startMobileEndpointLifecycle } from './mobile-endpoint-lifecycle' export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSink): RpcClient { // Why: the stable facade owns app-visible RPC/subscription state while the @@ -20,45 +12,25 @@ export function openHostLogicalClient(host: HostProfile, onLog: ConnectionLogSin connect(host.endpoint, host.deviceToken, host.publicKeyB64, { onLog }), directPathForEndpoint(host, host.endpoint) ) - if (Platform.OS === 'web' || !host.relay) { + if (Platform.OS === 'web') { return logical } - const supervisor = new MobileEndpointSupervisor(logical, host, { - openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }), - openRelay: (relay, credential, confirmReqId) => - connectMobileRelayRpcSession({ - relay, - resumeToken: credential.token, - resumeCredentialVersion: credential.version, - resumeConfirmReqId: confirmReqId, - deviceToken: host.deviceToken, - desktopPublicKeyB64: host.publicKeyB64 - }), - resolveRelay: resolveMobileRelayEndpoint, - readBundle: readMobileRelayCredentialBundle, - writeBundle: writeMobileRelayCredentialBundle, - saveHost, - now: Date.now, - randomBytes: ExpoCrypto.getRandomBytes, - setTimer: setTimeout, - clearTimer: clearTimeout - }) - supervisor.setForeground(AppState.currentState === 'active') + const endpointLifecycle = startMobileEndpointLifecycle(logical, host, onLog) + endpointLifecycle.setForeground(AppState.currentState === 'active') const appStateSubscription = AppState.addEventListener('change', (state) => { - supervisor.setForeground(state === 'active') + endpointLifecycle.setForeground(state === 'active') }) const closeLogical = logical.close logical.close = () => { appStateSubscription.remove() - supervisor.stop() + endpointLifecycle.stop() closeLogical() } const notifyLogicalForeground = logical.notifyForeground logical.notifyForeground = () => { - supervisor.setForeground(true) + endpointLifecycle.setForeground(true) notifyLogicalForeground() } - void supervisor.start() return logical } diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 2cfaa176e4e..a1aa3d677a1 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -35,9 +35,11 @@ vi.mock('./host-credential-cleanup', () => ({ import { loadHosts, + MobileRelayUpgradeHostRemovedError, removeHost, renameHost, resetHostStoreForTests, + saveExistingHostRelayUpgrade, updateLastConnected } from './host-store' import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' @@ -133,6 +135,17 @@ describe('host-store list mutations', () => { expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) }) + it('refuses to resurrect a removed host during relay upgrade publication', async () => { + storedHostsRaw = JSON.stringify([HOST_TWO]) + + await expect( + saveExistingHostRelayUpgrade({ ...HOST_ONE, deviceToken: 'token-1' }) + ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + + expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO]) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + it('awaits cleanup scheduling after metadata commit', async () => { let resolveSchedule: (() => void) | null = null scheduleCleanupMock.mockReturnValue( diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index d072fef1e13..a17e6f57a1b 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -18,6 +18,7 @@ import { saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store' import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal' import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup' const STORAGE_KEY = 'orca:hosts' @@ -71,6 +72,7 @@ async function deleteDeviceToken(hostId: string): Promise { async function deleteHostCredentials(hostId: string): Promise { await deleteDeviceToken(hostId) await deleteMobileRelayCredentialBundle(hostId) + await deleteMobileRelayDirectUpgradeJournal(hostId) } // Why: SecureStore reads on Android Keystore can take 50-200ms each, and @@ -227,7 +229,17 @@ function toStored(host: HostProfile): StoredHostProfile { } } +export class MobileRelayUpgradeHostRemovedError extends Error {} + export async function saveHost(host: HostProfile): Promise { + await persistHost(host, false) +} + +export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise { + await persistHost(host, true) +} + +async function persistHost(host: HostProfile, requireExisting: boolean): Promise { const validated = HostProfileSchema.parse(host) const stored = toStored(validated) await mutateStoredHosts((hosts) => { @@ -237,6 +249,10 @@ export async function saveHost(host: HostProfile): Promise { next[index] = stored return next } + if (requireExisting) { + // Why: an in-flight relay upgrade must not resurrect a host the user removed. + throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + } return [...hosts, stored] }) // Why: write metadata BEFORE the keychain token so a crash between the two diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts new file mode 100644 index 00000000000..57881022a56 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -0,0 +1,98 @@ +import * as ExpoCrypto from 'expo-crypto' +import type { ConnectionLogSink, HostProfile } from './types' +import { connect } from './rpc-client' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session' +import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' +import { + readMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { saveHost } from './host-store' +import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' + +type EndpointLifecycle = { + setForeground(foreground: boolean): void + stop(): void +} + +type EndpointOwner = EndpointLifecycle & { + start(): Promise +} + +export function startMobileEndpointLifecycle( + logical: StableLogicalRpcClient, + initialHost: HostProfile, + onLog: ConnectionLogSink +): EndpointLifecycle { + let stopped = false + let foreground = true + let owner: EndpointOwner + + const startSupervisor = async (host: HostProfile): Promise => { + if (stopped) { + return + } + const supervisor = createSupervisor(logical, host, onLog) + owner.stop() + owner = supervisor + supervisor.setForeground(foreground) + await supervisor.start() + } + + if (initialHost.relay) { + owner = createSupervisor(logical, initialHost, onLog) + void owner.start() + } else { + owner = new MobileRelayDirectUpgradeController(logical, initialHost, { + upgrade: (client, host) => + upgradeDirectMobileRelay({ + client, + host, + dependencies: { randomBytes: ExpoCrypto.getRandomBytes } + }), + onUpgraded: ({ host }) => startSupervisor(host) + }) + void owner.start() + } + + return { + setForeground(next) { + foreground = next + owner.setForeground(next) + }, + stop() { + stopped = true + owner.stop() + } + } +} + +function createSupervisor( + logical: StableLogicalRpcClient, + host: HostProfile, + onLog: ConnectionLogSink +): MobileEndpointSupervisor { + return new MobileEndpointSupervisor(logical, host, { + openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }), + openRelay: (relay, credential, confirmReqId) => + connectMobileRelayRpcSession({ + relay, + resumeToken: credential.token, + resumeCredentialVersion: credential.version, + resumeConfirmReqId: confirmReqId, + deviceToken: host.deviceToken, + desktopPublicKeyB64: host.publicKeyB64 + }), + resolveRelay: resolveMobileRelayEndpoint, + readBundle: readMobileRelayCredentialBundle, + writeBundle: writeMobileRelayCredentialBundle, + saveHost, + now: Date.now, + randomBytes: ExpoCrypto.getRandomBytes, + setTimer: setTimeout, + clearTimer: clearTimeout + }) +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts new file mode 100644 index 00000000000..28fb7c307f5 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileRelayDirectUpgradeController } from './mobile-relay-direct-upgrade-controller' +import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile } from './types' + +const directHost: HostProfile = { + id: 'host-direct', + name: 'Host 4', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 +} + +const upgraded = { + host: { + ...directHost, + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1 as const, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const + } + }, + bundle: { + v: 1 as const, + hostId: directHost.id, + deviceToken: directHost.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 1, + expiresAt: 99_999_999 + } + } +} satisfies MobileRelayDirectUpgradeResult + +function logicalClient(initial: ConnectionState) { + let state = initial + const listeners = new Set<(state: ConnectionState) => void>() + return { + client: { + getState: () => state, + onStateChange: (listener: (next: ConnectionState) => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } as unknown as StableLogicalRpcClient, + setState(next: ConnectionState) { + state = next + for (const listener of listeners) { + listener(next) + } + } + } +} + +describe('direct pairing upgrade controller', () => { + it('upgrades immediately after an authenticated direct connection', async () => { + const logical = logicalClient('connected') + const upgrade = vi.fn(async () => upgraded) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + + await controller.start() + + expect(upgrade).toHaveBeenCalledWith(logical.client, directHost) + expect(onUpgraded).toHaveBeenCalledWith(upgraded) + }) + + it('retries a deferred upgrade on the next foreground transition', async () => { + const logical = logicalClient('connected') + const upgrade = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(upgraded) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + + await controller.start() + controller.setForeground(false) + controller.setForeground(true) + await vi.waitFor(() => expect(onUpgraded).toHaveBeenCalledOnce()) + + expect(upgrade).toHaveBeenCalledTimes(2) + }) + + it('fences a completed request after the host client closes', async () => { + const logical = logicalClient('connecting') + let resolveUpgrade!: (result: MobileRelayDirectUpgradeResult) => void + const upgrade = vi.fn( + () => + new Promise((resolve) => { + resolveUpgrade = resolve + }) + ) + const onUpgraded = vi.fn(async () => {}) + const controller = new MobileRelayDirectUpgradeController(logical.client, directHost, { + upgrade, + onUpgraded + }) + await controller.start() + + logical.setState('connected') + controller.stop() + resolveUpgrade(upgraded) + await Promise.resolve() + + expect(onUpgraded).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts new file mode 100644 index 00000000000..1cd57cfcb72 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-controller.ts @@ -0,0 +1,69 @@ +import type { MobileRelayDirectUpgradeResult } from './mobile-relay-direct-upgrade' +import type { StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { HostProfile } from './types' + +type Dependencies = { + upgrade: ( + client: StableLogicalRpcClient, + host: HostProfile + ) => Promise + onUpgraded: (result: MobileRelayDirectUpgradeResult) => Promise +} + +export class MobileRelayDirectUpgradeController { + private foreground = true + private stopped = false + private inFlight = false + private unsubscribe: (() => void) | null = null + + constructor( + private readonly logical: StableLogicalRpcClient, + private readonly host: HostProfile, + private readonly dependencies: Dependencies + ) {} + + async start(): Promise { + this.unsubscribe = this.logical.onStateChange((state) => { + if (state === 'connected') { + void this.tryUpgrade() + } + }) + if (this.logical.getState() === 'connected') { + await this.tryUpgrade() + } + } + + setForeground(foreground: boolean): void { + this.foreground = foreground + if (foreground && this.logical.getState() === 'connected') { + void this.tryUpgrade() + } + } + + stop(): void { + this.stopped = true + this.unsubscribe?.() + this.unsubscribe = null + } + + private async tryUpgrade(): Promise { + if (this.stopped || !this.foreground || this.inFlight) { + return + } + this.inFlight = true + try { + const result = await this.dependencies.upgrade(this.logical, this.host) + if (!result || this.stopped) { + return + } + this.unsubscribe?.() + this.unsubscribe = null + await this.dependencies.onUpgraded(result) + } catch { + // Why: the journal survives transient auth/control failure; retry only on + // the next authenticated reconnect or foreground transition. + } finally { + this.inFlight = false + } + } +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts new file mode 100644 index 00000000000..f250b3c0b35 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -0,0 +1,95 @@ +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { sha256 } from '@noble/hashes/sha256' +import { z } from 'zod' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) + +export const MobileRelayDirectUpgradeJournalSchema = z + .object({ + v: z.literal(1), + hostId: z.string().min(1), + reqId: z.string().min(1).max(128), + pendingResumeToken: Base64Url32ByteSchema, + pendingResumeTokenHash: Base64Url32ByteSchema + }) + .strict() + +export type MobileRelayDirectUpgradeJournal = z.infer + +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function journalKey(hostId: string): string { + return `orca.mobile-relay.direct-upgrade.${hostId}` +} + +export function createMobileRelayDirectUpgradeJournal( + hostId: string, + randomBytes: (length: number) => Uint8Array +): MobileRelayDirectUpgradeJournal { + const pendingResumeToken = encodeBase64Url(randomBytes(32)) + return MobileRelayDirectUpgradeJournalSchema.parse({ + v: 1, + hostId, + reqId: `upgrade-${encodeBase64Url(randomBytes(16))}`, + pendingResumeToken, + pendingResumeTokenHash: encodeBase64Url(sha256(decodeBase64Url(pendingResumeToken))) + }) +} + +export async function readMobileRelayDirectUpgradeJournal( + hostId: string +): Promise { + requireNativeSecretStore() + const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + if (!raw) { + return null + } + try { + const parsed = MobileRelayDirectUpgradeJournalSchema.safeParse(JSON.parse(raw)) + return parsed.success && parsed.data.hostId === hostId ? parsed.data : null + } catch { + return null + } +} + +export async function writeMobileRelayDirectUpgradeJournal( + journal: MobileRelayDirectUpgradeJournal +): Promise { + requireNativeSecretStore() + const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) + await SecureStore.setItemAsync( + journalKey(parsed.hostId), + JSON.stringify(parsed), + KEYCHAIN_OPTIONS + ) +} + +export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { + if (Platform.OS === 'web') { + return + } + await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) +} + +function decodeBase64Url(value: string): Uint8Array { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = `${base64}${'='.repeat((4 - (base64.length % 4)) % 4)}` + return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)) +} + +function encodeBase64Url(value: Uint8Array): string { + let binary = '' + for (const byte of value) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay upgrade state requires a native secret store') + } +} diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.test.ts b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts new file mode 100644 index 00000000000..d77848b6e17 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import { MobileRelayUpgradeHostRemovedError } from './host-store' +import { + createMobileRelayDirectUpgradeJournal, + type MobileRelayDirectUpgradeJournal +} from './mobile-relay-direct-upgrade-journal' +import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade' +import type { RpcClient } from './rpc-client' +import type { HostProfile, RpcResponse } from './types' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +const relay: MobileRelayEndpoint = { + v: 1, + directorUrl: 'https://relay-staging.onorca.dev', + cellUrl: 'https://c1.relay-staging.onorca.dev', + assignmentEpoch: 4, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 +} + +const host: HostProfile = { + id: 'host-direct', + name: 'Host 4', + endpoint: 'ws://192.168.1.2:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 +} + +function success(result: unknown): RpcResponse { + return { id: 'rpc', ok: true, result, _meta: { runtimeId: 'runtime' } } +} + +function clientWith(responses: RpcResponse[]) { + return { + sendRequest: vi.fn(async () => responses.shift()!), + getState: () => 'connected' + } as unknown as RpcClient +} + +function installed(journal: MobileRelayDirectUpgradeJournal) { + return { + v: 1 as const, + reqId: journal.reqId, + authorizationMode: 'authenticated-direct' as const, + currentVersion: 1, + resumeExpiresAt: 9_999_999 + } +} + +function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) { + let stored = journal + return { + readJournal: vi.fn(async () => stored), + writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => { + stored = next + }), + clearJournal: vi.fn(async () => { + stored = null + }), + writeBundle: vi.fn(async () => {}), + deleteBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + randomBytes: (length: number) => new Uint8Array(length).fill(7) + } +} + +describe('existing direct pairing relay upgrade', () => { + it('persists pending material before install and publishes only after committed status', async () => { + const deps = dependencies() + let journal: MobileRelayDirectUpgradeJournal | null = null + deps.writeJournal.mockImplementation(async (next) => { + journal = next + }) + const client = clientWith([ + success({ v: 1, relay }), + success({ + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: 9_999_999 + }), + success({ + v: 1, + relay, + installStatus: { + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + state: 'committed', + result: { + v: 1, + reqId: 'upgrade-BwcHBwcHBwcHBwcHBwcHBw', + authorizationMode: 'authenticated-direct', + currentVersion: 1, + resumeExpiresAt: 9_999_999 + } + } + }) + ]) + + const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps }) + + expect(journal).not.toBeNull() + expect(deps.writeJournal.mock.invocationCallOrder[0]).toBeLessThan( + client.sendRequest.mock.invocationCallOrder[0]! + ) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: journal!.reqId, + newResumeTokenHash: journal!.pendingResumeTokenHash + }) + expect(deps.writeBundle).toHaveBeenCalledBefore(deps.saveHost) + expect(result?.host.relay).toEqual(relay) + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + }) + + it('recovers an already committed install without authorizing a second one', async () => { + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(3) + ) + const committed = installed(journal) + const deps = dependencies(journal) + const client = clientWith([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed } + }) + ]) + + const result = await upgradeDirectMobileRelay({ client, host, dependencies: deps }) + + expect(client.sendRequest).toHaveBeenCalledOnce() + expect(result?.bundle.current.version).toBe(1) + expect(deps.writeBundle).toHaveBeenCalledOnce() + }) + + it('cleans pending state and leaves direct access unchanged for an old desktop', async () => { + const deps = dependencies() + const client = clientWith([ + { + id: 'rpc', + ok: false, + error: { code: 'method_not_found', message: 'unsupported' }, + _meta: { runtimeId: 'runtime' } + } + ]) + + await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull() + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + expect(deps.writeBundle).not.toHaveBeenCalled() + expect(deps.saveHost).not.toHaveBeenCalled() + }) + + it('retains the durable journal when relay registration is temporarily unavailable', async () => { + const deps = dependencies() + const client = clientWith([success({ v: 1, relay: null })]) + + await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).rejects.toThrow( + 'relay endpoint unavailable' + ) + expect(deps.writeJournal).toHaveBeenCalledOnce() + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + it('cleans newly installed secrets instead of resurrecting a removed host', async () => { + const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) => + new Uint8Array(length).fill(5) + ) + const committed = installed(journal) + const deps = dependencies(journal) + deps.saveHost.mockRejectedValue( + new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed') + ) + const client = clientWith([ + success({ + v: 1, + relay, + installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed } + }) + ]) + + await expect( + upgradeDirectMobileRelay({ client, host, dependencies: deps }) + ).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError) + expect(deps.deleteBundle).toHaveBeenCalledWith(host.id) + expect(deps.clearJournal).toHaveBeenCalledWith(host.id) + }) +}) diff --git a/mobile/src/transport/mobile-relay-direct-upgrade.ts b/mobile/src/transport/mobile-relay-direct-upgrade.ts new file mode 100644 index 00000000000..b804d6fb610 --- /dev/null +++ b/mobile/src/transport/mobile-relay-direct-upgrade.ts @@ -0,0 +1,175 @@ +import * as ExpoCrypto from 'expo-crypto' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceCredentialInstalled, + type PairingGetEndpointsResult +} from '../../../src/shared/mobile-relay-credential-contract' +import { MobileRelayUpgradeHostRemovedError, saveExistingHostRelayUpgrade } from './host-store' +import { persistRelayHost } from './mobile-endpoint-supervisor-support' +import { + MobileRelayCredentialBundleSchema, + deleteMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle, + type MobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import { + createMobileRelayDirectUpgradeJournal, + deleteMobileRelayDirectUpgradeJournal, + readMobileRelayDirectUpgradeJournal, + writeMobileRelayDirectUpgradeJournal, + type MobileRelayDirectUpgradeJournal +} from './mobile-relay-direct-upgrade-journal' +import type { RpcClient } from './rpc-client' +import type { HostProfile, RpcResponse } from './types' + +export type MobileRelayDirectUpgradeResult = { + host: HostProfile + bundle: MobileRelayCredentialBundle +} + +type Dependencies = { + readJournal: typeof readMobileRelayDirectUpgradeJournal + writeJournal: typeof writeMobileRelayDirectUpgradeJournal + clearJournal: typeof deleteMobileRelayDirectUpgradeJournal + writeBundle: typeof writeMobileRelayCredentialBundle + saveHost: typeof saveExistingHostRelayUpgrade + deleteBundle: typeof deleteMobileRelayCredentialBundle + randomBytes: (length: number) => Uint8Array +} + +export async function upgradeDirectMobileRelay(args: { + client: RpcClient + host: HostProfile + dependencies?: Partial +}): Promise { + if (args.host.relay) { + return null + } + const dependencies: Dependencies = { + readJournal: readMobileRelayDirectUpgradeJournal, + writeJournal: writeMobileRelayDirectUpgradeJournal, + clearJournal: deleteMobileRelayDirectUpgradeJournal, + writeBundle: writeMobileRelayCredentialBundle, + saveHost: saveExistingHostRelayUpgrade, + deleteBundle: deleteMobileRelayCredentialBundle, + randomBytes: ExpoCrypto.getRandomBytes, + ...args.dependencies + } + let journal = await dependencies.readJournal(args.host.id) + if (!journal) { + journal = createMobileRelayDirectUpgradeJournal(args.host.id, dependencies.randomBytes) + // Why: the stable reqId and pending secret must survive a lost install response. + await dependencies.writeJournal(journal) + } + + const initial = await getEndpoints(args.client, journal.reqId) + if (initial === 'method-not-found') { + await dependencies.clearJournal(args.host.id) + return null + } + if (initial.installStatus?.state === 'committed') { + return publishCommitted(args.host, journal, initial, dependencies) + } + if (!initial.relay) { + throw new Error('relay endpoint unavailable for direct pairing upgrade') + } + + const provisionResponse = await args.client.sendRequest('pairing.provisionRelay', { + reqId: journal.reqId, + newResumeTokenHash: journal.pendingResumeTokenHash + }) + if (isMethodNotFound(provisionResponse)) { + await dependencies.clearJournal(args.host.id) + return null + } + const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse)) + assertDirectInstall(journal, installed) + const reconciled = await getEndpoints(args.client, journal.reqId) + if (reconciled === 'method-not-found') { + throw new Error('relay endpoint reconciliation became unavailable') + } + assertCommitted(reconciled, installed) + return publishCommitted(args.host, journal, reconciled, dependencies) +} + +async function publishCommitted( + host: HostProfile, + journal: MobileRelayDirectUpgradeJournal, + endpoints: PairingGetEndpointsResult, + dependencies: Dependencies +): Promise { + if (endpoints.installStatus?.state !== 'committed' || !endpoints.relay) { + throw new Error('direct pairing upgrade was not authoritatively committed') + } + const installed = endpoints.installStatus.result + assertDirectInstall(journal, installed) + const bundle = MobileRelayCredentialBundleSchema.parse({ + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: journal.pendingResumeToken, + hash: journal.pendingResumeTokenHash, + version: installed.currentVersion, + expiresAt: installed.resumeExpiresAt + } + }) + // Why: the overlay must never advertise relay without its matching credential. + await dependencies.writeBundle(bundle) + let updatedHost: HostProfile + try { + updatedHost = await persistRelayHost(host, endpoints.relay, dependencies.saveHost) + } catch (error) { + if (error instanceof MobileRelayUpgradeHostRemovedError) { + await dependencies.deleteBundle(host.id) + await dependencies.clearJournal(host.id) + } + throw error + } + await dependencies.clearJournal(host.id) + return { host: updatedHost, bundle } +} + +async function getEndpoints( + client: RpcClient, + installReqId: string +): Promise { + const response = await client.sendRequest('pairing.getEndpoints', { installReqId }) + if (isMethodNotFound(response)) { + return 'method-not-found' + } + return PairingGetEndpointsResultSchema.parse(requireSuccess(response)) +} + +function assertDirectInstall( + journal: MobileRelayDirectUpgradeJournal, + installed: DeviceCredentialInstalled +): void { + if (installed.reqId !== journal.reqId || installed.authorizationMode !== 'authenticated-direct') { + throw new Error('relay credential install does not match direct upgrade journal') + } +} + +function assertCommitted( + endpoints: PairingGetEndpointsResult, + installed: DeviceCredentialInstalled +): void { + if ( + endpoints.installStatus?.state !== 'committed' || + JSON.stringify(endpoints.installStatus.result) !== JSON.stringify(installed) + ) { + throw new Error('relay credential install was not authoritatively reconciled') + } +} + +function requireSuccess(response: RpcResponse): unknown { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result +} + +function isMethodNotFound(response: RpcResponse): boolean { + return !response.ok && response.error.code === 'method_not_found' +}