From 74039db6f0cbdb3e6ff29b6f1084b112ebd28bc9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 13 Jul 2026 01:41:02 -0400 Subject: [PATCH] feat(mobile): recover pairing through relay director Co-authored-by: Orca --- .../mobile-relay-invite-director.test.ts | 80 ++++++++++++++ .../transport/mobile-relay-invite-director.ts | 78 ++++++++++++++ .../transport/pairing-relay-candidate.test.ts | 102 ++++++++++++++++++ .../src/transport/pairing-relay-candidate.ts | 61 +++++++++++ .../pre-profile-pairing-coordinator.test.ts | 3 + .../pre-profile-pairing-coordinator.ts | 32 +++++- src/shared/mobile-relay-phone-protocol.ts | 16 +++ 7 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 mobile/src/transport/mobile-relay-invite-director.test.ts create mode 100644 mobile/src/transport/mobile-relay-invite-director.ts create mode 100644 mobile/src/transport/pairing-relay-candidate.test.ts create mode 100644 mobile/src/transport/pairing-relay-candidate.ts diff --git a/mobile/src/transport/mobile-relay-invite-director.test.ts b/mobile/src/transport/mobile-relay-invite-director.test.ts new file mode 100644 index 00000000000..04508024d07 --- /dev/null +++ b/mobile/src/transport/mobile-relay-invite-director.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' + +class FakeSocket { + sent: string[] = [] + onopen: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + onerror: (() => void) | null = null + onclose: ((event: { code: number }) => void) | null = null + send(value: string): void { + this.sent.push(value) + } + close(): void {} +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + inviteToken: 'abcdefghijklmnopqrstuvwxyzABCDEFGH012345678', + inviteExpiresAt: Date.now() + 300_000, + e2eeFraming: 2 as const +} + +describe('pairing invite director resolution', () => { + it('authenticates only to the configured director and accepts a strictly newer move', async () => { + const socket = new FakeSocket() + let url = '' + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: (value) => { + url = value + return socket as unknown as WebSocket + } + }) + socket.onopen?.() + expect(url).toBe('wss://relay.onorca.dev/v1/connect/AbCdEf0123_-xyZ9') + expect(url).not.toContain('?') + expect(JSON.parse(socket.sent[0]!)).toEqual({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: relay.inviteToken + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }) + }) + + await expect(resolving).resolves.toMatchObject({ + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }) + }) + + it('rejects same/older epochs and untrusted extra fields', async () => { + const socket = new FakeSocket() + const resolving = resolvePairingInviteThroughDirector({ + relay, + createSocket: () => socket as unknown as WebSocket + }) + socket.onmessage?.({ + data: JSON.stringify({ + type: 'relay-moved', + v: 1, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 7, + targetFromCell: true + }) + }) + + await expect(resolving).rejects.toThrow(/not strictly newer/) + }) +}) diff --git a/mobile/src/transport/mobile-relay-invite-director.ts b/mobile/src/transport/mobile-relay-invite-director.ts new file mode 100644 index 00000000000..5dc83f526b6 --- /dev/null +++ b/mobile/src/transport/mobile-relay-invite-director.ts @@ -0,0 +1,78 @@ +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import { RelayMovedSchema } from '../../../src/shared/mobile-relay-phone-protocol' + +export function resolvePairingInviteThroughDirector(args: { + relay: PairingRelay + timeoutMs?: number + createSocket?: (url: string) => WebSocket +}): Promise { + const socket = (args.createSocket ?? ((url) => new WebSocket(url)))( + directorWebSocketUrl(args.relay) + ) + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout( + () => finish(new Error('relay director resolution timed out')), + args.timeoutMs ?? 5_000 + ) + socket.onopen = () => { + socket.send( + JSON.stringify({ + type: 'relay-auth', + v: 1, + mode: 'connect', + credential: args.relay.inviteToken + }) + ) + } + socket.onmessage = (event) => { + if (typeof event.data !== 'string') { + finish(new Error('invalid relay director move')) + return + } + let value: unknown + try { + value = JSON.parse(event.data) + } catch { + finish(new Error('invalid relay director move')) + return + } + const moved = RelayMovedSchema.safeParse(value) + if (!moved.success || moved.data.assignmentEpoch <= args.relay.assignmentEpoch) { + finish(new Error('relay director move was not strictly newer')) + return + } + settled = true + clearTimeout(timeout) + socket.close() + resolve({ + ...args.relay, + cellUrl: moved.data.cellUrl, + assignmentEpoch: moved.data.assignmentEpoch + }) + } + socket.onerror = () => finish(new Error('relay director transport error')) + socket.onclose = (event) => { + if (!settled) { + finish(new Error(`relay director closed before move: ${event.code || 1006}`)) + } + } + + function finish(error: Error): void { + if (settled) { + return + } + settled = true + clearTimeout(timeout) + socket.close() + reject(error) + } + }) +} + +export function directorWebSocketUrl(relay: PairingRelay): string { + const url = new URL(relay.directorUrl) + url.protocol = 'wss:' + url.pathname = `/v1/connect/${encodeURIComponent(relay.relayHostId)}` + return url.toString() +} diff --git a/mobile/src/transport/pairing-relay-candidate.test.ts b/mobile/src/transport/pairing-relay-candidate.test.ts new file mode 100644 index 00000000000..fd08d8ee32d --- /dev/null +++ b/mobile/src/transport/pairing-relay-candidate.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PairingCandidateClient } from './mobile-relay-physical-client' +import { RelayOuterError } from './mobile-relay-physical-client' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length).fill(length) +})) + +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) + }, + secrets: { + v: 1, + journalId: 'pair-1', + deviceToken: 'device-token', + inviteToken: 'C'.repeat(43), + pendingResumeToken: 'D'.repeat(43) + } +} satisfies MobileRelayPairingJournal + +function client( + result: Promise | Promise> +): PairingCandidateClient { + return { sendRequest: vi.fn(() => result), close: vi.fn() } +} + +function success() { + return { + id: 'rpc-1', + ok: true as const, + result: { path: 'relay' }, + _meta: { runtimeId: 'runtime-1' } + } +} + +describe('recovering pairing relay candidate', () => { + it('persists a strictly-newer director move before retrying the target', async () => { + const events: string[] = [] + const stale = client(Promise.reject(new RelayOuterError(4409))) + const target = client(Promise.resolve(success())) + let connects = 0 + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => { + events.push(`connect:${relay.assignmentEpoch}`) + return connects++ === 0 ? stale : target + }, + resolveDirector: async (relay) => ({ + ...relay, + cellUrl: 'https://relay-c2.onorca.dev', + assignmentEpoch: 8 + }), + persistMove: async (relay) => { + events.push(`persist:${relay.assignmentEpoch}`) + }, + now: () => 1 + }) + + await expect(candidate.sendRequest('status.get')).resolves.toEqual(success()) + expect(events).toEqual(['connect:7', 'persist:8', 'connect:8']) + expect(stale.close).toHaveBeenCalledOnce() + }) + + it('does not ask the director to reinterpret endpoint-scoped host-offline', async () => { + const offline = client(Promise.reject(new RelayOuterError(4404))) + const resolveDirector = vi.fn() + const candidate = createRecoveringPairingRelayCandidate({ + journal, + connect: () => offline, + resolveDirector, + persistMove: vi.fn(), + now: () => 1 + }) + + await expect(candidate.sendRequest('status.get')).rejects.toEqual(new RelayOuterError(4404)) + expect(resolveDirector).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/pairing-relay-candidate.ts b/mobile/src/transport/pairing-relay-candidate.ts new file mode 100644 index 00000000000..6c46373d84e --- /dev/null +++ b/mobile/src/transport/pairing-relay-candidate.ts @@ -0,0 +1,61 @@ +import type { PairingRelay } from '../../../src/shared/mobile-relay-pairing-offer' +import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' + +export function createRecoveringPairingRelayCandidate(args: { + journal: MobileRelayPairingJournal + connect: (relay: PairingRelay) => PairingCandidateClient + resolveDirector: (relay: PairingRelay) => Promise + persistMove: (relay: PairingRelay) => Promise + now: () => number +}): PairingCandidateClient { + let relay = pairingRelayFromJournal(args.journal) + let client = args.connect(relay) + let recoveryAttempted = false + let closed = false + + return { + async sendRequest(method, params) { + try { + return await client.sendRequest(method, params) + } catch (error) { + if ( + method !== 'status.get' || + recoveryAttempted || + closed || + relay.inviteExpiresAt <= args.now() || + !isDirectorRecoverable(error) + ) { + throw error + } + recoveryAttempted = true + const moved = await args.resolveDirector(relay) + // Why: the authenticated newer assignment must be durable before a + // target dial so a crash cannot revert to the known-stale cell. + await args.persistMove(moved) + client.close() + relay = moved + client = args.connect(relay) + return client.sendRequest(method, params) + } + }, + close() { + closed = true + client.close() + } + } +} + +function pairingRelayFromJournal(journal: MobileRelayPairingJournal): PairingRelay { + return { + ...journal.metadata.relay, + inviteToken: journal.secrets.inviteToken + } +} + +function isDirectorRecoverable(error: unknown): boolean { + if (!(error instanceof RelayOuterError)) { + return true + } + return error.code === 4409 || error.code === 4503 || error.code === 1006 +} diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 318ab738cbb..16c833bf4f2 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -61,6 +61,9 @@ function dependencies(client: RpcClient, events: string[]) { return { connectDirect: vi.fn(() => (events.push('connect'), client)), connectRelay: vi.fn(() => unavailableRelay), + resolveInviteDirector: vi.fn(async () => { + throw new Error('director unavailable') + }), getNextHostName: vi.fn(async () => 'Blue Whale'), saveHost: vi.fn(async (_host: HostProfile) => { events.push('save-host') diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 1571e77545f..9d2a3b48ddb 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -26,6 +26,8 @@ import { type PairingCandidateClient } from './mobile-relay-physical-client' import { racePairingCandidates, type PairingCandidate } from './pairing-candidate-race' +import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' +import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' export type PreProfilePairingAttempt = { readonly result: Promise<{ hostId: string }> @@ -36,6 +38,7 @@ export type PreProfilePairingAttempt = { type Dependencies = { connectDirect: typeof connect connectRelay: typeof connectMobileRelayForPairing + resolveInviteDirector: typeof resolvePairingInviteThroughDirector getNextHostName: typeof getNextHostName saveHost: typeof saveHost saveJournal: typeof saveMobileRelayPairingJournal @@ -49,6 +52,7 @@ type Dependencies = { const defaultDependencies: Dependencies = { connectDirect: connect, connectRelay: connectMobileRelayForPairing, + resolveInviteDirector: resolvePairingInviteThroughDirector, getNextHostName, saveHost, saveJournal: saveMobileRelayPairingJournal, @@ -150,10 +154,30 @@ async function runPairing( clients.add(directClient) const candidates: PairingCandidate[] = [{ path: 'direct', client: directClient }] if (journal) { - const relayClient = dependencies.connectRelay({ - relay: offer.relay!, - deviceToken: offer.deviceToken, - desktopPublicKeyB64: offer.publicKeyB64 + const relayClient = createRecoveringPairingRelayCandidate({ + journal, + connect: (relay) => + dependencies.connectRelay({ + relay, + deviceToken: offer.deviceToken, + desktopPublicKeyB64: offer.publicKeyB64 + }), + resolveDirector: (relay) => dependencies.resolveInviteDirector({ relay }), + persistMove: async (relay) => { + journal = { + ...journal!, + metadata: { + ...journal!.metadata, + relay: { + ...journal!.metadata.relay, + cellUrl: relay.cellUrl, + assignmentEpoch: relay.assignmentEpoch + } + } + } + await dependencies.updateJournal(journal.metadata.journalId, () => journal!.metadata) + }, + now: dependencies.now }) clients.add(relayClient) candidates.push({ path: 'relay', client: relayClient }) diff --git a/src/shared/mobile-relay-phone-protocol.ts b/src/shared/mobile-relay-phone-protocol.ts index 935f502d40e..e4c354114b6 100644 --- a/src/shared/mobile-relay-phone-protocol.ts +++ b/src/shared/mobile-relay-phone-protocol.ts @@ -33,3 +33,19 @@ export const RelayPhoneHelloSchema = z.union([ ]) export type RelayPhoneHello = z.infer + +export const RelayMovedSchema = z + .object({ + type: z.literal('relay-moved'), + v: z.literal(1), + cellUrl: z.string().refine((value) => { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' && parsed.origin === value + } catch { + return false + } + }), + assignmentEpoch: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + }) + .strict()