diff --git a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts index 26cd6d0a0d3..95c29a06971 100644 --- a/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts +++ b/mobile/src/test-support/rpc-recording/adapters/pairing-journal-mount-adapters.ts @@ -46,6 +46,7 @@ export function pairingJournalMountAdapters( journal = null effect('journal-cleared', 'recovery') }, + claimJournal: async () => () => {}, readCredentialBundle: async () => null, writeCredentialBundle: async (written: { current: { version: number } }) => { effect('bundle-written', { version: written.current.version }) @@ -108,6 +109,8 @@ export function pairingJournalMountAdapters( clearJournal: async () => { effect('journal-cleared', JOURNAL_ID) }, + releaseJournal: () => {}, + recoverPendingJournal: async () => {}, writeCredentialBundle: async (written: { current: { version: number } }) => { effect('bundle-written', { version: written.current.version }) }, diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index 05f66979e8b..a8e96e5a786 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -16,7 +16,11 @@ import { relayConnectWebSocketUrl } from './mobile-relay-connect-url' const RELAY_ERROR_CLOSE_GRACE_MS = 250 export class RelayOuterError extends Error { - constructor(readonly code: number) { + // rejectedByRelayHello: the relay refused in a relay-hello frame, not just a close code. + constructor( + readonly code: number, + readonly rejectedByRelayHello = false + ) { super(`relay_outer_${code}`) } } @@ -167,7 +171,7 @@ export class MobileRelayE2eeLink { throw new Error('invalid relay hello') } if (!parsed.data.ok) { - throw new RelayOuterError(parsed.data.code) + throw new RelayOuterError(parsed.data.code, true) } if (parsed.data.credentialKind !== this.options.expectedCredentialKind) { throw new Error('relay credential resolved as an unexpected credential kind') diff --git a/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts index 13236d69cc7..15f2f71d9df 100644 --- a/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-error-timer-cleanup.test.ts @@ -175,7 +175,7 @@ describe('closed relay pairing client error timer ownership', () => { target.socket.onmessage?.({ data: JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 }) }) - expect(await waiting).toEqual(new RelayOuterError(4404)) + expect(await waiting).toEqual(new RelayOuterError(4404, true)) target.socket.onerror?.() expect(vi.getTimerCount()).toBe(0) expect(closedLogs(target.logs)).toEqual([ diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts index 2169d360b8f..66dbd5d2f6e 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -23,7 +23,9 @@ vi.mock('react-native', () => ({ Platform: platform })) import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { clearMobileRelayPairingJournal, + claimMobileRelayPairingJournalPublication, loadMobileRelayPairingJournal, + releaseMobileRelayPairingJournal, resetMobileRelayPairingJournalStoreForTests, saveMobileRelayPairingJournal, updateMobileRelayPairingJournal @@ -310,33 +312,101 @@ describe('mobile relay pairing journal store', () => { const replacementSave = saveMobileRelayPairingJournal(replacement) await expect(authorizationUpdate).resolves.toBeUndefined() - await expect(replacementSave).rejects.toThrow(/recovery pending/) + await expect(replacementSave).rejects.toThrow(/pairing in progress/) }) it.each([ ['winner', { winner: 'direct' as const }], ['authorization mode', { authorizationMode: 'authenticated-direct' as const }] - ])('refuses replacement once the durable %s exists', async (_name, authorization) => { + ])( + 'refuses replacement while the attempt with a durable %s runs', + async (_name, authorization) => { + const created = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + const journal = { + ...created, + metadata: { ...created.metadata, ...authorization } + } + + await saveMobileRelayPairingJournal(journal) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow( + /pairing in progress/ + ) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) + } + ) + + // Why: a stranded authorized journal used to refuse every later scan with + // "recovery pending" until it expired (#12518); a new scan now supersedes it. + it.each([ + ['an earlier launch', () => resetMobileRelayPairingJournalStoreForTests()], + ['an ended attempt', (journalId: string) => releaseMobileRelayPairingJournal(journalId)] + ])('lets a new scan supersede an authorized journal left by %s', async (_name, end) => { const created = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, hostId: 'host-1', hostName: 'Blue Whale', randomBytes: (length) => new Uint8Array(length).fill(10) }) - const journal = { + const stale = { ...created, - metadata: { ...created.metadata, ...authorization } + metadata: { + ...created.metadata, + winner: 'relay' as const, + authorizationMode: 'relay-basis' as const + } } - - await saveMobileRelayPairingJournal(journal) + await saveMobileRelayPairingJournal(stale) + end(stale.metadata.journalId) const replacement = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, hostId: 'host-2', hostName: 'Red Panda', randomBytes: (length) => new Uint8Array(length).fill(12) }) - await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/recovery pending/) - await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) + + await expect(saveMobileRelayPairingJournal(replacement)).resolves.toBeUndefined() + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + }) + + it('makes a superseding scan wait for a publication in progress, and refuses a stale claim', async () => { + const created = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(created) + releaseMobileRelayPairingJournal(created.metadata.journalId) + const release = await claimMobileRelayPairingJournalPublication(created.metadata.journalId) + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + let saved = false + const save = saveMobileRelayPairingJournal(replacement).then(() => (saved = true)) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(saved).toBe(false) + + await clearMobileRelayPairingJournal(created.metadata.journalId) + release() + await save + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + await expect( + claimMobileRelayPairingJournalPublication(created.metadata.journalId) + ).rejects.toThrow(/stale/) }) it('self-heals an undecryptable Android secret so the next QR scan can pair', async () => { diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index d02ebc72284..3702916e416 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -16,6 +16,12 @@ import { const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' let journalMutation: Promise = Promise.resolve() +// Journals whose pairing attempt is still running in this process. Only these +// are protected from replacement; a journal left by an ended attempt or an +// earlier launch belongs to recovery, which a new scan supersedes. +const inFlightJournalIds = new Set() +// Journals recovery is publishing right now; a superseding save waits for them. +const publications = new Map>() export async function saveMobileRelayPairingJournal( journal: MobileRelayPairingJournal @@ -26,27 +32,78 @@ export async function saveMobileRelayPairingJournal( if (metadata.journalId !== secrets.journalId) { throw new Error('mobile relay pairing journal identity mismatch') } + for (;;) { + const blocked = await replaceJournal(metadata, secrets) + if (!blocked) { + return + } + // Why: waiting outside the mutation chain lets the publication clear its journal. + await blocked.publication + } +} + +function replaceJournal( + metadata: MobileRelayPairingJournalMetadata, + secrets: MobileRelayPairingJournal['secrets'] +): Promise<{ publication: Promise } | null> { const mutation = journalMutation.then(async () => { const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) const existing = existingRaw ? parseMetadata(existingRaw) : null + const publication = existing ? publications.get(existing.journalId) : undefined + if (publication && existing?.journalId !== metadata.journalId) { + return { publication } + } if ( existing && existing.journalId !== metadata.journalId && + inFlightJournalIds.has(existing.journalId) && (existing.winner !== undefined || existing.authorizationMode !== undefined) ) { - throw new Error('mobile relay pairing recovery pending') + // Why: an authorized attempt may have its install RPC in flight right now. + throw new Error('mobile relay pairing in progress') } - // Why: no install RPC can run before winner+authorization are durable, so - // a new user-initiated scan may safely supersede a pre-authorization attempt. // 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 writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets)) + if (existing) { + inFlightJournalIds.delete(existing.journalId) + } + inFlightJournalIds.add(metadata.journalId) + return null }) - journalMutation = mutation.catch(() => {}) + journalMutation = mutation.then( + () => undefined, + () => undefined + ) return mutation } +// Recovery publishes a committed install only while its journal is still the +// stored one, and a superseding scan waits until that publication releases, so +// a late recovery can never overwrite the credentials a newer pairing wrote. +export async function claimMobileRelayPairingJournalPublication( + journalId: string +): Promise<() => void> { + let release = (): void => {} + const done = new Promise((resolve) => { + release = resolve + }) + const claim = journalMutation.then(async () => { + const raw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + if ((raw ? parseMetadata(raw) : null)?.journalId !== journalId) { + throw new Error('stale mobile relay pairing journal') + } + publications.set(journalId, done) + }) + journalMutation = claim.catch(() => {}) + await claim + return () => { + publications.delete(journalId) + release() + } +} + export async function loadMobileRelayPairingJournal(): Promise { requireNativeSecretStore() const load = journalMutation.then(async () => { @@ -117,11 +174,18 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise } await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) await deletePairingKeychainItem(JOURNAL_SECRET_KEY) + inFlightJournalIds.delete(journalId) }) journalMutation = mutation.catch(() => {}) return mutation } +// The attempt that saved this journal ended; the journal stays for recovery, +// and a later scan may replace it. +export function releaseMobileRelayPairingJournal(journalId: string): void { + inFlightJournalIds.delete(journalId) +} + function parseMetadata(raw: string): MobileRelayPairingJournalMetadata | null { try { const result = MobileRelayPairingJournalMetadataSchema.safeParse(JSON.parse(raw)) @@ -149,5 +213,7 @@ function requireNativeSecretStore(): void { /** Test-only: drain the module mutation chain between cases. */ export function resetMobileRelayPairingJournalStoreForTests(): void { journalMutation = Promise.resolve() + inFlightJournalIds.clear() + publications.clear() resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index e9024e23c24..30aaac8ca59 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -3,9 +3,10 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import { createMobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { recoverMobileRelayPairing, - resetMobileRelayPairingRecoveryForTests + resetMobileRelayPairingRecoveryForTests, + settleMobileRelayPairingRecovery } from './mobile-relay-pairing-recovery' -import type { PairingCandidateClient } from './mobile-relay-physical-client' +import { RelayOuterError, type PairingCandidateClient } from './mobile-relay-physical-client' import type { HostProfile, PairingOffer, RpcResponse } from './types' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) @@ -100,6 +101,7 @@ function dependencies(args: { Object.assign(args.journal.metadata, update(args.journal.metadata)) }), clearJournal: vi.fn(async () => {}), + claimJournal: vi.fn(async () => () => {}), readCredentialBundle: vi.fn(async () => args.bundle ?? null), writeCredentialBundle: vi.fn(async () => {}), loadHosts: vi.fn(async (): Promise => args.hosts ?? []), @@ -256,6 +258,78 @@ describe('mobile relay pairing recovery', () => { expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) }) + // Why: a desktop that rotated its QR revokes the pending device; the relay then + // rejects every credential, and waiting out the invite only locked the phone out. + it('abandons at once when the relay rejects every credential', async () => { + const saved = journal() + const rejected = client(async () => { + throw new RelayOuterError(4401, true) + }) + const deps = dependencies({ journal: saved, connectRelay: vi.fn(() => rejected) }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + // Why: the relay also closes 4401 on a first-frame timeout, with no relay-hello. + it('keeps a journal whose 4401 closes carried no relay-hello refusal', async () => { + const saved = journal() + const timedOut = client(async () => { + throw new RelayOuterError(4401) + }) + const deps = dependencies({ journal: saved, connectRelay: vi.fn(() => timedOut) }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + it('never publishes a committed install once a newer scan replaced its journal', async () => { + const saved = journal() + const committed = installed(saved, 'relay-basis') + const pending = client(async () => + response(endpoints(saved, { state: 'committed', result: committed })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => pending) }), + claimJournal: vi.fn(async () => { + throw new Error('stale mobile relay pairing journal') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.not.toBe('recovered') + expect(deps.writeCredentialBundle).not.toHaveBeenCalled() + expect(deps.savePairedHost).not.toHaveBeenCalled() + }) + + it('bounds how long a new scan waits for recovery', async () => { + vi.useFakeTimers() + try { + const saved = journal() + const hung = client(() => new Promise(() => {})) + const deps = dependencies({ journal: saved, connectRelay: vi.fn(() => hung) }) + let done = false + void settleMobileRelayPairingRecovery(8_000, deps).then(() => (done = true)) + await vi.advanceTimersByTimeAsync(7_999) + expect(done).toBe(false) + await vi.advanceTimersByTimeAsync(1) + expect(done).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('defers while any credential failed for a transport reason', async () => { + const saved = journal() + let calls = 0 + const mixed = client(async () => { + throw calls++ === 0 ? new RelayOuterError(4401, true) : new RelayOuterError(1006) + }) + const deps = dependencies({ journal: saved, connectRelay: vi.fn(() => mixed) }) + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { const saved = journal() const unreachable = client(async () => { diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index c93baec9c0d..3019a80a46c 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -14,6 +14,7 @@ import { import { resolvePairingInviteThroughDirector } from './mobile-relay-invite-director' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' import { + claimMobileRelayPairingJournalPublication, clearMobileRelayPairingJournal, loadMobileRelayPairingJournal, updateMobileRelayPairingJournal @@ -22,6 +23,7 @@ import { connectMobileRelayForPairing, type PairingCandidateClient } from './mobile-relay-physical-client' +import { isExplicitCredentialRejection } from './relay-credential-eligibility' import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import { relayHost } from './pairing-relay-host' import { @@ -35,6 +37,7 @@ type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal clearJournal: typeof clearMobileRelayPairingJournal + claimJournal: typeof claimMobileRelayPairingJournalPublication readCredentialBundle: typeof readMobileRelayCredentialBundle writeCredentialBundle: typeof writeMobileRelayCredentialBundle loadHosts: typeof loadHosts @@ -49,6 +52,7 @@ const defaultDependencies: RecoveryDependencies = { loadJournal: loadMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, clearJournal: clearMobileRelayPairingJournal, + claimJournal: claimMobileRelayPairingJournalPublication, readCredentialBundle: readMobileRelayCredentialBundle, writeCredentialBundle: writeMobileRelayCredentialBundle, loadHosts, @@ -78,6 +82,22 @@ export function recoverMobileRelayPairing( return recoveryPromise } +// Waits at most timeoutMs for recovery and never throws: a new scan proceeds +// either way, and a recovery still running only loses its stale journal. +export async function settleMobileRelayPairingRecovery( + timeoutMs: number, + overrides: Partial = {} +): Promise { + let timer: ReturnType | undefined + await Promise.race([ + recoverMobileRelayPairing(overrides).catch(() => {}), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + ]) + clearTimeout(timer) +} + async function runRecovery( dependencies: RecoveryDependencies ): Promise { @@ -106,6 +126,7 @@ async function runRecovery( // of an authoritatively committed install must not look like "nothing to // reconcile" — that journal is the only record left to retry the write from. let observedCommitted = false + let rejectedCredentials = 0 for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -140,9 +161,12 @@ async function runRecovery( await publishCommitted(journal, reconciled, dependencies) return 'recovered' } - } catch { + } catch (error) { // Why: ambiguous pairing state advances only by credential priority and // authoritative status; a transport failure never rewrites the journal. + if (isExplicitCredentialRejection(error)) { + rejectedCredentials++ + } } finally { client?.close() } @@ -153,9 +177,12 @@ async function runRecovery( // any uncommitted server-side install expires on its own. The extra invite // lifetime of slack keeps a brief relay outage from discarding a journal whose // resume credential would have reconciled it on the next launch. + // A relay that rejected every credential (desktop rotated the QR or revoked + // the device) is authoritative too: nothing can ever reconcile this journal. if ( !observedCommitted && - journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + (rejectedCredentials === credentials.length || + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now()) ) { await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) return 'abandoned' @@ -251,6 +278,20 @@ async function publishCommitted( throw new Error('relay pairing recovery was not committed') } const installed = endpoints.installStatus.result + const release = await dependencies.claimJournal(journal.metadata.journalId) + try { + await writeCommitted(journal, endpoints.relay, installed, dependencies) + } finally { + release() + } +} + +async function writeCommitted( + journal: MobileRelayPairingJournal, + relay: NonNullable, + installed: DeviceCredentialInstalled, + dependencies: RecoveryDependencies +): Promise { const reconciledJournal: MobileRelayPairingJournal = { ...journal, metadata: { @@ -265,7 +306,7 @@ async function publishCommitted( await dependencies.writeCredentialBundle( promotePairingJournalCredential({ journal: reconciledJournal, installed }) ) - await dependencies.savePairedHost(relayHost(reconciledJournal, endpoints.relay)) + await dependencies.savePairedHost(relayHost(reconciledJournal, relay)) await dependencies.clearJournal(journal.metadata.journalId) } diff --git a/mobile/src/transport/mobile-relay-physical-client.test.ts b/mobile/src/transport/mobile-relay-physical-client.test.ts index cf59264a407..a75a8b55640 100644 --- a/mobile/src/transport/mobile-relay-physical-client.test.ts +++ b/mobile/src/transport/mobile-relay-physical-client.test.ts @@ -130,7 +130,7 @@ describe('mobile relay physical pairing client', () => { const status = client.sendRequest('status.get') socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })) - await expect(status).rejects.toEqual(new RelayOuterError(4404)) + await expect(status).rejects.toEqual(new RelayOuterError(4404, true)) expect(fakes.start).not.toHaveBeenCalled() }) @@ -231,7 +231,7 @@ describe('mobile relay physical pairing client', () => { const status = client.sendRequest('status.get') socket.receive(JSON.stringify({ type: 'relay-hello', ok: false, code: 4404 })) - await expect(status).rejects.toEqual(new RelayOuterError(4404)) + await expect(status).rejects.toEqual(new RelayOuterError(4404, true)) expect(entries.at(-1)).toMatchObject({ level: 'warn', message: 'Relay: pairing socket closed', diff --git a/mobile/src/transport/mobile-relay-physical-client.ts b/mobile/src/transport/mobile-relay-physical-client.ts index 0883f6bf14a..95b357a4982 100644 --- a/mobile/src/transport/mobile-relay-physical-client.ts +++ b/mobile/src/transport/mobile-relay-physical-client.ts @@ -149,7 +149,7 @@ export function connectMobileRelayForPairing(args: { throw new Error('invalid relay hello') } if (!parsed.data.ok) { - throw new RelayOuterError(parsed.data.code) + throw new RelayOuterError(parsed.data.code, true) } if (parsed.data.credentialKind !== (args.expectedCredentialKind ?? 'invite')) { throw new Error('relay credential resolved as an unexpected credential kind') diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts index 0a4484f59c3..3217658584d 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.test.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.test.ts @@ -120,6 +120,8 @@ function dependencies(client: RpcClient, events: string[]) { clearJournal: vi.fn(async () => { events.push('clear-journal') }), + releaseJournal: vi.fn(), + recoverPendingJournal: vi.fn(async () => {}), writeCredentialBundle: vi.fn(async (_bundle: MobileRelayCredentialBundle) => { events.push('write-credential') }), @@ -523,6 +525,31 @@ describe('pre-profile pairing coordinator', () => { expect(entries[0]).toMatchObject({ level: 'warn', detail: '10.5.0.2:6768' }) }) + it('lets a stale journal recover before the new scan journals, and releases it on failure', async () => { + const events: string[] = [] + const client = fakeClient([]) + vi.mocked(client.sendRequest).mockRejectedValue(new Error('offline')) + const deps = dependencies(client, events) + let finishRecovery!: () => void + deps.recoverPendingJournal.mockImplementation( + () => new Promise((resolve) => (finishRecovery = resolve)) + ) + const attempt = startPreProfilePairing({ + offer: relayOffer, + timeoutMs: 5_000, + dependencies: deps + }) + const settled = attempt.result.catch(() => null) + await vi.waitFor(() => expect(deps.recoverPendingJournal).toHaveBeenCalled()) + expect(events).toEqual([]) + finishRecovery() + await settled + expect(events[0]).toBe('save-journal') + const journalId = deps.saveJournal.mock.calls[0]?.[0].metadata.journalId + expect(journalId).toEqual(expect.any(String)) + expect(deps.releaseJournal).toHaveBeenCalledWith(journalId) + }) + it('cancels the disposable physical client without publishing a host', async () => { let resolveStatus!: (response: RpcResponse) => void const status = new Promise((resolve) => { diff --git a/mobile/src/transport/pre-profile-pairing-coordinator.ts b/mobile/src/transport/pre-profile-pairing-coordinator.ts index 58b1c793118..2cc7b7becfa 100644 --- a/mobile/src/transport/pre-profile-pairing-coordinator.ts +++ b/mobile/src/transport/pre-profile-pairing-coordinator.ts @@ -13,9 +13,11 @@ import { } from './mobile-relay-pairing-journal' import { clearMobileRelayPairingJournal, + releaseMobileRelayPairingJournal, saveMobileRelayPairingJournal, updateMobileRelayPairingJournal } from './mobile-relay-pairing-journal-store' +import { settleMobileRelayPairingRecovery } from './mobile-relay-pairing-recovery' import { promotePairingJournalCredential, writeMobileRelayCredentialBundle @@ -49,6 +51,8 @@ type Dependencies = { saveJournal: typeof saveMobileRelayPairingJournal updateJournal: typeof updateMobileRelayPairingJournal clearJournal: typeof clearMobileRelayPairingJournal + releaseJournal: typeof releaseMobileRelayPairingJournal + recoverPendingJournal: () => Promise writeCredentialBundle: typeof writeMobileRelayCredentialBundle recordDescriptorFromStatus: typeof recordHostDescriptorFromStatus now: () => number @@ -64,12 +68,18 @@ const defaultDependencies: Dependencies = { saveJournal: saveMobileRelayPairingJournal, updateJournal: updateMobileRelayPairingJournal, clearJournal: clearMobileRelayPairingJournal, + releaseJournal: releaseMobileRelayPairingJournal, + recoverPendingJournal: () => settleMobileRelayPairingRecovery(PENDING_RECOVERY_WAIT_MS), writeCredentialBundle: writeMobileRelayCredentialBundle, recordDescriptorFromStatus: recordHostDescriptorFromStatus, now: Date.now, platform: Platform.OS } +// How long a new scan lets a previous attempt's recovery publish a committed +// install before the new journal supersedes it. +const PENDING_RECOVERY_WAIT_MS = 8_000 + export function startPreProfilePairing(args: { offer: PairingOffer timeoutMs: number @@ -102,7 +112,17 @@ export function startPreProfilePairing(args: { dispose() }, args.timeoutMs) - const result = runPairing(args.offer, args.connectOptions, dependencies, clients, () => disposed) + let savedJournalId: string | null = null + const result = runPairing( + args.offer, + args.connectOptions, + dependencies, + clients, + () => disposed, + (journalId) => { + savedJournalId = journalId + } + ) .catch((error: unknown) => { if (timedOut) { throw new Error('mobile pairing timed out') @@ -110,6 +130,9 @@ export function startPreProfilePairing(args: { throw error }) .finally(() => { + if (savedJournalId) { + dependencies.releaseJournal(savedJournalId) + } if (timer) { clearTimeout(timer) timer = null @@ -134,7 +157,8 @@ async function runPairing( connectOptions: ConnectOptions | undefined, dependencies: Dependencies, clients: Set, - isDisposed: () => boolean + isDisposed: () => boolean, + onJournalSaved: (journalId: string) => void ): Promise<{ hostId: string }> { const now = dependencies.now() // Why: every pairing artifact must share the preserved host id so re-pairing @@ -146,6 +170,10 @@ async function runPairing( assertActive(isDisposed) let journal: MobileRelayPairingJournal | null = null if (offer.relay && dependencies.platform !== 'web') { + // Why: a new scan always wins over a stale journal, but first gives its + // recovery a bounded chance to publish an install that already committed. + await dependencies.recoverPendingJournal() + assertActive(isDisposed) journal = createMobileRelayPairingJournal({ offer: { ...offer, relay: offer.relay }, hostId, @@ -153,6 +181,7 @@ async function runPairing( now }) await dependencies.saveJournal(journal) + onJournalSaved(journal.metadata.journalId) assertActive(isDisposed) } diff --git a/mobile/src/transport/relay-credential-eligibility.ts b/mobile/src/transport/relay-credential-eligibility.ts index d647d1fd28e..e181c05f455 100644 --- a/mobile/src/transport/relay-credential-eligibility.ts +++ b/mobile/src/transport/relay-credential-eligibility.ts @@ -1,6 +1,16 @@ import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes' import { RelayOuterError } from './mobile-relay-e2ee-link' +// Only a relay-hello refusal is the relay's verdict on a credential; a bare 4401 +// close also covers a first-frame timeout, which proves nothing about it. +export function isExplicitCredentialRejection(error: unknown): boolean { + return ( + error instanceof RelayOuterError && + error.rejectedByRelayHello && + error.code === MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL + ) +} + type RelayCredentialLease = { expiresAt: number; version: number } // Why: only a rejected outer credential can be repaired by the grace token;