diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts index 918e6e92f93..cee492dfd09 100644 --- a/mobile/src/transport/host-store.test.ts +++ b/mobile/src/transport/host-store.test.ts @@ -14,6 +14,7 @@ const secureStoreMock = vi.hoisted(() => ({ })) const scheduleCleanupMock = vi.hoisted(() => vi.fn()) +const platformMock = vi.hoisted(() => ({ OS: 'ios' })) vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock @@ -25,7 +26,7 @@ vi.mock('expo-secure-store', () => ({ })) vi.mock('react-native', () => ({ - Platform: { OS: 'ios' } + Platform: platformMock })) vi.mock('./host-credential-cleanup', () => ({ @@ -70,6 +71,7 @@ describe('host-store list mutations', () => { beforeEach(() => { vi.clearAllMocks() resetHostStoreForTests() + platformMock.OS = 'ios' resetMobileRelayHostOverlayStoreForTests() scheduleCleanupMock.mockReset() scheduleCleanupMock.mockResolvedValue(undefined) @@ -179,6 +181,25 @@ describe('host-store list mutations', () => { expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() }) + it('keeps the normal iOS save on the existing default keychain service', async () => { + await saveHost({ + id: 'host-new', + name: 'New Host', + endpoint: 'ws://127.0.0.1:3', + publicKeyB64: 'key-new', + deviceToken: 'new-token', + lastConnected: 0 + }) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledWith( + 'orca.host-token.host-new', + 'new-token', + { + keychainAccessible: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY' + } + ) + }) + it('commits the removal when credential cleanup scheduling rejects', async () => { scheduleCleanupMock.mockRejectedValue(new Error('intent storage unavailable')) @@ -328,3 +349,104 @@ describe('host-store list mutations', () => { expect(storedHostsRaw).toBe('{') }) }) + +describe('host-store pairing save after an Android encryption rejection', () => { + const NEW_HOST = { + id: 'host-1782629088232', + name: 'Host 1', + endpoint: 'ws://192.168.0.56:6769', + publicKeyB64: 'desktop-key', + lastConnected: 0, + deviceToken: 'device-token' + } + // Why: the verbatim Android rejection from #6600 — expo maps a null-message GeneralSecurityException to this. + const ENCRYPT_REJECTION = new Error( + "Could not encrypt the value for key 'orca.host-token.host-1782629088232' under keychain 'key_v1'. Caused by: unknown" + ) + const GENERATION_KEY = 'orca:pairing-keychain-generation' + let storedHostsRaw: string + let storedGenerationRaw: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetHostStoreForTests() + platformMock.OS = 'android' + resetMobileRelayHostOverlayStoreForTests() + scheduleCleanupMock.mockReset() + scheduleCleanupMock.mockResolvedValue(undefined) + storedHostsRaw = '[]' + storedGenerationRaw = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === HOSTS_STORAGE_KEY) { + return storedHostsRaw + } + // Why: the generation record is durable on device; a forgetful mock would fake a broken read path. + return key === GENERATION_KEY ? storedGenerationRaw : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === HOSTS_STORAGE_KEY) { + storedHostsRaw = raw + } else if (key === GENERATION_KEY) { + storedGenerationRaw = raw + } + }) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('saves the host when the reported Android failure is alias-local (#6600)', async () => { + const written = new Map() + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + + await expect(saveHost(NEW_HOST)).resolves.toBeUndefined() + + expect(written.get('orca.pairing.v1')).toBe('device-token') + expect(JSON.parse(storedHostsRaw)).toEqual([ + { + id: NEW_HOST.id, + name: NEW_HOST.name, + endpoint: NEW_HOST.endpoint, + publicKeyB64: NEW_HOST.publicKeyB64, + lastConnected: NEW_HOST.lastConnected + } + ]) + }) + + it('still surfaces the failure when no keystore alias can accept the token', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(saveHost(NEW_HOST)).rejects.toBe(ENCRYPT_REJECTION) + }) + + it('serves the rotated token to loadHosts so the saved host survives a relaunch', async () => { + const written = new Map() + secureStoreMock.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw ENCRYPT_REJECTION + } + written.set(options.keychainService, value) + } + ) + await saveHost(NEW_HOST) + // Why: a fresh process has no token cache, so the host list has to come back off the rotated alias. + resetHostStoreForTests() + secureStoreMock.getItemAsync.mockImplementation( + async (_key: string, options?: { keychainService?: string }) => + written.get(options?.keychainService) ?? null + ) + + const hosts = await loadHosts() + + expect(hosts).toHaveLength(1) + expect(hosts[0]!.deviceToken).toBe('device-token') + }) +}) diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts index 61ed6742d12..9d16235481c 100644 --- a/mobile/src/transport/host-store.ts +++ b/mobile/src/transport/host-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { HostProfileSchema, @@ -8,6 +7,12 @@ import { type StoredHostProfile } from './types' import { getNextHostNameFromHosts } from './host-names' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' import { retryPendingHostCredentialCleanups, scheduleHostCredentialCleanup @@ -27,12 +32,6 @@ const STORAGE_KEY = 'orca:hosts' const TOKEN_KEY_PREFIX = 'orca.host-token.' const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:' -// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps the pairing token off iCloud Keychain and out of backup restores onto another device. -// Reads/writes stay silent (no biometric prompt) because we don't request access control flags. -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function tokenKey(hostId: string): string { return `${TOKEN_KEY_PREFIX}${hostId}` } @@ -46,7 +45,7 @@ async function readDeviceToken(hostId: string): Promise { if (Platform.OS === 'web') { return AsyncStorage.getItem(webTokenKey(hostId)) } - return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + return readPairingKeychainItem(tokenKey(hostId)) } async function writeDeviceToken(hostId: string, token: string): Promise { @@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise { await AsyncStorage.setItem(webTokenKey(hostId), token) return } - await SecureStore.setItemAsync(tokenKey(hostId), token, KEYCHAIN_OPTIONS) + await writePairingKeychainItem(tokenKey(hostId), token) } async function deleteDeviceToken(hostId: string): Promise { @@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise { await AsyncStorage.removeItem(webTokenKey(hostId)) return } - await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(tokenKey(hostId)) } async function deleteHostCredentials(hostId: string): Promise { @@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void { hostListMutation = Promise.resolve() tokenCache.clear() inflightLoad = null + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/mobile-relay-credential-bundle.test.ts b/mobile/src/transport/mobile-relay-credential-bundle.test.ts index 4df9d44430b..185d5410980 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.test.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn() +})) const secureStore = vi.hoisted(() => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), @@ -7,6 +11,7 @@ const secureStore = vi.hoisted(() => ({ })) const platform = vi.hoisted(() => ({ OS: 'ios' })) +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', ...secureStore @@ -20,6 +25,7 @@ import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { resetPairingKeychainForTests } from './pairing-keychain' const journal = { metadata: { @@ -62,8 +68,11 @@ describe('mobile relay credential bundle', () => { beforeEach(() => { vi.clearAllMocks() + resetPairingKeychainForTests() platform.OS = 'ios' stored = null + asyncStorage.getItem.mockResolvedValue(null) + asyncStorage.setItem.mockResolvedValue(undefined) secureStore.getItemAsync.mockImplementation(async () => stored) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { stored = value diff --git a/mobile/src/transport/mobile-relay-credential-bundle.ts b/mobile/src/transport/mobile-relay-credential-bundle.ts index 87f6f3826f4..a08fa052459 100644 --- a/mobile/src/transport/mobile-relay-credential-bundle.ts +++ b/mobile/src/transport/mobile-relay-credential-bundle.ts @@ -1,8 +1,12 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import type { DeviceCredentialInstalled } from '../../../src/shared/mobile-relay-credential-contract' import type { MobileRelayPairingJournal } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) const ResumeCredentialSchema = z @@ -38,10 +42,6 @@ export const MobileRelayCredentialBundleSchema = z export type MobileRelayCredentialBundle = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function credentialKey(hostId: string): string { return `orca.mobile-relay.credentials.${hostId}` } @@ -74,7 +74,7 @@ export async function readMobileRelayCredentialBundle( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(credentialKey(hostId)) if (raw === null) { return null } @@ -91,18 +91,14 @@ export async function writeMobileRelayCredentialBundle( ): Promise { requireNativeSecretStore() const validated = MobileRelayCredentialBundleSchema.parse(bundle) - await SecureStore.setItemAsync( - credentialKey(validated.hostId), - JSON.stringify(validated), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated)) } export async function deleteMobileRelayCredentialBundle(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(credentialKey(hostId)) } function requireNativeSecretStore(): void { diff --git a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts index 19775270b45..2493c9e78af 100644 --- a/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts +++ b/mobile/src/transport/mobile-relay-direct-upgrade-journal.ts @@ -1,7 +1,11 @@ -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { z } from 'zod' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + writePairingKeychainItem +} from './pairing-keychain' const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/) @@ -17,10 +21,6 @@ export const MobileRelayDirectUpgradeJournalSchema = z export type MobileRelayDirectUpgradeJournal = z.infer -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} - function journalKey(hostId: string): string { return `orca.mobile-relay.direct-upgrade.${hostId}` } @@ -43,7 +43,7 @@ export async function readMobileRelayDirectUpgradeJournal( hostId: string ): Promise { requireNativeSecretStore() - const raw = await SecureStore.getItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + const raw = await readPairingKeychainItem(journalKey(hostId)) if (!raw) { return null } @@ -60,18 +60,14 @@ export async function writeMobileRelayDirectUpgradeJournal( ): Promise { requireNativeSecretStore() const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal) - await SecureStore.setItemAsync( - journalKey(parsed.hostId), - JSON.stringify(parsed), - KEYCHAIN_OPTIONS - ) + await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed)) } export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Promise { if (Platform.OS === 'web') { return } - await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(journalKey(hostId)) } function encodeBase64Url(value: Uint8Array): string { 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 5790eb456aa..cf249a90506 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.test.ts @@ -31,6 +31,8 @@ import { import type { PairingOffer } from './types' const now = Date.UTC(2026, 6, 13) +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const JOURNAL_PRESENCE_KEY = 'orca:pairing-keychain-presence:orca.mobile-relay.pairing-journal.v1' const offer = { v: 2, endpoint: 'ws://192.168.1.10:6768', @@ -51,6 +53,8 @@ const offer = { describe('mobile relay pairing journal store', () => { let metadataRaw: string | null let secretRaw: string | null + let generationRaw: string | null + let presenceRaw: string | null beforeEach(() => { vi.clearAllMocks() @@ -58,12 +62,32 @@ describe('mobile relay pairing journal store', () => { platform.OS = 'ios' metadataRaw = null secretRaw = null - asyncStorage.getItem.mockImplementation(async () => metadataRaw) - asyncStorage.setItem.mockImplementation(async (_key: string, value: string) => { - metadataRaw = value + generationRaw = null + presenceRaw = null + asyncStorage.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRaw + } + if (key === JOURNAL_PRESENCE_KEY) { + return presenceRaw + } + return metadataRaw }) - asyncStorage.removeItem.mockImplementation(async () => { - metadataRaw = null + asyncStorage.setItem.mockImplementation(async (key: string, value: string) => { + if (key === GENERATION_KEY) { + generationRaw = value + } else if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = value + } else { + metadataRaw = value + } + }) + asyncStorage.removeItem.mockImplementation(async (key: string) => { + if (key === JOURNAL_PRESENCE_KEY) { + presenceRaw = null + } else { + metadataRaw = null + } }) secureStore.getItemAsync.mockImplementation(async () => secretRaw) secureStore.setItemAsync.mockImplementation(async (_key: string, value: string) => { @@ -94,6 +118,36 @@ describe('mobile relay pairing journal store', () => { await expect(loadMobileRelayPairingJournal()).resolves.toEqual(journal) }) + it('retries the journal under a distinct alias before relay pairing connects (#6600)', async () => { + const journal = createMobileRelayPairingJournal({ + offer: offer as PairingOffer & { relay: NonNullable }, + hostId: 'host-1', + hostName: 'Blue Whale', + now, + randomBytes: (length) => new Uint8Array(length).fill(length) + }) + secureStore.setItemAsync.mockImplementation( + async (_key: string, value: string, options?: { keychainService?: string }) => { + if (options?.keychainService === undefined) { + throw new Error( + "Could not encrypt the value for key 'orca.mobile-relay.pairing-journal.v1' under keychain 'key_v1'. Caused by: unknown" + ) + } + secretRaw = value + } + ) + platform.OS = 'android' + + await expect(saveMobileRelayPairingJournal(journal)).resolves.toBeUndefined() + + expect(generationRaw).toBe('1') + expect(secureStore.setItemAsync).toHaveBeenLastCalledWith( + 'orca.mobile-relay.pairing-journal.v1', + expect.any(String), + expect.objectContaining({ keychainService: 'orca.pairing.v1' }) + ) + }) + it('records a provisional winner only for the active journal identity', async () => { const journal = createMobileRelayPairingJournal({ offer: offer as PairingOffer & { relay: NonNullable }, @@ -223,7 +277,7 @@ describe('mobile relay pairing journal store', () => { hostName: 'Red Panda', randomBytes: (length) => new Uint8Array(length).fill(12) }) - secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable')) + secureStore.setItemAsync.mockRejectedValue(new Error('keychain unavailable')) await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/) await expect(loadMobileRelayPairingJournal()).resolves.toBeNull() diff --git a/mobile/src/transport/mobile-relay-pairing-journal-store.ts b/mobile/src/transport/mobile-relay-pairing-journal-store.ts index b7044830da5..d02ebc72284 100644 --- a/mobile/src/transport/mobile-relay-pairing-journal-store.ts +++ b/mobile/src/transport/mobile-relay-pairing-journal-store.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import * as SecureStore from 'expo-secure-store' import { Platform } from 'react-native' import { MobileRelayPairingJournalMetadataSchema, @@ -7,12 +6,15 @@ import { type MobileRelayPairingJournal, type MobileRelayPairingJournalMetadata } from './mobile-relay-pairing-journal' +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' const JOURNAL_STORAGE_KEY = 'orca:mobile-relay:pairing-journal:v1' const JOURNAL_SECRET_KEY = 'orca.mobile-relay.pairing-journal.v1' -const KEYCHAIN_OPTIONS: SecureStore.SecureStoreOptions = { - keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY -} let journalMutation: Promise = Promise.resolve() export async function saveMobileRelayPairingJournal( @@ -39,7 +41,7 @@ export async function saveMobileRelayPairingJournal( // 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 SecureStore.setItemAsync(JOURNAL_SECRET_KEY, JSON.stringify(secrets), KEYCHAIN_OPTIONS) + await writePairingKeychainItem(JOURNAL_SECRET_KEY, JSON.stringify(secrets)) }) journalMutation = mutation.catch(() => {}) return mutation @@ -50,7 +52,7 @@ export async function loadMobileRelayPairingJournal(): Promise { const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY) if (rawMetadata === null) { - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) return null } const metadata = parseMetadata(rawMetadata) @@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise { // Why: metadata is the discoverable cleanup pointer; remove it before the // native secret so a second crash can only leave a self-cleaning orphan. await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {}) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {}) } export async function updateMobileRelayPairingJournal( @@ -114,7 +116,7 @@ export async function clearMobileRelayPairingJournal(journalId: string): Promise throw new Error('stale mobile relay pairing journal') } await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY) - await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS) + await deletePairingKeychainItem(JOURNAL_SECRET_KEY) }) journalMutation = mutation.catch(() => {}) return mutation @@ -147,4 +149,5 @@ function requireNativeSecretStore(): void { /** Test-only: drain the module mutation chain between cases. */ export function resetMobileRelayPairingJournalStoreForTests(): void { journalMutation = Promise.resolve() + resetPairingKeychainForTests() } diff --git a/mobile/src/transport/pairing-keychain.test.ts b/mobile/src/transport/pairing-keychain.test.ts new file mode 100644 index 00000000000..fda46d0a328 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.test.ts @@ -0,0 +1,403 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +const secureStoreMock = vi.hoisted(() => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn() +})) +const platformMock = vi.hoisted(() => ({ OS: 'android' })) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock })) + +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', + ...secureStoreMock +})) +vi.mock('react-native', () => ({ Platform: platformMock })) + +import { + deletePairingKeychainItem, + readPairingKeychainItem, + resetPairingKeychainForTests, + writePairingKeychainItem +} from './pairing-keychain' + +const GENERATION_KEY = 'orca:pairing-keychain-generation' +const TOKEN_KEY = 'orca.host-token.host-1782629088232' +const TOKEN_PRESENCE_KEY = `orca:pairing-keychain-presence:${TOKEN_KEY}` + +// Why: the exact Android failure from #6600 — expo maps a null-message GeneralSecurityException to this. +const ENCRYPT_REJECTION = new Error( + `Could not encrypt the value for key '${TOKEN_KEY}' under keychain 'key_v1'. Caused by: unknown` +) + +type Options = { keychainService?: string } | undefined + +function serviceOf(options: Options): string | undefined { + return options?.keychainService +} + +describe('pairing keychain', () => { + let generationRecord: string | null + + beforeEach(() => { + vi.clearAllMocks() + resetPairingKeychainForTests() + platformMock.OS = 'android' + generationRecord = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => + key === GENERATION_KEY ? generationRecord : null + ) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + }) + secureStoreMock.setItemAsync.mockResolvedValue(undefined) + secureStoreMock.deleteItemAsync.mockResolvedValue(undefined) + secureStoreMock.getItemAsync.mockResolvedValue(null) + }) + + it('writes under the default keychain service so existing installs keep their tokens', async () => { + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + const [key, value, options] = secureStoreMock.setItemAsync.mock.calls[0]! + expect(key).toBe(TOKEN_KEY) + expect(value).toBe('token') + // Why: passing any keychainService would change the keystore alias and orphan every already-stored token. + expect(serviceOf(options as Options)).toBeUndefined() + expect(generationRecord).toBeNull() + }) + + it('recovers when the reported Android encryption failure is alias-local', async () => { + // Why: simulate the unverified alias-local case; no affected physical device was available. + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + const rotated = secureStoreMock.setItemAsync.mock.calls.at(-1)! + expect(serviceOf(rotated[2] as Options)).toBe('orca.pairing.v1') + expect(rotated[1]).toBe('token') + expect(generationRecord).toBe('1') + }) + + it('surfaces the original error when a rotated write also fails', async () => { + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled() + }) + + it('retries an unconfirmed alias without consuming another generation', async () => { + generationRecord = '1:pending' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('confirms an unconfirmed alias after its first successful write', async () => { + generationRecord = '1:pending' + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(generationRecord).toBe('1') + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(serviceOf(secureStoreMock.setItemAsync.mock.calls[0]![2] as Options)).toBe( + 'orca.pairing.v1' + ) + }) + + it('does not rotate on an iOS keychain failure', async () => { + platformMock.OS = 'ios' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBeNull() + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + }) + + it('never stores a token under a generation it could not durably record', async () => { + // Why: reads only walk back from the recorded generation, so a token written under an + // unrecorded one is silently unreachable after a relaunch and the host vanishes. + asyncStorageMock.setItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage full') + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + const rotatedWrites = secureStoreMock.setItemAsync.mock.calls.filter( + (call) => serviceOf(call[2] as Options) !== undefined + ) + expect(rotatedWrites).toHaveLength(0) + expect(generationRecord).toBeNull() + }) + + it('records the rotation before storing the token so a relaunch can still find it', async () => { + const order: string[] = [] + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + order.push(`record:${raw}`) + } + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + const service = serviceOf(options) + if (service === undefined) { + throw ENCRYPT_REJECTION + } + order.push(`store:${service}`) + } + ) + + await writePairingKeychainItem(TOKEN_KEY, 'token') + + expect(order).toEqual(['record:1:pending', 'store:orca.pairing.v1', 'record:1']) + }) + + it('keeps a successful rotated write reachable when confirmation storage fails', async () => { + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key !== GENERATION_KEY) { + return + } + if (raw === '1') { + throw new Error('storage unavailable') + } + generationRecord = raw + }) + secureStoreMock.setItemAsync.mockImplementation( + async (_k: string, _v: string, options: Options) => { + if (serviceOf(options) === undefined) { + throw ENCRYPT_REJECTION + } + } + ) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + + expect(generationRecord).toBe('1:pending') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'token' : null + ) + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('token') + }) + + it('reads through the rotated service once a rotation has been committed', async () => { + generationRecord = '1' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v1' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + }) + + it('falls back to a retired service so rotation does not orphan a still-readable token', async () => { + generationRecord = '2' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + // Why: probes must walk v2 -> v1 -> default rather than stopping at the current generation. + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(3) + }) + + it('does not return a stale older value when the current alias throws', async () => { + generationRecord = '1' + const currentError = new Error('Could not decrypt the value') + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v1') { + throw currentError + } + return 'legacy-token' + }) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toBe(currentError) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('does not return a stale older value when a recorded current item decrypts as null', async () => { + generationRecord = '1' + let presenceRecord: string | null = null + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + return generationRecord + } + return key === TOKEN_PRESENCE_KEY ? presenceRecord : null + }) + asyncStorageMock.setItem.mockImplementation(async (key: string, raw: string) => { + if (key === GENERATION_KEY) { + generationRecord = raw + } + if (key === TOKEN_PRESENCE_KEY) { + presenceRecord = raw + } + }) + + await writePairingKeychainItem(TOKEN_KEY, 'rotated-token') + expect(presenceRecord).toBe('1') + expect(asyncStorageMock.setItem.mock.invocationCallOrder[0]).toBeLessThan( + secureStoreMock.setItemAsync.mock.invocationCallOrder[0]! + ) + + resetPairingKeychainForTests() + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).rejects.toThrow(/recorded generation/) + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(1) + }) + + it('reads an older value while a newly recorded alias has no item yet', async () => { + generationRecord = '1:pending' + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === undefined ? 'legacy-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('legacy-token') + }) + + it('probes every bounded service when the generation record is temporarily unreadable', async () => { + asyncStorageMock.getItem.mockImplementation(async (key: string) => { + if (key === GENERATION_KEY) { + throw new Error('storage unavailable') + } + return null + }) + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + + expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(7) + }) + + it('does not rotate from a guessed generation when the record is unreadable', async () => { + const storageError = new Error('storage unavailable') + asyncStorageMock.getItem.mockRejectedValueOnce(storageError) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(storageError) + + expect(asyncStorageMock.setItem).not.toHaveBeenCalled() + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + }) + + it('deletes the token under every generation so rotation cannot strand a live credential', async () => { + generationRecord = '2' + + await deletePairingKeychainItem(TOKEN_KEY) + + const services = secureStoreMock.deleteItemAsync.mock.calls.map((call) => + serviceOf(call[1] as Options) + ) + expect(services).toEqual(['orca.pairing.v2', 'orca.pairing.v1', undefined]) + expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY) + }) + + it('reports a partial delete failure after attempting every generation', async () => { + generationRecord = '2' + const deleteError = new Error('delete failed') + secureStoreMock.deleteItemAsync.mockImplementation(async (_k: string, options: Options) => { + if (serviceOf(options) === 'orca.pairing.v2') { + throw deleteError + } + }) + + await expect(deletePairingKeychainItem(TOKEN_KEY)).rejects.toBe(deleteError) + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(3) + expect(asyncStorageMock.removeItem).not.toHaveBeenCalled() + }) + + it('serializes writes that share the global generation', async () => { + let releaseFirst!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirst = resolve + }) + secureStoreMock.setItemAsync.mockImplementationOnce(async () => firstWrite) + + const first = writePairingKeychainItem(TOKEN_KEY, 'first') + const second = writePairingKeychainItem(`${TOKEN_KEY}.second`, 'second') + await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1)) + + releaseFirst() + await Promise.all([first, second]) + + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(2) + }) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'probes rotated services when generation record %j is malformed', + async (raw) => { + generationRecord = raw + secureStoreMock.getItemAsync.mockImplementation(async (_k: string, options: Options) => + serviceOf(options) === 'orca.pairing.v2' ? 'rotated-token' : null + ) + + await expect(readPairingKeychainItem(TOKEN_KEY)).resolves.toBe('rotated-token') + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'refuses to write from malformed generation record %j', + async (raw) => { + generationRecord = raw + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toThrow( + /generation record is invalid/ + ) + expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled() + } + ) + + it.each(['not-a-number', '', ' ', '0:pending'])( + 'deletes every bounded service when generation record %j is malformed', + async (raw) => { + generationRecord = raw + + await deletePairingKeychainItem(TOKEN_KEY) + + expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledTimes(9) + } + ) + + it('stops rotating at the generation cap rather than probing unbounded services', async () => { + generationRecord = '8' + secureStoreMock.setItemAsync.mockRejectedValue(ENCRYPT_REJECTION) + + await expect(writePairingKeychainItem(TOKEN_KEY, 'token')).rejects.toBe(ENCRYPT_REJECTION) + expect(secureStoreMock.setItemAsync).toHaveBeenCalledTimes(1) + expect(generationRecord).toBe('8') + }) +}) diff --git a/mobile/src/transport/pairing-keychain.ts b/mobile/src/transport/pairing-keychain.ts new file mode 100644 index 00000000000..bf3b1810807 --- /dev/null +++ b/mobile/src/transport/pairing-keychain.ts @@ -0,0 +1,257 @@ +import AsyncStorage from '@react-native-async-storage/async-storage' +import * as SecureStore from 'expo-secure-store' +import { Platform } from 'react-native' + +// Why: WHEN_UNLOCKED_THIS_DEVICE_ONLY keeps pairing credentials off iCloud Keychain and backup restores. +const BASE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY +} + +const GENERATION_STORAGE_KEY = 'orca:pairing-keychain-generation' +const PRESENCE_STORAGE_PREFIX = 'orca:pairing-keychain-presence:' +const SERVICE_PREFIX = 'orca.pairing.v' +// Why: every generation adds one read probe per miss; bound pathological recovery cost. +const MAX_GENERATION = 8 + +/** + * Why: expo-secure-store shares one Android keystore alias across every + * unauthenticated item under a keychain service. The #6600 rejection proves + * encryption reached expo's null-message GeneralSecurityException branch; a + * fresh service selects a distinct alias and can recover an alias-local fault. + */ +function serviceForGeneration(generation: number): string | undefined { + // Why: generation 0 must keep expo's default service so existing credentials stay readable. + return generation <= 0 ? undefined : `${SERVICE_PREFIX}${generation}` +} + +function optionsForGeneration(generation: number): SecureStore.SecureStoreOptions { + const keychainService = serviceForGeneration(generation) + return keychainService ? { ...BASE_OPTIONS, keychainService } : BASE_OPTIONS +} + +type LoadedGeneration = + | { generation: number; pending: boolean; reliable: true } + | { generation: 0; pending: false; reliable: false; error: unknown } + +type GenerationState = { generation: number; pending: boolean } +type PresenceChange = { storageKey: string; previousRaw: string | null } + +let cachedGeneration: GenerationState | null = null +let keychainMutation: Promise = Promise.resolve() + +function parseGeneration(raw: string | null): LoadedGeneration { + if (raw === null) { + return { generation: 0, pending: false, reliable: true } + } + const pending = raw.endsWith(':pending') + const generationRaw = pending ? raw.slice(0, -':pending'.length) : raw + const parsed = Number(generationRaw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + (pending && parsed === 0) || + String(parsed) !== generationRaw + ) { + return { + generation: 0, + pending: false, + reliable: false, + error: new Error('pairing keychain generation record is invalid') + } + } + return { generation: parsed, pending, reliable: true } +} + +function parsePresenceGeneration(raw: string | null): number | null { + if (raw === null) { + return null + } + const parsed = Number(raw) + if ( + !Number.isInteger(parsed) || + parsed < 0 || + parsed > MAX_GENERATION || + String(parsed) !== raw + ) { + throw new Error('pairing keychain presence record is invalid') + } + return parsed +} + +function presenceStorageKey(key: string): string { + return `${PRESENCE_STORAGE_PREFIX}${key}` +} + +async function loadPresenceGeneration(key: string): Promise { + if (Platform.OS !== 'android') { + return null + } + return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key))) +} + +async function loadGeneration(): Promise { + if (cachedGeneration !== null) { + return { ...cachedGeneration, reliable: true } + } + try { + const loaded = parseGeneration(await AsyncStorage.getItem(GENERATION_STORAGE_KEY)) + if (loaded.reliable) { + cachedGeneration = { generation: loaded.generation, pending: loaded.pending } + } + return loaded + } catch (error) { + // Why: don't cache or rotate from a guess; a later read may recover the durable pointer. + return { generation: 0, pending: false, reliable: false, error } + } +} + +// Why: reads only walk back from the recorded generation, so persist before writing under it. +async function commitGeneration(generation: number, pending: boolean): Promise { + const raw = pending ? `${generation}:pending` : String(generation) + await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw) + cachedGeneration = { generation, pending } +} + +function enqueueKeychainMutation(operation: () => Promise): Promise { + const mutation = keychainMutation.then(operation) + keychainMutation = mutation.catch(() => {}) + return mutation +} + +async function preparePresenceWrite( + key: string, + generation: number +): Promise { + if (Platform.OS !== 'android') { + return null + } + // Why: Expo Android returns null for both absent and undecryptable entries. + const storageKey = presenceStorageKey(key) + const previousRaw = await AsyncStorage.getItem(storageKey) + parsePresenceGeneration(previousRaw) + await AsyncStorage.setItem(storageKey, String(generation)) + return { storageKey, previousRaw } +} + +async function restorePresence(change: PresenceChange | null): Promise { + if (!change) { + return + } + if (change.previousRaw === null) { + await AsyncStorage.removeItem(change.storageKey) + return + } + await AsyncStorage.setItem(change.storageKey, change.previousRaw) +} + +async function setItemAtGeneration(key: string, value: string, generation: number): Promise { + const presenceChange = await preparePresenceWrite(key, generation) + try { + await SecureStore.setItemAsync(key, value, optionsForGeneration(generation)) + } catch (error) { + // Why: a failed rollback leaves reads fail-closed at the attempted generation. + await restorePresence(presenceChange).catch(() => {}) + throw error + } +} + +export async function readPairingKeychainItem(key: string): Promise { + await keychainMutation + const presenceGeneration = await loadPresenceGeneration(key) + if (presenceGeneration !== null) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration)) + if (value === null) { + throw new Error('pairing keychain item is unavailable at its recorded generation') + } + return value + } + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + const value = await SecureStore.getItemAsync(key, optionsForGeneration(candidate)) + if (value !== null) { + return value + } + } + return null +} + +async function writePairingKeychainItemImpl(key: string, value: string): Promise { + const loaded = await loadGeneration() + if (!loaded.reliable) { + throw loaded.error + } + const generation = loaded.generation + let firstError: unknown + try { + await setItemAtGeneration(key, value, generation) + if (loaded.pending) { + await commitGeneration(generation, false) + } + return + } catch (error) { + firstError = error + } + + // Why: retry an unconfirmed alias instead of exhausting generations on a device-wide failure. + if (loaded.pending || !isAndroidEncryptionFailure(firstError)) { + throw firstError + } + const rotated = generation + 1 + if (rotated > MAX_GENERATION) { + throw firstError + } + try { + await commitGeneration(rotated, true) + } catch { + throw firstError + } + try { + await setItemAtGeneration(key, value, rotated) + await commitGeneration(rotated, false) + } catch { + throw firstError + } +} + +function isAndroidEncryptionFailure(error: unknown): boolean { + if (Platform.OS !== 'android' || !error || typeof error !== 'object') { + return false + } + const message = 'message' in error ? error.message : null + return typeof message === 'string' && message.includes('Could not encrypt the value for key') +} + +export function writePairingKeychainItem(key: string, value: string): Promise { + return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value)) +} + +async function deletePairingKeychainItemImpl(key: string): Promise { + const loaded = await loadGeneration() + const firstCandidate = loaded.reliable ? loaded.generation : MAX_GENERATION + let firstError: unknown + for (let candidate = firstCandidate; candidate >= 0; candidate -= 1) { + try { + await SecureStore.deleteItemAsync(key, optionsForGeneration(candidate)) + } catch (error) { + firstError ??= error + } + } + if (firstError !== undefined) { + throw firstError + } + if (Platform.OS === 'android') { + await AsyncStorage.removeItem(presenceStorageKey(key)) + } +} + +export function deletePairingKeychainItem(key: string): Promise { + return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key)) +} + +/** Test-only: drop cached generation and mutation state between cases. */ +export function resetPairingKeychainForTests(): void { + cachedGeneration = null + keychainMutation = Promise.resolve() +}