diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index 6fff509d820..1db947b10b0 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -5,12 +5,10 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { - startPairingConnectionAttempt, - type PairingConnectionAttempt -} from '../src/transport/pairing-connection-attempt' -import { connect } from '../src/transport/rpc-client' -import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types' + startPreProfilePairing, + type PreProfilePairingAttempt +} from '../src/transport/pre-profile-pairing-coordinator' +import type { ConnectionLogEntry } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -35,7 +33,7 @@ export default function PairConfirmScreen() { // batch fewer setState calls when entries arrive in bursts. const logsRef = useRef([]) const mountedRef = useRef(true) - const activePairingAttemptRef = useRef(null) + const activePairingAttemptRef = useRef(null) const routeState = resolvePairConfirmRouteState(params.code) const offer = routeState.offer @@ -79,20 +77,12 @@ export default function PairConfirmScreen() { setStatus('connecting') logsRef.current = [] setLogs([]) - let client: ReturnType | null = null activePairingAttemptRef.current?.dispose() - // Why: split the try/catch around the network call vs the local save - // so a Keychain or AsyncStorage failure doesn't masquerade as a - // "Cannot connect" error. - let response: RpcResponse - const attempt = startPairingConnectionAttempt({ + const attempt = startPreProfilePairing({ + offer, timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, - closeClient: () => client?.close() - }) - activePairingAttemptRef.current = attempt - try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + connectOptions: { onLog: (entry) => { if (!mountedRef.current || activePairingAttemptRef.current !== attempt) { return @@ -100,8 +90,11 @@ export default function PairConfirmScreen() { logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } - }) - response = await client.sendRequest('status.get') + } + }) + activePairingAttemptRef.current = attempt + try { + const { hostId } = await attempt.result const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { @@ -110,6 +103,7 @@ export default function PairConfirmScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + router.replace(`/h/${hostId}`) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt @@ -125,47 +119,7 @@ export default function PairConfirmScreen() { setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` - : 'Cannot connect — check that your computer is on the same network' - ) - return - } - - if (!response.ok) { - if (!mountedRef.current) { - return - } - setStatus('error') - setErrorMessage( - response.error.code === 'unauthorized' - ? 'Authentication failed — token may be expired' - : `Server error: ${response.error.message}` - ) - return - } - - try { - const hostId = `host-${Date.now()}` - const hostName = await getNextHostName() - await saveHost({ - id: hostId, - name: hostName, - endpoint: offer.endpoint, - deviceToken: offer.deviceToken, - publicKeyB64: offer.publicKeyB64, - lastConnected: Date.now() - }) - if (!mountedRef.current) { - return - } - router.replace(`/h/${hostId}`) - } catch (err) { - if (!mountedRef.current) { - return - } - console.warn('[pair-confirm] save failed', err) - setStatus('error') - setErrorMessage( - `Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}` + : `Pairing failed: ${err instanceof Error ? err.message : String(err)}` ) } } diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 599aba41ad8..926812cffb1 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -14,12 +14,10 @@ import { useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' import { - startPairingConnectionAttempt, - type PairingConnectionAttempt -} from '../src/transport/pairing-connection-attempt' -import { connect } from '../src/transport/rpc-client' -import { saveHost, getNextHostName } from '../src/transport/host-store' -import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types' + startPreProfilePairing, + type PreProfilePairingAttempt +} from '../src/transport/pre-profile-pairing-coordinator' +import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' import { TextInputModal } from '../src/components/TextInputModal' import { ConnectionLog } from '../src/components/ConnectionLog' @@ -54,7 +52,7 @@ export default function PairScanScreen() { const logsRef = useRef([]) const processingRef = useRef(false) const mountedRef = useRef(true) - const activePairingAttemptRef = useRef(null) + const activePairingAttemptRef = useRef(null) const setPairScanRootRef = useCallback((node: View | null): void => { if (node !== null) { @@ -123,21 +121,12 @@ export default function PairScanScreen() { setStatus('connecting') logsRef.current = [] setLogs([]) - let client: ReturnType | null = null activePairingAttemptRef.current?.dispose() - // Why: split the try/catch around the network call vs the local save - // so a Keychain or AsyncStorage failure doesn't masquerade as a - // "Cannot connect — same network?" error. Pairing reached the - // desktop fine; the failure is local persistence. - let response: RpcResponse - const attempt = startPairingConnectionAttempt({ + const attempt = startPreProfilePairing({ + offer, timeoutMs: PAIRING_OVERALL_TIMEOUT_MS, - closeClient: () => client?.close() - }) - activePairingAttemptRef.current = attempt - try { - client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, { + connectOptions: { onLog: (entry) => { if (!mountedRef.current || activePairingAttemptRef.current !== attempt) { return @@ -145,8 +134,11 @@ export default function PairScanScreen() { logsRef.current = [...logsRef.current, entry] setLogs(logsRef.current) } - }) - response = await client.sendRequest('status.get') + } + }) + activePairingAttemptRef.current = attempt + try { + const { hostId } = await attempt.result const attemptIsCurrent = activePairingAttemptRef.current === attempt attempt.dispose() if (activePairingAttemptRef.current === attempt) { @@ -155,6 +147,7 @@ export default function PairScanScreen() { if (!mountedRef.current || !attemptIsCurrent) { return } + router.replace(`/h/${hostId}`) } catch (err) { const timedOut = attempt.timedOut const attemptIsCurrent = activePairingAttemptRef.current === attempt @@ -170,51 +163,7 @@ export default function PairScanScreen() { setErrorMessage( timedOut ? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled` - : 'Cannot connect — check that your computer is on the same network' - ) - processingRef.current = false - return - } - - if (!response.ok) { - if (!mountedRef.current) { - return - } - if (response.error.code === 'unauthorized') { - setStatus('error') - setErrorMessage('Authentication failed — token may be expired') - processingRef.current = false - return - } - setStatus('error') - setErrorMessage(`Server error: ${response.error.message}`) - processingRef.current = false - return - } - - try { - const hostId = `host-${Date.now()}` - const hostName = await getNextHostName() - await saveHost({ - id: hostId, - name: hostName, - endpoint: offer.endpoint, - deviceToken: offer.deviceToken, - publicKeyB64: offer.publicKeyB64, - lastConnected: Date.now() - }) - if (!mountedRef.current) { - return - } - router.replace(`/h/${hostId}`) - } catch (err) { - if (!mountedRef.current) { - return - } - console.warn('[pair] save failed', err) - setStatus('error') - setErrorMessage( - `Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}` + : `Pairing failed: ${err instanceof Error ? err.message : String(err)}` ) processingRef.current = false } diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 1f2c878271a..2cfaa176e4e 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' const asyncStorageMock = vi.hoisted(() => ({ getItem: vi.fn(), @@ -32,9 +33,17 @@ vi.mock('./host-credential-cleanup', () => ({ retryPendingHostCredentialCleanups: vi.fn() })) -import { removeHost, renameHost, resetHostStoreForTests, updateLastConnected } from './host-store' +import { + loadHosts, + removeHost, + renameHost, + resetHostStoreForTests, + updateLastConnected +} from './host-store' +import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store' const HOSTS_STORAGE_KEY = 'orca:hosts' +const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' const HOST_ONE = { id: 'host-1', name: 'Host 1', @@ -52,17 +61,23 @@ const HOST_TWO = { describe('host-store list mutations', () => { let storedHostsRaw: string + let storedOverlayRaw: string | null beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO]) + storedOverlayRaw = null asyncStorageMock.getItem.mockImplementation(async (key: string) => { if (key === HOSTS_STORAGE_KEY) { return storedHostsRaw } + if (key === OVERLAY_STORAGE_KEY) { + return storedOverlayRaw + } return null }) asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { @@ -70,6 +85,9 @@ describe('host-store list mutations', () => { storedHostsRaw = raw } }) + secureStoreMock.getItemAsync.mockImplementation(async (key: string) => + key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null + ) }) it('commits the removal when credential cleanup scheduling rejects', async () => { @@ -81,6 +99,40 @@ describe('host-store list mutations', () => { expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function)) }) + it('merges v2 endpoints only onto an existing legacy base host', async () => { + const overlay: MobileRelayHostOverlay = { + v: 2, + hostId: HOST_ONE.id, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } + } + storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }]) + + const hosts = await loadHosts() + + expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({ + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + }) + expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false) + }) + 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 e37b0427d8c..b6caa681569 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -12,6 +12,12 @@ import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup } from './host-credential-cleanup' +import { + loadMobileRelayHostOverlays, + removeMobileRelayHostOverlay, + saveMobileRelayHostOverlay +} from './mobile-relay-host-overlay-store' +import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' const STORAGE_KEY = 'orca:hosts' // Why: SecureStore keys must match [A-Za-z0-9._-]; colons are rejected. @@ -61,6 +67,11 @@ async function deleteDeviceToken(hostId: string): Promise { await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) } +async function deleteHostCredentials(hostId: string): Promise { + await deleteDeviceToken(hostId) + await deleteMobileRelayCredentialBundle(hostId) +} + // Why: SecureStore reads on Android Keystore can take 50-200ms each, and // loadHosts() is called from every screen mount + every useFocusEffect. // Stack with N hosts and you get N*200ms blocking every navigation, which @@ -120,6 +131,7 @@ async function doLoadHosts(): Promise { if (!storedHosts) { return [] } + const overlays = await loadMobileRelayHostOverlays(new Set(storedHosts.map(({ id }) => id))) const out: HostProfile[] = [] for (const stored of storedHosts) { @@ -144,7 +156,18 @@ async function doLoadHosts(): Promise { token = fetched tokenCache.set(stored.id, token) } - out.push({ ...stored, deviceToken: token }) + const overlay = overlays.get(stored.id) + out.push({ + ...stored, + deviceToken: token, + ...(overlay + ? { + endpoints: overlay.endpoints, + relayHostId: overlay.relayHostId, + relay: overlay.relay + } + : {}) + }) } return out } @@ -215,15 +238,30 @@ export async function saveHost(host: HostProfile): Promise { // from current metadata. await writeDeviceToken(stored.id, validated.deviceToken) tokenCache.set(stored.id, validated.deviceToken) + if (validated.endpoints) { + await saveMobileRelayHostOverlay({ + v: 2, + hostId: stored.id, + endpoints: validated.endpoints, + relayHostId: validated.relayHostId, + relay: validated.relay + }) + } } export async function removeHost(hostId: string): Promise { await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId)) tokenCache.delete(hostId) + try { + await removeMobileRelayHostOverlay(hostId) + } catch { + // The missing legacy base is authoritative, so a retained overlay cannot + // resurrect this host and can be cleaned on a later explicit retry. + } // Why: await only the durable cleanup intent (AsyncStorage). Native keychain // delete can reject or stall and must not freeze removeHost / the UI. try { - await scheduleHostCredentialCleanup(hostId, deleteDeviceToken) + await scheduleHostCredentialCleanup(hostId, deleteHostCredentials) } catch { // Metadata is already committed; orphan-token recovery is best-effort. } @@ -234,7 +272,7 @@ export async function retryPendingHostCredentialCleanup(): Promise<{ remainingIds: string[] storageUnreadable: boolean }> { - return retryPendingHostCredentialCleanups(deleteDeviceToken) + return retryPendingHostCredentialCleanups(deleteHostCredentials) } export async function renameHost(hostId: string, newName: string): Promise { diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts new file mode 100644 index 00000000000..9d70f71e021 --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const secureStore = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn() +})) +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('react-native', () => ({ Platform: platform })) + +import { + deleteMobileRelayCredentialBundle, + promotePairingJournalCredential, + readMobileRelayCredentialBundle, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +const journal = { + metadata: { + v: 1, + journalId: 'pair-1', + offerFingerprint: 'A'.repeat(43), + host: { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1 + }, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteExpiresAt: 10_000, + e2eeFraming: 2 + }, + installReqId: 'install-1', + pendingResumeTokenHash: 'B'.repeat(43), + winner: 'direct', + authorizationMode: 'authenticated-direct' + }, + secrets: { + v: 1, + journalId: 'pair-1', + deviceToken: 'device-token', + inviteToken: 'C'.repeat(43), + pendingResumeToken: 'D'.repeat(43) + } +} satisfies MobileRelayPairingJournal + +describe('mobile relay credential bundle', () => { + let stored: string | null + + beforeEach(() => { + vi.clearAllMocks() + platform.OS = 'ios' + stored = null + secureStore.getItemAsync.mockImplementation(async () => stored) + secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { + stored = value + }) + secureStore.deleteItemAsync.mockImplementation(async () => { + stored = null + }) + }) + + it('promotes only a matching committed install result to current', async () => { + const bundle = promotePairingJournalCredential({ + journal, + installed: { + v: 1, + reqId: 'install-1', + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: 50_000 + } + }) + await writeMobileRelayCredentialBundle(bundle) + + await expect(readMobileRelayCredentialBundle('host-1')).resolves.toEqual(bundle) + expect(stored).toContain(journal.secrets.pendingResumeToken) + expect(stored).not.toContain(journal.secrets.inviteToken) + }) + + it('rejects a result from another request or authorization mode', () => { + expect(() => + promotePairingJournalCredential({ + journal, + installed: { + v: 1, + reqId: 'other-request', + authorizationMode: 'relay-basis', + currentVersion: 1, + resumeExpiresAt: 50_000 + } + }) + ).toThrow(/does not match/) + }) + + it('deletes the namespaced bundle and never enables it on web', async () => { + await deleteMobileRelayCredentialBundle('host-1') + expect(secureStore.deleteItemAsync).toHaveBeenCalledWith( + 'orca.mobile-relay.credentials.host-1', + expect.any(Object) + ) + platform.OS = 'web' + await expect(readMobileRelayCredentialBundle('host-1')).rejects.toThrow(/native secret store/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts new file mode 100644 index 00000000000..87f6f3826f4 --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -0,0 +1,112 @@ +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { z } from 'zod' +import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) +const ResumeCredentialSchema = z + .object({ + token: Base64Url32ByteSchema, + hash: Base64Url32ByteSchema, + version: z.number().int().positive(), + expiresAt: z.number().int().nonnegative() + }) + .strict() + +export const MobileRelayCredentialBundleSchema = z + .object({ + v: z.literal(1), + hostId: z.string().min(1), + deviceToken: z.string().min(1), + current: ResumeCredentialSchema, + grace: ResumeCredentialSchema.optional(), + pending: z + .object({ + token: Base64Url32ByteSchema, + hash: Base64Url32ByteSchema, + reqId: z.string().min(1) + }) + .strict() + .optional(), + invite: z + .object({ token: Base64Url32ByteSchema, expiresAt: z.number().int().positive() }) + .strict() + .optional() + }) + .strict() + +export type MobileRelayCredentialBundle = z.infer + +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +function credentialKey(hostId: string): string { + return `orca.mobile-relay.credentials.${hostId}` +} + +export function promotePairingJournalCredential(args: { + journal: MobileRelayPairingJournal + installed: DeviceCredentialInstalled +}): MobileRelayCredentialBundle { + const { journal, installed } = args + if ( + installed.reqId !== journal.metadata.installReqId || + installed.authorizationMode !== journal.metadata.authorizationMode + ) { + throw new Error('relay credential install result does not match pairing journal') + } + return MobileRelayCredentialBundleSchema.parse({ + v: 1, + hostId: journal.metadata.host.id, + deviceToken: journal.secrets.deviceToken, + current: { + token: journal.secrets.pendingResumeToken, + hash: journal.metadata.pendingResumeTokenHash, + version: installed.currentVersion, + expiresAt: installed.resumeExpiresAt + } + }) +} + +export async function readMobileRelayCredentialBundle( + hostId: string +): Promise { + requireNativeSecretStore() + const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + if (raw === null) { + return null + } + try { + const result = MobileRelayCredentialBundleSchema.safeParse(JSON.parse(raw)) + return result.success && result.data.hostId === hostId ? result.data : null + } catch { + return null + } +} + +export async function writeMobileRelayCredentialBundle( + bundle: MobileRelayCredentialBundle +): Promise { + requireNativeSecretStore() + const validated = MobileRelayCredentialBundleSchema.parse(bundle) + await SecureStore.setItemAsync( + credentialKey(validated.hostId), + JSON.stringify(validated), + KEYCHAIN_OPTIONS + ) +} + +export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { + if (Platform.OS === 'web') { + return + } + await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay credentials require a native secret store') + } +} diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.test.ts b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts new file mode 100644 index 00000000000..8286fe67f5b --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay-store.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +import { + loadMobileRelayHostOverlays, + resetMobileRelayHostOverlayStoreForTests, + saveMobileRelayHostOverlay +} from './mobile-relay-host-overlay-store' +import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay' + +const STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' +const OVERLAY: MobileRelayHostOverlay = { + v: 2, + hostId: 'host-1', + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/AbCdEf0123_-xyZ9' + } + ], + relayHostId: 'AbCdEf0123_-xyZ9', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 + } +} + +describe('mobile relay host overlay store', () => { + let stored: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetMobileRelayHostOverlayStoreForTests() + stored = null + asyncStorage.getItem.mockImplementation(async (key: string) => + key === STORAGE_KEY ? stored : null + ) + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === STORAGE_KEY) { + stored = value + } + }) + }) + + it('round-trips v2 metadata in a namespace legacy builds do not rewrite', async () => { + await saveMobileRelayHostOverlay(OVERLAY) + + await expect(loadMobileRelayHostOverlays(new Set(['host-1']))).resolves.toEqual( + new Map([['host-1', OVERLAY]]) + ) + expect(asyncStorage.setItem).toHaveBeenCalledWith(STORAGE_KEY, expect.any(String)) + }) + + it('never overlays or resurrects a host whose legacy base was removed', async () => { + stored = JSON.stringify([OVERLAY]) + + await expect(loadMobileRelayHostOverlays(new Set())).resolves.toEqual(new Map()) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + expect(JSON.parse(stored)).toEqual([OVERLAY]) + }) + + it('refuses to overwrite unreadable overlay storage', async () => { + stored = '{' + + await expect(saveMobileRelayHostOverlay(OVERLAY)).rejects.toThrow(/unreadable/) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/mobile-relay-host-overlay-store.ts b/mobile/src/transport/mobile-relay-host-overlay-store.ts new file mode 100644 index 00000000000..9758e39c395 --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay-store.ts @@ -0,0 +1,85 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import { + MobileRelayHostOverlaySchema, + type MobileRelayHostOverlay +} from './mobile-relay-host-overlay' + +const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2' +let overlayMutation: Promise = Promise.resolve() + +function parseOverlays(raw: string | null): MobileRelayHostOverlay[] | null { + if (raw === null) { + return [] + } + try { + const value = JSON.parse(raw) as unknown + if (!Array.isArray(value)) { + return null + } + return value.flatMap((item) => { + const result = MobileRelayHostOverlaySchema.safeParse(item) + return result.success ? [result.data] : [] + }) + } catch { + return null + } +} + +async function readOverlaysForMutation(): Promise { + const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) + if (!overlays) { + // Why: never rewrite an unreadable v2 namespace as an empty list; doing so + // would destroy relay recovery data during an unrelated host mutation. + throw new Error('mobile relay host overlay storage unreadable') + } + return overlays +} + +async function mutateOverlays( + update: (overlays: MobileRelayHostOverlay[]) => MobileRelayHostOverlay[] +): Promise { + const mutation = overlayMutation.then(async () => { + const current = await readOverlaysForMutation() + await AsyncStorage.setItem(OVERLAY_STORAGE_KEY, JSON.stringify(update(current))) + }) + overlayMutation = mutation.catch(() => {}) + return mutation +} + +export async function loadMobileRelayHostOverlays( + existingHostIds: ReadonlySet +): Promise> { + await overlayMutation + const overlays = parseOverlays(await AsyncStorage.getItem(OVERLAY_STORAGE_KEY)) ?? [] + const active = new Map() + for (const overlay of overlays) { + // Why: an older app can remove the legacy base without knowing this + // namespace; never let the retained overlay resurrect that host later. + if (existingHostIds.has(overlay.hostId)) { + active.set(overlay.hostId, overlay) + } + } + return active +} + +export async function saveMobileRelayHostOverlay(overlay: MobileRelayHostOverlay): Promise { + const validated = MobileRelayHostOverlaySchema.parse(overlay) + return mutateOverlays((overlays) => { + const index = overlays.findIndex(({ hostId }) => hostId === validated.hostId) + if (index < 0) { + return [...overlays, validated] + } + const next = overlays.slice() + next[index] = validated + return next + }) +} + +export function removeMobileRelayHostOverlay(hostId: string): Promise { + return mutateOverlays((overlays) => overlays.filter((overlay) => overlay.hostId !== hostId)) +} + +/** Test-only: drain the module mutation chain between cases. */ +export function resetMobileRelayHostOverlayStoreForTests(): void { + overlayMutation = Promise.resolve() +} diff --git a/mobile/src/transport/mobile-relay-host-overlay.ts b/mobile/src/transport/mobile-relay-host-overlay.ts new file mode 100644 index 00000000000..59c407c9ee3 --- /dev/null +++ b/mobile/src/transport/mobile-relay-host-overlay.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' + +export const MobileAccessEndpointSchema = z + .object({ + id: z.string().min(1).max(128), + kind: z.enum(['lan', 'tailscale', 'relay']), + url: z.string().min(1).max(2048) + }) + .strict() + +export const MobileRelayHostOverlaySchema = z + .object({ + v: z.literal(2), + hostId: z.string().min(1), + endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16), + relayHostId: z + .string() + .regex(/^[A-Za-z0-9_-]{16}$/) + .optional(), + relay: MobileRelayEndpointSchema.optional() + }) + .strict() + .superRefine((overlay, context) => { + if ((overlay.relayHostId === undefined) !== (overlay.relay === undefined)) { + context.addIssue({ code: 'custom', message: 'Relay identity and endpoint must coexist' }) + return + } + if (overlay.relay && overlay.relay.relayHostId !== overlay.relayHostId) { + context.addIssue({ + code: 'custom', + path: ['relayHostId'], + message: 'Relay host identity mismatch' + }) + } + const relayEndpointCount = overlay.endpoints.filter(({ kind }) => kind === 'relay').length + if (relayEndpointCount !== (overlay.relay ? 1 : 0)) { + context.addIssue({ + code: 'custom', + path: ['endpoints'], + message: 'Expected exactly one endpoint for configured relay metadata' + }) + } + }) + +export type MobileAccessEndpoint = z.infer +export type MobileRelayHostOverlay = z.infer diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts new file mode 100644 index 00000000000..a76215be8f2 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) +const secureStore = vi.hoisted(() => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn() +})) +const platform = vi.hoisted(() => ({ OS: 'ios' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStore +})) +vi.mock('expo-crypto', () => ({ getRandomBytes: vi.fn() })) +vi.mock('react-native', () => ({ Platform: platform })) + +import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + clearMobileRelayPairingJournal, + loadMobileRelayPairingJournal, + resetMobileRelayPairingJournalStoreForTests, + saveMobileRelayPairingJournal, + updateMobileRelayPairingJournal +} from './mobile-relay-pairing-journal-store' +import type { PairingOffer } from './types' + +const now = Date.UTC(2026, 6, 13) +const offer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token-secret', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: now + 300_000, + e2eeFraming: 2 + } +} satisfies PairingOffer + +describe('mobile relay pairing journal store', () => { + let metadataRaw: string | null + let secretRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetMobileRelayPairingJournalStoreForTests() + platform.OS = 'ios' + metadataRaw = null + secretRaw = null + asyncStorage.getItem.mockImplementation(async () => metadataRaw) + asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { + metadataRaw = value + }) + asyncStorage.removeItem.mockImplementation(async () => { + metadataRaw = null + }) + secureStore.getItemAsync.mockImplementation(async () => secretRaw) + secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { + secretRaw = value + }) + secureStore.deleteItemAsync.mockImplementation(async () => { + secretRaw = null + }) + }) + + it('persists metadata before secrets and never places bearers in AsyncStorage', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + + await saveMobileRelayPairingJournal(journal) + + expect(asyncStorage.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStore.setItemAsync.mock.invocationCallOrder[0]! + ) + expect(metadataRaw).not.toContain(offer.deviceToken) + expect(metadataRaw).not.toContain(offer.relay.inviteToken) + expect(metadataRaw).not.toContain(journal.secrets.pendingResumeToken) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) + }) + + it('records a provisional winner only for the active journal identity', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(7) + }) + await saveMobileRelayPairingJournal(journal) + + await updateMobileRelayPairingJournal(journal.metadata.journalId, (metadata) => ({ + ...metadata, + winner: 'direct', + authorizationMode: 'authenticated-direct' + })) + await expect( + updateMobileRelayPairingJournal('stale-journal', (metadata) => metadata) + ).rejects.toThrow(/stale/) + expect(JSON.parse(metadataRaw!)).toMatchObject({ + winner: 'direct', + authorizationMode: 'authenticated-direct' + }) + }) + + it('treats metadata without its secret record as an incomplete crash', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(9) + }) + metadataRaw = JSON.stringify(journal.metadata) + + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(metadataRaw).toBeNull() + }) + + it('clears metadata before deleting its secret and keeps relay unavailable on web', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(11) + }) + await saveMobileRelayPairingJournal(journal) + await clearMobileRelayPairingJournal(journal.metadata.journalId) + expect(asyncStorage.removeItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStore.deleteItemAsync.mock.invocationCallOrder[0]! + ) + + platform.OS = 'web' + await expect(saveMobileRelayPairingJournal(journal)).rejects.toThrow(/native secret store/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts new file mode 100644 index 00000000000..756915792d6 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -0,0 +1,122 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' +import { + MobileRelayPairingJournalMetadataSchema, + MobileRelayPairingJournalSecretsSchema, + type MobileRelayPairingJournal, + type MobileRelayPairingJournalMetadata +} from './mobile-relay-pairing-journal' + +const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' +const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' +const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} +let journalMutation: Promise = Promise.resolve() + +export async function saveMobileRelayPairingJournal( + journal: MobileRelayPairingJournal +): Promise { + requireNativeSecretStore() + const metadata = MobileRelayPairingJournalMetadataSchema.parse(journal.metadata) + const secrets = MobileRelayPairingJournalSecretsSchema.parse(journal.secrets) + if (metadata.journalId !== secrets.journalId) { + throw new Error('mobile relay pairing journal identity mismatch') + } + const mutation = journalMutation.then(async () => { + // Why: metadata-first makes a crash before the keychain write recover as + // an incomplete journal, never as an untracked bearer secret. + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata)) + await SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +export async function loadMobileRelayPairingJournal(): Promise { + requireNativeSecretStore() + await journalMutation + const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + if (rawMetadata === null) { + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + return null + } + const metadata = parseMetadata(rawMetadata) + if (!metadata) { + return null + } + const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + if (rawSecrets === null) { + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + return null + } + const secrets = parseSecrets(rawSecrets) + if (!secrets || secrets.journalId !== metadata.journalId) { + return null + } + return { metadata, secrets } +} + +export async function updateMobileRelayPairingJournal( + journalId: string, + update: (metadata: MobileRelayPairingJournalMetadata) => MobileRelayPairingJournalMetadata +): Promise { + const mutation = journalMutation.then(async () => { + const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + const current = raw ? parseMetadata(raw) : null + if (!current || current.journalId !== journalId) { + throw new Error('stale mobile relay pairing journal') + } + const next = MobileRelayPairingJournalMetadataSchema.parse(update(current)) + if (next.journalId !== journalId) { + throw new Error('mobile relay pairing journal identity mismatch') + } + await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(next)) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +export async function clearMobileRelayPairingJournal(journalId: string): Promise { + const mutation = journalMutation.then(async () => { + const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + const current = raw ? parseMetadata(raw) : null + if (current && current.journalId !== journalId) { + throw new Error('stale mobile relay pairing journal') + } + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + }) + journalMutation = mutation.catch(() => {}) + return mutation +} + +function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null { + try { + const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw)) + return result.success ? result.data : null + } catch { + return null + } +} + +function parseSecrets(raw: string) { + try { + const result = MobileRelayPairingJournalSecretsSchema.safeParse(JSON.parse(raw)) + return result.success ? result.data : null + } catch { + return null + } +} + +function requireNativeSecretStore(): void { + if (Platform.OS === 'web') { + throw new Error('Orca Relay pairing requires a native secret store') + } +} + +/** Test-only: drain the module mutation chain between cases. */ +export function resetMobileRelayPairingJournalStoreForTests(): void { + journalMutation = Promise.resolve() +} diff --git a/mobile/src/transport/mobile-relay-pairing-journal.ts b/mobile/src/transport/mobile-relay-pairing-journal.ts new file mode 100644 index 00000000000..3fd8cad57d8 --- /dev/null +++ b/mobile/src/transport/mobile-relay-pairing-journal.ts @@ -0,0 +1,112 @@ +import * as ExpoCrypto from 'expo-crypto' +import { sha256 } from '@noble/hashes/sha256' +import { z } from 'zod' +import type { PairingOffer, PairingRelay } from './types' + +const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) + +export const MobileRelayPairingJournalMetadataSchema = z + .object({ + v: z.literal(1), + journalId: z.string().min(1).max(128), + offerFingerprint: Base64Url32ByteSchema, + host: z + .object({ + id: z.string().min(1), + name: z.string().min(1), + endpoint: z.string().min(1), + publicKeyB64: z.string().min(1), + lastConnected: z.number().int().nonnegative() + }) + .strict(), + relay: z + .object({ + v: z.literal(1), + directorUrl: z.string().min(1), + cellUrl: z.string().min(1), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/), + inviteExpiresAt: z.number().int().positive(), + e2eeFraming: z.literal(2) + }) + .strict(), + installReqId: z.string().min(1).max(128), + pendingResumeTokenHash: Base64Url32ByteSchema, + winner: z.enum(['direct', 'relay']).optional(), + authorizationMode: z.enum(['authenticated-direct', 'relay-basis']).optional() + }) + .strict() + +export const MobileRelayPairingJournalSecretsSchema = z + .object({ + v: z.literal(1), + journalId: z.string().min(1).max(128), + deviceToken: z.string().min(1), + inviteToken: Base64Url32ByteSchema, + pendingResumeToken: Base64Url32ByteSchema + }) + .strict() + +export type MobileRelayPairingJournalMetadata = z.infer< + typeof MobileRelayPairingJournalMetadataSchema +> +export type MobileRelayPairingJournalSecrets = z.infer< + typeof MobileRelayPairingJournalSecretsSchema +> +export type MobileRelayPairingJournal = { + metadata: MobileRelayPairingJournalMetadata + secrets: MobileRelayPairingJournalSecrets +} + +export function createMobileRelayPairingJournal(args: { + offer: PairingOffer & { relay: PairingRelay } + hostId: string + hostName: string + now?: number + randomBytes?: (length: number) => Uint8Array +}): MobileRelayPairingJournal { + const randomBytes = args.randomBytes ?? ExpoCrypto.getRandomBytes + const pendingResumeToken = encodeBase64Url(randomBytes(32)) + const journalId = `pair-${encodeBase64Url(randomBytes(16))}` + const installReqId = `install-${encodeBase64Url(randomBytes(16))}` + const { inviteToken, ...relayMetadata } = args.offer.relay + return { + metadata: MobileRelayPairingJournalMetadataSchema.parse({ + v: 1, + journalId, + offerFingerprint: encodeBase64Url(sha256(JSON.stringify(args.offer))), + host: { + id: args.hostId, + name: args.hostName, + endpoint: args.offer.endpoint, + publicKeyB64: args.offer.publicKeyB64, + lastConnected: args.now ?? Date.now() + }, + relay: relayMetadata, + installReqId, + pendingResumeTokenHash: encodeBase64Url(sha256(decodeBase64Url(pendingResumeToken))) + }), + secrets: { + v: 1, + journalId, + deviceToken: args.offer.deviceToken, + inviteToken, + pendingResumeToken + } + } +} + +function encodeBase64Url(value: Uint8Array | string): string { + const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +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)) +} diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts new file mode 100644 index 00000000000..585d9f47c0a --- /dev/null +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { startPreProfilePairing } from './pre-profile-pairing-coordinator' +import type { HostProfile, PairingOffer, RpcResponse } from './types' +import type { RpcClient } from './rpc-client' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(length) +})) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED' })) + +const now = Date.UTC(2026, 6, 13) +const directOffer: PairingOffer = { + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' +} +const relayOffer: PairingOffer = { + ...directOffer, + relay: { + v: 1, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: now + 300_000, + e2eeFraming: 2 + } +} + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function failure(code: string): RpcResponse { + return { + id: 'rpc-1', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function fakeClient(responses: RpcResponse[]) { + return { + sendRequest: vi.fn().mockImplementation(async () => responses.shift()!), + close: vi.fn() + } as unknown as RpcClient +} + +function dependencies(client: RpcClient, events: string[]) { + return { + connectDirect: vi.fn(() => (events.push('connect'), client)), + getNextHostName: vi.fn(async () => 'Blue Whale'), + saveHost: vi.fn(async (_host: HostProfile) => { + events.push('save-host') + }), + saveJournal: vi.fn(async (_journal: MobileRelayPairingJournal) => { + events.push('save-journal') + }), + updateJournal: vi.fn(async () => { + events.push('update-journal') + }), + clearJournal: vi.fn(async () => { + events.push('clear-journal') + }), + writeCredentialBundle: vi.fn(async (_bundle: MobileRelayCredentialBundle) => { + events.push('write-credential') + }), + now: () => now, + platform: 'ios' + } +} + +describe('pre-profile pairing coordinator', () => { + it('keeps a legacy offer direct-only through the shared path', async () => { + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' })]) + const deps = dependencies(client, events) + + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + expect(deps.saveHost).toHaveBeenCalledWith({ + id: `host-${now}`, + name: 'Blue Whale', + endpoint: directOffer.endpoint, + deviceToken: directOffer.deviceToken, + publicKeyB64: directOffer.publicKeyB64, + lastConnected: now + }) + expect(events).toEqual(['connect', 'save-host']) + }) + + it('journals before connecting and publishes only after authoritative direct install', async () => { + const events: string[] = [] + let journal: MobileRelayPairingJournal | null = null + const client = { + sendRequest: vi.fn(async (method: string) => { + if (method === 'status.get') { + return success({ version: '1.0.0' }) + } + if (!journal) { + throw new Error('journal was not saved before RPC') + } + const installed = { + v: 1 as const, + reqId: journal.metadata.installReqId, + authorizationMode: 'authenticated-direct' as const, + currentVersion: 1, + resumeExpiresAt: now + 86_400_000 + } + if (method === 'pairing.provisionRelay') { + return success(installed) + } + return success({ + v: 1, + relay: { + v: 1, + directorUrl: relayOffer.relay!.directorUrl, + cellUrl: relayOffer.relay!.cellUrl, + assignmentEpoch: 7, + relayHostId: relayOffer.relay!.relayHostId, + e2eeFraming: 2 + }, + installStatus: { + v: 1, + reqId: journal.metadata.installReqId, + state: 'committed', + result: installed + } + }) + }), + close: vi.fn() + } as unknown as RpcClient + const deps = dependencies(client, events) + deps.saveJournal.mockImplementation(async (value) => { + journal = value + events.push('save-journal') + }) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(journal).not.toBeNull() + expect(events).toEqual([ + 'save-journal', + 'connect', + 'update-journal', + 'write-credential', + 'save-host', + 'clear-journal' + ]) + expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'pairing.provisionRelay', { + reqId: journal!.metadata.installReqId, + newResumeTokenHash: journal!.metadata.pendingResumeTokenHash + }) + expect(deps.saveHost).toHaveBeenCalledWith( + expect.objectContaining({ + id: `host-${now}`, + endpoint: directOffer.endpoint, + relayHostId: relayOffer.relay!.relayHostId, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: directOffer.endpoint }, + { + id: 'relay-primary', + kind: 'relay', + url: `wss://relay-c1.onorca.dev/v1/connect/${relayOffer.relay!.relayHostId}` + } + ] + }) + ) + }) + + it('tolerates an old desktop method_not_found and commits a direct-only host', async () => { + const events: string[] = [] + const client = fakeClient([success({ version: '1.0.0' }), failure('method_not_found')]) + const deps = dependencies(client, events) + + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await expect(attempt.result).resolves.toEqual({ hostId: `host-${now}` }) + + expect(deps.saveHost).toHaveBeenCalledWith( + expect.not.objectContaining({ endpoints: expect.anything() }) + ) + expect(events).toEqual([ + 'save-journal', + 'connect', + 'update-journal', + 'save-host', + 'clear-journal' + ]) + }) + + it('cancels the disposable physical client without publishing a host', async () => { + let resolveStatus!: (response: RpcResponse) => void + const status = new Promise((resolve) => { + resolveStatus = resolve + }) + const client = fakeClient([]) + ;(client.sendRequest as ReturnType).mockReturnValue(status) + const deps = dependencies(client, []) + const attempt = startPreProfilePairing({ + offer: directOffer, + timeoutMs: 5_000, + dependencies: deps + }) + await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledWith('status.get')) + attempt.dispose() + resolveStatus(success({ version: '1.0.0' })) + + await expect(attempt.result).rejects.toThrow(/cancelled/) + expect(client.close).toHaveBeenCalledOnce() + expect(deps.saveHost).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts new file mode 100644 index 00000000000..35cdcb060ea --- /dev/null +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -0,0 +1,257 @@ +import { Platform } from 'react-native' +import { + DeviceCredentialInstalledSchema, + PairingGetEndpointsResultSchema, + type DeviceCredentialInstalled, + type MobileRelayEndpoint +} from '../../../src/shared/mobile-relay-credential-contract' +import { connect, type ConnectOptions, type RpcClient } from './rpc-client' +import { getNextHostName, saveHost } from './host-store' +import type { HostProfile, PairingOffer, RpcResponse } from './types' +import { + createMobileRelayPairingJournal, + type MobileRelayPairingJournal +} from './mobile-relay-pairing-journal' +import { + clearMobileRelayPairingJournal, + saveMobileRelayPairingJournal, + updateMobileRelayPairingJournal +} from './mobile-relay-pairing-journal-store' +import { + promotePairingJournalCredential, + writeMobileRelayCredentialBundle +} from './mobile-relay-credential-bundle' + +export type PreProfilePairingAttempt = { + readonly result: Promise<{ hostId: string }> + readonly timedOut: boolean + dispose(): void +} + +type Dependencies = { + connectDirect: typeof connect + getNextHostName: typeof getNextHostName + saveHost: typeof saveHost + saveJournal: typeof saveMobileRelayPairingJournal + updateJournal: typeof updateMobileRelayPairingJournal + clearJournal: typeof clearMobileRelayPairingJournal + writeCredentialBundle: typeof writeMobileRelayCredentialBundle + now: () => number + platform: string +} + +const defaultDependencies: Dependencies = { + connectDirect: connect, + getNextHostName, + saveHost, + saveJournal: saveMobileRelayPairingJournal, + updateJournal: updateMobileRelayPairingJournal, + clearJournal: clearMobileRelayPairingJournal, + writeCredentialBundle: writeMobileRelayCredentialBundle, + now: Date.now, + platform: Platform.OS +} + +export function startPreProfilePairing(args: { + offer: PairingOffer + timeoutMs: number + connectOptions?: ConnectOptions + dependencies?: Partial +}): PreProfilePairingAttempt { + const dependencies = { ...defaultDependencies, ...args.dependencies } + const clients = new Set() + let disposed = false + let timedOut = false + let timer: ReturnType | null = null + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + if (timer) { + clearTimeout(timer) + timer = null + } + for (const client of clients) { + client.close() + } + clients.clear() + } + + timer = setTimeout(() => { + timedOut = true + dispose() + }, args.timeoutMs) + + const result = runPairing(args.offer, args.connectOptions, dependencies, clients, () => disposed) + .catch((error: unknown) => { + if (timedOut) { + throw new Error('mobile pairing timed out') + } + throw error + }) + .finally(() => { + if (timer) { + clearTimeout(timer) + timer = null + } + for (const client of clients) { + client.close() + } + clients.clear() + }) + + return { + result, + get timedOut() { + return timedOut + }, + dispose + } +} + +async function runPairing( + offer: PairingOffer, + connectOptions: ConnectOptions | undefined, + dependencies: Dependencies, + clients: Set, + isDisposed: () => boolean +): Promise<{ hostId: string }> { + const now = dependencies.now() + const hostId = `host-${now}` + const hostName = await dependencies.getNextHostName() + assertActive(isDisposed) + let journal: MobileRelayPairingJournal | null = null + if (offer.relay && dependencies.platform !== 'web') { + journal = createMobileRelayPairingJournal({ + offer: { ...offer, relay: offer.relay }, + hostId, + hostName, + now + }) + await dependencies.saveJournal(journal) + assertActive(isDisposed) + } + + const client = dependencies.connectDirect( + offer.endpoint, + offer.deviceToken, + offer.publicKeyB64, + connectOptions + ) + clients.add(client) + requireSuccess(await client.sendRequest('status.get')) + assertActive(isDisposed) + + if (!journal) { + await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + return { hostId } + } + + journal = { + ...journal, + metadata: { + ...journal.metadata, + winner: 'direct', + authorizationMode: 'authenticated-direct' + } + } + await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) + const provision = await client.sendRequest('pairing.provisionRelay', { + reqId: journal.metadata.installReqId, + newResumeTokenHash: journal.metadata.pendingResumeTokenHash + }) + if (isMethodNotFound(provision)) { + await dependencies.saveHost(baseHost(offer, hostId, hostName, now)) + await dependencies.clearJournal(journal.metadata.journalId) + return { hostId } + } + const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provision)) + const endpoints = PairingGetEndpointsResultSchema.parse( + requireSuccess( + await client.sendRequest('pairing.getEndpoints', { + installReqId: journal.metadata.installReqId + }) + ) + ) + assertCommittedInstall(endpoints.installStatus, installed) + if (!endpoints.relay) { + throw new Error('desktop returned no relay endpoint after credential install') + } + assertActive(isDisposed) + await dependencies.writeCredentialBundle(promotePairingJournalCredential({ journal, installed })) + await dependencies.saveHost(relayHost(journal, endpoints.relay)) + await dependencies.clearJournal(journal.metadata.journalId) + return { hostId } +} + +function baseHost( + offer: PairingOffer, + hostId: string, + name: string, + lastConnected: number +): HostProfile { + return { + id: hostId, + name, + endpoint: offer.endpoint, + deviceToken: offer.deviceToken, + publicKeyB64: offer.publicKeyB64, + lastConnected + } +} + +function relayHost(journal: MobileRelayPairingJournal, relay: MobileRelayEndpoint): HostProfile { + const host = journal.metadata.host + return { + ...host, + deviceToken: journal.secrets.deviceToken, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: host.endpoint }, + { id: 'relay-primary', kind: 'relay', url: relayWebSocketUrl(relay) } + ], + relayHostId: relay.relayHostId, + relay + } +} + +function relayWebSocketUrl(relay: MobileRelayEndpoint): string { + const url = new URL(relay.cellUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} + +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' +} + +function assertCommittedInstall( + status: + | { state: 'not-found' } + | { state: 'committed'; result: DeviceCredentialInstalled } + | undefined, + installed: DeviceCredentialInstalled +): void { + if ( + !status || + status.state !== 'committed' || + JSON.stringify(status.result) !== JSON.stringify(installed) + ) { + throw new Error('relay credential install was not authoritatively reconciled') + } +} + +function assertActive(isDisposed: () => boolean): void { + if (isDisposed()) { + throw new Error('mobile pairing cancelled') + } +} diff --git a/mobile/src/transport/types.ts b/mobile/src/transport/types.ts index a61ca864b81..d031d11ff08 100644 --- a/mobile/src/transport/types.ts +++ b/mobile/src/transport/types.ts @@ -3,6 +3,12 @@ import { PairingOfferSchema, type PairingOffer } from '../../../src/shared/mobile-relay-pairing-offer' +import { + MobileAccessEndpointSchema, + type MobileAccessEndpoint, + type MobileRelayHostOverlay +} from './mobile-relay-host-overlay' +import { MobileRelayEndpointSchema } from '../../../src/shared/mobile-relay-credential-contract' export { PairingOfferSchema } export type { PairingOffer } @@ -60,6 +66,9 @@ export type HostProfile = { deviceToken: string publicKeyB64: string lastConnected: number + endpoints?: MobileAccessEndpoint[] + relayHostId?: MobileRelayHostOverlay['relayHostId'] + relay?: MobileRelayHostOverlay['relay'] } export const HostProfileSchema = z.object({ @@ -68,7 +77,13 @@ export const HostProfileSchema = z.object({ endpoint: z.string().min(1), deviceToken: z.string().min(1), publicKeyB64: z.string().min(1), - lastConnected: z.number().finite() + lastConnected: z.number().finite(), + endpoints: z.array(MobileAccessEndpointSchema).min(1).max(16).optional(), + relayHostId: z + .string() + .regex(/^[A-Za-z0-9_-]{16}$/) + .optional(), + relay: MobileRelayEndpointSchema.optional() }) // Why: persisted host record after the v0.0.3 keychain split. The