diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 4e8d183c8d..ce6ed3d3d4 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -25,6 +25,7 @@ export enum Features { FollowSortEnable = 'follow_sort:enable', OnboardingInterestsRequiredEnable = 'onboarding:interests:required:enable', CanonicalPostNumberingEnable = 'canonical_post_numbering:enable', + SessionSecureStorageReadEnable = 'session:secure_storage:read:enable', // values TrendingDiscoverValues = 'trending_discover:values', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 6e30c9d912..00b258c244 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -50,6 +50,21 @@ export type Events = { | 'AgeAssuranceNoAccessScreen' scope: 'current' | 'every' } + /** + * Emitted once per native launch, describing what the secure session store + * held relative to the persisted blob and which of the two the app booted + * from. `divergence` is the point of it: `legacy-lost` means the persisted + * blob lost credentials the secure store still had. + */ + 'sessionStorage:boot': { + source: 'secure' | 'legacy' + gateEnabled: boolean + secureStatus: + 'ok' | 'missing' | 'invalid' | 'unavailable' | 'foreign-install' + divergence: 'none' | 'legacy-lost' | 'secure-behind' + secureAccountsWithCreds: number + legacyAccountsWithCreds: number + } 'notifications:openApp': { reason: NotificationReason causedBoot: boolean diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 58229b3deb..0588981472 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -12,7 +12,7 @@ const externalEmbedOptions = ['show', 'hide'] as const * A account persisted to storage. Stored in the `accounts[]` array. Contains * base account info and access tokens. */ -const accountSchema = z.object({ +export const accountSchema = z.object({ service: z.string(), /** * Genuinely validated, not just branded: the refinement rejects malformed diff --git a/src/state/session/storage/__tests__/boot-test.ts b/src/state/session/storage/__tests__/boot-test.ts new file mode 100644 index 0000000000..80acec4379 --- /dev/null +++ b/src/state/session/storage/__tests__/boot-test.ts @@ -0,0 +1,404 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('expo-secure-store', () => { + const values = new Map() + return { + AFTER_FIRST_UNLOCK: 0, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + __mock: {values}, + } +}) + +jest.mock('#/state/persisted', () => { + const schema: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') + const state: Record = {...schema.defaults} + return { + defaults: schema.defaults, + get: (key: string) => state[key], + readLatest: (key: string) => state[key], + write: (key: string, value: unknown) => { + state[key] = value + return Promise.resolve() + }, + onUpdate: () => () => {}, + __state: state, + } +}) + +jest.mock('#/logger', () => { + const noop = () => {} + const instance = { + debug: noop, + info: noop, + log: noop, + warn: noop, + error: noop, + } + return { + Logger: {Context: {Session: 'session'}, create: () => instance}, + logger: instance, + } +}) + +import {type Schema} from '#/state/persisted' +import {type SessionAccount} from '#/state/session/types' +import {device} from '#/storage' +import { + type BootDecision, + decideBootSource, + type SecureRead, + type SessionStorageBootReport, +} from '../boot' +import {consumeSessionStorageBootReport, initSessionStorage} from '../index' +import {SESSION_INSTALL_KEY} from '../keys' +import {EMPTY_SNAPSHOT, type SessionSnapshot} from '../schema' +import {readSessions, writeInstallMarker, writeSessions} from '../secureStore' + +const {__mock: secure} = jest.requireMock('expo-secure-store') as { + __mock: {values: Map} +} +const persistedState = ( + jest.requireMock('#/state/persisted') as {__state: Schema} +).__state + +const alice: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:alice', + handle: 'alice.test', + refreshJwt: 'alice-refresh', + accessJwt: 'alice-access', +} +const expiredAlice: SessionAccount = { + ...alice, + refreshJwt: undefined, + accessJwt: undefined, +} + +/** An account with usable credentials. */ +const withCreds: SessionSnapshot = {accounts: [alice], currentDid: alice.did} +/** The same account, logged out or expired. */ +const withoutCreds: SessionSnapshot = { + accounts: [expiredAlice], + currentDid: alice.did, +} + +type ExpectedDecision = Omit & { + adopts: boolean + divergence: SessionStorageBootReport['divergence'] +} + +const rows: { + name: string + gateEnabled: boolean + secure: SecureRead + legacy: SessionSnapshot + expected: ExpectedDecision +}[] = [ + { + name: 'gate off, nothing stored yet', + gateEnabled: false, + secure: {status: 'missing'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, + { + name: 'gate off, both stores agree', + gateEnabled: false, + secure: {status: 'ok', snapshot: withCreds}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: false, + divergence: 'none', + }, + }, + { + name: 'gate off, the blob lost its credentials', + gateEnabled: false, + secure: {status: 'ok', snapshot: withCreds}, + legacy: withoutCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: false, + divergence: 'legacy-lost', + }, + }, + { + name: 'gate off, the secure store is behind', + gateEnabled: false, + secure: {status: 'ok', snapshot: withoutCreds}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: false, + divergence: 'secure-behind', + }, + }, + { + name: 'gate off, stored data is unreadable', + gateEnabled: false, + secure: {status: 'invalid'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, + { + name: 'gate off, the keychain is unreachable', + gateEnabled: false, + secure: {status: 'unavailable'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'none', + forceIndex: false, + divergence: 'none', + }, + }, + { + name: 'gate off, data from a previous install', + gateEnabled: false, + secure: {status: 'foreign-install'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, + { + name: 'gate on, nothing stored yet', + gateEnabled: true, + secure: {status: 'missing'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, + { + name: 'gate on, stored data is unreadable', + gateEnabled: true, + secure: {status: 'invalid'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, + { + name: 'gate on, both stores agree', + gateEnabled: true, + secure: {status: 'ok', snapshot: withCreds}, + legacy: withCreds, + expected: { + source: 'secure', + adopts: true, + backfill: 'none', + forceIndex: false, + divergence: 'none', + }, + }, + { + name: 'gate on, the blob lost its credentials', + gateEnabled: true, + secure: {status: 'ok', snapshot: withCreds}, + legacy: withoutCreds, + expected: { + source: 'secure', + adopts: true, + backfill: 'none', + forceIndex: false, + divergence: 'legacy-lost', + }, + }, + { + name: 'gate on, neither store holds credentials', + gateEnabled: true, + secure: {status: 'ok', snapshot: EMPTY_SNAPSHOT}, + legacy: withoutCreds, + expected: { + source: 'secure', + adopts: true, + backfill: 'none', + forceIndex: false, + divergence: 'none', + }, + }, + { + name: 'gate on, the secure store is behind', + gateEnabled: true, + secure: {status: 'ok', snapshot: withoutCreds}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: false, + divergence: 'secure-behind', + }, + }, + { + name: 'gate on, the keychain is unreachable', + gateEnabled: true, + secure: {status: 'unavailable'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'none', + forceIndex: false, + divergence: 'none', + }, + }, + { + name: 'gate on, data from a previous install', + gateEnabled: true, + secure: {status: 'foreign-install'}, + legacy: withCreds, + expected: { + source: 'legacy', + adopts: false, + backfill: 'secure-from-legacy', + forceIndex: true, + divergence: 'none', + }, + }, +] + +describe('decideBootSource', () => { + it.each(rows)('$name', ({gateEnabled, secure: read, legacy, expected}) => { + const decision = decideBootSource({gateEnabled, secure: read, legacy}) + + expect({ + source: decision.source, + adopts: decision.adopt !== undefined, + backfill: decision.backfill, + forceIndex: decision.forceIndex, + divergence: decision.report.divergence, + }).toEqual(expected) + expect(decision.adopt).toEqual( + expected.adopts && read.status === 'ok' ? read.snapshot : undefined, + ) + expect(decision.report.gateEnabled).toBe(gateEnabled) + expect(decision.report.secureStatus).toBe(read.status) + expect(decision.report.source).toBe(expected.source) + }) + + it('counts the accounts holding credentials in each store', () => { + const {report} = decideBootSource({ + gateEnabled: false, + secure: { + status: 'ok', + snapshot: { + accounts: [alice, {...expiredAlice, did: 'did:plc:bob'}], + currentDid: alice.did, + }, + }, + legacy: withoutCreds, + }) + + expect(report.secureAccountsWithCreds).toBe(1) + expect(report.legacyAccountsWithCreds).toBe(0) + }) +}) + +describe('initSessionStorage', () => { + beforeEach(() => { + secure.values.clear() + persistedState.session = {accounts: [], currentAccount: undefined} + device.set(['sessionSecureStorageInstallId'], 'install-1') + writeInstallMarker('install-1') + consumeSessionStorageBootReport() + }) + + it('adopts the secure store into the persisted state when it wins', () => { + device.set(['sessionSecureStorageReadEnabled'], true) + writeSessions(EMPTY_SNAPSHOT, withCreds, {forceIndex: true}) + persistedState.session = { + accounts: [expiredAlice], + currentAccount: expiredAlice, + } + + initSessionStorage() + + expect(persistedState.session).toEqual({ + accounts: [alice], + currentAccount: alice, + }) + expect(consumeSessionStorageBootReport()).toEqual({ + source: 'secure', + gateEnabled: true, + secureStatus: 'ok', + divergence: 'legacy-lost', + secureAccountsWithCreds: 1, + legacyAccountsWithCreds: 0, + }) + }) + + it('rewrites from the blob instead of adopting a store that fell behind', () => { + device.set(['sessionSecureStorageReadEnabled'], true) + writeSessions(EMPTY_SNAPSHOT, withoutCreds, {forceIndex: true}) + persistedState.session = {accounts: [alice], currentAccount: alice} + + initSessionStorage() + + expect(persistedState.session).toEqual({ + accounts: [alice], + currentAccount: alice, + }) + expect(readSessions()).toEqual(withCreds) + expect(consumeSessionStorageBootReport()).toMatchObject({ + source: 'legacy', + divergence: 'secure-behind', + }) + }) + + it('erases sessions belonging to a previous install', () => { + device.set(['sessionSecureStorageReadEnabled'], true) + writeSessions(EMPTY_SNAPSHOT, withCreds, {forceIndex: true}) + // The keychain outlived the install that wrote it. + writeInstallMarker('install-0') + + initSessionStorage() + + expect(persistedState.session).toEqual({ + accounts: [], + currentAccount: undefined, + }) + expect(readSessions()).toEqual(EMPTY_SNAPSHOT) + expect(secure.values.get(SESSION_INSTALL_KEY)).toBe('install-1') + expect(consumeSessionStorageBootReport()).toMatchObject({ + source: 'legacy', + secureStatus: 'foreign-install', + }) + }) +}) diff --git a/src/state/session/storage/__tests__/secure-store-test.ts b/src/state/session/storage/__tests__/secure-store-test.ts new file mode 100644 index 0000000000..ce7bbb88b2 --- /dev/null +++ b/src/state/session/storage/__tests__/secure-store-test.ts @@ -0,0 +1,293 @@ +import {beforeEach, describe, expect, it, jest} from '@jest/globals' + +/* + * A real in-memory keychain, with a way to make one key fail. Everything the + * test drives lives inside the factory so it is safe to touch from the very + * first module evaluation. + */ +jest.mock('expo-secure-store', () => { + const values = new Map() + const writes: [key: string, value: string][] = [] + const options: unknown[] = [] + const control: {failKey?: string} = {} + return { + AFTER_FIRST_UNLOCK: 0, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string, opts?: unknown) => { + if (key === control.failKey) throw new Error('disk full') + writes.push([key, value]) + options.push(opts) + values.set(key, value) + }, + __mock: {values, writes, options, control}, + } +}) + +import * as SecureStore from 'expo-secure-store' + +import {type SessionAccount} from '#/state/session/types' +import {accountKeys, SESSION_INDEX_KEY} from '../keys' +import {EMPTY_SNAPSHOT, type SessionSnapshot} from '../schema' +import { + eraseSessions, + hasStoredIndex, + readSessions, + writeSessions, +} from '../secureStore' + +const {__mock: secure} = jest.requireMock('expo-secure-store') as { + __mock: { + values: Map + writes: [key: string, value: string][] + options: unknown[] + control: {failKey?: string} + } +} + +const alice: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:alice', + handle: 'alice.test', + refreshJwt: 'alice-refresh', + accessJwt: 'alice-access', +} +const aliceKeys = accountKeys(alice.did) +const active: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + +beforeEach(() => { + secure.values.clear() + secure.writes.length = 0 + secure.options.length = 0 + secure.control.failKey = undefined +}) + +function writtenKeys() { + return secure.writes.map(([key]) => key) +} + +describe('secureStore', () => { + it('writes credentials before descriptors and publishes the index last', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + + expect(writtenKeys()).toEqual([ + aliceKeys.refresh, + aliceKeys.access, + aliceKeys.descriptor, + SESSION_INDEX_KEY, + ]) + expect(hasStoredIndex()).toBe(true) + expect(readSessions()).toEqual(active) + }) + + it('derives keys that fit the storage grammar and hide the did', () => { + const keys = accountKeys('did:web:example.com:some/path?with=chars') + + for (const key of Object.values(keys)) { + expect(key).toMatch(/^[A-Za-z0-9._-]+$/) + expect(key).not.toContain('example.com') + } + }) + + it('writes every key so it survives a locked device', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + + expect(secure.options.length).toBe(secure.writes.length) + for (const options of secure.options) { + expect(options).toEqual({ + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, + }) + } + }) + + it('journals a retained-account logout against the previous index', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + secure.writes.length = 0 + + const loggedOut: SessionSnapshot = { + accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}], + currentDid: undefined, + } + writeSessions(active, loggedOut) + + expect(secure.writes).toEqual([ + [ + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + currentDid: alice.did, + dids: [alice.did], + revokedDids: [alice.did], + }), + ], + [aliceKeys.refresh, ''], + [aliceKeys.access, ''], + [ + SESSION_INDEX_KEY, + JSON.stringify({version: 1, currentDid: undefined, dids: [alice.did]}), + ], + ]) + expect(readSessions()).toEqual(loggedOut) + }) + + it('journals a removed account, then tombstones it, then cleans the index', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + secure.writes.length = 0 + + writeSessions(active, EMPTY_SNAPSHOT) + + expect(secure.writes).toEqual([ + [ + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + currentDid: undefined, + dids: [], + retiredDids: [alice.did], + }), + ], + [aliceKeys.refresh, ''], + [aliceKeys.access, ''], + [aliceKeys.descriptor, ''], + [ + SESSION_INDEX_KEY, + JSON.stringify({version: 1, currentDid: undefined, dids: []}), + ], + ]) + expect(readSessions()).toEqual(EMPTY_SNAPSHOT) + }) + + it('finishes an interrupted credential revocation on read', () => { + const { + accessJwt: _accessJwt, + refreshJwt: _refreshJwt, + ...descriptor + } = alice + secure.values.set(aliceKeys.refresh, '') + secure.values.set(aliceKeys.access, alice.accessJwt!) + secure.values.set(aliceKeys.descriptor, JSON.stringify(descriptor)) + secure.values.set( + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + dids: [alice.did], + revokedDids: [alice.did], + }), + ) + + expect(readSessions()).toEqual({ + accounts: [{...descriptor, refreshJwt: undefined, accessJwt: undefined}], + currentDid: undefined, + }) + expect(secure.values.get(aliceKeys.access)).toBe('') + expect(JSON.parse(secure.values.get(SESSION_INDEX_KEY)!)).toEqual({ + version: 1, + dids: [alice.did], + }) + }) + + it('finishes an interrupted account removal on read', () => { + secure.values.set(aliceKeys.refresh, alice.refreshJwt!) + secure.values.set(aliceKeys.access, alice.accessJwt!) + secure.values.set(aliceKeys.descriptor, JSON.stringify(alice)) + secure.values.set( + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + dids: [], + retiredDids: [alice.did], + }), + ) + + expect(readSessions()).toEqual(EMPTY_SNAPSHOT) + expect(secure.values.get(aliceKeys.refresh)).toBe('') + expect(secure.values.get(aliceKeys.access)).toBe('') + expect(secure.values.get(aliceKeys.descriptor)).toBe('') + expect(JSON.parse(secure.values.get(SESSION_INDEX_KEY)!)).toEqual({ + version: 1, + dids: [], + }) + }) + + it('erases an account and resets the index', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + secure.writes.length = 0 + + eraseSessions([alice.did]) + + expect(secure.writes).toEqual([ + [ + SESSION_INDEX_KEY, + JSON.stringify({ + version: 1, + currentDid: undefined, + dids: [], + retiredDids: [alice.did], + }), + ], + [aliceKeys.refresh, ''], + [aliceKeys.access, ''], + [aliceKeys.descriptor, ''], + [ + SESSION_INDEX_KEY, + JSON.stringify({version: 1, currentDid: undefined, dids: []}), + ], + ]) + expect(readSessions()).toEqual(EMPTY_SNAPSHOT) + }) + + it('treats the index as the commit point when a write is interrupted', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + const bob: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:bob', + handle: 'bob.test', + refreshJwt: 'bob-refresh', + accessJwt: 'bob-access', + } + secure.control.failKey = SESSION_INDEX_KEY + + expect(() => + writeSessions(active, {accounts: [alice, bob], currentDid: alice.did}), + ).toThrow('disk full') + + secure.control.failKey = undefined + // bob's credentials landed, but no index ever named him. + expect(secure.values.get(accountKeys(bob.did).refresh)).toBe('bob-refresh') + expect(readSessions()).toEqual(active) + }) + + it('treats a key-reordered equivalent snapshot as unchanged', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + /* + * A snapshot read back carries the schema's key order while one built by + * the reducer carries its own, so this is the shape of every first write + * after a boot. + */ + const previous = readSessions() + secure.writes.length = 0 + + writeSessions(previous, { + currentDid: alice.did, + accounts: [ + { + did: alice.did, + handle: alice.handle, + service: alice.service, + accessJwt: alice.accessJwt, + refreshJwt: alice.refreshJwt, + }, + ], + }) + + expect(secure.writes).toEqual([]) + }) + + it('does not republish the index when nothing changed', () => { + writeSessions(EMPTY_SNAPSHOT, active, {forceIndex: true}) + secure.writes.length = 0 + + writeSessions(active, active) + + expect(secure.writes).toEqual([]) + }) +}) diff --git a/src/state/session/storage/__tests__/store-test.ts b/src/state/session/storage/__tests__/store-test.ts new file mode 100644 index 0000000000..8f189ecefe --- /dev/null +++ b/src/state/session/storage/__tests__/store-test.ts @@ -0,0 +1,212 @@ +import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals' + +jest.mock('expo-secure-store', () => { + const values = new Map() + const writes: [key: string, value: string][] = [] + const control: {failKey?: string} = {} + return { + AFTER_FIRST_UNLOCK: 0, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + if (key === control.failKey) throw new Error('disk full') + writes.push([key, value]) + values.set(key, value) + }, + __mock: {values, writes, control}, + } +}) + +jest.mock('#/lib/appState', () => { + const listeners: ((state: string) => void)[] = [] + return { + onAppStateChange: (cb: (state: string) => void) => { + listeners.push(cb) + return {remove: () => {}} + }, + __listeners: listeners, + } +}) + +jest.mock('#/logger', () => { + const errors: string[] = [] + const noop = () => {} + const instance = { + debug: noop, + info: noop, + log: noop, + warn: noop, + error: (message: string) => { + errors.push(message) + }, + } + return { + Logger: {Context: {Session: 'session'}, create: () => instance}, + logger: instance, + __errors: errors, + } +}) + +import {type SessionAccount} from '#/state/session/types' +import {accountKeys, SESSION_INDEX_KEY} from '../keys' +import {EMPTY_SNAPSHOT, type SessionSnapshot} from '../schema' +import {createSessionStorageStore} from '../store' + +const {__mock: secure} = jest.requireMock('expo-secure-store') as { + __mock: { + values: Map + writes: [key: string, value: string][] + control: {failKey?: string} + } +} +const {__listeners: appStateListeners} = jest.requireMock('#/lib/appState') as { + __listeners: ((state: string) => void)[] +} +const {__errors: loggedErrors} = jest.requireMock('#/logger') as { + __errors: string[] +} + +const RETRY_DELAY = 5_000 + +const alice: SessionAccount = { + service: 'https://bsky.social', + did: 'did:plc:alice', + handle: 'alice.test', + refreshJwt: 'alice-refresh', + accessJwt: 'alice-access', +} +const aliceKeys = accountKeys(alice.did) +const active: SessionSnapshot = {accounts: [alice], currentDid: alice.did} + +beforeEach(() => { + jest.useFakeTimers() + secure.values.clear() + secure.writes.length = 0 + secure.control.failKey = undefined + appStateListeners.length = 0 + loggedErrors.length = 0 +}) + +afterEach(() => { + jest.clearAllTimers() + jest.useRealTimers() +}) + +function storedIndex() { + return JSON.parse(secure.values.get(SESSION_INDEX_KEY)!) +} + +describe('createSessionStorageStore', () => { + it('holds no retry trigger while writes succeed', () => { + const store = createSessionStorageStore() + + store.write(active) + + expect(store.getDurable()).toEqual(active) + expect(appStateListeners.length).toBe(0) + expect(loggedErrors).toEqual([]) + }) + + it('retries the newest snapshot as one complete write', () => { + const store = createSessionStorageStore() + secure.control.failKey = aliceKeys.access + + store.write(active) + const refreshed: SessionSnapshot = { + accounts: [ + {...alice, refreshJwt: 'new-refresh', accessJwt: 'new-access'}, + ], + currentDid: alice.did, + } + store.write(refreshed) + expect(loggedErrors.length).toBe(2) + expect(store.getDurable()).toEqual(EMPTY_SNAPSHOT) + + secure.control.failKey = undefined + jest.advanceTimersByTime(RETRY_DELAY) + + expect(secure.values.get(aliceKeys.refresh)).toBe('new-refresh') + expect(secure.values.get(aliceKeys.access)).toBe('new-access') + expect(storedIndex()).toEqual({ + version: 1, + currentDid: alice.did, + dids: [alice.did], + }) + expect(store.getDurable()).toEqual(refreshed) + }) + + it('tombstones credentials from a superseded partial write', () => { + const store = createSessionStorageStore() + secure.control.failKey = SESSION_INDEX_KEY + + store.write(active) + expect(secure.values.get(aliceKeys.refresh)).toBe(alice.refreshJwt) + + secure.control.failKey = undefined + store.write(EMPTY_SNAPSHOT) + + expect(secure.values.get(aliceKeys.refresh)).toBe('') + expect(secure.values.get(aliceKeys.access)).toBe('') + expect(secure.values.get(aliceKeys.descriptor)).toBe('') + }) + + it('clears accounts known only to the last durable snapshot', () => { + const store = createSessionStorageStore() + store.write(active) + + secure.control.failKey = SESSION_INDEX_KEY + store.write(EMPTY_SNAPSHOT) + secure.control.failKey = undefined + + store.clear() + + expect(secure.values.get(aliceKeys.refresh)).toBe('') + expect(secure.values.get(aliceKeys.access)).toBe('') + expect(secure.values.get(aliceKeys.descriptor)).toBe('') + expect(storedIndex()).toEqual({version: 1, dids: []}) + expect(store.getDurable()).toEqual(EMPTY_SNAPSHOT) + }) + + it('drops writes until a failed clear succeeds', () => { + const store = createSessionStorageStore() + store.write(active) + + secure.control.failKey = aliceKeys.access + store.write({ + accounts: [{...alice, accessJwt: 'new-access'}], + currentDid: alice.did, + }) + + secure.control.failKey = SESSION_INDEX_KEY + expect(() => store.clear()).not.toThrow() + + const writesBeforeDroppedWrite = secure.writes.length + store.write(active) + expect(secure.writes.length).toBe(writesBeforeDroppedWrite) + + secure.control.failKey = undefined + jest.advanceTimersByTime(RETRY_DELAY) + + expect(secure.values.get(aliceKeys.refresh)).toBe('') + expect(secure.values.get(aliceKeys.access)).toBe('') + expect(secure.values.get(aliceKeys.descriptor)).toBe('') + expect(storedIndex()).toEqual({version: 1, dids: []}) + }) + + it('retries when the app returns to the foreground', () => { + const store = createSessionStorageStore() + secure.control.failKey = aliceKeys.access + store.write(active) + expect(appStateListeners.length).toBe(1) + + secure.control.failKey = undefined + appStateListeners.forEach(listener => listener('active')) + + expect(secure.values.get(aliceKeys.access)).toBe(alice.accessJwt) + expect(storedIndex()).toEqual({ + version: 1, + currentDid: alice.did, + dids: [alice.did], + }) + expect(store.getDurable()).toEqual(active) + }) +}) diff --git a/src/state/session/storage/boot.ts b/src/state/session/storage/boot.ts new file mode 100644 index 0000000000..02eaf159af --- /dev/null +++ b/src/state/session/storage/boot.ts @@ -0,0 +1,120 @@ +import {type SessionSnapshot} from './schema' + +/** Which store the app boots its session state from. */ +export type BootSource = 'secure' | 'legacy' + +export type SecureReadStatus = + 'ok' | 'missing' | 'invalid' | 'unavailable' | 'foreign-install' + +export type SecureRead = + | {status: 'ok'; snapshot: SessionSnapshot} + | {status: Exclude} + +/** + * Emitted once per boot. While the read gate is off this is the measurement: + * `legacy-lost` is the signature of the corruption this module exists to fix, + * and its rate is what says whether the gate is worth turning on. + */ +export type SessionStorageBootReport = { + source: BootSource + gateEnabled: boolean + secureStatus: SecureReadStatus + divergence: 'none' | 'legacy-lost' | 'secure-behind' + secureAccountsWithCreds: number + legacyAccountsWithCreds: number +} + +export type BootDecision = { + source: BootSource + /** The snapshot to adopt, set only when booting from the secure store. */ + adopt: SessionSnapshot | undefined + backfill: 'none' | 'secure-from-legacy' + /** Publish an index even if the backfill changes nothing. */ + forceIndex: boolean + report: SessionStorageBootReport +} + +/** + * Decide which store the session boots from, and what the secure store needs + * written back to it. Pure, so the whole matrix is testable without mocks. + * + * The secure store only wins when the gate is on, it read cleanly, and it is + * not obviously behind the legacy blob. "Behind" means the legacy blob holds + * credentials and the secure store does not, which is what silently failing + * mirror writes look like - booting from the secure store there would log the + * user out. A real logout clears the credentials in both stores, so it does + * not trip the guard. + */ +export function decideBootSource({ + gateEnabled, + secure, + legacy, +}: { + gateEnabled: boolean + secure: SecureRead + legacy: SessionSnapshot +}): BootDecision { + const secureSnapshot = secure.status === 'ok' ? secure.snapshot : undefined + const secureHasCreds = secureSnapshot ? hasCreds(secureSnapshot) : false + const legacyHasCreds = hasCreds(legacy) + + const bootSecure = + gateEnabled && + secureSnapshot !== undefined && + (secureHasCreds || !legacyHasCreds) + + let backfill: BootDecision['backfill'] = 'none' + let forceIndex = false + switch (secure.status) { + case 'unavailable': + /* + * Nothing can be written either, and an attempt would only add another + * failure. The next boot reads again. + */ + break + case 'missing': + case 'invalid': + case 'foreign-install': + backfill = 'secure-from-legacy' + forceIndex = true + break + case 'ok': + /* + * Booting from legacy means legacy is authoritative for this run, so the + * secure store is brought level with it. The write is diff-aware and + * no-ops when the two already agree, which is the steady state. + */ + backfill = bootSecure ? 'none' : 'secure-from-legacy' + break + } + + return { + source: bootSecure ? 'secure' : 'legacy', + adopt: bootSecure ? secureSnapshot : undefined, + backfill, + forceIndex, + report: { + source: bootSecure ? 'secure' : 'legacy', + gateEnabled, + secureStatus: secure.status, + divergence: + secureSnapshot === undefined + ? 'none' + : secureHasCreds && !legacyHasCreds + ? 'legacy-lost' + : !secureHasCreds && legacyHasCreds + ? 'secure-behind' + : 'none', + secureAccountsWithCreds: secureSnapshot ? countCreds(secureSnapshot) : 0, + legacyAccountsWithCreds: countCreds(legacy), + }, + } +} + +function hasCreds(snapshot: SessionSnapshot): boolean { + return snapshot.accounts.some(account => Boolean(account.refreshJwt)) +} + +function countCreds(snapshot: SessionSnapshot): number { + return snapshot.accounts.filter(account => Boolean(account.refreshJwt)).length +} diff --git a/src/state/session/storage/errors.ts b/src/state/session/storage/errors.ts new file mode 100644 index 0000000000..a45b82490d --- /dev/null +++ b/src/state/session/storage/errors.ts @@ -0,0 +1,58 @@ +import {Logger} from '#/logger' + +const logger = Logger.create(Logger.Context.Session) + +export type SessionStorageErrorKind = + 'unavailable' | 'invalid-data' | 'storage-full' | 'write-failed' + +export type SessionStorageErrorOperation = + 'init' | 'backfill' | 'write' | 'retry' | 'clear' + +export type SessionStorageError = { + kind: SessionStorageErrorKind + operation: SessionStorageErrorOperation +} + +/** + * Thrown when stored session data exists but fails to parse or validate. Kept + * distinct from an unavailable-storage failure so callers can tell "the store + * holds nothing usable" from "the store could not be reached". + */ +export class InvalidSessionStorageDataError extends Error {} + +/** + * Classify a thrown error into a {@link SessionStorageError}. Invalid data is + * reported as such; a storage-full message is detected by the regex below; + * every other failure is `unavailable` while reading at boot and + * `write-failed` otherwise. + */ +export function storageError( + operation: SessionStorageErrorOperation, + cause: unknown, +): SessionStorageError { + const message = cause instanceof Error ? cause.message : String(cause) + const kind = + cause instanceof InvalidSessionStorageDataError + ? 'invalid-data' + : /quota|disk.*full|storage.*full|no space/i.test(message) + ? 'storage-full' + : operation === 'init' + ? 'unavailable' + : 'write-failed' + return {kind, operation} +} + +export function logStorageError(error: SessionStorageError) { + /* + * Never attach the underlying native error: some platforms include the key + * in it. Keys are hashed, but keeping telemetry credential-agnostic is safer. + */ + logger.error('session storage operation failed', { + kind: error.kind, + operation: error.operation, + tags: { + session_storage_kind: error.kind, + session_storage_operation: error.operation, + }, + }) +} diff --git a/src/state/session/storage/gate-sync.ts b/src/state/session/storage/gate-sync.ts new file mode 100644 index 0000000000..80bf73d630 --- /dev/null +++ b/src/state/session/storage/gate-sync.ts @@ -0,0 +1,43 @@ +import {useEffect} from 'react' + +import {consumeSessionStorageBootReport} from '#/state/session/storage' +import {useAnalytics} from '#/analytics' +import {device} from '#/storage' + +/** + * Caches the secure-session-storage read gate into device storage, and emits + * the boot report the storage module stashed. + * + * The gate cannot be evaluated during bootstrap - GrowthBook is not loaded + * until the analytics providers mount, and the storage module has already + * chosen a boot source by then - so each run caches the gate for the next one. + * The boot report is emitted here for the same reason: this is the first point + * in the tree where a metric can be sent. + * + * Mount under `AnalyticsFeaturesContext`. That provider sits inside the + * `` remount breaker, so this component remounts on every + * account switch: the gate rule must bucket on `deviceId`, not on the did, or + * the cached value will flip-flop between accounts. + */ +export function SecureSessionStorageGateSync() { + const ax = useAnalytics() + const enabled = ax.features.enabled( + ax.features.SessionSecureStorageReadEnable, + ) + + useEffect(() => { + /* + * Guard against a redundant write on every mount. Writing triggers the + * storage change listener, which re-renders the analytics subtree. + */ + if (device.get(['sessionSecureStorageReadEnabled']) !== enabled) { + device.set(['sessionSecureStorageReadEnabled'], enabled) + } + const report = consumeSessionStorageBootReport() + if (report) { + ax.metric('sessionStorage:boot', report) + } + }, [ax, enabled]) + + return null +} diff --git a/src/state/session/storage/index.ts b/src/state/session/storage/index.ts new file mode 100644 index 0000000000..706dc855a1 --- /dev/null +++ b/src/state/session/storage/index.ts @@ -0,0 +1,192 @@ +/** + * Native session storage: a mirror of the session state held in the persisted + * blob, kept in the system keychain. + * + * Every native install writes both stores. Which one the app boots from is + * decided per launch by `decideBootSource`, gated on a device flag cached from + * a feature gate on the previous run. Nothing here throws, and nothing here is + * awaited: a keychain failure must never keep the app from booting. + * + * This module must not import `./gate-sync` or anything from `#/analytics`. + * Doing so pulls GrowthBook's module-scope initialization into the session + * provider's module graph. + */ +import uuid from 'react-native-uuid' + +import * as persisted from '#/state/persisted' +import {device} from '#/storage' +import { + type BootDecision, + decideBootSource, + type SecureRead, + type SessionStorageBootReport, +} from './boot' +import { + InvalidSessionStorageDataError, + logStorageError, + storageError, +} from './errors' +import {EMPTY_SNAPSHOT, type SessionSnapshot} from './schema' +import { + canonicalJson, + eraseSessions, + hasStoredIndex, + readInstallMarker, + readSessions, + writeInstallMarker, + writeSessions, +} from './secureStore' +import {createSessionStorageStore} from './store' + +const store = createSessionStorageStore() +let bootReport: SessionStorageBootReport | undefined + +/** + * Read both stores, decide which one wins, and bring the loser level with it. + * Call once during bootstrap, after `persisted.init()` has resolved and before + * anything reads the session. + * + * When the secure store wins, its snapshot is adopted by writing it back into + * the persisted state. `persisted.write` updates its in-memory copy + * synchronously before awaiting storage, so every existing reader - the + * session store constructor, the last-active-account lookup, the expiry rescue + * path - sees the adopted data with no seam of its own. It also means the + * legacy blob stays current, so turning the gate back off is safe. + */ +export function initSessionStorage(): void { + try { + const legacy = toSnapshot(persisted.get('session')) + let secure = readSecure() + + /* + * The keychain outlives an uninstall on iOS while device storage dies with + * the app container, so a stored marker that does not match the device's + * is data from a previous install of the app. Only meaningful once there + * is an index to have been read. + */ + const storedInstallId = device.get(['sessionSecureStorageInstallId']) + const installId = storedInstallId ?? uuid.v4() + if (!storedInstallId) { + device.set(['sessionSecureStorageInstallId'], installId) + } + if (secure.status === 'ok' || secure.status === 'invalid') { + if (!storedInstallId || readInstallMarker() !== storedInstallId) { + const dids = + secure.status === 'ok' + ? secure.snapshot.accounts.map(account => account.did) + : [] + try { + eraseSessions(dids) + } catch (cause) { + logStorageError(storageError('clear', cause)) + } + secure = {status: 'foreign-install'} + } + } + + const decision = decideBootSource({ + gateEnabled: device.get(['sessionSecureStorageReadEnabled']) ?? false, + secure, + legacy, + }) + + if (decision.adopt) { + adopt(decision.adopt) + } + store.setDurable(secure.status === 'ok' ? secure.snapshot : EMPTY_SNAPSHOT) + if (decision.backfill === 'secure-from-legacy') { + backfill(legacy, decision, installId) + } + + bootReport = decision.report + } catch (cause) { + logStorageError(storageError('init', cause)) + } +} + +/** + * Mirror the session state the reducer just persisted. Fire-and-forget: a + * failure is logged and retried in the background, and never surfaces here. + */ +export function mirrorSessionSnapshot(data: persisted.Schema['session']): void { + store.write(toSnapshot(data)) +} + +/** Erase all stored session data, including any orphaned leftovers. */ +export function clearSessionStorage(): void { + store.clear() +} + +/** + * The boot report, once. Emitted as a metric by `SecureSessionStorageGateSync` + * as soon as an analytics context exists, which is long after boot. + */ +export function consumeSessionStorageBootReport(): + SessionStorageBootReport | undefined { + const report = bootReport + bootReport = undefined + return report +} + +function readSecure(): SecureRead { + try { + if (!hasStoredIndex()) return {status: 'missing'} + return {status: 'ok', snapshot: readSessions()} + } catch (cause) { + if (cause instanceof InvalidSessionStorageDataError) { + return {status: 'invalid'} + } + logStorageError(storageError('init', cause)) + return {status: 'unavailable'} + } +} + +function adopt(snapshot: SessionSnapshot) { + const {accounts, currentDid} = snapshot + const next = { + accounts, + currentAccount: accounts.find(account => account.did === currentDid), + } + if (canonicalJson(next) === canonicalJson(persisted.get('session'))) return + void persisted.write('session', next) +} + +function backfill( + legacy: SessionSnapshot, + decision: BootDecision, + installId: string, +) { + try { + if (decision.forceIndex) { + /* + * Written before the index so a failure here can only produce a marker + * with no session data, which the next boot backfills over. The reverse + * order could leave stored sessions looking foreign. + */ + writeInstallMarker(installId) + } + writeSessions(store.getDurable(), legacy, {forceIndex: decision.forceIndex}) + store.setDurable(legacy) + } catch (cause) { + /* + * No retry: the next boot attempts the backfill again, and in the meantime + * every mirrored write converges the store on its own. + */ + logStorageError(storageError('backfill', cause)) + } +} + +/** + * Drop a current account that names no stored account. Both stores allow that + * state, but an index whose current did is absent fails validation on read, + * which would discard every account's credentials rather than one field. + */ +function toSnapshot(data: persisted.Schema['session']): SessionSnapshot { + const currentDid = data.currentAccount?.did + return { + accounts: data.accounts, + currentDid: data.accounts.some(account => account.did === currentDid) + ? currentDid + : undefined, + } +} diff --git a/src/state/session/storage/index.web.ts b/src/state/session/storage/index.web.ts new file mode 100644 index 0000000000..4d77c92aeb --- /dev/null +++ b/src/state/session/storage/index.web.ts @@ -0,0 +1,18 @@ +/** + * Secure session storage is native-only: the web build keeps using the + * persisted blob alone. These no-ops exist so the call sites need no platform + * branching. + */ +import {type Schema} from '#/state/persisted' +import {type SessionStorageBootReport} from './boot' + +export function initSessionStorage(): void {} + +export function mirrorSessionSnapshot(_data: Schema['session']): void {} + +export function clearSessionStorage(): void {} + +export function consumeSessionStorageBootReport(): + SessionStorageBootReport | undefined { + return undefined +} diff --git a/src/state/session/storage/keys.ts b/src/state/session/storage/keys.ts new file mode 100644 index 0000000000..8d6b8397ac --- /dev/null +++ b/src/state/session/storage/keys.ts @@ -0,0 +1,47 @@ +import {sha256} from 'js-sha256' + +/** Holds the commit index describing the stored set of accounts. */ +export const SESSION_INDEX_KEY = 'bsky.session.index.v1' + +/** + * Holds a random per-install marker, cross-checked at boot against the copy in + * device storage. The iOS keychain outlives an app uninstall while device + * storage dies with the app container, so a mismatch means the stored sessions + * belong to a previous install of the app. + */ +export const SESSION_INSTALL_KEY = 'bsky.session.install.v1' + +type AccountKeys = { + descriptor: string + refresh: string + access: string +} + +const cache = new Map() + +/** + * The three storage keys owned by an account: its descriptor (the account + * minus its tokens), its refresh token, and its access token. + * + * SecureStore enforces a key grammar of `[A-Za-z0-9._-]` and every did + * contains at least one `:`, so dids cannot be keys. One fixed sha256 + * derivation keeps the layout uniform for every did method. + * + * `.create()` forces the library's portable implementation. The convenience + * function eval-requires node crypto, which the jest setup mocks away. + * + * Memoized: these are derived for every account on every write. + */ +export function accountKeys(did: string): AccountKeys { + const cached = cache.get(did) + if (cached) return cached + const id = sha256.create().update(did).hex() + const prefix = `bsky.session.${id}` + const keys: AccountKeys = { + descriptor: `${prefix}.descriptor`, + refresh: `${prefix}.refresh`, + access: `${prefix}.access`, + } + cache.set(did, keys) + return keys +} diff --git a/src/state/session/storage/schema.ts b/src/state/session/storage/schema.ts new file mode 100644 index 0000000000..8441211e5d --- /dev/null +++ b/src/state/session/storage/schema.ts @@ -0,0 +1,51 @@ +import {z} from 'zod' + +import {accountSchema} from '#/state/persisted/schema' +import {type SessionAccount} from '#/state/session/types' + +/** + * An account minus its tokens, stored under its own key so credentials can be + * revoked without losing the account itself. + * + * Derived from the persisted account schema rather than redeclared: the did + * field is genuinely validated there, and its narrowed `DidString` output is + * what makes a parsed descriptor assignable to {@link SessionAccount}. + */ +export const descriptorSchema = accountSchema.omit({ + accessJwt: true, + refreshJwt: true, +}) +export type AccountDescriptor = z.infer + +/** + * The commit index. Names the stored dids, the current did, and the journals + * an interrupted write leaves behind for the next read to finish. + * + * The dids here are deliberately plain strings. Each account's did is validated + * by {@link descriptorSchema} when its descriptor is read, and `readSessions` + * cross-checks that the descriptor's did matches the did it was listed under. + * Validating dids in the index as well would let one odd value invalidate the + * whole store, taking every other account's credentials with it. + */ +export const storedIndexSchema = z.object({ + version: z.literal(1), + currentDid: z.string().optional(), + dids: z.array(z.string()), + retiredDids: z.array(z.string()).optional(), + revokedDids: z.array(z.string()).optional(), +}) +export type StoredIndex = z.infer + +/** + * The full session state as this module stores it: every account, plus which + * one is current. `currentDid` must always name an account in `accounts`. + */ +export type SessionSnapshot = { + accounts: SessionAccount[] + currentDid: string | undefined +} + +export const EMPTY_SNAPSHOT: SessionSnapshot = { + accounts: [], + currentDid: undefined, +} diff --git a/src/state/session/storage/secureStore.ts b/src/state/session/storage/secureStore.ts new file mode 100644 index 0000000000..93b79038be --- /dev/null +++ b/src/state/session/storage/secureStore.ts @@ -0,0 +1,336 @@ +/** + * Native session storage layout over expo-secure-store. + * + * All reads and writes use the synchronous SecureStore variants. The agent + * does not await its persist callback, so a token refresh must land in the + * keychain before the OS can suspend the app; async writes could be dropped. + * + * Storage layout + * - One index key (`SESSION_INDEX_KEY`) holds a JSON object listing the stored + * dids, the current did, and optional recovery journals. + * - Each account stores three keys derived from sha256(did): a descriptor (the + * account minus its tokens), a refresh token, and an access token. + * + * The index is the commit point. An index that names a did is only valid if + * that did's descriptor is present. Publishing the index last means an + * interrupted write is either fully visible or invisible. + * + * Tombstoning writes an empty string rather than deleting a key. This keeps + * every write a uniform synchronous `setItem` and never touches the async + * delete path, which is the only delete SecureStore offers. + * + * Note that {@link readSessions} writes: it finishes any journaled tombstoning + * before returning, so even a boot-time read can fail when the keychain is + * unavailable. + * + * Crash-recovery protocol (readSessions): + * - `revokedDids` still present in `dids` had a credential cleared while the + * account was kept; finish clearing their tokens. + * - `retiredDids` no longer in `dids` were removed entirely; finish tombstoning + * the whole account. + * - Then rewrite a clean index with the journals stripped. + * + * Write ordering (writeSessions), designed so a crash between any two steps + * recovers to a valid state: + * 1. If any credentials are being revoked, journal against the PREVIOUS index + * (its descriptors are all durably present) annotated with the retired and + * revoked dids. A crash here recovers to "previous state minus the revoked + * credentials", which is correct because the commit never reached its + * commit point. Journaling against `next` instead would let a same-commit + * account addition leave a did with no descriptor, which recovery reads as + * invalid-data and resets everything - the mass-logout bug this avoids. + * 2. Write the changed credentials, then the changed descriptors, for every + * account in `next`. + * 3. Publish the commit index (`next` plus the retired-did journal). This is + * the commit point. It is skipped only when nothing changed and an index + * already exists; the first-ever write must still create the index. + * 4. If any accounts were retired, tombstone them, then publish a clean index. + * + * Accepted limitation: credentials written by a commit that fails at step 3 + * (before its index write) are orphaned across a process restart, because the + * durable index never named them and the in-memory maybe-orphaned set is lost. + * They are overwritten on the next login for that did and erased by a clear. + */ +import * as SecureStore from 'expo-secure-store' + +import {type SessionAccount} from '#/state/session/types' +import {InvalidSessionStorageDataError} from './errors' +import {accountKeys, SESSION_INDEX_KEY, SESSION_INSTALL_KEY} from './keys' +import { + type AccountDescriptor, + descriptorSchema, + EMPTY_SNAPSHOT, + type SessionSnapshot, + type StoredIndex, + storedIndexSchema, +} from './schema' + +/** + * Keychain items default to `WHEN_UNLOCKED`, and the accessibility attribute + * is stamped onto an item when it is written. The app can cold launch in the + * background - handling a push notification, say - before the device has been + * unlocked since boot, so session data has to survive that state. + */ +const WRITE_OPTIONS: SecureStore.SecureStoreOptions = { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, +} + +/** + * Whether a commit index exists. True once anything has been written, so it + * marks the store as initialized - not as authoritative, and not as migrated. + */ +export function hasStoredIndex(): boolean { + return SecureStore.getItem(SESSION_INDEX_KEY) !== null +} + +/** + * Read the stored snapshot, first completing any journaled tombstoning left by + * an interrupted write. Throws {@link InvalidSessionStorageDataError} if the + * index is missing, unparseable, or references a did whose descriptor is + * absent. + */ +export function readSessions(): SessionSnapshot { + const rawIndex = SecureStore.getItem(SESSION_INDEX_KEY) + if (rawIndex === null) { + throw new InvalidSessionStorageDataError() + } + let index: StoredIndex + try { + index = storedIndexSchema.parse(JSON.parse(rawIndex)) + } catch { + throw new InvalidSessionStorageDataError() + } + if (index.revokedDids?.length) { + const activeDids = new Set(index.dids) + index.revokedDids + .filter(did => activeDids.has(did)) + .forEach(tombstoneCredentials) + } + if (index.retiredDids?.length) { + const activeDids = new Set(index.dids) + index.retiredDids + .filter(did => !activeDids.has(did)) + .forEach(tombstoneAccount) + } + if (index.revokedDids?.length || index.retiredDids?.length) { + const cleanedIndex = { + version: index.version, + currentDid: index.currentDid, + dids: index.dids, + } satisfies StoredIndex + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(cleanedIndex), + WRITE_OPTIONS, + ) + index = cleanedIndex + } + if (index.currentDid && !index.dids.includes(index.currentDid)) { + throw new InvalidSessionStorageDataError() + } + const accounts = index.dids.map(did => { + const keys = accountKeys(did) + const rawDescriptor = SecureStore.getItem(keys.descriptor) + if (!rawDescriptor) { + throw new InvalidSessionStorageDataError() + } + let descriptor: AccountDescriptor + try { + descriptor = descriptorSchema.parse(JSON.parse(rawDescriptor)) + } catch { + throw new InvalidSessionStorageDataError() + } + if (descriptor.did !== did) { + throw new InvalidSessionStorageDataError() + } + return { + ...descriptor, + refreshJwt: SecureStore.getItem(keys.refresh) || undefined, + accessJwt: SecureStore.getItem(keys.access) || undefined, + } + }) + return {accounts, currentDid: index.currentDid} +} + +/** + * Persist the transition from `previous` to `next` following the four-step + * ordering documented in the file header. `alsoRetire` names dids that a prior + * failed write may have partially persisted, so they are tombstoned too when + * absent from `next`. `forceIndex` publishes the index even when nothing + * changed, used for the first write into an uninitialized store. + */ +export function writeSessions( + previous: SessionSnapshot, + next: SessionSnapshot, + { + alsoRetire = [], + forceIndex = false, + }: {alsoRetire?: string[]; forceIndex?: boolean} = {}, +) { + const previousByDid = new Map(previous.accounts.map(a => [a.did, a])) + const nextDids = new Set(next.accounts.map(a => a.did)) + const retiredDids = [ + ...new Set([ + ...previous.accounts + .filter(account => !nextDids.has(account.did)) + .map(account => account.did), + ...alsoRetire.filter(did => !nextDids.has(did)), + ]), + ] + const revokedDids = next.accounts + .filter(account => { + const prior = previousByDid.get(account.did) + return ( + (Boolean(prior?.refreshJwt) && !account.refreshJwt) || + (Boolean(prior?.accessJwt) && !account.accessJwt) + ) + }) + .map(account => account.did) + + if (revokedDids.length) { + /* + * Journal against the previous index, whose descriptors are all durably + * present. On interruption, readSessions finishes the tombstoning before + * loading, recovering to the previous state minus the revoked credentials. + */ + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(previous, retiredDids, revokedDids)), + WRITE_OPTIONS, + ) + } + + /* + * Credentials go first. The agent does not await its persistence callback, + * so these must complete synchronously before the app can be suspended. + */ + for (const account of next.accounts) { + const prior = previousByDid.get(account.did) + const keys = accountKeys(account.did) + if (prior?.refreshJwt !== account.refreshJwt) { + SecureStore.setItem(keys.refresh, account.refreshJwt ?? '', WRITE_OPTIONS) + } + if (prior?.accessJwt !== account.accessJwt) { + SecureStore.setItem(keys.access, account.accessJwt ?? '', WRITE_OPTIONS) + } + const descriptor = canonicalJson(toDescriptor(account)) + if (canonicalJson(toDescriptor(prior)) !== descriptor) { + SecureStore.setItem(keys.descriptor, descriptor, WRITE_OPTIONS) + } + } + + const changed = + retiredDids.length > 0 || + revokedDids.length > 0 || + canonicalJson(previous) !== canonicalJson(next) + if (forceIndex || changed) { + /* + * Publishing the index is the commit point. `retiredDids` keeps the token + * cleanup recoverable if the process stops between these sync writes. + */ + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(next, retiredDids)), + WRITE_OPTIONS, + ) + } + + if (retiredDids.length) { + retiredDids.forEach(tombstoneAccount) + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(next)), + WRITE_OPTIONS, + ) + } +} + +/** + * Erase every named did and reset the index to empty. Journals the removal + * first so an interrupted erase is finished by the next read. + */ +export function eraseSessions(dids: string[]) { + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT, dids)), + WRITE_OPTIONS, + ) + dids.forEach(tombstoneAccount) + SecureStore.setItem( + SESSION_INDEX_KEY, + JSON.stringify(toStoredIndex(EMPTY_SNAPSHOT)), + WRITE_OPTIONS, + ) +} + +/** The install marker stored alongside the session data, if any. */ +export function readInstallMarker(): string | null { + return SecureStore.getItem(SESSION_INSTALL_KEY) || null +} + +export function writeInstallMarker(id: string) { + SecureStore.setItem(SESSION_INSTALL_KEY, id, WRITE_OPTIONS) +} + +/** + * `JSON.stringify` with object keys sorted at every depth, so two objects that + * differ only in key order serialize identically. + * + * Load-bearing for every equality check in this module. A snapshot read back + * through the schemas carries the schema's key order, while one built by the + * session reducer carries its construction order, so a raw stringify would + * report a change on the first write after every boot and rewrite the whole + * store. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)) +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys) + if (value === null || typeof value !== 'object') return value + const source = value as Record + return Object.fromEntries( + Object.keys(source) + .sort() + .map(key => [key, sortKeys(source[key])]), + ) +} + +function toDescriptor( + account: SessionAccount | undefined, +): AccountDescriptor | undefined { + if (!account) return undefined + const { + accessJwt: _accessJwt, + refreshJwt: _refreshJwt, + ...descriptor + } = account + return descriptor +} + +function toStoredIndex( + snapshot: SessionSnapshot, + retiredDids: string[] = [], + revokedDids: string[] = [], +): StoredIndex { + return { + version: 1, + currentDid: snapshot.currentDid, + dids: snapshot.accounts.map(account => account.did), + ...(retiredDids.length ? {retiredDids} : {}), + ...(revokedDids.length ? {revokedDids} : {}), + } +} + +function tombstoneAccount(did: string) { + tombstoneCredentials(did) + const keys = accountKeys(did) + SecureStore.setItem(keys.descriptor, '', WRITE_OPTIONS) +} + +function tombstoneCredentials(did: string) { + const keys = accountKeys(did) + SecureStore.setItem(keys.refresh, '', WRITE_OPTIONS) + SecureStore.setItem(keys.access, '', WRITE_OPTIONS) +} diff --git a/src/state/session/storage/store.ts b/src/state/session/storage/store.ts new file mode 100644 index 0000000000..30e8855b2c --- /dev/null +++ b/src/state/session/storage/store.ts @@ -0,0 +1,173 @@ +import {onAppStateChange} from '#/lib/appState' +import { + logStorageError, + type SessionStorageErrorOperation, + storageError, +} from './errors' +import {EMPTY_SNAPSHOT, type SessionSnapshot} from './schema' +import {eraseSessions, writeSessions} from './secureStore' + +const RETRY_DELAY = 5_000 + +/** + * Pending work is a union so the sticky-clear rule reads as one guard: while a + * clear is pending, a write is dropped rather than risk resurrecting a session + * after a requested wipe. + */ +type PendingWork = + {type: 'write'; snapshot: SessionSnapshot} | {type: 'clear'; dids: string[]} + +export type SessionStorageStore = { + /** The snapshot last known to be durably stored. */ + getDurable(): SessionSnapshot + /** + * Adopt `snapshot` as the durable baseline, discarding any pending work. + * Called once at boot with whatever the store was found to hold. + */ + setDurable(snapshot: SessionSnapshot): void + /** + * Mirror a snapshot into storage. Fire-and-forget: a failure is logged and + * retried, and the newest snapshot wins. + */ + write(next: SessionSnapshot): void + /** Erase everything, including leftovers from interrupted writes. */ + clear(): void +} + +/** + * The in-memory write lifecycle: the last durable snapshot, the pending work, + * the maybe-orphaned did set, and the retry timer. All storage layout and + * crash recovery live in `secureStore.ts`. + * + * This store is a mirror, not a source of truth - the session reducer owns + * in-memory session state - so nothing here throws and nothing here is awaited. + */ +export function createSessionStorageStore(): SessionStorageStore { + let durableSnapshot: SessionSnapshot = EMPTY_SNAPSHOT + let pending: PendingWork | undefined + let retryTimer: ReturnType | undefined + let retryTriggersAttached = false + const maybeOrphanedDids = new Set() + + function getDurable() { + return durableSnapshot + } + + function setDurable(snapshot: SessionSnapshot) { + durableSnapshot = snapshot + maybeOrphanedDids.clear() + pending = undefined + cancelRetry() + } + + function write(next: SessionSnapshot) { + if (pending?.type === 'clear') return + persist(next, 'write') + } + + function clear() { + const dids = [ + ...new Set([ + ...durableSnapshot.accounts.map(account => account.did), + ...(pending?.type === 'write' + ? pending.snapshot.accounts.map(account => account.did) + : []), + ...maybeOrphanedDids, + ]), + ] + pending = {type: 'clear', dids} + cancelRetry() + try { + eraseSessions(dids) + durableSnapshot = EMPTY_SNAPSHOT + maybeOrphanedDids.clear() + pending = undefined + } catch (cause) { + logStorageError(storageError('clear', cause)) + scheduleRetry() + } + } + + function persist( + next: SessionSnapshot, + operation: SessionStorageErrorOperation, + ) { + trackMaybeOrphaned(next) + try { + writeSessions(durableSnapshot, next, { + alsoRetire: [...maybeOrphanedDids], + }) + durableSnapshot = next + maybeOrphanedDids.clear() + pending = undefined + cancelRetry() + } catch (cause) { + pending = {type: 'write', snapshot: next} + logStorageError(storageError(operation, cause)) + scheduleRetry() + } + } + + function retry() { + if (!pending) return + if (pending.type === 'write') { + persist(pending.snapshot, 'retry') + return + } + const {dids} = pending + try { + eraseSessions(dids) + durableSnapshot = EMPTY_SNAPSHOT + maybeOrphanedDids.clear() + pending = undefined + cancelRetry() + } catch (cause) { + logStorageError(storageError('retry', cause)) + scheduleRetry() + } + } + + /** + * Remember dids we may have partially written under a did the durable index + * never named, so a later retire or clear still tombstones their keys even + * if the failing commit never reached its index. + */ + function trackMaybeOrphaned(next: SessionSnapshot) { + const durableDids = new Set( + durableSnapshot.accounts.map(account => account.did), + ) + for (const account of next.accounts) { + if (!durableDids.has(account.did)) { + maybeOrphanedDids.add(account.did) + } + } + } + + /** + * Attached on the first failure rather than up front, so a store that never + * fails never holds a listener. + */ + function attachRetryTriggers() { + if (retryTriggersAttached) return + retryTriggersAttached = true + onAppStateChange(state => { + if (state === 'active' && pending) retry() + }) + } + + function scheduleRetry() { + attachRetryTriggers() + if (retryTimer) return + retryTimer = setTimeout(() => { + retryTimer = undefined + retry() + }, RETRY_DELAY) + } + + function cancelRetry() { + if (retryTimer) clearTimeout(retryTimer) + retryTimer = undefined + } + + return {getDurable, setDurable, write, clear} +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 53b4ffb022..4cfa894198 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -73,6 +73,20 @@ export type Device = { */ inviteFriendsThemeKey?: InviteThemeKey + /** + * Whether the app may boot its session from the secure session store, cached + * from the feature gate of the same name. The gate is not evaluable during + * bootstrap, so each run caches it here for the next one. Absent means off. + */ + sessionSecureStorageReadEnabled?: boolean + /** + * Random marker identifying this install to the secure session store. Device + * storage dies with the app container while the iOS keychain does not, so a + * marker that disagrees with the stored one means those sessions belong to a + * previous install and must not be resurrected. + */ + sessionSecureStorageInstallId?: string + /** * Policy update overlays. New IDs are required for each new announcement. */