fix(persistence): fail closed when safeStorage cannot encrypt (#12983)
Co-authored-by: Jinwoo-H <Jinwoo-H@users.noreply.github.com>
This commit is contained in:
parent
c9485fdded
commit
7e60b338ea
|
|
@ -0,0 +1,427 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable'
|
||||
|
||||
const testState = { dir: '' }
|
||||
const cipherState = {
|
||||
availability: 'available' as 'available' | 'throws' | 'unavailable',
|
||||
encryptionThrows: false,
|
||||
decryptionThrows: false
|
||||
}
|
||||
|
||||
vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: vi.fn(),
|
||||
sshConfigHostsToTargets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => {
|
||||
if (cipherState.availability === 'throws') {
|
||||
throw new Error('keychain access denied')
|
||||
}
|
||||
return cipherState.availability === 'available'
|
||||
},
|
||||
encryptString: (plaintext: string) => {
|
||||
if (cipherState.encryptionThrows) {
|
||||
throw new Error('keychain encryption failed')
|
||||
}
|
||||
return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8')
|
||||
},
|
||||
decryptString: (ciphertext: Buffer) => {
|
||||
if (cipherState.decryptionThrows) {
|
||||
throw new Error('keychain decryption failed')
|
||||
}
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('enc:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('enc:'.length + 36 + 1)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 })
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
}
|
||||
|
||||
function dataFile(): string {
|
||||
return join(testState.dir, 'orca-data.json')
|
||||
}
|
||||
|
||||
type ProtectedState = {
|
||||
settings: {
|
||||
httpProxyUrl: string
|
||||
httpProxyBypassRules: string
|
||||
opencodeSessionCookie: string
|
||||
}
|
||||
ui: { browserKagiSessionLink: string | null }
|
||||
sshPtyConsumerRecoveries: { ownerLease: string }[]
|
||||
}
|
||||
|
||||
function readState(path = dataFile()): ProtectedState {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
}
|
||||
|
||||
const ORIGINAL = {
|
||||
proxy: 'http://old-user:old-pass@proxy.test:8080',
|
||||
cookie: 'old-opencode-cookie',
|
||||
kagi: 'https://kagi.test/session/old-token',
|
||||
ownerLease: `old-ssh-owner-lease-${'x'.repeat(480)}`
|
||||
} as const
|
||||
const PENDING = {
|
||||
proxy: 'http://new-user:new-pass@proxy.test:8080',
|
||||
cookie: 'new-opencode-cookie',
|
||||
kagi: 'https://kagi.test/session/new-token',
|
||||
ownerLease: `new-ssh-owner-lease-${'y'.repeat(480)}`
|
||||
} as const
|
||||
|
||||
function setFailure(mode: FailureMode): void {
|
||||
cipherState.availability =
|
||||
mode === 'availability-throws' ? 'throws' : mode === 'unavailable' ? 'unavailable' : 'available'
|
||||
cipherState.encryptionThrows = mode === 'encryption-throws'
|
||||
}
|
||||
|
||||
async function writeProtectedState(
|
||||
store: Awaited<ReturnType<typeof createStore>>,
|
||||
values: typeof ORIGINAL | typeof PENDING,
|
||||
bypassRules: string
|
||||
): Promise<void> {
|
||||
store.updateSettings({
|
||||
httpProxyUrl: values.proxy,
|
||||
httpProxyBypassRules: bypassRules,
|
||||
opencodeSessionCookie: values.cookie
|
||||
})
|
||||
store.updateUI({ browserKagiSessionLink: values.kagi })
|
||||
await store.upsertSshPtyConsumerRecovery({
|
||||
targetId: 'ssh-1',
|
||||
clientInstanceId: 'client-1',
|
||||
serverBuildId: 'relay-build-1',
|
||||
clientGeneration: 1,
|
||||
ownerGeneration: 1,
|
||||
ownerLease: values.ownerLease
|
||||
})
|
||||
}
|
||||
|
||||
function expectPlaintextsAbsent(raw: string, values: typeof ORIGINAL | typeof PENDING): void {
|
||||
for (const plaintext of Object.values(values)) {
|
||||
expect.soft(raw).not.toContain(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
async function settleSave(store: Awaited<ReturnType<typeof createStore>>): Promise<void> {
|
||||
vi.advanceTimersByTime(2_000)
|
||||
await store.waitForPendingWrite()
|
||||
}
|
||||
|
||||
describe('protected persistence when safeStorage fails', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-safe-storage-test-'))
|
||||
cipherState.availability = 'available'
|
||||
cipherState.encryptionThrows = false
|
||||
cipherState.decryptionThrows = false
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each<FailureMode>(['availability-throws', 'encryption-throws', 'unavailable'])(
|
||||
'omits newly introduced protected values during a persistent failure: %s',
|
||||
async (failureMode) => {
|
||||
setFailure(failureMode)
|
||||
const store = await createStore()
|
||||
await writeProtectedState(store, PENDING, 'non-secret-saved')
|
||||
|
||||
const raw = readFileSync(dataFile(), 'utf-8')
|
||||
const persisted = readState()
|
||||
expectPlaintextsAbsent(raw, PENDING)
|
||||
expect(persisted.settings.httpProxyUrl).toBe('')
|
||||
expect(persisted.settings.opencodeSessionCookie).toBe('')
|
||||
expect(persisted.ui.browserKagiSessionLink).toBe('')
|
||||
expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('')
|
||||
expect(persisted.settings.httpProxyBypassRules).toBe('non-secret-saved')
|
||||
}
|
||||
)
|
||||
|
||||
it.each<FailureMode>(['availability-throws', 'encryption-throws', 'unavailable'])(
|
||||
'persists the pending protected state after same-instance recovery: %s',
|
||||
async (failureMode) => {
|
||||
const store = await createStore()
|
||||
await writeProtectedState(store, ORIGINAL, 'before')
|
||||
setFailure(failureMode)
|
||||
await writeProtectedState(store, PENDING, 'during-failure')
|
||||
|
||||
cipherState.availability = 'available'
|
||||
cipherState.encryptionThrows = false
|
||||
await writeProtectedState(store, PENDING, 'during-failure')
|
||||
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy)
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie)
|
||||
expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi)
|
||||
expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease)
|
||||
}
|
||||
)
|
||||
|
||||
it('honors explicit clears during an outage without later resurrecting ciphertext', async () => {
|
||||
const store = await createStore()
|
||||
await writeProtectedState(store, ORIGINAL, 'before')
|
||||
cipherState.availability = 'unavailable'
|
||||
|
||||
store.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' })
|
||||
store.updateUI({ browserKagiSessionLink: null })
|
||||
await store.removeSshPtyConsumerRecovery('ssh-1')
|
||||
await settleSave(store)
|
||||
|
||||
await writeProtectedState(store, PENDING, 'after-clear')
|
||||
const persisted = readState()
|
||||
expect(persisted.settings.httpProxyUrl).toBe('')
|
||||
expect(persisted.settings.opencodeSessionCookie).toBe('')
|
||||
expect(persisted.ui.browserKagiSessionLink).toBe('')
|
||||
expect(persisted.sshPtyConsumerRecoveries[0]?.ownerLease).toBe('')
|
||||
|
||||
cipherState.availability = 'available'
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().httpProxyUrl).toBe('')
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe('')
|
||||
expect(restarted.getUI().browserKagiSessionLink).toBe('')
|
||||
expect(restarted.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('persists clears after a healthy save preserves sealed ciphertext', async () => {
|
||||
const initial = await createStore()
|
||||
await writeProtectedState(initial, ORIGINAL, 'before')
|
||||
const originalCiphertext = readState()
|
||||
|
||||
cipherState.availability = 'unavailable'
|
||||
const sealed = await createStore()
|
||||
expect(sealed.getSettings().httpProxyUrl).toBe('')
|
||||
expect(sealed.getSettings().opencodeSessionCookie).toBe('')
|
||||
|
||||
cipherState.availability = 'available'
|
||||
sealed.updateSettings({ httpProxyBypassRules: 'healthy-preserve' })
|
||||
await settleSave(sealed)
|
||||
expect(readState().settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl)
|
||||
expect(readState().settings.opencodeSessionCookie).toBe(
|
||||
originalCiphertext.settings.opencodeSessionCookie
|
||||
)
|
||||
|
||||
sealed.updateSettings({ httpProxyUrl: '', opencodeSessionCookie: '' })
|
||||
await settleSave(sealed)
|
||||
expect(readState().settings.httpProxyUrl).toBe('')
|
||||
expect(readState().settings.opencodeSessionCookie).toBe('')
|
||||
})
|
||||
|
||||
it('JSON-escapes retained legacy plaintext while storage is unavailable', async () => {
|
||||
const legacyCookie = 'legacy-"cookie\\value'
|
||||
writeFileSync(dataFile(), JSON.stringify({ settings: { opencodeSessionCookie: legacyCookie } }))
|
||||
cipherState.availability = 'unavailable'
|
||||
const store = await createStore()
|
||||
|
||||
store.updateSettings({ opencodeSessionCookie: PENDING.cookie })
|
||||
await settleSave(store)
|
||||
|
||||
expect(readState().settings.opencodeSessionCookie).toBe(legacyCookie)
|
||||
})
|
||||
|
||||
it('evicts ciphertext when decrypted SSH recovery validation rejects the record', async () => {
|
||||
const oversizedLease = 'z'.repeat(513)
|
||||
const encryptedLease = Buffer.from(`enc:${randomUUID()}:${oversizedLease}`).toString('base64')
|
||||
writeFileSync(
|
||||
dataFile(),
|
||||
JSON.stringify({
|
||||
sshPtyConsumerRecoveries: [
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
clientInstanceId: 'client-1',
|
||||
serverBuildId: 'relay-build-1',
|
||||
clientGeneration: 1,
|
||||
ownerGeneration: 1,
|
||||
ownerLease: encryptedLease
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const store = await createStore()
|
||||
expect(store.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
|
||||
|
||||
cipherState.availability = 'unavailable'
|
||||
await store.upsertSshPtyConsumerRecovery({
|
||||
targetId: 'ssh-1',
|
||||
clientInstanceId: 'client-1',
|
||||
serverBuildId: 'relay-build-1',
|
||||
clientGeneration: 2,
|
||||
ownerGeneration: 2,
|
||||
ownerLease: 'replacement-owner-lease'
|
||||
})
|
||||
|
||||
expect(readState().sshPtyConsumerRecoveries[0]?.ownerLease).toBe('')
|
||||
})
|
||||
|
||||
it('keeps loaded ciphertext sealed through same-instance recovery', async () => {
|
||||
const initial = await createStore()
|
||||
await writeProtectedState(initial, ORIGINAL, 'before')
|
||||
const originalCiphertext = readState()
|
||||
|
||||
cipherState.availability = 'unavailable'
|
||||
const sealed = await createStore()
|
||||
expect(sealed.getSettings().httpProxyUrl).toBe('')
|
||||
expect(sealed.getSettings().opencodeSessionCookie).toBe('')
|
||||
expect(sealed.getUI().browserKagiSessionLink).toBe('')
|
||||
expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
|
||||
|
||||
sealed.updateSettings({ httpProxyBypassRules: 'during-outage' })
|
||||
await settleSave(sealed)
|
||||
expect(readState().settings.opencodeSessionCookie).toBe(
|
||||
originalCiphertext.settings.opencodeSessionCookie
|
||||
)
|
||||
|
||||
cipherState.availability = 'available'
|
||||
sealed.updateSettings({ httpProxyBypassRules: 'after-recovery' })
|
||||
await settleSave(sealed)
|
||||
expect(readState()).toMatchObject({
|
||||
settings: {
|
||||
httpProxyUrl: originalCiphertext.settings.httpProxyUrl,
|
||||
opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie
|
||||
},
|
||||
ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink },
|
||||
sshPtyConsumerRecoveries: [
|
||||
{ ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease }
|
||||
]
|
||||
})
|
||||
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy)
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie)
|
||||
expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi)
|
||||
expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease)
|
||||
expect(restarted.getSettings().httpProxyBypassRules).toBe('after-recovery')
|
||||
})
|
||||
|
||||
it('keeps undecryptable ciphertext sealed from consumers and later saves', async () => {
|
||||
const initial = await createStore()
|
||||
await writeProtectedState(initial, ORIGINAL, 'before')
|
||||
const originalCiphertext = readState()
|
||||
|
||||
cipherState.decryptionThrows = true
|
||||
const sealed = await createStore()
|
||||
expect(sealed.getSettings().httpProxyUrl).toBe('')
|
||||
expect(sealed.getSettings().opencodeSessionCookie).toBe('')
|
||||
expect(sealed.getUI().browserKagiSessionLink).toBe('')
|
||||
expect(sealed.getSshPtyConsumerRecovery('ssh-1')).toBeNull()
|
||||
|
||||
sealed.updateSettings({ httpProxyBypassRules: 'decryption-failed' })
|
||||
await settleSave(sealed)
|
||||
expect(readState()).toMatchObject({
|
||||
settings: {
|
||||
httpProxyUrl: originalCiphertext.settings.httpProxyUrl,
|
||||
opencodeSessionCookie: originalCiphertext.settings.opencodeSessionCookie
|
||||
},
|
||||
ui: { browserKagiSessionLink: originalCiphertext.ui.browserKagiSessionLink },
|
||||
sshPtyConsumerRecoveries: [
|
||||
{ ownerLease: originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease }
|
||||
]
|
||||
})
|
||||
|
||||
cipherState.decryptionThrows = false
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy)
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie)
|
||||
expect(restarted.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi)
|
||||
expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease)
|
||||
})
|
||||
|
||||
it('accepts validated legacy plaintext for OpenCode and SSH migration', async () => {
|
||||
const legacyCookie = 'auth=Fe26.2**legacy-token'
|
||||
const legacyOwnerLease = '9ab3f53d-0de9-4b80-af38-0cc15f62a6ba'
|
||||
writeFileSync(
|
||||
dataFile(),
|
||||
JSON.stringify({
|
||||
settings: { opencodeSessionCookie: legacyCookie },
|
||||
sshPtyConsumerRecoveries: [
|
||||
{
|
||||
targetId: 'ssh-1',
|
||||
clientInstanceId: 'client-1',
|
||||
serverBuildId: 'relay-build-1',
|
||||
clientGeneration: 1,
|
||||
ownerGeneration: 1,
|
||||
ownerLease: legacyOwnerLease
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().opencodeSessionCookie).toBe(legacyCookie)
|
||||
expect(store.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(legacyOwnerLease)
|
||||
})
|
||||
|
||||
it.each<FailureMode>(['availability-throws', 'encryption-throws', 'unavailable'])(
|
||||
'saves non-secrets without exposing or destroying protected values: %s',
|
||||
async (failureMode) => {
|
||||
const store = await createStore()
|
||||
await writeProtectedState(store, ORIGINAL, 'before')
|
||||
const originalCiphertext = readState()
|
||||
expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), ORIGINAL)
|
||||
|
||||
vi.advanceTimersByTime(60 * 60 * 1_000 + 1)
|
||||
setFailure(failureMode)
|
||||
await writeProtectedState(store, PENDING, 'during-failure')
|
||||
|
||||
const primaryRaw = readFileSync(dataFile(), 'utf-8')
|
||||
const backupRaw = readFileSync(`${dataFile()}.bak.0`, 'utf-8')
|
||||
const persisted = readState()
|
||||
expectPlaintextsAbsent(primaryRaw, PENDING)
|
||||
expectPlaintextsAbsent(backupRaw, PENDING)
|
||||
expect.soft(persisted.settings.httpProxyUrl).toBe(originalCiphertext.settings.httpProxyUrl)
|
||||
expect
|
||||
.soft(persisted.settings.opencodeSessionCookie)
|
||||
.toBe(originalCiphertext.settings.opencodeSessionCookie)
|
||||
expect
|
||||
.soft(persisted.ui.browserKagiSessionLink)
|
||||
.toBe(originalCiphertext.ui.browserKagiSessionLink)
|
||||
expect
|
||||
.soft(persisted.sshPtyConsumerRecoveries[0]?.ownerLease)
|
||||
.toBe(originalCiphertext.sshPtyConsumerRecoveries[0]?.ownerLease)
|
||||
expect.soft(persisted.settings.httpProxyBypassRules).toBe('during-failure')
|
||||
|
||||
const loadedDuringFailure = await createStore()
|
||||
expect(loadedDuringFailure.getSettings().httpProxyBypassRules).toBe('during-failure')
|
||||
await settleSave(loadedDuringFailure)
|
||||
expectPlaintextsAbsent(readFileSync(dataFile(), 'utf-8'), PENDING)
|
||||
|
||||
cipherState.availability = 'available'
|
||||
cipherState.encryptionThrows = false
|
||||
const recovered = await createStore()
|
||||
expect(recovered.getSettings().httpProxyUrl).toBe(ORIGINAL.proxy)
|
||||
expect(recovered.getSettings().opencodeSessionCookie).toBe(ORIGINAL.cookie)
|
||||
expect(recovered.getUI().browserKagiSessionLink).toBe(ORIGINAL.kagi)
|
||||
expect(recovered.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(ORIGINAL.ownerLease)
|
||||
expect(recovered.getSettings().httpProxyBypassRules).toBe('during-failure')
|
||||
|
||||
await writeProtectedState(recovered, PENDING, 'recovered')
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().httpProxyUrl).toBe(PENDING.proxy)
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe(PENDING.cookie)
|
||||
expect(restarted.getUI().browserKagiSessionLink).toBe(PENDING.kagi)
|
||||
expect(restarted.getSshPtyConsumerRecovery('ssh-1')?.ownerLease).toBe(PENDING.ownerLease)
|
||||
expect(restarted.getSettings().httpProxyBypassRules).toBe('recovered')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const testState = { dir: '' }
|
||||
const cipherState = { available: true }
|
||||
|
||||
const renameGate = vi.hoisted(() => ({
|
||||
sourcePrefix: '',
|
||||
release: null as Promise<void> | null,
|
||||
started: null as (() => void) | null
|
||||
}))
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
rename: async (source: string, destination: string) => {
|
||||
if (renameGate.release && source.startsWith(renameGate.sourcePrefix)) {
|
||||
const release = renameGate.release
|
||||
renameGate.release = null
|
||||
renameGate.started?.()
|
||||
await release
|
||||
}
|
||||
return actual.rename(source, destination)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
loadUserSshConfig: vi.fn(),
|
||||
sshConfigHostsToTargets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 })
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => cipherState.available,
|
||||
encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length)
|
||||
}
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((next) => {
|
||||
resolve = next
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('protected-secret async write retention', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-protected-secret-write-race-'))
|
||||
cipherState.available = true
|
||||
renameGate.sourcePrefix = join(testState.dir, 'orca-data.json')
|
||||
renameGate.release = null
|
||||
renameGate.started = null
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(testState.dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('does not retain ciphertext from a superseded async secret write', async () => {
|
||||
const store = await createStore()
|
||||
store.updateSettings({ opencodeSessionCookie: 'durable-cookie' })
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await store.waitForPendingWrite()
|
||||
|
||||
const renameRelease = deferred()
|
||||
const renameStarted = deferred()
|
||||
renameGate.release = renameRelease.promise
|
||||
renameGate.started = renameStarted.resolve
|
||||
|
||||
store.updateSettings({ opencodeSessionCookie: 'intermediate-cookie' })
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await renameStarted.promise
|
||||
|
||||
store.updateSettings({ opencodeSessionCookie: 'replacement-cookie' })
|
||||
cipherState.available = false
|
||||
vi.advanceTimersByTime(1_000)
|
||||
renameRelease.resolve()
|
||||
await store.waitForPendingWrite()
|
||||
|
||||
cipherState.available = true
|
||||
const restarted = await createStore()
|
||||
expect(restarted.getSettings().opencodeSessionCookie).toBe('durable-cookie')
|
||||
})
|
||||
})
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
// On macOS a keychain reset/denial makes decryptString throw at load, and the
|
||||
// raw ciphertext then masqueraded as a configured proxy: applyElectronProxySettings
|
||||
// silently fell back to DIRECT and the garbage re-persisted forever. These tests
|
||||
// pin the recovery contract: undecryptable values are cleared (never applied,
|
||||
// never re-saved as garbage), plaintext values survive as the upgrade path, and
|
||||
// a throwing safeStorage.isEncryptionAvailable() cannot kill the whole save.
|
||||
// pin the recovery contract: undecryptable values stay sealed (never applied
|
||||
// or destroyed), plaintext values survive as the upgrade path, and safeStorage
|
||||
// failures cannot expose the proxy secret or kill unrelated saves.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
|
@ -131,8 +131,9 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('clears an undecryptable httpProxyUrl instead of surfacing ciphertext as settings', async () => {
|
||||
it('seals an undecryptable httpProxyUrl without destroying its ciphertext', async () => {
|
||||
await seedConfiguredProxy()
|
||||
const originalCiphertext = JSON.parse(readFileSync(dataFile(), 'utf-8')).settings.httpProxyUrl
|
||||
|
||||
// Keychain reset/denial: every decrypt now fails.
|
||||
cipherState.decryptAlwaysThrows = true
|
||||
|
|
@ -142,13 +143,14 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => {
|
|||
// Bypass rules are not encrypted and must survive.
|
||||
expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES)
|
||||
|
||||
// The cleanup must reach disk so garbage never round-trips back in.
|
||||
// Unrelated durable changes must not erase ciphertext that can recover later.
|
||||
reloaded.updateSettings({ httpProxyBypassRules: 'localhost' })
|
||||
vi.advanceTimersByTime(2000)
|
||||
await reloaded.waitForPendingWrite()
|
||||
const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as {
|
||||
settings: { httpProxyUrl: string }
|
||||
}
|
||||
expect(persisted.settings.httpProxyUrl).toBe('')
|
||||
expect(persisted.settings.httpProxyUrl).toBe(originalCiphertext)
|
||||
})
|
||||
|
||||
it('keeps a plaintext httpProxyUrl on disk readable (pre-encryption/hand-edited upgrade path)', async () => {
|
||||
|
|
@ -164,7 +166,7 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => {
|
|||
expect(store.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES)
|
||||
})
|
||||
|
||||
it('still saves and reloads the proxy when safeStorage.isEncryptionAvailable throws', async () => {
|
||||
it('saves non-secret proxy settings without plaintext when availability throws', async () => {
|
||||
cipherState.availabilityThrows = true
|
||||
|
||||
const store = await createStore()
|
||||
|
|
@ -172,15 +174,15 @@ describe('httpProxyUrl secret recovery (STA-3442)', () => {
|
|||
vi.advanceTimersByTime(1000)
|
||||
await store.waitForPendingWrite()
|
||||
|
||||
// Encryption degraded to plaintext passthrough; the save must not be lost.
|
||||
expect(existsSync(dataFile())).toBe(true)
|
||||
const persisted = JSON.parse(readFileSync(dataFile(), 'utf-8')) as {
|
||||
settings: { httpProxyUrl: string; httpProxyBypassRules: string }
|
||||
}
|
||||
expect(persisted.settings.httpProxyUrl).toBe(PROXY_URL)
|
||||
expect(persisted.settings.httpProxyUrl).toBe('')
|
||||
expect(persisted.settings.httpProxyBypassRules).toBe(BYPASS_RULES)
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSettings().httpProxyUrl).toBe(PROXY_URL)
|
||||
expect(reloaded.getSettings().httpProxyUrl).toBe('')
|
||||
expect(reloaded.getSettings().httpProxyBypassRules).toBe(BYPASS_RULES)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ describe('persistence single-serialize save guard', () => {
|
|||
expect(reloaded.getUI().browserKagiSessionLink).toBe(KAGI_LINK)
|
||||
})
|
||||
|
||||
it('handles empty secrets and unavailable encryption (payload stays plaintext, guard still skips)', async () => {
|
||||
it('omits new secrets when encryption is unavailable and still skips an identical state', async () => {
|
||||
cipherState.encryptionAvailable = false
|
||||
const store = await createStore()
|
||||
store.updateSettings({ ...SECRETS, opencodeSessionCookie: '' })
|
||||
|
|
@ -156,7 +156,7 @@ describe('persistence single-serialize save guard', () => {
|
|||
settings: { opencodeSessionCookie: string; httpProxyUrl: string }
|
||||
}
|
||||
expect(persisted.settings.opencodeSessionCookie).toBe('')
|
||||
expect(persisted.settings.httpProxyUrl).toBe(SECRETS.httpProxyUrl)
|
||||
expect(persisted.settings.httpProxyUrl).toBe('')
|
||||
|
||||
const inoBefore = statSync(dataFile()).ino
|
||||
store.updateSettings({ httpProxyUrl: SECRETS.httpProxyUrl })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable max-lines -- Why: persistence keeps schema defaults, migration, and load/save/flush in one file so the storage contract reviews as a unit. */
|
||||
import { app, safeStorage } from 'electron'
|
||||
import { app } from 'electron'
|
||||
import {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
|
|
@ -39,6 +39,7 @@ import {
|
|||
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
|
||||
import { normalizeAutomationPrecheck } from '../shared/automation-precheck'
|
||||
import { normalizeProxyUrl } from '../shared/network-proxy'
|
||||
import { normalizeKagiSessionLink } from '../shared/browser-url'
|
||||
import type {
|
||||
PersistedState,
|
||||
Project,
|
||||
|
|
@ -280,47 +281,23 @@ import {
|
|||
import { track } from './telemetry/client'
|
||||
import { getCohortAtEmit } from './telemetry/cohort-classifier'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
|
||||
import {
|
||||
PROTECTED_SECRET_SLOT,
|
||||
ProtectedSecretPersistence,
|
||||
sshPtyOwnerLeaseSecretSlot,
|
||||
type ProtectedSecretRetentionUpdate
|
||||
} from './protected-secret-persistence'
|
||||
|
||||
// Why (STA-3442): isEncryptionAvailable() itself can throw (keychain/API errors, pre-ready
|
||||
// use); an uncaught throw here failed the entire save/load, silently losing every setting.
|
||||
function safeStorageEncryptionAvailable(): boolean {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
} catch (err) {
|
||||
console.warn('[persistence] safeStorage availability check failed:', err)
|
||||
return false
|
||||
}
|
||||
function isLegacyOpenCodeSessionCookie(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
return (
|
||||
trimmed.startsWith('Fe26.2**') ||
|
||||
trimmed.split(';').some((pair) => /^(?:auth|__Host-auth)=\S+$/i.test(pair.trim()))
|
||||
)
|
||||
}
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
if (!plaintext || !safeStorageEncryptionAvailable()) {
|
||||
return plaintext
|
||||
}
|
||||
try {
|
||||
return safeStorage.encryptString(plaintext).toString('base64')
|
||||
} catch (err) {
|
||||
console.error('[persistence] Encryption failed:', err)
|
||||
return plaintext
|
||||
}
|
||||
}
|
||||
|
||||
function decrypt(ciphertext: string): string {
|
||||
if (!ciphertext || !safeStorageEncryptionAvailable()) {
|
||||
return ciphertext
|
||||
}
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(ciphertext, 'base64'))
|
||||
} catch {
|
||||
// Why: decrypt failure usually means plaintext (pre-encryption) or a changed keychain; return raw so the cookie survives upgrade.
|
||||
console.warn(
|
||||
'[persistence] safeStorage decryption failed — returning ciphertext as-is. Possible keychain reset.'
|
||||
)
|
||||
return ciphertext
|
||||
}
|
||||
}
|
||||
|
||||
function decryptOptionalSecret(value: string | null | undefined): string | null {
|
||||
return value ? decrypt(value) : null
|
||||
function isLegacySshPtyOwnerLease(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
function retireLegacyInstructionsForClearedTextActionRecipes(
|
||||
|
|
@ -2813,6 +2790,7 @@ export class Store {
|
|||
private pendingGithubCacheWrite: Promise<void> | null = null
|
||||
private readonly staleGithubCacheTempCleanup: Promise<void>
|
||||
private gitUsernameCache = new Map<string, string>()
|
||||
private readonly protectedSecrets = new ProtectedSecretPersistence()
|
||||
private loadNeedsSave = false
|
||||
private settingsChangeListeners = new Set<
|
||||
(
|
||||
|
|
@ -3075,25 +3053,43 @@ export class Store {
|
|||
|
||||
// Why: secrets are stored encrypted via safeStorage; decrypt at the load boundary so the app sees plaintext.
|
||||
if (parsed.settings?.opencodeSessionCookie) {
|
||||
parsed.settings.opencodeSessionCookie = decrypt(parsed.settings.opencodeSessionCookie)
|
||||
parsed.settings.opencodeSessionCookie = this.protectedSecrets.decrypt(
|
||||
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
|
||||
parsed.settings.opencodeSessionCookie,
|
||||
isLegacyOpenCodeSessionCookie
|
||||
)
|
||||
}
|
||||
if (parsed.settings?.httpProxyUrl) {
|
||||
const decryptedProxyUrl = decrypt(parsed.settings.httpProxyUrl)
|
||||
const decryptedProxy = this.protectedSecrets.decryptWithStatus(
|
||||
PROTECTED_SECRET_SLOT.httpProxyUrl,
|
||||
parsed.settings.httpProxyUrl,
|
||||
(value) => normalizeProxyUrl(value).ok
|
||||
)
|
||||
// Why (STA-3442): after a keychain reset decrypt returns raw ciphertext; a non-URL
|
||||
// value must not masquerade as a configured proxy (silent DIRECT fallback) or
|
||||
// re-persist as garbage. Plaintext URLs still pass, preserving the upgrade path.
|
||||
if (normalizeProxyUrl(decryptedProxyUrl).ok) {
|
||||
parsed.settings.httpProxyUrl = decryptedProxyUrl
|
||||
if (
|
||||
decryptedProxy.status === 'unavailable' ||
|
||||
(decryptedProxy.status === 'failed' && !decryptedProxy.plaintext)
|
||||
) {
|
||||
parsed.settings.httpProxyUrl = ''
|
||||
} else if (normalizeProxyUrl(decryptedProxy.plaintext).ok) {
|
||||
parsed.settings.httpProxyUrl = decryptedProxy.plaintext
|
||||
} else {
|
||||
console.warn(
|
||||
'[persistence] httpProxyUrl could not be decrypted — clearing the stored proxy URL. Re-enter it in Settings > Advanced > Network.'
|
||||
)
|
||||
parsed.settings.httpProxyUrl = ''
|
||||
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl)
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
}
|
||||
if (parsed.ui?.browserKagiSessionLink) {
|
||||
parsed.ui.browserKagiSessionLink = decryptOptionalSecret(parsed.ui.browserKagiSessionLink)
|
||||
parsed.ui.browserKagiSessionLink = this.protectedSecrets.decrypt(
|
||||
PROTECTED_SECRET_SLOT.browserKagiSessionLink,
|
||||
parsed.ui.browserKagiSessionLink,
|
||||
(value) => normalizeKagiSessionLink(value) !== null
|
||||
)
|
||||
}
|
||||
parsed.sshPtyConsumerRecoveries = (
|
||||
Array.isArray(parsed.sshPtyConsumerRecoveries) ? parsed.sshPtyConsumerRecoveries : []
|
||||
|
|
@ -3102,8 +3098,23 @@ export class Store {
|
|||
normalizeSshPtyConsumerRecovery(record, ENCRYPTED_SSH_PTY_OWNER_LEASE_MAX_LENGTH)
|
||||
)
|
||||
.filter((record): record is SshPtyConsumerRecovery => record !== null)
|
||||
.map((record) => ({ ...record, ownerLease: decrypt(record.ownerLease) }))
|
||||
.map((record) => normalizeSshPtyConsumerRecovery(record))
|
||||
.map((record) => {
|
||||
const slot = sshPtyOwnerLeaseSecretSlot(record.targetId)
|
||||
const decrypted = this.protectedSecrets.decryptWithStatus(
|
||||
slot,
|
||||
record.ownerLease,
|
||||
isLegacySshPtyOwnerLease
|
||||
)
|
||||
const normalized =
|
||||
decrypted.status === 'unavailable' ||
|
||||
(decrypted.status === 'failed' && !decrypted.plaintext)
|
||||
? record
|
||||
: normalizeSshPtyConsumerRecovery({ ...record, ownerLease: decrypted.plaintext })
|
||||
if (!normalized) {
|
||||
this.protectedSecrets.removeRetainedBlob(slot)
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
.filter((record): record is SshPtyConsumerRecovery => record !== null)
|
||||
|
||||
// Merge with defaults in case new fields were added
|
||||
|
|
@ -3904,8 +3915,12 @@ export class Store {
|
|||
return durable
|
||||
}
|
||||
|
||||
// Why: build payload synchronously so hash and serialized bytes reflect the same state tick (no await interleave). One full-state stringify serves both the on-disk payload and the no-op-write guard hash: each secret slot is serialized as a fresh unguessable sentinel, then sentinels are substituted to ciphertext for the payload and to plaintext for the hash. The hash is thus a pure function of plaintext state (skips a byte-identical rewrite) without a second stringify.
|
||||
private buildStateToSave(): { payload: string; stateHash: string } {
|
||||
// Why: build payload synchronously so hash and bytes reflect one state tick. A degraded prefix makes the first healthy retry durable even when plaintext state is unchanged.
|
||||
private buildStateToSave(): {
|
||||
payload: string
|
||||
stateHash: string
|
||||
protectedSecretUpdates: ProtectedSecretRetentionUpdate[]
|
||||
} {
|
||||
// Why sentinels (not a blob/key string match): the substitution must be
|
||||
// position-exact. A plain search for the ciphertext — or even for a
|
||||
// `"key":"blob"` token — can be mimicked by user-controlled state (e.g. an
|
||||
|
|
@ -3915,54 +3930,79 @@ export class Store {
|
|||
// on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot
|
||||
// random UUID can't occur anywhere else in the serialized state (the user
|
||||
// sets their data before it is minted), so it appears exactly once.
|
||||
const secretSubs: { sentinel: string; blob: string; plaintext: string }[] = []
|
||||
const encryptToSentinel = (plaintext: string): string => {
|
||||
const blob = encrypt(plaintext)
|
||||
// Deterministic already (empty secret / safeStorage unavailable / encrypt
|
||||
// failure): blob === plaintext, so no normalization — and no sentinel,
|
||||
// which also avoids substituting an empty or plaintext-shaped slot.
|
||||
if (blob === plaintext) {
|
||||
const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = []
|
||||
const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = []
|
||||
let protectedStorageDegraded = false
|
||||
const encryptToSentinel = (slot: string, plaintext: string): string => {
|
||||
const encrypted = this.protectedSecrets.encrypt(slot, plaintext)
|
||||
if (encrypted.retentionUpdate) {
|
||||
protectedSecretUpdates.push(encrypted.retentionUpdate)
|
||||
}
|
||||
protectedStorageDegraded ||= encrypted.degraded
|
||||
const { blob, hashValue = plaintext } = encrypted
|
||||
// Values already identical in payload and hash need no sentinel substitution.
|
||||
if (blob === plaintext && hashValue === plaintext) {
|
||||
return blob
|
||||
}
|
||||
const sentinel = `orca-secret-slot-${randomUUID()}`
|
||||
secretSubs.push({ sentinel, blob, plaintext })
|
||||
secretSubs.push({ sentinel, blob, hashValue })
|
||||
return sentinel
|
||||
}
|
||||
const encryptOptionalToSentinel = (
|
||||
slot: string,
|
||||
plaintext: string | null | undefined
|
||||
): string | null => {
|
||||
const encrypted = encryptToSentinel(slot, plaintext ?? '')
|
||||
return encrypted || null
|
||||
}
|
||||
// Why: clone before encrypting secrets so in-memory this.state stays plaintext.
|
||||
const stateToSave = {
|
||||
...this.getDurableState(),
|
||||
sshPtyConsumerRecoveries: (this.state.sshPtyConsumerRecoveries ?? []).map((record) => ({
|
||||
...record,
|
||||
ownerLease: encryptToSentinel(record.ownerLease)
|
||||
ownerLease: encryptToSentinel(
|
||||
sshPtyOwnerLeaseSecretSlot(record.targetId),
|
||||
record.ownerLease
|
||||
)
|
||||
})),
|
||||
settings: {
|
||||
...this.state.settings,
|
||||
opencodeSessionCookie: encryptToSentinel(this.state.settings.opencodeSessionCookie),
|
||||
httpProxyUrl: encryptToSentinel(this.state.settings.httpProxyUrl ?? '')
|
||||
opencodeSessionCookie: encryptToSentinel(
|
||||
PROTECTED_SECRET_SLOT.opencodeSessionCookie,
|
||||
this.state.settings.opencodeSessionCookie
|
||||
),
|
||||
httpProxyUrl: encryptToSentinel(
|
||||
PROTECTED_SECRET_SLOT.httpProxyUrl,
|
||||
this.state.settings.httpProxyUrl ?? ''
|
||||
)
|
||||
},
|
||||
ui: {
|
||||
...this.state.ui,
|
||||
browserKagiSessionLink: this.state.ui.browserKagiSessionLink
|
||||
? encryptToSentinel(this.state.ui.browserKagiSessionLink)
|
||||
: null
|
||||
browserKagiSessionLink: encryptOptionalToSentinel(
|
||||
PROTECTED_SECRET_SLOT.browserKagiSessionLink,
|
||||
this.state.ui.browserKagiSessionLink
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant.
|
||||
// One full-state stringify; secret slots currently hold sentinels.
|
||||
const serialized = JSON.stringify(stateToSave)
|
||||
// Substitute each unique sentinel exactly once: ciphertext for the on-disk
|
||||
// payload, plaintext for the guard hash. Function-form replacement keeps
|
||||
// `$` in blob/plaintext inert; both sides read the sentinel as JSON-escaped
|
||||
// payload, a stable normalized value for the guard hash. Function-form
|
||||
// replacement keeps `$` inert; both sides read the sentinel as JSON-escaped
|
||||
// in `serialized`, so each replace is byte-for-byte position-exact.
|
||||
let payload = serialized
|
||||
let hashInput = serialized
|
||||
for (const { sentinel, blob, plaintext } of secretSubs) {
|
||||
for (const { sentinel, blob, hashValue } of secretSubs) {
|
||||
const escapedSentinel = JSON.stringify(sentinel).slice(1, -1)
|
||||
payload = payload.replace(escapedSentinel, () => blob)
|
||||
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(plaintext).slice(1, -1))
|
||||
payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1))
|
||||
hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1))
|
||||
}
|
||||
const stateHash = createHash('sha1').update(hashInput).digest('hex')
|
||||
return { payload, stateHash }
|
||||
const stateHash = createHash('sha1')
|
||||
.update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '')
|
||||
.update(hashInput)
|
||||
.digest('hex')
|
||||
return { payload, stateHash, protectedSecretUpdates }
|
||||
}
|
||||
|
||||
// Why: async writes avoid blocking the main Electron thread on every debounced save.
|
||||
|
|
@ -3971,7 +4011,7 @@ export class Store {
|
|||
return
|
||||
}
|
||||
const gen = this.writeGeneration
|
||||
const { payload, stateHash } = this.buildStateToSave()
|
||||
const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave()
|
||||
// Why: don't rewrite a byte-identical multi-MB file when state nets out to already-persisted.
|
||||
if (stateHash === this.lastWrittenStateHash) {
|
||||
this.lastDurableWriteGeneration = Math.max(this.lastDurableWriteGeneration, gen)
|
||||
|
|
@ -4013,6 +4053,7 @@ export class Store {
|
|||
// Why re-check gen: a mutation or sync flush during rename makes the installed hash ambiguous; invalidate the no-op guard.
|
||||
if (renamed && this.writeGeneration === gen) {
|
||||
this.lastWrittenStateHash = stateHash
|
||||
this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates)
|
||||
} else if (renamed) {
|
||||
this.lastWrittenStateHash = null
|
||||
}
|
||||
|
|
@ -4039,7 +4080,7 @@ export class Store {
|
|||
if (this.writesFrozen) {
|
||||
return
|
||||
}
|
||||
const { payload, stateHash } = this.buildStateToSave()
|
||||
const { payload, stateHash, protectedSecretUpdates } = this.buildStateToSave()
|
||||
// Why: matching hash means the file already holds this state; force overrides when an async rename may be racing past the gen check.
|
||||
if (!opts.force && stateHash === this.lastWrittenStateHash) {
|
||||
return
|
||||
|
|
@ -4059,6 +4100,7 @@ export class Store {
|
|||
writeFileDurableSync(tmpFile, dataFile, payload)
|
||||
renamed = true
|
||||
this.lastWrittenStateHash = stateHash
|
||||
this.protectedSecrets.commitRetentionUpdates(protectedSecretUpdates)
|
||||
this.lastDurableWriteGeneration = Math.max(
|
||||
this.lastDurableWriteGeneration,
|
||||
this.writeGeneration
|
||||
|
|
@ -5724,6 +5766,12 @@ export class Store {
|
|||
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
|
||||
): GlobalSettings {
|
||||
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
|
||||
if ('opencodeSessionCookie' in updates && !updates.opencodeSessionCookie) {
|
||||
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.opencodeSessionCookie)
|
||||
}
|
||||
if ('httpProxyUrl' in updates && !updates.httpProxyUrl) {
|
||||
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.httpProxyUrl)
|
||||
}
|
||||
// Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on".
|
||||
if ('minimizeToTrayOnClose' in updates) {
|
||||
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
|
||||
|
|
@ -5948,6 +5996,9 @@ export class Store {
|
|||
}
|
||||
|
||||
updateUI(updates: Partial<PersistedState['ui']>): void {
|
||||
if ('browserKagiSessionLink' in updates && !updates.browserKagiSessionLink) {
|
||||
this.protectedSecrets.removeRetainedBlob(PROTECTED_SECRET_SLOT.browserKagiSessionLink)
|
||||
}
|
||||
const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates)
|
||||
const { activeView, ...durableUpdates } = sanitizedUpdates
|
||||
const activeViewChanged = this.activeViewPreference.set(activeView)
|
||||
|
|
@ -6853,6 +6904,7 @@ export class Store {
|
|||
}
|
||||
this.state.sshTargets = nextTargets
|
||||
this.state.sshPtyConsumerRecoveries = nextRecoveries
|
||||
this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(id))
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
|
|
@ -7003,6 +7055,7 @@ export class Store {
|
|||
const retainedRecoveries = recoveries.filter((record) => record.targetId !== oldTargetId)
|
||||
if (retainedRecoveries.length !== recoveries.length) {
|
||||
this.state.sshPtyConsumerRecoveries = retainedRecoveries
|
||||
this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(oldTargetId))
|
||||
carrierChanged = true
|
||||
}
|
||||
let setupsChanged = false
|
||||
|
|
@ -7045,6 +7098,12 @@ export class Store {
|
|||
const record = (this.state.sshPtyConsumerRecoveries ?? []).find(
|
||||
(candidate) => candidate.targetId === targetId
|
||||
)
|
||||
if (
|
||||
record &&
|
||||
this.protectedSecrets.isSealed(sshPtyOwnerLeaseSecretSlot(record.targetId), record.ownerLease)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return record ? structuredClone(record) : null
|
||||
}
|
||||
|
||||
|
|
@ -7068,6 +7127,7 @@ export class Store {
|
|||
return
|
||||
}
|
||||
this.state.sshPtyConsumerRecoveries = next
|
||||
this.protectedSecrets.removeRetainedBlob(sshPtyOwnerLeaseSecretSlot(targetId))
|
||||
await this.flushSshPtyConsumerRecovery()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const cipherState = { available: true }
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => cipherState.available,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString().slice('encrypted:'.length)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ProtectedSecretPersistence', () => {
|
||||
beforeEach(() => {
|
||||
cipherState.available = true
|
||||
})
|
||||
|
||||
it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => {
|
||||
const { ProtectedSecretPersistence, sshPtyOwnerLeaseSecretSlot } =
|
||||
await import('./protected-secret-persistence')
|
||||
const secrets = new ProtectedSecretPersistence()
|
||||
const slots = Array.from({ length: 100 }, (_, index) =>
|
||||
sshPtyOwnerLeaseSecretSlot(`ssh-${index}`)
|
||||
)
|
||||
|
||||
for (const slot of slots) {
|
||||
expect(secrets.encrypt(slot, 'owner-lease').blob).not.toBe('owner-lease')
|
||||
secrets.removeRetainedBlob(slot)
|
||||
}
|
||||
|
||||
cipherState.available = false
|
||||
for (const slot of slots) {
|
||||
expect(secrets.encrypt(slot, 'replacement-lease')).toEqual({ blob: '', degraded: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps unavailable ciphertext sealed across recovery until replacement or clear', async () => {
|
||||
const { ProtectedSecretPersistence } = await import('./protected-secret-persistence')
|
||||
const secrets = new ProtectedSecretPersistence()
|
||||
const slot = 'protected-slot'
|
||||
const ciphertext = Buffer.from('encrypted:original').toString('base64')
|
||||
|
||||
cipherState.available = false
|
||||
expect(secrets.decryptWithStatus(slot, ciphertext)).toEqual({
|
||||
plaintext: '',
|
||||
status: 'unavailable'
|
||||
})
|
||||
expect(secrets.isSealed(slot, ciphertext)).toBe(true)
|
||||
expect(secrets.encrypt(slot, '')).toEqual({
|
||||
blob: ciphertext,
|
||||
degraded: true,
|
||||
hashValue: ciphertext
|
||||
})
|
||||
|
||||
cipherState.available = true
|
||||
expect(secrets.encrypt(slot, '')).toEqual({
|
||||
blob: ciphertext,
|
||||
degraded: false,
|
||||
hashValue: ciphertext
|
||||
})
|
||||
expect(secrets.encrypt(slot, 'replacement').blob).not.toBe(ciphertext)
|
||||
|
||||
secrets.removeRetainedBlob(slot)
|
||||
expect(secrets.encrypt(slot, '')).toEqual({ blob: '', degraded: false })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { safeStorage } from 'electron'
|
||||
|
||||
export const PROTECTED_SECRET_SLOT = {
|
||||
opencodeSessionCookie: 'settings.opencodeSessionCookie',
|
||||
httpProxyUrl: 'settings.httpProxyUrl',
|
||||
browserKagiSessionLink: 'ui.browserKagiSessionLink'
|
||||
} as const
|
||||
|
||||
export function sshPtyOwnerLeaseSecretSlot(targetId: string): string {
|
||||
return `sshPtyConsumerRecoveries.ownerLease:${targetId}`
|
||||
}
|
||||
|
||||
export type ProtectedSecretDecryption = {
|
||||
plaintext: string
|
||||
status: 'decrypted' | 'failed' | 'unavailable'
|
||||
}
|
||||
|
||||
export type ProtectedSecretRetentionUpdate = {
|
||||
slot: string
|
||||
blob: string | null
|
||||
}
|
||||
|
||||
export type LegacyPlaintextValidator = (value: string) => boolean
|
||||
|
||||
type ProtectedSecretEncryption = {
|
||||
blob: string
|
||||
degraded: boolean
|
||||
hashValue?: string
|
||||
retentionUpdate?: ProtectedSecretRetentionUpdate
|
||||
}
|
||||
|
||||
// Preserve prior ciphertext or omit a new secret so unrelated state can still save safely.
|
||||
export class ProtectedSecretPersistence {
|
||||
private readonly retainedBlobs = new Map<string, string>()
|
||||
private readonly sealedSlots = new Set<string>()
|
||||
|
||||
removeRetainedBlob(slot: string): void {
|
||||
this.retainedBlobs.delete(slot)
|
||||
this.sealedSlots.delete(slot)
|
||||
}
|
||||
|
||||
isSealed(slot: string, value: string): boolean {
|
||||
return this.sealedSlots.has(slot) && this.retainedBlobs.get(slot) === value
|
||||
}
|
||||
|
||||
commitRetentionUpdates(updates: readonly ProtectedSecretRetentionUpdate[]): void {
|
||||
for (const update of updates) {
|
||||
if (update.blob === null) {
|
||||
this.removeRetainedBlob(update.slot)
|
||||
} else {
|
||||
this.retainedBlobs.set(update.slot, update.blob)
|
||||
this.sealedSlots.delete(update.slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
encrypt(slot: string, plaintext: string): ProtectedSecretEncryption {
|
||||
const retained = this.retainedBlobs.get(slot) ?? ''
|
||||
if (!plaintext && !retained) {
|
||||
return { blob: '', degraded: false }
|
||||
}
|
||||
if (!this.encryptionAvailable()) {
|
||||
return {
|
||||
blob: retained,
|
||||
degraded: true,
|
||||
...(!plaintext && retained ? { hashValue: retained } : {})
|
||||
}
|
||||
}
|
||||
if (this.isSealed(slot, plaintext) || (!plaintext && this.sealedSlots.has(slot))) {
|
||||
return { blob: retained, degraded: false, hashValue: retained }
|
||||
}
|
||||
if (!plaintext) {
|
||||
return {
|
||||
blob: '',
|
||||
degraded: false,
|
||||
retentionUpdate: { slot, blob: null }
|
||||
}
|
||||
}
|
||||
try {
|
||||
const blob = safeStorage.encryptString(plaintext).toString('base64')
|
||||
return {
|
||||
blob,
|
||||
degraded: false,
|
||||
retentionUpdate: { slot, blob }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[persistence] Encryption failed; retaining the prior protected value:', err)
|
||||
return { blob: retained, degraded: true }
|
||||
}
|
||||
}
|
||||
|
||||
decrypt(slot: string, ciphertext: string, isLegacyPlaintext?: LegacyPlaintextValidator): string {
|
||||
return this.decryptWithStatus(slot, ciphertext, isLegacyPlaintext).plaintext
|
||||
}
|
||||
|
||||
decryptWithStatus(
|
||||
slot: string,
|
||||
ciphertext: string,
|
||||
isLegacyPlaintext?: LegacyPlaintextValidator
|
||||
): ProtectedSecretDecryption {
|
||||
if (!ciphertext) {
|
||||
this.removeRetainedBlob(slot)
|
||||
return { plaintext: '', status: 'decrypted' }
|
||||
}
|
||||
this.retainedBlobs.set(slot, ciphertext)
|
||||
if (!this.encryptionAvailable()) {
|
||||
this.sealedSlots.add(slot)
|
||||
return { plaintext: '', status: 'unavailable' }
|
||||
}
|
||||
try {
|
||||
const decrypted = {
|
||||
plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')),
|
||||
status: 'decrypted' as const
|
||||
}
|
||||
this.sealedSlots.delete(slot)
|
||||
return decrypted
|
||||
} catch {
|
||||
if (isLegacyPlaintext?.(ciphertext)) {
|
||||
this.sealedSlots.delete(slot)
|
||||
console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.')
|
||||
return { plaintext: ciphertext, status: 'failed' }
|
||||
}
|
||||
this.sealedSlots.add(slot)
|
||||
console.warn(
|
||||
'[persistence] safeStorage decryption failed; retaining the protected value without exposing it.'
|
||||
)
|
||||
return { plaintext: '', status: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
private encryptionAvailable(): boolean {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
} catch (err) {
|
||||
console.warn('[persistence] safeStorage availability check failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue