af92c6de21
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
/**
|
|
* 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)
|
|
/*
|
|
* The absent-prior case must write, not skip. A caller that distrusts its
|
|
* baseline passes an empty `previous` to force a full rewrite, and there
|
|
* `undefined !== undefined` would skip an account whose token is absent -
|
|
* leaving whatever the keychain still holds under that key to be read back
|
|
* as a live credential under an index that names the did.
|
|
*/
|
|
if (!prior || prior.refreshJwt !== account.refreshJwt) {
|
|
SecureStore.setItem(keys.refresh, account.refreshJwt ?? '', WRITE_OPTIONS)
|
|
}
|
|
if (!prior || 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)
|
|
}
|