fix(mobile): fail closed on unreadable pairing credentials

This commit is contained in:
Brennan Benson
2026-07-30 11:46:14 -07:00
parent 8d926417b7
commit 8708e6372f
3 changed files with 146 additions and 9 deletions
@@ -32,6 +32,7 @@ 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',
@@ -53,6 +54,7 @@ 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()
@@ -61,18 +63,31 @@ describe('mobile relay pairing journal store', () => {
metadataRaw = null
secretRaw = null
generationRaw = null
asyncStorage.getItem.mockImplementation(async (key: string) =>
key === GENERATION_KEY ? generationRaw : metadataRaw
)
presenceRaw = null
asyncStorage.getItem.mockImplementation(async (key: string) => {
if (key === GENERATION_KEY) {
return generationRaw
}
if (key === JOURNAL_PRESENCE_KEY) {
return presenceRaw
}
return metadataRaw
})
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 () => {
metadataRaw = null
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) => {
+47 -2
View File
@@ -30,6 +30,7 @@ import {
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(
@@ -139,7 +140,11 @@ describe('pairing keychain', () => {
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.mockRejectedValue(new Error('storage full'))
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) {
@@ -241,6 +246,39 @@ describe('pairing keychain', () => {
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) =>
@@ -251,7 +289,12 @@ describe('pairing keychain', () => {
})
it('probes every bounded service when the generation record is temporarily unreadable', async () => {
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
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
)
@@ -280,6 +323,7 @@ describe('pairing keychain', () => {
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 () => {
@@ -293,6 +337,7 @@ describe('pairing keychain', () => {
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 () => {
+79 -2
View File
@@ -8,6 +8,7 @@ const BASE_OPTIONS: SecureStore.SecureStoreOptions = {
}
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
@@ -33,6 +34,7 @@ type LoadedGeneration =
| { 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()
@@ -61,6 +63,33 @@ function parseGeneration(raw: string | null): LoadedGeneration {
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 }
@@ -90,8 +119,53 @@ function enqueueKeychainMutation(operation: () => Promise<void>): Promise<void>
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) {
@@ -111,7 +185,7 @@ async function writePairingKeychainItemImpl(key: string, value: string): Promise
const generation = loaded.generation
let firstError: unknown
try {
await SecureStore.setItemAsync(key, value, optionsForGeneration(generation))
await setItemAtGeneration(key, value, generation)
if (loaded.pending) {
await commitGeneration(generation, false)
}
@@ -134,7 +208,7 @@ async function writePairingKeychainItemImpl(key: string, value: string): Promise
throw firstError
}
try {
await SecureStore.setItemAsync(key, value, optionsForGeneration(rotated))
await setItemAtGeneration(key, value, rotated)
await commitGeneration(rotated, false)
} catch {
throw firstError
@@ -167,6 +241,9 @@ async function deletePairingKeychainItemImpl(key: string): Promise<void> {
if (firstError !== undefined) {
throw firstError
}
if (Platform.OS === 'android') {
await AsyncStorage.removeItem(presenceStorageKey(key))
}
}
export function deletePairingKeychainItem(key: string): Promise<void> {