mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user