harden secure storage against stale mirrors and post-clear writes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,7 @@ export type Events = {
|
|||||||
divergence: 'none' | 'legacy-lost' | 'secure-behind'
|
divergence: 'none' | 'legacy-lost' | 'secure-behind'
|
||||||
secureAccountsWithCreds: number
|
secureAccountsWithCreds: number
|
||||||
legacyAccountsWithCreds: number
|
legacyAccountsWithCreds: number
|
||||||
|
adoptTokensFromLegacy: number
|
||||||
}
|
}
|
||||||
'notifications:openApp': {
|
'notifications:openApp': {
|
||||||
reason: NotificationReason
|
reason: NotificationReason
|
||||||
|
|||||||
@@ -76,6 +76,28 @@ const expiredAlice: SessionAccount = {
|
|||||||
refreshJwt: undefined,
|
refreshJwt: undefined,
|
||||||
accessJwt: undefined,
|
accessJwt: undefined,
|
||||||
}
|
}
|
||||||
|
const bob: SessionAccount = {
|
||||||
|
service: 'https://bsky.social',
|
||||||
|
did: 'did:plc:bob',
|
||||||
|
handle: 'bob.test',
|
||||||
|
refreshJwt: 'bob-refresh',
|
||||||
|
accessJwt: 'bob-access',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal unsigned JWT carrying an `iat`. `jwt-decode` reads the payload
|
||||||
|
* without verifying anything, so the signature can be junk. `jti` is there to
|
||||||
|
* make two tokens of the same age differ as strings.
|
||||||
|
*/
|
||||||
|
function jwtWithIat(iat: number, jti = 'a'): string {
|
||||||
|
const encode = (value: object) =>
|
||||||
|
Buffer.from(JSON.stringify(value)).toString('base64url')
|
||||||
|
return [
|
||||||
|
encode({alg: 'HS256', typ: 'JWT'}),
|
||||||
|
encode({iat, jti}),
|
||||||
|
'not-a-signature',
|
||||||
|
].join('.')
|
||||||
|
}
|
||||||
|
|
||||||
/** An account with usable credentials. */
|
/** An account with usable credentials. */
|
||||||
const withCreds: SessionSnapshot = {accounts: [alice], currentDid: alice.did}
|
const withCreds: SessionSnapshot = {accounts: [alice], currentDid: alice.did}
|
||||||
@@ -88,6 +110,12 @@ const withoutCreds: SessionSnapshot = {
|
|||||||
type ExpectedDecision = Omit<BootDecision, 'adopt' | 'report'> & {
|
type ExpectedDecision = Omit<BootDecision, 'adopt' | 'report'> & {
|
||||||
adopts: boolean
|
adopts: boolean
|
||||||
divergence: SessionStorageBootReport['divergence']
|
divergence: SessionStorageBootReport['divergence']
|
||||||
|
/**
|
||||||
|
* The adopted snapshot, when the per-account merge makes it something other
|
||||||
|
* than the secure snapshot as read.
|
||||||
|
*/
|
||||||
|
adopt?: SessionSnapshot
|
||||||
|
adoptTokensFromLegacy?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows: {
|
const rows: {
|
||||||
@@ -248,6 +276,8 @@ const rows: {
|
|||||||
expected: {
|
expected: {
|
||||||
source: 'secure',
|
source: 'secure',
|
||||||
adopts: true,
|
adopts: true,
|
||||||
|
// The merge keeps the credential-less account the blob still lists.
|
||||||
|
adopt: withoutCreds,
|
||||||
backfill: 'none',
|
backfill: 'none',
|
||||||
forceIndex: false,
|
forceIndex: false,
|
||||||
divergence: 'none',
|
divergence: 'none',
|
||||||
@@ -296,6 +326,11 @@ const rows: {
|
|||||||
|
|
||||||
describe('decideBootSource', () => {
|
describe('decideBootSource', () => {
|
||||||
it.each(rows)('$name', ({gateEnabled, secure: read, legacy, expected}) => {
|
it.each(rows)('$name', ({gateEnabled, secure: read, legacy, expected}) => {
|
||||||
|
const {
|
||||||
|
adopt: expectedAdopt,
|
||||||
|
adoptTokensFromLegacy = 0,
|
||||||
|
...expectedCore
|
||||||
|
} = expected
|
||||||
const decision = decideBootSource({gateEnabled, secure: read, legacy})
|
const decision = decideBootSource({gateEnabled, secure: read, legacy})
|
||||||
|
|
||||||
expect({
|
expect({
|
||||||
@@ -304,10 +339,13 @@ describe('decideBootSource', () => {
|
|||||||
backfill: decision.backfill,
|
backfill: decision.backfill,
|
||||||
forceIndex: decision.forceIndex,
|
forceIndex: decision.forceIndex,
|
||||||
divergence: decision.report.divergence,
|
divergence: decision.report.divergence,
|
||||||
}).toEqual(expected)
|
}).toEqual(expectedCore)
|
||||||
expect(decision.adopt).toEqual(
|
expect(decision.adopt).toEqual(
|
||||||
expected.adopts && read.status === 'ok' ? read.snapshot : undefined,
|
expected.adopts
|
||||||
|
? (expectedAdopt ?? (read.status === 'ok' ? read.snapshot : undefined))
|
||||||
|
: undefined,
|
||||||
)
|
)
|
||||||
|
expect(decision.report.adoptTokensFromLegacy).toBe(adoptTokensFromLegacy)
|
||||||
expect(decision.report.gateEnabled).toBe(gateEnabled)
|
expect(decision.report.gateEnabled).toBe(gateEnabled)
|
||||||
expect(decision.report.secureStatus).toBe(read.status)
|
expect(decision.report.secureStatus).toBe(read.status)
|
||||||
expect(decision.report.source).toBe(expected.source)
|
expect(decision.report.source).toBe(expected.source)
|
||||||
@@ -331,6 +369,80 @@ describe('decideBootSource', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adopting the secure store wholesale would lose whatever only the blob holds,
|
||||||
|
* which is what a single failed mirror write looks like: the store as a whole
|
||||||
|
* still has credentials, so the whole-store guard sees nothing wrong.
|
||||||
|
*/
|
||||||
|
describe('decideBootSource credential merge', () => {
|
||||||
|
function adoptedBy(secureSnapshot: SessionSnapshot, legacy: SessionSnapshot) {
|
||||||
|
return decideBootSource({
|
||||||
|
gateEnabled: true,
|
||||||
|
secure: {status: 'ok', snapshot: secureSnapshot},
|
||||||
|
legacy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps an account the secure store never received', () => {
|
||||||
|
const legacy: SessionSnapshot = {
|
||||||
|
accounts: [alice, bob],
|
||||||
|
currentDid: alice.did,
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = adoptedBy(withCreds, legacy)
|
||||||
|
|
||||||
|
expect(decision.source).toBe('secure')
|
||||||
|
expect(decision.adopt).toEqual(legacy)
|
||||||
|
expect(decision.report.adoptTokensFromLegacy).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps an account only the secure store still holds', () => {
|
||||||
|
const secureSnapshot: SessionSnapshot = {
|
||||||
|
accounts: [alice, bob],
|
||||||
|
currentDid: bob.did,
|
||||||
|
}
|
||||||
|
|
||||||
|
const decision = adoptedBy(secureSnapshot, withCreds)
|
||||||
|
|
||||||
|
expect(decision.adopt).toEqual(secureSnapshot)
|
||||||
|
expect(decision.report.adoptTokensFromLegacy).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('takes whichever refresh token was issued last', () => {
|
||||||
|
const older = {...alice, refreshJwt: jwtWithIat(1_000), accessJwt: 'older'}
|
||||||
|
const newer = {...alice, refreshJwt: jwtWithIat(2_000), accessJwt: 'newer'}
|
||||||
|
const snapshotOf = (account: SessionAccount): SessionSnapshot => ({
|
||||||
|
accounts: [account],
|
||||||
|
currentDid: alice.did,
|
||||||
|
})
|
||||||
|
|
||||||
|
const legacyIsNewer = adoptedBy(snapshotOf(older), snapshotOf(newer))
|
||||||
|
expect(legacyIsNewer.adopt).toEqual(snapshotOf(newer))
|
||||||
|
expect(legacyIsNewer.report.adoptTokensFromLegacy).toBe(1)
|
||||||
|
|
||||||
|
const secureIsNewer = adoptedBy(snapshotOf(newer), snapshotOf(older))
|
||||||
|
expect(secureIsNewer.adopt).toEqual(snapshotOf(newer))
|
||||||
|
expect(secureIsNewer.report.adoptTokensFromLegacy).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the blob when the comparison cannot be made', () => {
|
||||||
|
const snapshotOf = (account: SessionAccount): SessionSnapshot => ({
|
||||||
|
accounts: [account],
|
||||||
|
currentDid: alice.did,
|
||||||
|
})
|
||||||
|
const undecodable = {...alice, refreshJwt: 'not-a-jwt'}
|
||||||
|
const decodable = {...alice, refreshJwt: jwtWithIat(1_000)}
|
||||||
|
const sameAge = {...alice, refreshJwt: jwtWithIat(1_000, 'b')}
|
||||||
|
|
||||||
|
const unreadable = adoptedBy(snapshotOf(undecodable), snapshotOf(decodable))
|
||||||
|
expect(unreadable.adopt).toEqual(snapshotOf(decodable))
|
||||||
|
expect(unreadable.report.adoptTokensFromLegacy).toBe(1)
|
||||||
|
|
||||||
|
const tied = adoptedBy(snapshotOf(decodable), snapshotOf(sameAge))
|
||||||
|
expect(tied.adopt).toEqual(snapshotOf(sameAge))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('initSessionStorage', () => {
|
describe('initSessionStorage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
secure.values.clear()
|
secure.values.clear()
|
||||||
@@ -361,6 +473,47 @@ describe('initSessionStorage', () => {
|
|||||||
divergence: 'legacy-lost',
|
divergence: 'legacy-lost',
|
||||||
secureAccountsWithCreds: 1,
|
secureAccountsWithCreds: 1,
|
||||||
legacyAccountsWithCreds: 0,
|
legacyAccountsWithCreds: 0,
|
||||||
|
adoptTokensFromLegacy: 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adopts the newer blob credential and converges the store on it', () => {
|
||||||
|
device.set(['sessionSecureStorageReadEnabled'], true)
|
||||||
|
const stored = {
|
||||||
|
...alice,
|
||||||
|
refreshJwt: jwtWithIat(1_000),
|
||||||
|
accessJwt: 'stored-access',
|
||||||
|
}
|
||||||
|
const refreshed = {
|
||||||
|
...alice,
|
||||||
|
refreshJwt: jwtWithIat(2_000),
|
||||||
|
accessJwt: 'refreshed-access',
|
||||||
|
}
|
||||||
|
writeSessions(
|
||||||
|
EMPTY_SNAPSHOT,
|
||||||
|
{accounts: [stored], currentDid: alice.did},
|
||||||
|
{forceIndex: true},
|
||||||
|
)
|
||||||
|
persistedState.session = {
|
||||||
|
accounts: [refreshed],
|
||||||
|
currentAccount: refreshed,
|
||||||
|
}
|
||||||
|
|
||||||
|
initSessionStorage()
|
||||||
|
|
||||||
|
expect(persistedState.session).toEqual({
|
||||||
|
accounts: [refreshed],
|
||||||
|
currentAccount: refreshed,
|
||||||
|
})
|
||||||
|
// The mirror write lands the adopted credential back in the keychain.
|
||||||
|
expect(readSessions()).toEqual({
|
||||||
|
accounts: [refreshed],
|
||||||
|
currentDid: alice.did,
|
||||||
|
})
|
||||||
|
expect(consumeSessionStorageBootReport()).toMatchObject({
|
||||||
|
source: 'secure',
|
||||||
|
divergence: 'none',
|
||||||
|
adoptTokensFromLegacy: 1,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,23 @@ import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'
|
|||||||
jest.mock('expo-secure-store', () => {
|
jest.mock('expo-secure-store', () => {
|
||||||
const values = new Map<string, string>()
|
const values = new Map<string, string>()
|
||||||
const writes: [key: string, value: string][] = []
|
const writes: [key: string, value: string][] = []
|
||||||
const control: {failKey?: string} = {}
|
/*
|
||||||
|
* `failAfter` lets a key succeed that many times before it starts failing,
|
||||||
|
* which is how a write is made to fail on its second index write - the one
|
||||||
|
* that cleans up a journal it already published.
|
||||||
|
*/
|
||||||
|
const control: {failKey?: string; failAfter?: number} = {}
|
||||||
return {
|
return {
|
||||||
AFTER_FIRST_UNLOCK: 0,
|
AFTER_FIRST_UNLOCK: 0,
|
||||||
getItem: (key: string) => values.get(key) ?? null,
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
setItem: (key: string, value: string) => {
|
setItem: (key: string, value: string) => {
|
||||||
if (key === control.failKey) throw new Error('disk full')
|
if (key === control.failKey) {
|
||||||
|
if (control.failAfter) {
|
||||||
|
control.failAfter -= 1
|
||||||
|
} else {
|
||||||
|
throw new Error('disk full')
|
||||||
|
}
|
||||||
|
}
|
||||||
writes.push([key, value])
|
writes.push([key, value])
|
||||||
values.set(key, value)
|
values.set(key, value)
|
||||||
},
|
},
|
||||||
@@ -49,13 +60,14 @@ jest.mock('#/logger', () => {
|
|||||||
import {type SessionAccount} from '#/state/session/types'
|
import {type SessionAccount} from '#/state/session/types'
|
||||||
import {accountKeys, SESSION_INDEX_KEY} from '../keys'
|
import {accountKeys, SESSION_INDEX_KEY} from '../keys'
|
||||||
import {EMPTY_SNAPSHOT, type SessionSnapshot} from '../schema'
|
import {EMPTY_SNAPSHOT, type SessionSnapshot} from '../schema'
|
||||||
|
import {readSessions} from '../secureStore'
|
||||||
import {createSessionStorageStore} from '../store'
|
import {createSessionStorageStore} from '../store'
|
||||||
|
|
||||||
const {__mock: secure} = jest.requireMock('expo-secure-store') as {
|
const {__mock: secure} = jest.requireMock('expo-secure-store') as {
|
||||||
__mock: {
|
__mock: {
|
||||||
values: Map<string, string>
|
values: Map<string, string>
|
||||||
writes: [key: string, value: string][]
|
writes: [key: string, value: string][]
|
||||||
control: {failKey?: string}
|
control: {failKey?: string; failAfter?: number}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const {__listeners: appStateListeners} = jest.requireMock('#/lib/appState') as {
|
const {__listeners: appStateListeners} = jest.requireMock('#/lib/appState') as {
|
||||||
@@ -82,6 +94,7 @@ beforeEach(() => {
|
|||||||
secure.values.clear()
|
secure.values.clear()
|
||||||
secure.writes.length = 0
|
secure.writes.length = 0
|
||||||
secure.control.failKey = undefined
|
secure.control.failKey = undefined
|
||||||
|
secure.control.failAfter = undefined
|
||||||
appStateListeners.length = 0
|
appStateListeners.length = 0
|
||||||
loggedErrors.length = 0
|
loggedErrors.length = 0
|
||||||
})
|
})
|
||||||
@@ -192,6 +205,101 @@ describe('createSessionStorageStore', () => {
|
|||||||
expect(storedIndex()).toEqual({version: 1, dids: []})
|
expect(storedIndex()).toEqual({version: 1, dids: []})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('drops writes for the rest of the process once a clear succeeds', () => {
|
||||||
|
const store = createSessionStorageStore()
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
store.clear()
|
||||||
|
const writesAfterClear = secure.writes.length
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Clearing storage does not end the session, so a token refresh already in
|
||||||
|
* flight will still ask for a mirror write.
|
||||||
|
*/
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
expect(secure.writes.length).toBe(writesAfterClear)
|
||||||
|
expect(secure.values.get(aliceKeys.refresh)).toBe('')
|
||||||
|
expect(secure.values.get(aliceKeys.access)).toBe('')
|
||||||
|
expect(readSessions()).toEqual(EMPTY_SNAPSHOT)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('force-publishes a clean index over a journal left by a failed write', () => {
|
||||||
|
const store = createSessionStorageStore()
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Removing alice fails on the final index write: the journaled index that
|
||||||
|
* names her as retired is already published and her keys are tombstoned.
|
||||||
|
*/
|
||||||
|
secure.control.failKey = SESSION_INDEX_KEY
|
||||||
|
secure.control.failAfter = 1
|
||||||
|
store.write(EMPTY_SNAPSHOT)
|
||||||
|
expect(storedIndex()).toMatchObject({dids: [], retiredDids: [alice.did]})
|
||||||
|
expect(secure.values.get(aliceKeys.refresh)).toBe('')
|
||||||
|
|
||||||
|
// alice is back, byte-identical to the snapshot still believed durable.
|
||||||
|
secure.control.failKey = undefined
|
||||||
|
secure.control.failAfter = undefined
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
expect(secure.values.get(aliceKeys.refresh)).toBe(alice.refreshJwt)
|
||||||
|
expect(secure.values.get(aliceKeys.access)).toBe(alice.accessJwt)
|
||||||
|
expect(storedIndex()).toEqual({
|
||||||
|
version: 1,
|
||||||
|
currentDid: alice.did,
|
||||||
|
dids: [alice.did],
|
||||||
|
})
|
||||||
|
expect(readSessions()).toEqual(active)
|
||||||
|
expect(store.getDurable()).toEqual(active)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rewrites credentials after a failure even when the snapshot is unchanged', () => {
|
||||||
|
const store = createSessionStorageStore()
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
secure.control.failKey = SESSION_INDEX_KEY
|
||||||
|
store.write({
|
||||||
|
accounts: [{...alice, refreshJwt: 'rotated-refresh'}],
|
||||||
|
currentDid: alice.did,
|
||||||
|
})
|
||||||
|
secure.control.failKey = undefined
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A throw can land anywhere in the write ordering, so the keychain may hold
|
||||||
|
* something the baseline does not describe.
|
||||||
|
*/
|
||||||
|
secure.values.set(aliceKeys.refresh, 'partially-applied')
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
expect(secure.values.get(aliceKeys.refresh)).toBe(alice.refreshJwt)
|
||||||
|
expect(secure.values.get(aliceKeys.access)).toBe(alice.accessJwt)
|
||||||
|
expect(readSessions()).toEqual(active)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tombstones absent credentials when rewriting from an unknown baseline', () => {
|
||||||
|
const store = createSessionStorageStore()
|
||||||
|
store.write(active)
|
||||||
|
|
||||||
|
secure.control.failKey = SESSION_INDEX_KEY
|
||||||
|
store.write({
|
||||||
|
accounts: [{...alice, accessJwt: 'rotated-access'}],
|
||||||
|
currentDid: alice.did,
|
||||||
|
})
|
||||||
|
secure.control.failKey = undefined
|
||||||
|
|
||||||
|
// The user logs out: the account is kept, its credentials are not.
|
||||||
|
const loggedOut: SessionSnapshot = {
|
||||||
|
accounts: [{...alice, refreshJwt: undefined, accessJwt: undefined}],
|
||||||
|
currentDid: undefined,
|
||||||
|
}
|
||||||
|
store.write(loggedOut)
|
||||||
|
|
||||||
|
expect(secure.values.get(aliceKeys.refresh)).toBe('')
|
||||||
|
expect(secure.values.get(aliceKeys.access)).toBe('')
|
||||||
|
expect(readSessions()).toEqual(loggedOut)
|
||||||
|
})
|
||||||
|
|
||||||
it('retries when the app returns to the foreground', () => {
|
it('retries when the app returns to the foreground', () => {
|
||||||
const store = createSessionStorageStore()
|
const store = createSessionStorageStore()
|
||||||
secure.control.failKey = aliceKeys.access
|
secure.control.failKey = aliceKeys.access
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {jwtDecode} from 'jwt-decode'
|
||||||
|
|
||||||
|
import {type SessionAccount} from '#/state/session/types'
|
||||||
import {type SessionSnapshot} from './schema'
|
import {type SessionSnapshot} from './schema'
|
||||||
|
|
||||||
/** Which store the app boots its session state from. */
|
/** Which store the app boots its session state from. */
|
||||||
@@ -22,11 +25,22 @@ export type SessionStorageBootReport = {
|
|||||||
divergence: 'none' | 'legacy-lost' | 'secure-behind'
|
divergence: 'none' | 'legacy-lost' | 'secure-behind'
|
||||||
secureAccountsWithCreds: number
|
secureAccountsWithCreds: number
|
||||||
legacyAccountsWithCreds: number
|
legacyAccountsWithCreds: number
|
||||||
|
/**
|
||||||
|
* How many accounts in the adopted snapshot took their credentials from the
|
||||||
|
* legacy blob, i.e. how often the secure store held a stale or missing token
|
||||||
|
* that would have been adopted as-is. Always 0 when not booting from the
|
||||||
|
* secure store.
|
||||||
|
*/
|
||||||
|
adoptTokensFromLegacy: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BootDecision = {
|
export type BootDecision = {
|
||||||
source: BootSource
|
source: BootSource
|
||||||
/** The snapshot to adopt, set only when booting from the secure store. */
|
/**
|
||||||
|
* The snapshot to adopt, set only when booting from the secure store. It is
|
||||||
|
* the secure snapshot merged with the legacy blob, not the secure snapshot
|
||||||
|
* itself - see {@link mergeFreshestCredentials}.
|
||||||
|
*/
|
||||||
adopt: SessionSnapshot | undefined
|
adopt: SessionSnapshot | undefined
|
||||||
backfill: 'none' | 'secure-from-legacy'
|
backfill: 'none' | 'secure-from-legacy'
|
||||||
/** Publish an index even if the backfill changes nothing. */
|
/** Publish an index even if the backfill changes nothing. */
|
||||||
@@ -44,6 +58,11 @@ export type BootDecision = {
|
|||||||
* mirror writes look like - booting from the secure store there would log the
|
* 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
|
* user out. A real logout clears the credentials in both stores, so it does
|
||||||
* not trip the guard.
|
* not trip the guard.
|
||||||
|
*
|
||||||
|
* That guard is whole-store, so winning the boot does not mean the secure
|
||||||
|
* snapshot is adopted verbatim: it is merged per account against the blob by
|
||||||
|
* {@link mergeFreshestCredentials}, which is what keeps a single failed mirror
|
||||||
|
* write from dropping an account or reinstating a spent refresh token.
|
||||||
*/
|
*/
|
||||||
export function decideBootSource({
|
export function decideBootSource({
|
||||||
gateEnabled,
|
gateEnabled,
|
||||||
@@ -62,6 +81,10 @@ export function decideBootSource({
|
|||||||
gateEnabled &&
|
gateEnabled &&
|
||||||
secureSnapshot !== undefined &&
|
secureSnapshot !== undefined &&
|
||||||
(secureHasCreds || !legacyHasCreds)
|
(secureHasCreds || !legacyHasCreds)
|
||||||
|
const merged =
|
||||||
|
bootSecure && secureSnapshot
|
||||||
|
? mergeFreshestCredentials(secureSnapshot, legacy)
|
||||||
|
: undefined
|
||||||
|
|
||||||
let backfill: BootDecision['backfill'] = 'none'
|
let backfill: BootDecision['backfill'] = 'none'
|
||||||
let forceIndex = false
|
let forceIndex = false
|
||||||
@@ -90,7 +113,7 @@ export function decideBootSource({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
source: bootSecure ? 'secure' : 'legacy',
|
source: bootSecure ? 'secure' : 'legacy',
|
||||||
adopt: bootSecure ? secureSnapshot : undefined,
|
adopt: merged?.snapshot,
|
||||||
backfill,
|
backfill,
|
||||||
forceIndex,
|
forceIndex,
|
||||||
report: {
|
report: {
|
||||||
@@ -107,10 +130,97 @@ export function decideBootSource({
|
|||||||
: 'none',
|
: 'none',
|
||||||
secureAccountsWithCreds: secureSnapshot ? countCreds(secureSnapshot) : 0,
|
secureAccountsWithCreds: secureSnapshot ? countCreds(secureSnapshot) : 0,
|
||||||
legacyAccountsWithCreds: countCreds(legacy),
|
legacyAccountsWithCreds: countCreds(legacy),
|
||||||
|
adoptTokensFromLegacy: merged?.tokensFromLegacy ?? 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the two stores account by account, taking the freshest credentials for
|
||||||
|
* each did, so that adopting the secure store cannot lose what only the blob
|
||||||
|
* holds. A mirror write can fail for one account - a login whose write failed
|
||||||
|
* and whose in-memory retry died with the process, or a token rotation that
|
||||||
|
* never landed - while the rest of the store looks perfectly healthy, which is
|
||||||
|
* exactly what the whole-store guard cannot see.
|
||||||
|
*
|
||||||
|
* Ties and unprovable comparisons go to the blob: it is the historical write of
|
||||||
|
* record, and the secure store is the mirror on trial.
|
||||||
|
*
|
||||||
|
* Deliberate asymmetry: this can resurrect a credential the other store
|
||||||
|
* recorded as revoked, e.g. a logout only one of the two writes captured. That
|
||||||
|
* is accepted. A token the server has already revoked self-corrects on first
|
||||||
|
* use with a re-login prompt, while a valid token dropped here logs the user
|
||||||
|
* out of an account with no way back - the failure this module exists to
|
||||||
|
* prevent.
|
||||||
|
*/
|
||||||
|
function mergeFreshestCredentials(
|
||||||
|
secure: SessionSnapshot,
|
||||||
|
legacy: SessionSnapshot,
|
||||||
|
): {snapshot: SessionSnapshot; tokensFromLegacy: number} {
|
||||||
|
const legacyByDid = new Map(legacy.accounts.map(a => [a.did, a]))
|
||||||
|
const secureDids = new Set(secure.accounts.map(a => a.did))
|
||||||
|
let tokensFromLegacy = 0
|
||||||
|
|
||||||
|
const accounts = [
|
||||||
|
...secure.accounts.map(account => {
|
||||||
|
const other = legacyByDid.get(account.did)
|
||||||
|
if (!other) return account
|
||||||
|
if (freshest(account, other) === 'secure') return account
|
||||||
|
if (other.refreshJwt) tokensFromLegacy += 1
|
||||||
|
return other
|
||||||
|
}),
|
||||||
|
/*
|
||||||
|
* Accounts the secure store never received. The blob is the only copy, so
|
||||||
|
* dropping them here is the multi-account logout this merge exists to stop.
|
||||||
|
*/
|
||||||
|
...legacy.accounts.filter(account => {
|
||||||
|
if (secureDids.has(account.did)) return false
|
||||||
|
if (account.refreshJwt) tokensFromLegacy += 1
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const dids = new Set<string>(accounts.map(account => account.did))
|
||||||
|
const currentDid = secure.currentDid ?? legacy.currentDid
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
accounts,
|
||||||
|
currentDid: currentDid && dids.has(currentDid) ? currentDid : undefined,
|
||||||
|
},
|
||||||
|
tokensFromLegacy,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which store holds the newer refresh token for one account. The caller takes
|
||||||
|
* the winner's account object whole, so its tokens and descriptor fields stay
|
||||||
|
* coherent with each other.
|
||||||
|
*/
|
||||||
|
function freshest(
|
||||||
|
secure: SessionAccount,
|
||||||
|
legacy: SessionAccount,
|
||||||
|
): 'secure' | 'legacy' {
|
||||||
|
if (secure.refreshJwt === legacy.refreshJwt) return 'secure'
|
||||||
|
if (!legacy.refreshJwt) return 'secure'
|
||||||
|
if (!secure.refreshJwt) return 'legacy'
|
||||||
|
const secureIssuedAt = issuedAt(secure.refreshJwt)
|
||||||
|
const legacyIssuedAt = issuedAt(legacy.refreshJwt)
|
||||||
|
if (secureIssuedAt === undefined || legacyIssuedAt === undefined) {
|
||||||
|
return 'legacy'
|
||||||
|
}
|
||||||
|
return secureIssuedAt > legacyIssuedAt ? 'secure' : 'legacy'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A token's `iat` claim, or undefined if it cannot be read. */
|
||||||
|
function issuedAt(token: string): number | undefined {
|
||||||
|
try {
|
||||||
|
const {iat} = jwtDecode(token)
|
||||||
|
return typeof iat === 'number' ? iat : undefined
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function hasCreds(snapshot: SessionSnapshot): boolean {
|
function hasCreds(snapshot: SessionSnapshot): boolean {
|
||||||
return snapshot.accounts.some(account => Boolean(account.refreshJwt))
|
return snapshot.accounts.some(account => Boolean(account.refreshJwt))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ let bootReport: SessionStorageBootReport | undefined
|
|||||||
* Call once during bootstrap, after `persisted.init()` has resolved and before
|
* Call once during bootstrap, after `persisted.init()` has resolved and before
|
||||||
* anything reads the session.
|
* anything reads the session.
|
||||||
*
|
*
|
||||||
* When the secure store wins, its snapshot is adopted by writing it back into
|
* When the secure store wins, its snapshot - merged per account with the blob,
|
||||||
* the persisted state. `persisted.write` updates its in-memory copy
|
* see `decideBootSource` - 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
|
* synchronously before awaiting storage, so every existing reader - the
|
||||||
* session store constructor, the last-active-account lookup, the expiry rescue
|
* 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
|
* path - sees the adopted data with no seam of its own. It also means the
|
||||||
@@ -90,10 +91,17 @@ export function initSessionStorage(): void {
|
|||||||
legacy,
|
legacy,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
store.setDurable(secure.status === 'ok' ? secure.snapshot : EMPTY_SNAPSHOT)
|
||||||
if (decision.adopt) {
|
if (decision.adopt) {
|
||||||
adopt(decision.adopt)
|
adopt(decision.adopt)
|
||||||
|
/*
|
||||||
|
* The adopted snapshot is a merge, so it can hold credentials the
|
||||||
|
* keychain does not. The write diffs against the durable snapshot set
|
||||||
|
* just above - the secure store as it was read - so it no-ops when the
|
||||||
|
* merge took nothing from the blob, and retries if it fails.
|
||||||
|
*/
|
||||||
|
store.write(decision.adopt)
|
||||||
}
|
}
|
||||||
store.setDurable(secure.status === 'ok' ? secure.snapshot : EMPTY_SNAPSHOT)
|
|
||||||
if (decision.backfill === 'secure-from-legacy') {
|
if (decision.backfill === 'secure-from-legacy') {
|
||||||
backfill(legacy, decision, installId)
|
backfill(legacy, decision, installId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,10 +207,17 @@ export function writeSessions(
|
|||||||
for (const account of next.accounts) {
|
for (const account of next.accounts) {
|
||||||
const prior = previousByDid.get(account.did)
|
const prior = previousByDid.get(account.did)
|
||||||
const keys = accountKeys(account.did)
|
const keys = accountKeys(account.did)
|
||||||
if (prior?.refreshJwt !== account.refreshJwt) {
|
/*
|
||||||
|
* 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)
|
SecureStore.setItem(keys.refresh, account.refreshJwt ?? '', WRITE_OPTIONS)
|
||||||
}
|
}
|
||||||
if (prior?.accessJwt !== account.accessJwt) {
|
if (!prior || prior.accessJwt !== account.accessJwt) {
|
||||||
SecureStore.setItem(keys.access, account.accessJwt ?? '', WRITE_OPTIONS)
|
SecureStore.setItem(keys.access, account.accessJwt ?? '', WRITE_OPTIONS)
|
||||||
}
|
}
|
||||||
const descriptor = canonicalJson(toDescriptor(account))
|
const descriptor = canonicalJson(toDescriptor(account))
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import {eraseSessions, writeSessions} from './secureStore'
|
|||||||
const RETRY_DELAY = 5_000
|
const RETRY_DELAY = 5_000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pending work is a union so the sticky-clear rule reads as one guard: while a
|
* Pending work is a union: a failed write and a failed clear retry the same
|
||||||
* clear is pending, a write is dropped rather than risk resurrecting a session
|
* way, and only the newest of them is ever outstanding.
|
||||||
* after a requested wipe.
|
|
||||||
*/
|
*/
|
||||||
type PendingWork =
|
type PendingWork =
|
||||||
{type: 'write'; snapshot: SessionSnapshot} | {type: 'clear'; dids: string[]}
|
{type: 'write'; snapshot: SessionSnapshot} | {type: 'clear'; dids: string[]}
|
||||||
@@ -45,6 +44,14 @@ export type SessionStorageStore = {
|
|||||||
export function createSessionStorageStore(): SessionStorageStore {
|
export function createSessionStorageStore(): SessionStorageStore {
|
||||||
let durableSnapshot: SessionSnapshot = EMPTY_SNAPSHOT
|
let durableSnapshot: SessionSnapshot = EMPTY_SNAPSHOT
|
||||||
let pending: PendingWork | undefined
|
let pending: PendingWork | undefined
|
||||||
|
/**
|
||||||
|
* Sticky: once a clear is requested, every later write is dropped for the
|
||||||
|
* rest of the process, including after the erase succeeds. Clearing storage
|
||||||
|
* does not stop the session, so an in-flight token refresh would otherwise
|
||||||
|
* mirror the credentials straight back into the keychain. The user is told to
|
||||||
|
* restart the app, and the next boot lifts the latch via `setDurable`.
|
||||||
|
*/
|
||||||
|
let clearRequested = false
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
let retryTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let retryTriggersAttached = false
|
let retryTriggersAttached = false
|
||||||
const maybeOrphanedDids = new Set<string>()
|
const maybeOrphanedDids = new Set<string>()
|
||||||
@@ -57,15 +64,17 @@ export function createSessionStorageStore(): SessionStorageStore {
|
|||||||
durableSnapshot = snapshot
|
durableSnapshot = snapshot
|
||||||
maybeOrphanedDids.clear()
|
maybeOrphanedDids.clear()
|
||||||
pending = undefined
|
pending = undefined
|
||||||
|
clearRequested = false
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(next: SessionSnapshot) {
|
function write(next: SessionSnapshot) {
|
||||||
if (pending?.type === 'clear') return
|
if (clearRequested) return
|
||||||
persist(next, 'write')
|
persist(next, 'write')
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
|
clearRequested = true
|
||||||
const dids = [
|
const dids = [
|
||||||
...new Set([
|
...new Set([
|
||||||
...durableSnapshot.accounts.map(account => account.did),
|
...durableSnapshot.accounts.map(account => account.did),
|
||||||
@@ -93,9 +102,26 @@ export function createSessionStorageStore(): SessionStorageStore {
|
|||||||
operation: SessionStorageErrorOperation,
|
operation: SessionStorageErrorOperation,
|
||||||
) {
|
) {
|
||||||
trackMaybeOrphaned(next)
|
trackMaybeOrphaned(next)
|
||||||
|
/*
|
||||||
|
* A pending write means the last attempt threw, and a throw can land
|
||||||
|
* anywhere in the write ordering - including after a journaled index was
|
||||||
|
* published. The baseline is then a guess, so it is thrown away: diffing
|
||||||
|
* from empty rewrites every account in `next` in full, retires everything
|
||||||
|
* else the store might still name, and force-publishes a clean index over
|
||||||
|
* any journal the failure left behind.
|
||||||
|
*/
|
||||||
|
const rebaseline = pending !== undefined
|
||||||
|
const previous = rebaseline ? EMPTY_SNAPSHOT : durableSnapshot
|
||||||
|
const alsoRetire = rebaseline
|
||||||
|
? [
|
||||||
|
...durableSnapshot.accounts.map(account => account.did),
|
||||||
|
...maybeOrphanedDids,
|
||||||
|
]
|
||||||
|
: [...maybeOrphanedDids]
|
||||||
try {
|
try {
|
||||||
writeSessions(durableSnapshot, next, {
|
writeSessions(previous, next, {
|
||||||
alsoRetire: [...maybeOrphanedDids],
|
alsoRetire,
|
||||||
|
forceIndex: rebaseline,
|
||||||
})
|
})
|
||||||
durableSnapshot = next
|
durableSnapshot = next
|
||||||
maybeOrphanedDids.clear()
|
maybeOrphanedDids.clear()
|
||||||
|
|||||||
Reference in New Issue
Block a user