mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor(host): route secret storage through a SecretStore port (#15916)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { beforeEach } from 'vitest'
|
||||
import { setSecretStore } from '../../src/shared/secret-store'
|
||||
|
||||
/**
|
||||
* Why: `getSecretStore()` throws until an entrypoint installs a store, which is the
|
||||
* right production behaviour but would fail ~67 suites that only ever cared that
|
||||
* *some* store existed. Install a reversible in-memory one before every test so
|
||||
* those suites stay unchanged; a suite that asserts on sealing behaviour calls
|
||||
* `setSecretStore()` itself and wins, because this runs first.
|
||||
*
|
||||
* Deliberately not a plaintext passthrough: encryptString must return something a
|
||||
* test can tell apart from the input, or a test that forgot to seal would pass.
|
||||
*/
|
||||
const SEAL_PREFIX = 'vitest-sealed:'
|
||||
|
||||
beforeEach(() => {
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plainText) => Buffer.from(`${SEAL_PREFIX}${plainText}`),
|
||||
decryptString: (cipher) => {
|
||||
const text = cipher.toString()
|
||||
if (!text.startsWith(SEAL_PREFIX)) {
|
||||
throw new Error('vitest secret store: ciphertext was not produced by this store')
|
||||
}
|
||||
return text.slice(SEAL_PREFIX.length)
|
||||
},
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,10 @@ export default defineConfig({
|
||||
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
|
||||
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
setupFiles: [
|
||||
resolve('config/scripts/happy-dom-mutation-observer-retention.ts'),
|
||||
resolve('config/scripts/vitest-secret-store-setup.ts')
|
||||
],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
@@ -10,13 +10,13 @@ let tempHome = ''
|
||||
|
||||
async function loadModule() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: (value) => value.toString('utf-8'),
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -19,13 +19,13 @@ async function loadStore(
|
||||
// Why: doMock registrations outlive resetModules, so an injected failure from
|
||||
// one case would leak into every later one in this file.
|
||||
vi.doUnmock('node:fs')
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptStringMock
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: decryptStringMock,
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -19,13 +19,13 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() }))
|
||||
|
||||
async function loadModules() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: decryptSpy
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: decryptSpy,
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('node:os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const safeStorageMock = vi.hoisted(() => ({
|
||||
isEncryptionAvailable: vi.fn(() => true),
|
||||
encryptString: vi.fn((plainText: string) => Buffer.from(`os-sealed:${plainText}`)),
|
||||
decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length))
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({ safeStorage: safeStorageMock }))
|
||||
|
||||
const { ElectronSecretStore } = await import('./electron-secret-store')
|
||||
|
||||
describe('ElectronSecretStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
|
||||
})
|
||||
|
||||
// Why this shape: the whole safety argument for the SecretStore refactor is that the
|
||||
// desktop byte path did not change. That is only true if this adapter forwards
|
||||
// verbatim — same argument, same return value, no re-encoding.
|
||||
it('forwards encryptString to safeStorage and returns its buffer unchanged', () => {
|
||||
const sealed = new ElectronSecretStore().encryptString('token')
|
||||
expect(safeStorageMock.encryptString).toHaveBeenCalledExactlyOnceWith('token')
|
||||
expect(sealed).toBe(safeStorageMock.encryptString.mock.results[0]!.value)
|
||||
})
|
||||
|
||||
it('forwards decryptString to safeStorage and returns its string unchanged', () => {
|
||||
const cipher = Buffer.from('os-sealed:token')
|
||||
expect(new ElectronSecretStore().decryptString(cipher)).toBe('token')
|
||||
expect(safeStorageMock.decryptString).toHaveBeenCalledExactlyOnceWith(cipher)
|
||||
})
|
||||
|
||||
// Why the narrower claim: safeStorage is mocked here, so this proves the adapter
|
||||
// pairs encrypt/decrypt without mangling the buffer — NOT that credentials sealed by
|
||||
// a previous build still open. Real-ciphertext compatibility needs a captured fixture.
|
||||
it('pairs encryptString and decryptString without altering the payload', () => {
|
||||
const store = new ElectronSecretStore()
|
||||
expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token')
|
||||
})
|
||||
|
||||
it('reports availability from safeStorage rather than caching it', () => {
|
||||
const store = new ElectronSecretStore()
|
||||
expect(store.isEncryptionAvailable()).toBe(true)
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
expect(store.isEncryptionAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('has no reason to give while sealing works', () => {
|
||||
expect(new ElectronSecretStore().describeUnavailable()).toBeNull()
|
||||
})
|
||||
|
||||
it('names the missing facility when sealing is unavailable, so the plaintext fallback is explainable', () => {
|
||||
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
|
||||
const reason = new ElectronSecretStore().describeUnavailable()
|
||||
expect(reason).toContain('unencrypted')
|
||||
if (process.platform === 'linux') {
|
||||
expect(reason).toContain('keyring')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import type { SecretStore } from '../../shared/secret-store'
|
||||
|
||||
/**
|
||||
* Electron-backed SecretStore for the desktop app: a pass-through to
|
||||
* `electron.safeStorage`, which seals against the OS keychain.
|
||||
*/
|
||||
export class ElectronSecretStore implements SecretStore {
|
||||
isEncryptionAvailable(): boolean {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
}
|
||||
|
||||
encryptString(plainText: string): Buffer {
|
||||
return safeStorage.encryptString(plainText)
|
||||
}
|
||||
|
||||
decryptString(cipher: Buffer): string {
|
||||
return safeStorage.decryptString(cipher)
|
||||
}
|
||||
|
||||
describeUnavailable(): string | null {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return null
|
||||
}
|
||||
// Why platform-specific: the fix differs, and "encryption unavailable" alone
|
||||
// sends users looking in the wrong place.
|
||||
return process.platform === 'linux'
|
||||
? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.'
|
||||
: 'The OS keychain is unavailable, so secrets are stored unencrypted.'
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
getCanonicalUserDataPath,
|
||||
migrateMobilePairingDataToCanonicalUserDataPath
|
||||
} from './persistence'
|
||||
import { setSecretStore } from '../shared/secret-store'
|
||||
import { ElectronSecretStore } from './host/electron-secret-store'
|
||||
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
|
||||
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
|
||||
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
|
||||
@@ -850,6 +852,11 @@ if (!hasSingleInstanceLock) {
|
||||
|
||||
// Why: when another process holds the lock we've already exited; skip file-writing side effects so this transient process never touches userData.
|
||||
if (hasSingleInstanceLock) {
|
||||
// Why: every secret read resolves through this port, and getSecretStore() throws
|
||||
// until it is installed — so install before any other bootstrap step can touch a
|
||||
// credential. Constructing it does not call safeStorage, so this stays clear of the
|
||||
// pre-ready Keychain service-name resolution the setName block below depends on.
|
||||
setSecretStore(new ElectronSecretStore())
|
||||
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
|
||||
const shouldCoupleToDevParent = is.dev && !isServeMode
|
||||
installDevParentDisconnectQuit(shouldCoupleToDevParent)
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
unlinkSync,
|
||||
writeSync
|
||||
} from 'node:fs'
|
||||
import { safeStorage } from 'electron'
|
||||
import { getSecretStore } from '../shared/secret-store'
|
||||
import {
|
||||
credentialDecryptionMessage,
|
||||
type IntegrationCredentialService
|
||||
@@ -31,12 +31,12 @@ export function writeEncryptedCredential(
|
||||
path: string,
|
||||
value: string
|
||||
): void {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeCredentialFileAtomic(path, safeStorage.encryptString(value))
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
writeCredentialFileAtomic(path, getSecretStore().encryptString(value))
|
||||
return
|
||||
}
|
||||
console.warn(
|
||||
`[${service.toLowerCase()}] safeStorage encryption unavailable — storing credential in plaintext`
|
||||
`[${service.toLowerCase()}] secret encryption unavailable — storing credential in plaintext`
|
||||
)
|
||||
writeCredentialFileAtomic(path, Buffer.from(value, 'utf-8'))
|
||||
}
|
||||
@@ -111,9 +111,9 @@ export function readStoredCredentialToken(
|
||||
return null
|
||||
}
|
||||
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
try {
|
||||
return usableToken(safeStorage.decryptString(raw))
|
||||
return usableToken(getSecretStore().decryptString(raw))
|
||||
} catch {
|
||||
return readPlaintextLegacyCredential(service, raw)
|
||||
}
|
||||
@@ -127,7 +127,7 @@ function readPlaintextLegacyCredential(
|
||||
raw: Buffer
|
||||
): string | null {
|
||||
const plaintext = decodeUtf8(raw)
|
||||
// Why: legacy plaintext tokens are printable UTF-8; safeStorage ciphertext
|
||||
// Why: legacy plaintext tokens are printable UTF-8; sealed ciphertext
|
||||
// such as macOS v10 blobs must not be decoded into auth-header junk.
|
||||
if (plaintext === null || hasControlCharacter(plaintext)) {
|
||||
throw new CredentialDecryptionError(service)
|
||||
|
||||
@@ -93,11 +93,6 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
|
||||
},
|
||||
session: {
|
||||
defaultSession: {
|
||||
closeAllConnections: closeAllConnectionsMock,
|
||||
@@ -106,6 +101,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
}
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { safeStorage } from 'electron'
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import {
|
||||
CredentialDecryptionError,
|
||||
credentialFileHasContent,
|
||||
@@ -157,11 +157,11 @@ export function writeSiteFile(file: JiraSiteFile): void {
|
||||
}
|
||||
|
||||
function writeEncryptedToken(path: string, apiToken: string): void {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeFileSync(path, safeStorage.encryptString(apiToken), { mode: 0o600 })
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
writeFileSync(path, getSecretStore().encryptString(apiToken), { mode: 0o600 })
|
||||
return
|
||||
}
|
||||
console.warn('[jira] safeStorage encryption unavailable — storing token in plaintext')
|
||||
console.warn('[jira] secret encryption unavailable — storing token in plaintext')
|
||||
writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
|
||||
@@ -90,13 +90,13 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
|
||||
})
|
||||
})
|
||||
})
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value: string) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value: Buffer) => value.toString('utf-8'))
|
||||
}
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
|
||||
encryptString: (value) => Buffer.from(value),
|
||||
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
LEGACY_WORKSPACE_ID,
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
import type { LinearWorkspace } from '../../shared/linear/workspace-types'
|
||||
|
||||
function writeEncryptedToken(path: string, apiKey: string): void {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const encrypted = safeStorage.encryptString(apiKey)
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
const encrypted = getSecretStore().encryptString(apiKey)
|
||||
writeFileSync(path, encrypted, { mode: 0o600 })
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[linear] safeStorage encryption unavailable — storing token in plaintext')
|
||||
console.warn('[linear] secret encryption unavailable — storing token in plaintext')
|
||||
writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
|
||||
@@ -19,31 +19,7 @@ vi.mock('./ssh/ssh-config-parser', () => ({
|
||||
}))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
app: { getPath: () => testState.dir }
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
|
||||
@@ -53,6 +29,32 @@ vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { setSecretStore } = await import('../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => {
|
||||
if (cipherState.availability === 'throws') {
|
||||
throw new Error('keychain access denied')
|
||||
}
|
||||
return cipherState.availability === 'available'
|
||||
},
|
||||
encryptString: (plaintext) => {
|
||||
if (cipherState.encryptionThrows) {
|
||||
throw new Error('keychain encryption failed')
|
||||
}
|
||||
return Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8')
|
||||
},
|
||||
decryptString: (ciphertext) => {
|
||||
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)
|
||||
},
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
|
||||
@@ -40,16 +40,18 @@ vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
}))
|
||||
|
||||
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)
|
||||
}
|
||||
app: { getPath: () => testState.dir }
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { setSecretStore } = await import('../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => cipherState.available,
|
||||
encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length),
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
|
||||
@@ -28,26 +28,7 @@ vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.dir
|
||||
},
|
||||
session: { defaultSession: undefined },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => {
|
||||
if (cipherState.availabilityThrows) {
|
||||
throw new Error('safeStorage cannot be used before the app is ready')
|
||||
}
|
||||
return cipherState.encryptionAvailable
|
||||
},
|
||||
encryptString: (plaintext: string) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => {
|
||||
if (cipherState.decryptAlwaysThrows) {
|
||||
throw new Error('keychain access denied')
|
||||
}
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('enc:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('enc:'.length + 36 + 1)
|
||||
}
|
||||
}
|
||||
session: { defaultSession: undefined }
|
||||
}))
|
||||
|
||||
vi.mock('./telemetry/client', () => ({
|
||||
@@ -60,6 +41,27 @@ vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { setSecretStore } = await import('../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => {
|
||||
if (cipherState.availabilityThrows) {
|
||||
throw new Error('safeStorage cannot be used before the app is ready')
|
||||
}
|
||||
return cipherState.encryptionAvailable
|
||||
},
|
||||
encryptString: (plaintext) => Buffer.from(`enc:${randomUUID()}:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext) => {
|
||||
if (cipherState.decryptAlwaysThrows) {
|
||||
throw new Error('keychain access denied')
|
||||
}
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('enc:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('enc:'.length + 36 + 1)
|
||||
},
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
|
||||
@@ -27,21 +27,6 @@ const DETERMINISTIC_IV = 'd'.repeat(36)
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.dir
|
||||
},
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => cipherState.encryptionAvailable,
|
||||
encryptString: (plaintext: string) =>
|
||||
Buffer.from(
|
||||
`enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`,
|
||||
'utf-8'
|
||||
),
|
||||
decryptString: (ciphertext: Buffer) => {
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('enc:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('enc:'.length + 36 + 1)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -55,6 +40,23 @@ vi.mock('./telemetry/cohort-classifier', () => ({
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { setSecretStore } = await import('../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => cipherState.encryptionAvailable,
|
||||
encryptString: (plaintext) =>
|
||||
Buffer.from(
|
||||
`enc:${cipherState.deterministic ? DETERMINISTIC_IV : randomUUID()}:${plaintext}`,
|
||||
'utf-8'
|
||||
),
|
||||
decryptString: (ciphertext) => {
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('enc:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('enc:'.length + 36 + 1)
|
||||
},
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
|
||||
@@ -7,7 +7,6 @@ import { getDefaultPersistedState } from '../shared/constants'
|
||||
import { createDefaultWorkspaceCleanupBrowseState } from '../shared/workspace-cleanup-browse-state'
|
||||
import {
|
||||
testState,
|
||||
createStore,
|
||||
dataFile,
|
||||
writeDataFile,
|
||||
readDataFile,
|
||||
@@ -32,19 +31,28 @@ const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => testState.dir
|
||||
},
|
||||
safeStorage: {
|
||||
}
|
||||
}))
|
||||
|
||||
async function createStore() {
|
||||
vi.resetModules()
|
||||
const { setSecretStore } = await import('../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => {
|
||||
encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext) => {
|
||||
const decoded = ciphertext.toString('utf-8')
|
||||
if (!decoded.startsWith('encrypted:')) {
|
||||
throw new Error('invalid ciphertext')
|
||||
}
|
||||
return decoded.slice('encrypted:'.length)
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
const { Store, initDataPath } = await import('./persistence')
|
||||
initDataPath()
|
||||
return new Store()
|
||||
}
|
||||
|
||||
vi.mock('./telemetry/client', () => ({
|
||||
track: trackMock
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { _resetSecretStoreForTests, setSecretStore } from '../shared/secret-store'
|
||||
|
||||
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
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => cipherState.available,
|
||||
encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`),
|
||||
decryptString: (ciphertext) => ciphertext.toString().slice('encrypted:'.length),
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces an uninstalled secret store instead of degrading silently', async () => {
|
||||
// Why: a missing setSecretStore() is a startup bug. If the availability check
|
||||
// swallows it, encrypt() hands back an empty blob and decryptWithStatus() reports
|
||||
// 'unavailable' — a real secret silently not stored, which is the outcome the
|
||||
// port throws to prevent.
|
||||
const { ProtectedSecretPersistence } = await import('./protected-secret-persistence')
|
||||
const secrets = new ProtectedSecretPersistence()
|
||||
_resetSecretStoreForTests()
|
||||
|
||||
expect(() => secrets.encrypt('slot', 'token')).toThrow(/SecretStore not initialized/)
|
||||
})
|
||||
|
||||
it('evicts dynamic slots across repeated SSH recovery lifecycles', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { getSecretStore } from '../shared/secret-store'
|
||||
|
||||
export const PROTECTED_SECRET_SLOT = {
|
||||
opencodeSessionCookie: 'settings.opencodeSessionCookie',
|
||||
@@ -77,7 +77,7 @@ export class ProtectedSecretPersistence {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const blob = safeStorage.encryptString(plaintext).toString('base64')
|
||||
const blob = getSecretStore().encryptString(plaintext).toString('base64')
|
||||
return {
|
||||
blob,
|
||||
degraded: false,
|
||||
@@ -109,7 +109,7 @@ export class ProtectedSecretPersistence {
|
||||
}
|
||||
try {
|
||||
const decrypted = {
|
||||
plaintext: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')),
|
||||
plaintext: getSecretStore().decryptString(Buffer.from(ciphertext, 'base64')),
|
||||
status: 'decrypted' as const
|
||||
}
|
||||
this.sealedSlots.delete(slot)
|
||||
@@ -117,22 +117,27 @@ export class ProtectedSecretPersistence {
|
||||
} catch {
|
||||
if (isLegacyPlaintext?.(ciphertext)) {
|
||||
this.sealedSlots.delete(slot)
|
||||
console.warn('[persistence] safeStorage decryption failed; accepting legacy plaintext.')
|
||||
console.warn('[persistence] secret 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.'
|
||||
'[persistence] secret decryption failed; retaining the protected value without exposing it.'
|
||||
)
|
||||
return { plaintext: '', status: 'failed' }
|
||||
}
|
||||
}
|
||||
|
||||
private encryptionAvailable(): boolean {
|
||||
// Why getSecretStore() sits outside the try: an uninstalled store is a startup bug,
|
||||
// not a keyring failure. Swallowing it would degrade to an empty blob and report
|
||||
// 'unavailable' — the silent-wrong-state outcome the port throws to prevent. Only
|
||||
// the backend probe itself may fail softly.
|
||||
const store = getSecretStore()
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable()
|
||||
return store.isEncryptionAvailable()
|
||||
} catch (err) {
|
||||
console.warn('[persistence] safeStorage availability check failed:', err)
|
||||
console.warn('[persistence] secret store availability check failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ let tempHome = ''
|
||||
|
||||
async function loadStoreModule() {
|
||||
vi.resetModules()
|
||||
vi.doMock('electron', () => ({
|
||||
safeStorage: safeStorageMock
|
||||
}))
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
...safeStorageMock,
|
||||
describeUnavailable: () => null
|
||||
})
|
||||
vi.doMock('os', async () => {
|
||||
const actual = await vi.importActual<typeof Os>('os')
|
||||
return { ...actual, homedir: () => tempHome }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { safeStorage } from 'electron'
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -43,7 +43,7 @@ function readLegacyJsonStoredOpenAiKey(): StoredOpenAiKey | null {
|
||||
|
||||
export function hasOpenAiSpeechApiKey(): boolean {
|
||||
// Why: Settings and model-state refresh call this on startup; checking file
|
||||
// existence avoids decrypting safeStorage and triggering macOS keychain prompts.
|
||||
// existence avoids a decrypt that triggers macOS keychain prompts.
|
||||
return existsSync(getOpenAiKeyPath())
|
||||
}
|
||||
|
||||
@@ -53,15 +53,13 @@ export function saveOpenAiSpeechApiKey(apiKey: string): void {
|
||||
throw new Error('OpenAI API key is required')
|
||||
}
|
||||
ensureOrcaDir()
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
writeFileSync(getOpenAiKeyPath(), safeStorage.encryptString(trimmed), { mode: 0o600 })
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
writeFileSync(getOpenAiKeyPath(), getSecretStore().encryptString(trimmed), { mode: 0o600 })
|
||||
cachedOpenAiSpeechApiKey = trimmed
|
||||
return
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[speech] safeStorage encryption unavailable — storing OpenAI speech key in plaintext'
|
||||
)
|
||||
console.warn('[speech] secret encryption unavailable — storing OpenAI speech key in plaintext')
|
||||
writeFileSync(getOpenAiKeyPath(), trimmed, { encoding: 'utf8', mode: 0o600 })
|
||||
cachedOpenAiSpeechApiKey = trimmed
|
||||
}
|
||||
@@ -79,13 +77,13 @@ export function readOpenAiSpeechApiKey(): string {
|
||||
const raw = readFileSync(keyPath)
|
||||
const legacyJson = readLegacyJsonStoredOpenAiKey()
|
||||
if (legacyJson) {
|
||||
cachedOpenAiSpeechApiKey = safeStorage.decryptString(
|
||||
cachedOpenAiSpeechApiKey = getSecretStore().decryptString(
|
||||
Buffer.from(legacyJson.encryptedKeyBase64, 'base64')
|
||||
)
|
||||
return cachedOpenAiSpeechApiKey
|
||||
}
|
||||
cachedOpenAiSpeechApiKey = safeStorage.isEncryptionAvailable()
|
||||
? safeStorage.decryptString(raw)
|
||||
cachedOpenAiSpeechApiKey = getSecretStore().isEncryptionAvailable()
|
||||
? getSecretStore().decryptString(raw)
|
||||
: raw.toString('utf8')
|
||||
return cachedOpenAiSpeechApiKey
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
getSecretStore,
|
||||
hasSecretStore,
|
||||
_resetSecretStoreForTests,
|
||||
setSecretStore,
|
||||
type SecretStore
|
||||
} from './secret-store'
|
||||
|
||||
function fakeStore(overrides: Partial<SecretStore> = {}): SecretStore {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plainText) => Buffer.from(`sealed:${plainText}`),
|
||||
decryptString: (cipher) => cipher.toString().slice('sealed:'.length),
|
||||
describeUnavailable: () => null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('SecretStore registry', () => {
|
||||
beforeEach(() => {
|
||||
_resetSecretStoreForTests()
|
||||
})
|
||||
|
||||
it('throws until a store is installed, rather than defaulting to one that cannot seal', () => {
|
||||
expect(hasSecretStore()).toBe(false)
|
||||
expect(() => getSecretStore()).toThrow(/SecretStore not initialized/)
|
||||
})
|
||||
|
||||
it('returns the installed store', () => {
|
||||
const store = fakeStore()
|
||||
setSecretStore(store)
|
||||
expect(hasSecretStore()).toBe(true)
|
||||
expect(getSecretStore()).toBe(store)
|
||||
expect(getSecretStore().encryptString('token').toString()).toBe('sealed:token')
|
||||
})
|
||||
|
||||
it('lets a later install replace an earlier one, so a test fake wins over the global default', () => {
|
||||
setSecretStore(fakeStore())
|
||||
setSecretStore(fakeStore({ isEncryptionAvailable: () => false }))
|
||||
expect(getSecretStore().isEncryptionAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('carries a reason when sealing is unavailable, so the degradation can be surfaced', () => {
|
||||
setSecretStore(
|
||||
fakeStore({
|
||||
isEncryptionAvailable: () => false,
|
||||
describeUnavailable: () => 'The OS keyring is unavailable.'
|
||||
})
|
||||
)
|
||||
expect(getSecretStore().describeUnavailable()).toBe('The OS keyring is unavailable.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* SecretStore abstracts at-rest secret encryption that the desktop gets from
|
||||
* Electron's `safeStorage` (OS keychain). A plain-Node host installs its own
|
||||
* implementation so core modules never import `electron`.
|
||||
*
|
||||
* The contract mirrors safeStorage exactly, including the part that matters most:
|
||||
* `isEncryptionAvailable()` may return false. A store that cannot seal must say so
|
||||
* rather than throw; how a caller degrades is its own decision (persistence retains
|
||||
* the prior sealed blob rather than writing plaintext). See `describeUnavailable()`,
|
||||
* which exists so the reason reaches the user, not a console warning nobody reads.
|
||||
*/
|
||||
|
||||
export type SecretStore = {
|
||||
isEncryptionAvailable(): boolean
|
||||
encryptString(plainText: string): Buffer
|
||||
decryptString(cipher: Buffer): string
|
||||
/**
|
||||
* Why: "encryption unavailable" is a security posture, not a detail. When
|
||||
* `isEncryptionAvailable()` is false this returns a short, user-safe sentence
|
||||
* explaining which host facility is missing, for the degradation surface.
|
||||
* Returns null when encryption IS available.
|
||||
*/
|
||||
describeUnavailable(): string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the
|
||||
* re-imported graph a fresh copy of this module, so a store installed before the reset
|
||||
* would silently read back as uninstalled — and `getSecretStore()` throws on that.
|
||||
* Anchoring to the realm keeps one instance per process however often the module
|
||||
* registry is rebuilt.
|
||||
*/
|
||||
const SLOT = Symbol.for('orca.host.secretStore')
|
||||
|
||||
type Slot = { [SLOT]?: SecretStore | null }
|
||||
|
||||
function slot(): Slot {
|
||||
return globalThis as unknown as Slot
|
||||
}
|
||||
|
||||
function read(): SecretStore | null {
|
||||
return slot()[SLOT] ?? null
|
||||
}
|
||||
|
||||
export function setSecretStore(store: SecretStore): void {
|
||||
slot()[SLOT] = store
|
||||
}
|
||||
|
||||
export function getSecretStore(): SecretStore {
|
||||
const current = read()
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
'SecretStore not initialized — call setSecretStore() during startup before reading or writing secrets'
|
||||
)
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
export function hasSecretStore(): boolean {
|
||||
return read() !== null
|
||||
}
|
||||
|
||||
/** Test-only: drop the installed store so suites do not leak one across files. */
|
||||
export function _resetSecretStoreForTests(): void {
|
||||
slot()[SLOT] = null
|
||||
}
|
||||
Reference in New Issue
Block a user