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 bc65bc2e6..5790eb456 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => { expect(metadataRaw).toBeNull() }) - it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => { + it('cleans mismatched secret records', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, hostId: 'host-1', @@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() expect(metadataRaw).toBeNull() expect(secretRaw).toBeNull() + }) + + it('replaces a journal that never selected an authorization path', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + 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) + }) + + const replacementSave = saveMobileRelayPairingJournal(replacement) + const staleUpdate = updateMobileRelayPairingJournal( + journal.metadata.journalId, + (metadata) => metadata + ) + + await expect(replacementSave).resolves.toBeUndefined() + await expect(staleUpdate).rejects.toThrow(/stale/) + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + }) + + it('serializes a replacement behind an in-flight journal snapshot', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + await saveMobileRelayPairingJournal(journal) + let releaseSecretRead!: () => void + const secretReadGate = new Promise((resolve) => { + releaseSecretRead = resolve + }) + secureStore.getItemAsync.mockImplementationOnce(async () => { + await secretReadGate + return secretRaw + }) + const loading = loadMobileRelayPairingJournal() + await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled()) + + const replacement = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-2', + hostName: 'Red Panda', + randomBytes: (length) => new Uint8Array(length).fill(12) + }) + let replacementSaved = false + const saving = saveMobileRelayPairingJournal(replacement).then(() => { + replacementSaved = true + }) + await Promise.resolve() + expect(replacementSaved).toBe(false) + + releaseSecretRead() + await expect(loading).resolves.toEqual(journal) + await expect(saving).resolves.toBeUndefined() + await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement) + expect(asyncStorage.removeItem).not.toHaveBeenCalled() + }) + + it('cleans a replacement whose secret write fails', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + 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) + }) + secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + + await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) + await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() + expect(metadataRaw).toBeNull() + expect(secretRaw).toBeNull() + }) + + it('blocks replacement when the old authorization update wins the mutation race', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + randomBytes: (length) => new Uint8Array(length).fill(10) + }) + 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) + }) + const authorizationUpdate = updateMobileRelayPairingJournal( + journal.metadata.journalId, + (metadata) => ({ + ...metadata, + winner: 'direct', + authorizationMode: 'authenticated-direct' + }) + ) + const replacementSave = saveMobileRelayPairingJournal(replacement) + + await expect(authorizationUpdate).resolves.toBeUndefined() + await expect(replacementSave).rejects.toThrow(/recovery pending/) + }) + + it.each([ + ['winner', { winner: 'direct' as const }], + ['authorization mode', { authorizationMode: 'authenticated-direct' as const }] + ])('refuses replacement once the durable %s exists', 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({ diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index 6e82bf38b..b7044830d 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal( const mutation = journalMutation.then(async () => { const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) const existing = existingRaw ? parseMetadata(existingRaw) : null - if (existing && existing.journalId !== metadata.journalId) { + if ( + existing && + existing.journalId !== metadata.journalId && + (existing.winner !== undefined || existing.authorizationMode !== undefined) + ) { throw new Error('mobile relay pairing recovery pending') } + // 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)) @@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal( export async function loadMobileRelayPairingJournal(): Promise { requireNativeSecretStore() - await journalMutation - const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) - if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) - return null - } - const metadata = parseMetadata(rawMetadata) - if (!metadata) { - await removeIncompleteJournal() - return null - } - const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) - if (rawSecrets === null) { - await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - return null - } - const secrets = parseSecrets(rawSecrets) - if (!secrets || secrets.journalId !== metadata.journalId) { - await removeIncompleteJournal() - return null - } - return { metadata, secrets } + const load = journalMutation.then(async () => { + const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) + if (rawMetadata === null) { + await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + return null + } + const metadata = parseMetadata(rawMetadata) + if (!metadata) { + await removeIncompleteJournal() + return null + } + const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + if (rawSecrets === null) { + await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) + return null + } + const secrets = parseSecrets(rawSecrets) + if (!secrets || secrets.journalId !== metadata.journalId) { + await removeIncompleteJournal() + return null + } + return { metadata, secrets } + }) + // Why: recovery may load while a new scan saves; serialize the complete + // metadata/secret snapshot and any cleanup so it cannot delete the new journal. + journalMutation = load.then( + () => undefined, + () => undefined + ) + return load } async function removeIncompleteJournal(): Promise {