fix(mobile): a new pairing scan supersedes a stale relay pairing journal (#27157)

* fix(mobile): a new pairing scan supersedes a stale relay pairing journal

* fix(mobile): a late recovery cannot overwrite a newer pairing; only a relay-hello 4401 abandons a journal

---------

Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 22:47:08 -07:00
committed by GitHub
co-authored by m4air
parent 94b7936b8b
commit df54bbb5ed
12 changed files with 349 additions and 25 deletions
@@ -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 })
},
@@ -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')
@@ -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([
@@ -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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
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 () => {
@@ -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<void> = 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<string>()
// Journals recovery is publishing right now; a superseding save waits for them.
const publications = new Map<string, Promise<void>>()
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<void> } | 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<void>((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<MobileRelayPairingJournal | null> {
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()
}
@@ -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<HostProfile[]> => 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 () => {
@@ -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<RecoveryDependencies> = {}
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
await Promise.race([
recoverMobileRelayPairing(overrides).catch(() => {}),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
})
])
clearTimeout(timer)
}
async function runRecovery(
dependencies: RecoveryDependencies
): Promise<MobileRelayPairingRecoveryResult> {
@@ -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<PairingGetEndpointsResult['relay']>,
installed: DeviceCredentialInstalled,
dependencies: RecoveryDependencies
): Promise<void> {
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)
}
@@ -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',
@@ -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')
@@ -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<void>((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<RpcResponse>((resolve) => {
@@ -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<void>
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<PairingCandidateClient>,
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)
}
@@ -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;