mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user