fix(mobile): recover pairing saves from Android SecureStore failures (#11430)

* fix(mobile): recover pairing save when the Android keystore alias is unusable

Orca Mobile could reach a state where pairing succeeded but the host could
never be saved, with every attempt failing identically:

  Could not encrypt the value for key 'orca.host-token.host-...'
  under keychain 'key_v1'. Caused by: unknown

expo-secure-store derives ONE Android keystore alias from the keychain
service (`<service>:unauthenticated`) and shares it across every host token,
so a single unusable alias rejects all writes. Its built-in self-heal only
covers KeyPermanentlyInvalidatedException, and a null-message
GeneralSecurityException takes the unrecoverable branch instead — leaving
onboarding permanently blocked, which a reinstall does not clear.

Route host-token persistence through a keychain generation that rotates to a
fresh service (and therefore a fresh alias) only after a write has already
failed. Generation 0 keeps expo's default service so tokens written by
earlier builds stay readable, reads walk back through retired services, and
deletes clear every generation so a rotation cannot strand a live credential.

Refs #6600

* fix(mobile): record a keychain rotation before storing the token under it

Greptile flagged that a token could be stored under a generation the
generation record never captured. `commitGeneration` swallowed the
AsyncStorage failure and cached the new generation in memory, so the write
succeeded for the rest of the session — but the next launch re-read the old
record, and because reads only walk back from the recorded generation they
never probed the newer service. The host silently vanished and the user had
to re-pair, which is the same class of loss this change set out to fix.

Record the rotation first and let a storage failure propagate, so a token is
never written under a generation reads won't reach. Advancing the record
before the write is safe because reads walk back through every older service;
the worst case is one spent generation and one extra probe per miss.

* fix(mobile): harden pairing keychain recovery

* fix(mobile): harden pairing keychain recovery state

* fix(mobile): fail closed on unreadable pairing credentials
This commit is contained in:
Brennan Benson
2026-07-30 17:29:41 -07:00
committed by GitHub
parent 5cc502cc55
commit 7db0101bcb
9 changed files with 890 additions and 50 deletions
+123 -1
View File
@@ -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<string | undefined, string>()
// 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<string | undefined, string>()
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')
})
})
+10 -10
View File
@@ -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<string | null> {
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<void> {
@@ -54,7 +53,7 @@ async function writeDeviceToken(hostId: string, token: string): Promise<void> {
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<void> {
@@ -62,7 +61,7 @@ async function deleteDeviceToken(hostId: string): Promise<void> {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await SecureStore.deleteItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(tokenKey(hostId))
}
async function deleteHostCredentials(hostId: string): Promise<void> {
@@ -342,4 +341,5 @@ export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
tokenCache.clear()
inflightLoad = null
resetPairingKeychainForTests()
}
@@ -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
@@ -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<typeof MobileRelayCredentialBundleSchema>
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<MobileRelayCredentialBundle | null> {
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<void> {
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<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(credentialKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(credentialKey(hostId))
}
function requireNativeSecretStore(): void {
@@ -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<typeof MobileRelayDirectUpgradeJournalSchema>
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<MobileRelayDirectUpgradeJournal | null> {
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<void> {
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<void> {
if (Platform.OS === 'web') {
return
}
await SecureStore.deleteItemAsync(journalKey(hostId), KEYCHAIN_OPTIONS)
await deletePairingKeychainItem(journalKey(hostId))
}
function encodeBase64Url(value: Uint8Array): string {
@@ -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<PairingOffer['relay']> },
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<PairingOffer['relay']> },
@@ -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()
@@ -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<void> = 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<MobileRelayPairin
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(() => {})
await deletePairingKeychainItem(JOURNAL_SECRET_KEY).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
@@ -58,7 +60,7 @@ export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairin
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
const rawSecrets = await readPairingKeychainItem(JOURNAL_SECRET_KEY)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
@@ -83,7 +85,7 @@ async function removeIncompleteJournal(): Promise<void> {
// 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()
}
@@ -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<void>((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')
})
})
+257
View File
@@ -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<void> = 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<number | null> {
if (Platform.OS !== 'android') {
return null
}
return parsePresenceGeneration(await AsyncStorage.getItem(presenceStorageKey(key)))
}
async function loadGeneration(): Promise<LoadedGeneration> {
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<void> {
const raw = pending ? `${generation}:pending` : String(generation)
await AsyncStorage.setItem(GENERATION_STORAGE_KEY, raw)
cachedGeneration = { generation, pending }
}
function enqueueKeychainMutation(operation: () => Promise<void>): Promise<void> {
const mutation = keychainMutation.then(operation)
keychainMutation = mutation.catch(() => {})
return mutation
}
async function preparePresenceWrite(
key: string,
generation: number
): Promise<PresenceChange | null> {
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<void> {
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<void> {
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<string | null> {
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<void> {
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<void> {
return enqueueKeychainMutation(() => writePairingKeychainItemImpl(key, value))
}
async function deletePairingKeychainItemImpl(key: string): Promise<void> {
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<void> {
return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key))
}
/** Test-only: drop cached generation and mutation state between cases. */
export function resetPairingKeychainForTests(): void {
cachedGeneration = null
keychainMutation = Promise.resolve()
}