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:
Jinwoo Hong 2026-08-06 21:19:04 -07:00 committed by GitHub
parent c9485fdded
commit 7e60b338ea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 883 additions and 83 deletions

View File

@ -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')
}
)
})

View File

@ -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')
})
})

View File

@ -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)
})
})

View File

@ -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 })

View File

@ -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()
}

View File

@ -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 })
})
})

View File

@ -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
}
}
}