add secure session storage module
Adds src/state/session/storage: a native-only mirror of the persisted session blob kept in the system keychain, plus the boot decision that picks which of the two stores the app loads from. secureStore.ts owns the keychain layout - one commit index published last, three sha256-derived keys per account, tombstone-by-empty-string, and journaled crash recovery for interrupted writes. store.ts owns the in-memory write lifecycle: durable baseline, pending work, sticky clear, and retries on a timer or on foreground. boot.ts is the pure decision matrix, and index.ts wires the three together behind a surface that never throws. Nothing calls into any of it yet, so runtime behavior is unchanged. persisted's accountSchema is now exported so the descriptor schema can be derived from it rather than duplicated, which is what keeps a parsed descriptor assignable to SessionAccount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('expo-secure-store', () => {
|
||||
const values = new Map<string, string>()
|
||||
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<string, unknown> = {...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<string, string>}
|
||||
}
|
||||
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<BootDecision, 'adopt' | 'report'> & {
|
||||
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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, string>()
|
||||
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<string, string>
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('expo-secure-store', () => {
|
||||
const values = new Map<string, string>()
|
||||
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<string, string>
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<SecureReadStatus, 'ok'>}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
* `<Fragment key={did}>` 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, AccountKeys>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -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<typeof descriptorSchema>
|
||||
|
||||
/**
|
||||
* 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<typeof storedIndexSchema>
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
@@ -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<string>(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<string, unknown>
|
||||
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)
|
||||
}
|
||||
@@ -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<typeof setTimeout> | undefined
|
||||
let retryTriggersAttached = false
|
||||
const maybeOrphanedDids = new Set<string>()
|
||||
|
||||
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}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user