mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
fix(orcad): seal integration credentials on the headless server instead of storing them in plaintext (#29412)
* fix(orcad): seal integration credentials on the headless server instead of storing them in plaintext The Node SecretStore always reported encryption unavailable, so every credential writer on orcad fell back to plaintext. orcad now seals with AES-256-GCM using a host-owned master key kept in the OS keyring (macOS Keychain, Linux Secret Service) when reachable at first start, else in a 0600 profile file bound to the machine ID. A key that cannot load fails closed instead of downgrading. Existing plaintext credentials are sealed on startup without loss. * docs: describe how the headless server seals credentials and its limits * fix(orcad): unique credential staging files, no keyring items for temp profiles, isolate startup test * fix(orcad): keep the credential key with the credentials, one record per machine * fix(orcad): never re-key over an unreadable record, serialize first-start key creation, refuse an unrestricted key file * fix(orcad): verify a key record's Windows ACL and wait out a concurrent first start's keyring write * fix(orcad): keep a keyring claim and fail closed when its store result is uncertain * fix(credentials): restrict the staged file's Windows ACL before it replaces a credential * fix(orcad): a temporary profile never creates a key in, or re-seals, the real credential home * ci: baseline the two single-file credential ACL restrictions * fix(credentials): stage Windows credentials born restricted and verify the key record asynchronously; document rollback * test(bitbucket): inject the write failure into the Windows staging writer too
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# How the headless server stores credentials
|
||||
|
||||
The desktop app seals saved credentials (Linear, Jira, Bitbucket, API keys) with Electron's
|
||||
`safeStorage`, which uses the OS keychain. The headless server (`orcad`, the managed SSH server)
|
||||
has no Electron, so it seals them itself, on the machine it runs on
|
||||
(`src/main/orcad/orcad-secret-store.ts`).
|
||||
|
||||
## Where the key lives
|
||||
|
||||
Each credential is encrypted with AES-256-GCM. The master key is created on first start and
|
||||
stored in one of two places. The choice is recorded in `~/.orca/orcad-secret-store-<machine>.json`.
|
||||
That is the directory the credentials live in, whatever `ORCA_USER_DATA` says, so the key lasts
|
||||
exactly as long as the credentials it seals. There is one record per machine, so hosts that share
|
||||
a home each keep their own key.
|
||||
|
||||
| Host | Where the key goes |
|
||||
| --- | --- |
|
||||
| macOS | The login Keychain (through `/usr/bin/security`) if the server can reach it on first start |
|
||||
| Linux | The Secret Service keyring (`secret-tool`) if the session has a D-Bus session bus and a keyring answers |
|
||||
| Otherwise, and on Windows | the record file itself, mode `0600` (an owner-only ACL on Windows), mixed with the machine ID (`/etc/machine-id`, macOS hardware UUID, Windows `MachineGuid`) |
|
||||
|
||||
The keyring is never used for a key directory under the system temp directory or `/tmp` (tests),
|
||||
or when `ORCA_HEADLESS_SECRET_STORE=file` is set. That setting suits a macOS host reached only
|
||||
over SSH, where the login Keychain is often locked.
|
||||
|
||||
A server started with `ORCA_USER_DATA` under a temporary directory (tests, smoke launchers)
|
||||
keeps the old behavior. It stores credentials unencrypted and never creates a key in the real
|
||||
`~/.orca`, or re-seals what is there.
|
||||
|
||||
Windows does not use DPAPI. Reaching it from Node takes PowerShell or a native addon, and running
|
||||
PowerShell for each operation is an EDR signal.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **The key file is weaker than a keychain.** The key sits next to the credentials, and the
|
||||
machine ID it is mixed with is not secret. Profile-state records include it, for example. So the
|
||||
key file mainly stops a single credential file from opening on another machine. Anyone who can
|
||||
read this account's files, or a backup of `~/.orca`, can unseal the credentials. Treat the key
|
||||
file like the credentials themselves. The server logs this gap once with the `[secrets]` prefix.
|
||||
A keychain does not protect against other processes running as the same unlocked user either.
|
||||
- **The key belongs to the host.** If the recorded keyring is locked or can't be reached, or the
|
||||
record is unreadable, the server does not fall back to plaintext. It leaves the saved credentials
|
||||
alone and refuses new saves until the key loads again. If the key is gone for good, deleting the
|
||||
record starts over, and every integration has to be connected again. A new machine ID counts as
|
||||
a new machine, so it gets a new key, and credentials sealed under the old one have to be
|
||||
connected again.
|
||||
- **Shared home directories.** Integration credentials live in `~/.orca` under the login home.
|
||||
Hosts that share that home (over NFS, for example), or a desktop app on the same machine, each
|
||||
seal with their own key, so a credential one of them saves can't be read by the others. To keep
|
||||
the old shared plaintext behavior on such hosts, set `ORCA_HEADLESS_SECRET_STORE=plaintext`.
|
||||
- **Rolling back to an older server.** A server from before this change can't read credentials a
|
||||
newer one sealed. An SSH or WSL rollback restores profile state, not `~/.orca`, so after a
|
||||
rollback the old server shows "Could not decrypt saved Linear credential… reconnect Linear".
|
||||
Connect each integration again on that server. Plaintext copies are deliberately not kept for
|
||||
rollback.
|
||||
- **GitHub and GitLab** tokens are not stored by Orca at all. `gh` and `glab` keep them, under
|
||||
their own storage settings.
|
||||
|
||||
## Migration
|
||||
|
||||
On startup, once the key loads, the server seals every credential that an older version stored in
|
||||
plaintext. Each file is rewritten only after the new value has been decrypted and checked, and
|
||||
only if no other process changed the file in the meantime. A file that can't be sealed is left as
|
||||
it was. Readers still accept the legacy plaintext form, so no credential is lost.
|
||||
@@ -1,7 +1,8 @@
|
||||
import { existsSync, mkdtempSync, statSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readdirSync, statSync } from 'node:fs'
|
||||
import type * as Fs from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import type * as WindowsAcl from '../../shared/secure-path-windows-acl'
|
||||
import { join } from 'node:path'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -19,6 +20,7 @@ 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.doUnmock('../../shared/secure-path-windows-acl')
|
||||
const { setSecretStore } = await import('../../shared/secret-store')
|
||||
setSecretStore({
|
||||
isEncryptionAvailable: () => true,
|
||||
@@ -53,6 +55,13 @@ async function loadStore(
|
||||
}
|
||||
}
|
||||
})
|
||||
// Why: on Windows the staged credential is written by the ACL helper, not writeSync.
|
||||
vi.doMock('../../shared/secure-path-windows-acl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WindowsAcl>()),
|
||||
writeRestrictedWindowsFile: () => {
|
||||
throw error
|
||||
}
|
||||
}))
|
||||
}
|
||||
if (options.unlinkError) {
|
||||
// Why mocked: chmod-based failure injection is not portable — Windows has no
|
||||
@@ -235,7 +244,7 @@ describe('Bitbucket credential store', () => {
|
||||
).toThrow(/disk full/)
|
||||
|
||||
expect(readFileSync(join(tempHome, '.orca', 'bitbucket-credential.enc'))).toEqual(before)
|
||||
expect(existsSync(join(tempHome, '.orca', 'bitbucket-credential.enc.tmp'))).toBe(false)
|
||||
expect(readdirSync(join(tempHome, '.orca')).filter((name) => name.endsWith('.tmp'))).toEqual([])
|
||||
})
|
||||
|
||||
it('authenticates from the envelope when metadata is stale (STA-3941)', async () => {
|
||||
|
||||
@@ -58,7 +58,7 @@ function getMetadataPath(): string {
|
||||
return join(getOrcaDir(), 'bitbucket-credential.json')
|
||||
}
|
||||
|
||||
function getSecretPath(): string {
|
||||
export function getSecretPath(): string {
|
||||
return join(getOrcaDir(), 'bitbucket-credential.enc')
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protect
|
||||
import { ApiKeyFileUnreadableError } from './api-key-file-unreadable-error'
|
||||
|
||||
type EncryptedApiKeyFileStore = {
|
||||
/** Where the envelope lives and how it starts, for orcad's plaintext migration. */
|
||||
envelopeFile: () => { path: string; envelopePrefix: string }
|
||||
protection: () => SecretAtRestProtection | null
|
||||
has: () => boolean
|
||||
save: (key: string) => void
|
||||
@@ -173,5 +175,12 @@ export function createEncryptedApiKeyFileStore({
|
||||
rmSync(getApiKeyPath(), { force: true })
|
||||
}
|
||||
|
||||
return { has, save, read, clear, protection }
|
||||
return {
|
||||
has,
|
||||
save,
|
||||
read,
|
||||
clear,
|
||||
protection,
|
||||
envelopeFile: () => ({ path: getApiKeyPath(), envelopePrefix })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const created = vi.hoisted((): string[] => [])
|
||||
vi.mock('../shared/secure-path-windows-acl', () => ({
|
||||
writeRestrictedWindowsFile: (path: string, contents: string | Buffer) => {
|
||||
created.push(path)
|
||||
writeFileSync(path, contents)
|
||||
return true
|
||||
}
|
||||
}))
|
||||
|
||||
describe('writeCredentialFileAtomic on Windows', () => {
|
||||
const platform = process.platform
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
})
|
||||
|
||||
it('stages the replacement as a born-restricted file, so the owner-only ACL survives', async () => {
|
||||
const { writeCredentialFileAtomic } = await import('./integration-credential-file')
|
||||
const dir = mkdtempSync(join(tmpdir(), 'credential-acl-'))
|
||||
try {
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
const target = join(dir, 'linear-token.enc')
|
||||
expect(writeCredentialFileAtomic(target, Buffer.from([0, 1, 2, 3]))).toBe(true)
|
||||
expect(created).toHaveLength(1)
|
||||
expect(created[0]).toMatch(/linear-token\.enc\..+\.tmp$/)
|
||||
expect(readFileSync(target)).toEqual(Buffer.from([0, 1, 2, 3]))
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
@@ -9,6 +10,8 @@ import {
|
||||
writeSync
|
||||
} from 'node:fs'
|
||||
import { getSecretStore } from '../shared/secret-store'
|
||||
import { fsyncFileSync } from '../shared/secure-file'
|
||||
import { writeRestrictedWindowsFile } from '../shared/secure-path-windows-acl'
|
||||
import {
|
||||
credentialDecryptionMessage,
|
||||
type IntegrationCredentialService
|
||||
@@ -45,10 +48,22 @@ export function writeEncryptedCredential(
|
||||
// fails or the app dies mid-write, leaving nothing to authenticate with. Write
|
||||
// a sibling temp file, fsync it, then rename — readers only ever observe the
|
||||
// old bytes or the complete new ones.
|
||||
export function writeCredentialFileAtomic(path: string, data: Buffer): void {
|
||||
const tempPath = `${path}.tmp`
|
||||
export function writeCredentialFileAtomic(
|
||||
path: string,
|
||||
data: Buffer,
|
||||
shouldPublish?: () => boolean
|
||||
): boolean {
|
||||
// Why unique: a concurrent writer sharing one temp name could truncate the bytes we publish.
|
||||
const tempPath = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
|
||||
let handle: number | null = null
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// Why born restricted: a sibling would otherwise inherit the directory's DACL and replace
|
||||
// the previous credential's owner-only ACL.
|
||||
writeRestrictedWindowsFile(tempPath, data)
|
||||
fsyncFileSync(tempPath)
|
||||
return publishStagedCredential(tempPath, path, shouldPublish)
|
||||
}
|
||||
handle = openSync(tempPath, 'w', 0o600)
|
||||
// Why: write(2) is allowed to return a short count, so a single writeSync
|
||||
// could publish a truncated credential — the very corruption this avoids.
|
||||
@@ -63,8 +78,7 @@ export function writeCredentialFileAtomic(path: string, data: Buffer): void {
|
||||
fsyncSync(handle)
|
||||
closeSync(handle)
|
||||
handle = null
|
||||
renameSync(tempPath, path)
|
||||
restrictCredentialFileToOwner(path)
|
||||
return publishStagedCredential(tempPath, path, shouldPublish)
|
||||
} catch (error) {
|
||||
if (handle !== null) {
|
||||
try {
|
||||
@@ -82,9 +96,23 @@ export function writeCredentialFileAtomic(path: string, data: Buffer): void {
|
||||
}
|
||||
}
|
||||
|
||||
function publishStagedCredential(
|
||||
tempPath: string,
|
||||
path: string,
|
||||
shouldPublish: (() => boolean) | undefined
|
||||
): boolean {
|
||||
if (shouldPublish && !shouldPublish()) {
|
||||
unlinkSync(tempPath)
|
||||
return false
|
||||
}
|
||||
renameSync(tempPath, path)
|
||||
restrictCredentialFileToOwner(path)
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: writeFileSync's `mode` only applies when it creates the file, so
|
||||
// rewriting an existing credential would silently keep looser permissions.
|
||||
// Best-effort: Windows has no POSIX modes, so chmod is a documented no-op there.
|
||||
// Best-effort: Windows has no POSIX modes; its staging file is created owner-only instead.
|
||||
export function restrictCredentialFileToOwner(path: string): void {
|
||||
try {
|
||||
chmodSync(path, 0o600)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import { writeEncryptedCredential } from '../integration-credential-file'
|
||||
import { readCredentialFileProtection } from '../credential-file-protection'
|
||||
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
||||
import type { JiraSite, JiraSiteSelection } from '../../shared/jira-types'
|
||||
@@ -34,7 +34,7 @@ function getSiteFilePath(): string {
|
||||
return join(getOrcaDir(), 'jira-sites.json')
|
||||
}
|
||||
|
||||
function getTokenDir(): string {
|
||||
export function getTokenDir(): string {
|
||||
return join(getOrcaDir(), 'jira-tokens')
|
||||
}
|
||||
|
||||
@@ -168,15 +168,6 @@ export function writeSiteFile(file: JiraSiteFile): void {
|
||||
siteFileCache.invalidate()
|
||||
}
|
||||
|
||||
function writeEncryptedToken(path: string, apiToken: string): void {
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
writeFileSync(path, getSecretStore().encryptString(apiToken), { mode: 0o600 })
|
||||
return
|
||||
}
|
||||
console.warn('[jira] secret encryption unavailable — storing token in plaintext')
|
||||
writeFileSync(path, apiToken, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
export function readToken(siteId: string): string | null {
|
||||
return tokenFileCache.read(siteId)
|
||||
}
|
||||
@@ -189,7 +180,7 @@ export function getSiteTokenProtection(siteId: string): SecretAtRestProtection |
|
||||
export function saveToken(siteId: string, apiToken: string): void {
|
||||
ensureOrcaDir()
|
||||
ensureTokenDir()
|
||||
writeEncryptedToken(getTokenPath(siteId), apiToken)
|
||||
writeEncryptedCredential('Jira', getTokenPath(siteId), apiToken)
|
||||
// The next action reads the actual file, including any overlapping writer's replacement.
|
||||
tokenFileCache.forget(siteId)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getWorkspaceFilePath(): string {
|
||||
return join(getOrcaDir(), 'linear-workspaces.json')
|
||||
}
|
||||
|
||||
function getWorkspaceTokenDir(): string {
|
||||
export function getWorkspaceTokenDir(): string {
|
||||
return join(getOrcaDir(), 'linear-tokens')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import { unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { unlinkSync } from 'node:fs'
|
||||
import { writeEncryptedCredential } from '../integration-credential-file'
|
||||
import {
|
||||
LEGACY_WORKSPACE_ID,
|
||||
ensureOrcaDir,
|
||||
@@ -26,24 +26,14 @@ import type { LinearWorkspace } from '../../shared/linear/workspace-types'
|
||||
import { readCredentialFileProtection } from '../credential-file-protection'
|
||||
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
||||
|
||||
function writeEncryptedToken(path: string, apiKey: string): void {
|
||||
if (getSecretStore().isEncryptionAvailable()) {
|
||||
const encrypted = getSecretStore().encryptString(apiKey)
|
||||
writeFileSync(path, encrypted, { mode: 0o600 })
|
||||
return
|
||||
}
|
||||
|
||||
console.warn('[linear] secret encryption unavailable — storing token in plaintext')
|
||||
writeFileSync(path, apiKey, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
export function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
|
||||
ensureOrcaDir()
|
||||
if (workspaceId !== LEGACY_WORKSPACE_ID) {
|
||||
ensureWorkspaceTokenDir()
|
||||
}
|
||||
const tokenPath = getWorkspaceTokenPath(workspaceId)
|
||||
writeEncryptedToken(tokenPath, apiKey)
|
||||
// Why the shared writer: it re-applies 0600 to an existing file and never truncates it mid-write.
|
||||
writeEncryptedCredential('Linear', tokenPath, apiKey)
|
||||
workspaceTokenFileCache.forget(workspaceId)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,3 +12,4 @@ export const getMiniMaxApiKeyProtection = store.protection
|
||||
export const saveMiniMaxApiKey = store.save
|
||||
export const readMiniMaxApiKey = store.read
|
||||
export const clearMiniMaxApiKey = store.clear
|
||||
export const miniMaxApiKeyEnvelopeFile = store.envelopeFile
|
||||
|
||||
@@ -6,7 +6,7 @@ import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-f
|
||||
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
||||
|
||||
const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc'
|
||||
const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
|
||||
export const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:'
|
||||
let cachedMiniMaxCookie: string | null = null
|
||||
let warnedMiniMaxCookieStatusHardenFailure = false
|
||||
|
||||
@@ -19,7 +19,7 @@ function getOrcaDir(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
function getMiniMaxCookiePath(): string {
|
||||
export function getMiniMaxCookiePath(): string {
|
||||
return join(getOrcaDir(), MINIMAX_COOKIE_FILE)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,3 +11,4 @@ export const hasOpenCodeGoApiKey = store.has
|
||||
export const saveOpenCodeGoApiKey = store.save
|
||||
export const readOpenCodeGoApiKey = store.read
|
||||
export const clearOpenCodeGoApiKey = store.clear
|
||||
export const openCodeGoApiKeyEnvelopeFile = store.envelopeFile
|
||||
|
||||
@@ -29,8 +29,8 @@ export type OrcadDataRootPrivacyHooks = {
|
||||
/**
|
||||
* Fail closed on a data root other identities can read or write.
|
||||
*
|
||||
* Why self-heal first and refuse second: orcad stores credentials unsealed (there is no OS
|
||||
* keyring on this host), so a group- or world-accessible root is a real exposure — but if
|
||||
* Why self-heal first and refuse second: this root holds session and credential state (and, at the
|
||||
* default `~/.orca`, the credential key), so a group- or world-accessible root is a real exposure — but if
|
||||
* we own the directory, tightening it is strictly better than refusing to start. We refuse
|
||||
* only when the permissions are not ours to fix.
|
||||
*/
|
||||
@@ -46,8 +46,8 @@ export function assertOrcadDataRootIsPrivate(
|
||||
if (!restrict(dataRoot)) {
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`Could not restrict the orcad data root ${dataRoot} to this user. orcad stores ` +
|
||||
'credentials there unsealed, so it refuses to start. Point ORCA_USER_DATA at a ' +
|
||||
`Could not restrict the orcad data root ${dataRoot} to this user. orcad keeps ` +
|
||||
'private session and credential state there, so it refuses to start. Point ORCA_USER_DATA at a ' +
|
||||
'directory this account owns.'
|
||||
)
|
||||
}
|
||||
@@ -91,8 +91,8 @@ export function assertOrcadDataRootIsPrivate(
|
||||
throw new OrcadInstanceLockError(
|
||||
'orcad_data_root_shared',
|
||||
`The orcad data root ${dataRoot} is accessible to other users (mode ` +
|
||||
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad stores credentials ` +
|
||||
'there unsealed, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
|
||||
`${(mode & 0o777).toString(8)}) and could not be tightened. orcad keeps ` +
|
||||
'private session and credential state there, so it refuses to start. Run `chmod 700` on it, or point ORCA_USER_DATA ' +
|
||||
'at a private directory.'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,17 @@
|
||||
* the runtime factory, but only when an Electron serve sidecar or an operator-supplied
|
||||
* Chromium proves available at startup.
|
||||
*/
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import type { ServeReadiness } from '../server/serve-readiness'
|
||||
import { resolveOrcadInstallRoot, resolveUserDataPath } from './orcad-app-paths'
|
||||
import { prepareOrcadCliLauncher } from './orcad-cli-launcher'
|
||||
import { installOrcadHostAdapters, runOrcadQuitHandlers } from './orcad-host-adapters'
|
||||
import {
|
||||
installOrcadHostAdapters,
|
||||
prepareOrcadSecretStore,
|
||||
runOrcadQuitHandlers
|
||||
} from './orcad-host-adapters'
|
||||
import { describeOrcadBindExposure, resolveOrcadBindHost } from './orcad-bind-address'
|
||||
import {
|
||||
flushOrcadProfileStoreForShutdown,
|
||||
@@ -92,6 +97,18 @@ export async function startOrcad(options: OrcadOptions = {}): Promise<OrcadHandl
|
||||
return { readiness, managedStop: { version, runtimeId: readiness.runtimeId, instance }, stop }
|
||||
}
|
||||
|
||||
async function prepareOrcadSecretStoreAndMigrate(userDataPath: string): Promise<void> {
|
||||
const { getSecretStore } = await import('../../shared/secret-store')
|
||||
const { reportSecretProtectionGap } = await import('../host/secret-protection-report')
|
||||
const { migratePlaintextCredentials, orcadCredentialRoot } =
|
||||
await import('./orcad-plaintext-credential-migration')
|
||||
// Why the credential root, not userData: a disposable ORCA_USER_DATA would take the key with it.
|
||||
if (await prepareOrcadSecretStore(orcadCredentialRoot(), userDataPath)) {
|
||||
migratePlaintextCredentials(getSecretStore())
|
||||
}
|
||||
reportSecretProtectionGap({ dataFile: join(userDataPath, 'orca-data.json') })
|
||||
}
|
||||
|
||||
async function startOrcadRuntime(
|
||||
options: OrcadOptions,
|
||||
registerCleanup: (cleanup: OrcadRuntimeCleanup) => void
|
||||
@@ -162,6 +179,8 @@ async function startOrcadRuntime(
|
||||
console.warn('[orcad] Could not prepare the profile CLI launcher', error)
|
||||
})
|
||||
const idleExitStartup = beginOrcadIdleExit(runtimeUserDataPath)
|
||||
// Why before the profile store: it unseals protected settings as it loads.
|
||||
await prepareOrcadSecretStoreAndMigrate(runtimeUserDataPath)
|
||||
const { store: profileStore, authority: profileStateAuthority } =
|
||||
await createOrcadProfileStateStartup(runtimeUserDataPath)
|
||||
const observedPaneIdentities = new AgentStatusObservedPaneIdentities()
|
||||
|
||||
@@ -4,10 +4,10 @@ import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { ORCAD_CLI_PACKAGE_FILENAME } from '../../shared/orcad-artifacts'
|
||||
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
|
||||
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
|
||||
import { setSecretStore } from '../../shared/secret-store'
|
||||
import { resolveOrcadInstallRoot, resolveOrcadPath } from './orcad-app-paths'
|
||||
import { getOrcadCliLauncherPath } from './orcad-cli-launcher'
|
||||
import { describeOrcadSecretProtectionGap } from './orcad-secret-protection-gap'
|
||||
import { createOrcadSecretStore, type OrcadSecretStore } from './orcad-secret-store'
|
||||
|
||||
let quitRegisteredHandlers = (): void => {}
|
||||
|
||||
@@ -51,22 +51,17 @@ function createNodeAppEnvironment(): AppEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Why not silently plaintext: `isEncryptionAvailable() === false` already makes every
|
||||
* caller fall back to unsealed storage, which is a security posture, not a detail.
|
||||
* `describeProtectionGap()` gives the reason a client can surface.
|
||||
*/
|
||||
function createNodeSecretStore(): SecretStore {
|
||||
return {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: () => {
|
||||
throw new Error('orcad_secret_sealing_unavailable')
|
||||
},
|
||||
decryptString: () => {
|
||||
throw new Error('orcad_secret_sealing_unavailable')
|
||||
},
|
||||
describeProtectionGap: () => describeOrcadSecretProtectionGap(process.platform)
|
||||
let orcadSecretStore: OrcadSecretStore | null = null
|
||||
|
||||
/** Loads the sealing key kept in `keyDir`; resolves whether sealing works. */
|
||||
export async function prepareOrcadSecretStore(
|
||||
keyDir: string,
|
||||
userDataPath: string
|
||||
): Promise<boolean> {
|
||||
if (!orcadSecretStore) {
|
||||
throw new Error('orcad_secret_store_not_installed — call installOrcadHostAdapters() first')
|
||||
}
|
||||
return orcadSecretStore.prepare({ keyDir, userDataPath })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,5 +86,6 @@ export function stampOrcadAppVersion(installRoot: () => string = resolveOrcadIns
|
||||
export function installOrcadHostAdapters(): void {
|
||||
stampOrcadAppVersion()
|
||||
setAppEnvironment(createNodeAppEnvironment())
|
||||
setSecretStore(createNodeSecretStore())
|
||||
orcadSecretStore = createOrcadSecretStore()
|
||||
setSecretStore(orcadSecretStore)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
import { resolveOrcadOsKeyring } from './orcad-os-keyring'
|
||||
|
||||
function result(partial: Partial<ProcessResult>): ProcessResult {
|
||||
return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false, ...partial }
|
||||
}
|
||||
|
||||
describe('resolveOrcadOsKeyring', () => {
|
||||
it('uses no keyring on Windows, or on Linux without a session bus', () => {
|
||||
expect(resolveOrcadOsKeyring('win32', {})).toBeNull()
|
||||
expect(resolveOrcadOsKeyring('linux', {})).toBeNull()
|
||||
expect(resolveOrcadOsKeyring('linux', { DBUS_SESSION_BUS_ADDRESS: 'unix:path=/x' })).not.toBe(
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('macOS: tells a missing item apart from a locked or slow Keychain', async () => {
|
||||
const runner = vi.fn<(spec: ProcessSpec) => Promise<ProcessResult>>()
|
||||
const keychain = resolveOrcadOsKeyring('darwin', {}, runner)!
|
||||
runner.mockResolvedValueOnce(result({ stdout: 'a2V5\n' }))
|
||||
expect(await keychain.lookup('acct')).toEqual({ kind: 'found', value: 'a2V5' })
|
||||
runner.mockResolvedValueOnce(result({ code: 44 }))
|
||||
expect(await keychain.lookup('acct')).toEqual({ kind: 'missing' })
|
||||
runner.mockResolvedValueOnce(result({ code: 36, stderr: 'User interaction is not allowed.' }))
|
||||
expect((await keychain.lookup('acct')).kind).toBe('unreachable')
|
||||
runner.mockResolvedValueOnce(result({ code: null, timedOut: true }))
|
||||
expect(await keychain.lookup('acct')).toEqual({
|
||||
kind: 'unreachable',
|
||||
reason: 'timed out after 5s'
|
||||
})
|
||||
expect(runner.mock.calls.every(([spec]) => spec.timeoutMs === 5_000)).toBe(true)
|
||||
})
|
||||
|
||||
it('macOS: never puts the key on argv, where every user can read it in ps', async () => {
|
||||
const runner = vi.fn(async (_spec: ProcessSpec) => result({}))
|
||||
await resolveOrcadOsKeyring('darwin', {}, runner)!.store('acct', 'SECRETKEY=')
|
||||
const [spec] = runner.mock.calls[0]
|
||||
expect(spec.args).toEqual(['-i'])
|
||||
expect(spec.input).toContain('-w SECRETKEY=')
|
||||
// Never -U: an existing item must not be replaced.
|
||||
expect(spec.input).not.toContain('-U')
|
||||
})
|
||||
|
||||
it('Linux: passes the key on stdin and treats a silent exit 1 as missing', async () => {
|
||||
const runner = vi.fn<(spec: ProcessSpec) => Promise<ProcessResult>>()
|
||||
const keyring = resolveOrcadOsKeyring('linux', { DBUS_SESSION_BUS_ADDRESS: 'x' }, runner)!
|
||||
runner.mockResolvedValueOnce(result({}))
|
||||
expect(await keyring.store('acct', 'SECRETKEY=')).toBe(true)
|
||||
expect(runner.mock.calls[0][0].args).not.toContain('SECRETKEY=')
|
||||
expect(runner.mock.calls[0][0].input).toBe('SECRETKEY=')
|
||||
runner.mockResolvedValueOnce(result({ code: 1 }))
|
||||
expect(await keyring.lookup('acct')).toEqual({ kind: 'missing' })
|
||||
runner.mockResolvedValueOnce(
|
||||
result({ code: 1, stderr: 'Cannot create an item in a locked collection' })
|
||||
)
|
||||
expect((await keyring.lookup('acct')).kind).toBe('unreachable')
|
||||
runner.mockRejectedValueOnce(
|
||||
Object.assign(new Error('spawn secret-tool ENOENT'), { code: 'ENOENT' })
|
||||
)
|
||||
expect(await keyring.lookup('acct')).toEqual({
|
||||
kind: 'unreachable',
|
||||
reason: 'spawn secret-tool ENOENT'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* The OS keyring orcad keeps its master key in, when the host has one it can reach.
|
||||
*
|
||||
* macOS: the login Keychain through `/usr/bin/security`. Linux: the Secret Service through
|
||||
* `secret-tool`, only with a session bus (a bare SSH login has none, and dbus autolaunch can
|
||||
* hang). Windows: none — DPAPI needs PowerShell or a native addon, and per-operation
|
||||
* PowerShell is an EDR signal (see AGENTS.md), so Windows uses the profile key file.
|
||||
*/
|
||||
import { runProcess } from '@orca/process-host'
|
||||
import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec'
|
||||
|
||||
const SERVICE = 'orca-headless-secret-store'
|
||||
const KEYRING_TIMEOUT_MS = 5_000
|
||||
const MAX_OUTPUT_BYTES = 4096
|
||||
|
||||
export type OrcadKeyringRunner = (spec: ProcessSpec) => Promise<ProcessResult>
|
||||
|
||||
/** `missing`: the keyring answered and has no item. `unreachable`: absent, locked, or timed out. */
|
||||
export type OrcadKeyringLookup =
|
||||
| { kind: 'found'; value: string }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'unreachable'; reason: string }
|
||||
|
||||
export type OrcadOsKeyring = {
|
||||
label: string
|
||||
lookup(account: string): Promise<OrcadKeyringLookup>
|
||||
/** Resolves true only when the keyring reports success; callers still read the item back. */
|
||||
store(account: string, value: string): Promise<boolean>
|
||||
}
|
||||
|
||||
function describeFailure(result: ProcessResult): string {
|
||||
if (result.timedOut) {
|
||||
return `timed out after ${KEYRING_TIMEOUT_MS / 1000}s`
|
||||
}
|
||||
// Why the first line only: stderr is diagnostics, never the secret (it travels on stdin).
|
||||
const detail = result.stderr.trim().split('\n')[0]?.slice(0, 200)
|
||||
return detail ? `exited ${result.code}: ${detail}` : `exited ${result.code}`
|
||||
}
|
||||
|
||||
async function run(runner: OrcadKeyringRunner, spec: ProcessSpec): Promise<ProcessResult | string> {
|
||||
try {
|
||||
return await runner({
|
||||
...spec,
|
||||
timeoutMs: KEYRING_TIMEOUT_MS,
|
||||
maxOutputBytes: MAX_OUTPUT_BYTES
|
||||
})
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
function macosKeychain(runner: OrcadKeyringRunner): OrcadOsKeyring {
|
||||
const program = '/usr/bin/security'
|
||||
return {
|
||||
label: 'the macOS Keychain',
|
||||
async lookup(account) {
|
||||
const result = await run(runner, {
|
||||
program,
|
||||
args: ['find-generic-password', '-s', SERVICE, '-a', account, '-w']
|
||||
})
|
||||
if (typeof result === 'string') {
|
||||
return { kind: 'unreachable', reason: result }
|
||||
}
|
||||
if (result.code === 0 && result.stdout.trim()) {
|
||||
return { kind: 'found', value: result.stdout.trim() }
|
||||
}
|
||||
// errSecItemNotFound.
|
||||
return result.code === 44
|
||||
? { kind: 'missing' }
|
||||
: { kind: 'unreachable', reason: describeFailure(result) }
|
||||
},
|
||||
async store(account, value) {
|
||||
// Why `security -i` over stdin: `-w <value>` on argv is visible to every user in `ps`.
|
||||
// Why no -U: an item that already exists must never be replaced under a sealed record.
|
||||
const result = await run(runner, {
|
||||
program,
|
||||
args: ['-i'],
|
||||
input: `add-generic-password -s ${SERVICE} -a ${account} -w ${value}\n`
|
||||
})
|
||||
return typeof result !== 'string' && result.code === 0 && !result.timedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function linuxSecretService(runner: OrcadKeyringRunner): OrcadOsKeyring {
|
||||
const attributes = (account: string): string[] => ['service', SERVICE, 'account', account]
|
||||
return {
|
||||
label: 'the Secret Service keyring',
|
||||
async lookup(account) {
|
||||
const result = await run(runner, {
|
||||
program: 'secret-tool',
|
||||
args: ['lookup', ...attributes(account)]
|
||||
})
|
||||
if (typeof result === 'string') {
|
||||
return { kind: 'unreachable', reason: result }
|
||||
}
|
||||
if (result.code === 0 && result.stdout.trim()) {
|
||||
return { kind: 'found', value: result.stdout.trim() }
|
||||
}
|
||||
// Why: secret-tool exits 1 silently for "no such item" and prints a reason otherwise.
|
||||
return result.code === 1 && !result.stderr.trim() && !result.timedOut
|
||||
? { kind: 'missing' }
|
||||
: { kind: 'unreachable', reason: describeFailure(result) }
|
||||
},
|
||||
async store(account, value) {
|
||||
const result = await run(runner, {
|
||||
program: 'secret-tool',
|
||||
args: ['store', '--label', 'Orca headless server secret key', ...attributes(account)],
|
||||
input: value
|
||||
})
|
||||
return typeof result !== 'string' && result.code === 0 && !result.timedOut
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOrcadOsKeyring(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
runner: OrcadKeyringRunner = runProcess
|
||||
): OrcadOsKeyring | null {
|
||||
if (platform === 'darwin') {
|
||||
return macosKeychain(runner)
|
||||
}
|
||||
if (platform === 'linux' && env.DBUS_SESSION_BUS_ADDRESS) {
|
||||
return linuxSecretService(runner)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { SecretStore } from '../../shared/secret-store'
|
||||
import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store'
|
||||
import { readStoredCredentialToken } from '../integration-credential-file'
|
||||
import { migratePlaintextCredentials } from './orcad-plaintext-credential-migration'
|
||||
import { orcadSecretStateFileName } from './orcad-secret-key-record'
|
||||
import { createOrcadSecretStore } from './orcad-secret-store'
|
||||
|
||||
describe('migratePlaintextCredentials', () => {
|
||||
let dir: string
|
||||
let store: SecretStore
|
||||
const logs: string[] = []
|
||||
const log = (message: string): void => {
|
||||
logs.push(message)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orcad-credential-migration-'))
|
||||
const orcadStore = createOrcadSecretStore()
|
||||
await orcadStore.prepare({
|
||||
keyDir: dir,
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
keyring: null,
|
||||
machineIdentity: () => '0123456789abcdef0123456789abcdef'
|
||||
})
|
||||
store = orcadStore
|
||||
setSecretStore(store)
|
||||
logs.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetSecretStoreForTests()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('seals a legacy plaintext token in place without losing it, and tightens its mode', () => {
|
||||
const path = join(dir, 'linear-token.enc')
|
||||
writeFileSync(path, 'lin_api_plaintext', 'utf8')
|
||||
chmodSync(path, 0o644)
|
||||
|
||||
expect(migratePlaintextCredentials(store, [{ path, raw: true }], log)).toEqual({
|
||||
sealed: 1,
|
||||
failed: 0
|
||||
})
|
||||
const raw = readFileSync(path)
|
||||
expect(raw.includes(Buffer.from('lin_api_plaintext'))).toBe(false)
|
||||
expect(readStoredCredentialToken('Linear', raw)).toBe('lin_api_plaintext')
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(path).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
expect(logs.join('\n')).not.toContain('lin_api_plaintext')
|
||||
})
|
||||
|
||||
it("stages in its own temp file, never a concurrent writer's shared one", () => {
|
||||
const path = join(dir, 'linear-token.enc')
|
||||
writeFileSync(path, 'lin_api_plaintext', 'utf8')
|
||||
writeFileSync(`${path}.tmp`, 'other-writer-in-progress', 'utf8')
|
||||
|
||||
migratePlaintextCredentials(store, [{ path, raw: true }], log)
|
||||
expect(readFileSync(`${path}.tmp`, 'utf8')).toBe('other-writer-in-progress')
|
||||
expect(readStoredCredentialToken('Linear', readFileSync(path))).toBe('lin_api_plaintext')
|
||||
})
|
||||
|
||||
it('rewrites a plaintext envelope as an encrypted one', () => {
|
||||
const path = join(dir, 'minimax-api-key.enc')
|
||||
const prefix = 'orca-minimax-api-key:v1:'
|
||||
writeFileSync(path, `${prefix}plaintext:${Buffer.from('mm-key').toString('base64')}`)
|
||||
|
||||
migratePlaintextCredentials(store, [{ path, envelopePrefix: prefix }], log)
|
||||
const text = readFileSync(path, 'utf8')
|
||||
expect(text.startsWith(`${prefix}encrypted:`)).toBe(true)
|
||||
expect(
|
||||
store.decryptString(Buffer.from(text.slice(`${prefix}encrypted:`.length), 'base64'))
|
||||
).toBe('mm-key')
|
||||
})
|
||||
|
||||
it('leaves sealed files, foreign ciphertext, and the speech JSON format alone', () => {
|
||||
const sealedPath = join(dir, 'sealed.enc')
|
||||
const sealed = store.encryptString('already')
|
||||
writeFileSync(sealedPath, sealed)
|
||||
const foreignPath = join(dir, 'foreign.enc')
|
||||
const foreign = Buffer.from([0x76, 0x31, 0x30, 0x00, 0xff, 0x10])
|
||||
writeFileSync(foreignPath, foreign)
|
||||
|
||||
expect(
|
||||
migratePlaintextCredentials(
|
||||
store,
|
||||
[
|
||||
{ path: sealedPath, raw: true },
|
||||
{ path: foreignPath, raw: true },
|
||||
{ path: join(dir, 'absent.enc'), raw: true }
|
||||
],
|
||||
log
|
||||
)
|
||||
).toEqual({ sealed: 0, failed: 0 })
|
||||
expect(readFileSync(sealedPath)).toEqual(sealed)
|
||||
expect(readFileSync(foreignPath)).toEqual(foreign)
|
||||
})
|
||||
|
||||
it('leaves the plaintext file intact when sealing fails', async () => {
|
||||
const path = join(dir, 'jira.enc')
|
||||
writeFileSync(path, 'jira-plaintext', 'utf8')
|
||||
const otherMachine = 'fedcba9876543210fedcba9876543210'
|
||||
// An unreadable key record makes the store refuse to seal.
|
||||
writeFileSync(join(dir, orcadSecretStateFileName(otherMachine)), '{', 'utf8')
|
||||
const locked = createOrcadSecretStore()
|
||||
await locked.prepare({
|
||||
keyDir: dir,
|
||||
platform: 'linux',
|
||||
env: {},
|
||||
keyring: null,
|
||||
machineIdentity: () => otherMachine
|
||||
})
|
||||
|
||||
expect(migratePlaintextCredentials(locked, [{ path, raw: true }], log)).toEqual({
|
||||
sealed: 0,
|
||||
failed: 1
|
||||
})
|
||||
expect(readFileSync(path, 'utf8')).toBe('jira-plaintext')
|
||||
expect(logs.join('\n')).not.toContain('jira-plaintext')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Seal integration credentials an older orcad wrote in plaintext, once its key is loaded.
|
||||
*
|
||||
* Why every store and not only Linear: each wrote plaintext under the same unsealable store.
|
||||
* GitHub and GitLab are not here because Orca never stores their tokens; `gh`/`glab` own them.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { SecretStore } from '../../shared/secret-store'
|
||||
import { classifyUnenvelopedCredential } from '../../shared/secret-at-rest-protection'
|
||||
import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file'
|
||||
import { writeCredentialFileAtomic } from '../integration-credential-file'
|
||||
import {
|
||||
getWorkspaceTokenDir,
|
||||
getWorkspaceTokenPath,
|
||||
LEGACY_WORKSPACE_ID
|
||||
} from '../linear/linear-credential-paths'
|
||||
import { getTokenDir as getJiraTokenDir } from '../jira/site-credential-store'
|
||||
import { getSecretPath as getBitbucketSecretPath } from '../bitbucket/credential-store'
|
||||
import { getOpenAiKeyPath } from '../speech/openai-api-key-store'
|
||||
import { API_KEY_ENVELOPE_PREFIX, getZcodePlanApiKeyPath } from '../zcode/zcode-plan-api-key-store'
|
||||
import { COOKIE_ENVELOPE_PREFIX, getMiniMaxCookiePath } from '../minimax/minimax-cookie-store'
|
||||
import { miniMaxApiKeyEnvelopeFile } from '../minimax/minimax-api-key-store'
|
||||
import { openCodeGoApiKeyEnvelopeFile } from '../opencode/opencode-go-api-key-store'
|
||||
|
||||
export type PlaintextCredentialTarget = {
|
||||
path: string
|
||||
/** `<prefix>plaintext:<base64>` becomes `<prefix>encrypted:<base64>`. */
|
||||
envelopePrefix?: string
|
||||
/** The file may hold the bare value (no envelope), which is sealed as raw bytes. */
|
||||
raw?: boolean
|
||||
}
|
||||
|
||||
export type PlaintextCredentialMigrationResult = { sealed: number; failed: number }
|
||||
|
||||
function encFilesIn(dir: string): string[] {
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter((name) => name.endsWith('.enc'))
|
||||
.map((name) => join(dir, name))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Where every integration store keeps its files, whatever ORCA_USER_DATA says. */
|
||||
export function orcadCredentialRoot(): string {
|
||||
return join(homedir(), '.orca')
|
||||
}
|
||||
|
||||
export function listPlaintextCredentialTargets(): PlaintextCredentialTarget[] {
|
||||
return [
|
||||
{ path: getWorkspaceTokenPath(LEGACY_WORKSPACE_ID), raw: true },
|
||||
...encFilesIn(getWorkspaceTokenDir()).map((path) => ({ path, raw: true })),
|
||||
...encFilesIn(getJiraTokenDir()).map((path) => ({ path, raw: true })),
|
||||
{ path: getBitbucketSecretPath(), raw: true },
|
||||
{ path: getOpenAiKeyPath(), raw: true },
|
||||
{ path: getMiniMaxCookiePath(), envelopePrefix: COOKIE_ENVELOPE_PREFIX, raw: true },
|
||||
{ path: getZcodePlanApiKeyPath(), envelopePrefix: API_KEY_ENVELOPE_PREFIX },
|
||||
miniMaxApiKeyEnvelopeFile(),
|
||||
openCodeGoApiKeyEnvelopeFile()
|
||||
]
|
||||
}
|
||||
|
||||
function sealTarget(store: SecretStore, target: PlaintextCredentialTarget): boolean {
|
||||
const original = readFileSync(target.path)
|
||||
// Why first: a file this pass cannot seal still should not stay group- or world-readable.
|
||||
hardenExistingSecureFile(target.path)
|
||||
const unchanged = (): boolean => readFileSync(target.path).equals(original)
|
||||
const text = original.toString('utf8')
|
||||
if (target.envelopePrefix && text.startsWith(target.envelopePrefix)) {
|
||||
const plaintextTag = `${target.envelopePrefix}plaintext:`
|
||||
if (!text.startsWith(plaintextTag)) {
|
||||
return false
|
||||
}
|
||||
const value = Buffer.from(text.slice(plaintextTag.length), 'base64').toString('utf8')
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
const sealed = store.encryptString(value)
|
||||
if (store.decryptString(sealed) !== value) {
|
||||
throw new Error('sealed value did not read back')
|
||||
}
|
||||
const envelope = `${target.envelopePrefix}encrypted:${sealed.toString('base64')}`
|
||||
return writeSecureFile(target.path, envelope, { durable: true, shouldPublish: unchanged })
|
||||
}
|
||||
// Why the JSON skip: the speech store's oldest format is JSON wrapping an already-sealed key.
|
||||
if (
|
||||
!target.raw ||
|
||||
original.length === 0 ||
|
||||
classifyUnenvelopedCredential(original) !== 'plaintext' ||
|
||||
(target.path === getOpenAiKeyPath() && text.trimStart().startsWith('{'))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const sealed = store.encryptString(text)
|
||||
if (store.decryptString(sealed) !== text) {
|
||||
throw new Error('sealed value did not read back')
|
||||
}
|
||||
// Why compare before the rename: the desktop app may share ~/.orca and save concurrently.
|
||||
const published = writeCredentialFileAtomic(target.path, sealed, unchanged)
|
||||
if (published) {
|
||||
hardenExistingSecureFile(target.path)
|
||||
}
|
||||
return published
|
||||
}
|
||||
|
||||
/** Never logs credential contents; a failed file is left exactly as it was. */
|
||||
export function migratePlaintextCredentials(
|
||||
store: SecretStore,
|
||||
targets: readonly PlaintextCredentialTarget[] = listPlaintextCredentialTargets(),
|
||||
log: (message: string) => void = (message) => console.warn(message)
|
||||
): PlaintextCredentialMigrationResult {
|
||||
const result: PlaintextCredentialMigrationResult = { sealed: 0, failed: 0 }
|
||||
for (const target of targets) {
|
||||
if (!existsSync(target.path)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (sealTarget(store, target)) {
|
||||
result.sealed += 1
|
||||
}
|
||||
} catch (error) {
|
||||
result.failed += 1
|
||||
log(
|
||||
`[secrets] could not seal ${target.path}; left unchanged: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (result.sealed > 0) {
|
||||
log(
|
||||
`[secrets] sealed ${result.sealed} credential file(s) that an older server stored in plaintext`
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -57,6 +57,11 @@ vi.mock('./orcad-app-paths', () => ({
|
||||
resolveUserDataPath: () => state.root
|
||||
}))
|
||||
// The fake store has no account settings; orcad-account-services.test.ts covers the real services.
|
||||
// Migration targets the real ~/.orca, which these fixtures do not redirect.
|
||||
vi.mock('./orcad-plaintext-credential-migration', () => ({
|
||||
orcadCredentialRoot: () => state.root,
|
||||
migratePlaintextCredentials: () => ({ sealed: 0, failed: 0 })
|
||||
}))
|
||||
vi.mock('./orcad-account-services', () => ({ createOrcadAccountServices: () => null }))
|
||||
vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: state.browserProvider }))
|
||||
vi.mock('./orcad-instance-lock', () => ({
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/** orcad's key record on disk: naming, reading, and exclusive first publication. */
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import { linkSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
bestEffortFsyncDirectorySync,
|
||||
hardenExistingSecureFile,
|
||||
writeSecureFile
|
||||
} from '../../shared/secure-file'
|
||||
import { restrictWindowsFile } from '../../shared/secure-path-windows-acl'
|
||||
import { ORCAD_MASTER_KEY_BYTES } from './orcad-secret-sealing'
|
||||
|
||||
export type StoredKeyState =
|
||||
| { version: 1; backend: 'os-keyring'; account: string; check: string }
|
||||
| { version: 1; backend: 'host-file'; key: string; hostBound: boolean; check: string }
|
||||
|
||||
export type RecordRead =
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'record'; stored: StoredKeyState }
|
||||
| { kind: 'unreadable'; reason: string }
|
||||
|
||||
export function parseStoredState(raw: string): StoredKeyState | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null
|
||||
}
|
||||
const record: Record<string, unknown> = { ...parsed }
|
||||
if (record.version !== 1 || typeof record.check !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (record.backend === 'os-keyring' && typeof record.account === 'string') {
|
||||
return { version: 1, backend: 'os-keyring', account: record.account, check: record.check }
|
||||
}
|
||||
if (
|
||||
record.backend === 'host-file' &&
|
||||
typeof record.key === 'string' &&
|
||||
typeof record.hostBound === 'boolean'
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
backend: 'host-file',
|
||||
key: record.key,
|
||||
hostBound: record.hostBound,
|
||||
check: record.check
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function decodeMasterKey(encoded: string): Buffer | null {
|
||||
const key = Buffer.from(encoded, 'base64')
|
||||
return key.length === ORCAD_MASTER_KEY_BYTES ? key : null
|
||||
}
|
||||
|
||||
function machineTag(machineId: string | null): string {
|
||||
return createHash('sha256')
|
||||
.update(machineId ?? 'no-machine-id')
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one record per machine: hosts sharing a home (NFS) each keep their own key instead of
|
||||
* overwriting one another's, and a credential sealed elsewhere fails to unseal rather than
|
||||
* being silently re-keyed.
|
||||
*/
|
||||
export function orcadSecretStateFileName(machineId: string | null): string {
|
||||
return `orcad-secret-store-${machineTag(machineId)}.json`
|
||||
}
|
||||
|
||||
export function keyringAccount(keyDir: string, machineId: string | null): string {
|
||||
const digest = createHash('sha256')
|
||||
.update(`${resolve(keyDir)}\0${machineTag(machineId)}`)
|
||||
.digest('hex')
|
||||
return `home-${digest.slice(0, 16)}`
|
||||
}
|
||||
|
||||
function isInside(root: string, path: string): boolean {
|
||||
const fromRoot = relative(root, path)
|
||||
return !fromRoot.startsWith('..') && !isAbsolute(fromRoot)
|
||||
}
|
||||
|
||||
/** A throwaway key directory (tests, smoke runs) must not leave an item in the user's keyring. */
|
||||
export function isTemporaryKeyDir(keyDir: string): boolean {
|
||||
const roots = [tmpdir(), '/tmp', '/var/tmp'].flatMap((root) => {
|
||||
try {
|
||||
return [resolve(root), realpathSync(root)]
|
||||
} catch {
|
||||
return [resolve(root)]
|
||||
}
|
||||
})
|
||||
let target = resolve(keyDir)
|
||||
try {
|
||||
target = realpathSync(target)
|
||||
} catch {
|
||||
// Not created yet; the lexical path is what will be created.
|
||||
}
|
||||
return roots.some((root) => isInside(root, target) || isInside(root, resolve(keyDir)))
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): unknown {
|
||||
return error instanceof Error && 'code' in error ? error.code : undefined
|
||||
}
|
||||
|
||||
export function readStoredRecord(statePath: string): RecordRead {
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(statePath, 'utf8')
|
||||
} catch (error) {
|
||||
// Why only ENOENT: EACCES on an existing record must never be mistaken for "no key yet".
|
||||
if (errorCode(error) === 'ENOENT') {
|
||||
return { kind: 'absent' }
|
||||
}
|
||||
return { kind: 'unreadable', reason: `its key record could not be read (${String(error)})` }
|
||||
}
|
||||
const stored = parseStoredState(raw)
|
||||
// Why not regenerate: the record may hold the only copy of the key.
|
||||
return stored
|
||||
? { kind: 'record', stored }
|
||||
: { kind: 'unreadable', reason: `its key record ${statePath} is not valid` }
|
||||
}
|
||||
|
||||
/** Creates the record only if none exists, already restricted; otherwise says why not. */
|
||||
export function publishRecordExclusively(path: string, record: StoredKeyState): string {
|
||||
const staging = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.staging`
|
||||
try {
|
||||
// Why refuse an unrestricted write: the record may carry the master key itself.
|
||||
if (!writeSecureFile(staging, JSON.stringify(record), { durable: true })) {
|
||||
return `its key record ${path} could not be restricted to this user`
|
||||
}
|
||||
try {
|
||||
linkSync(staging, path)
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'EEXIST') {
|
||||
return 'exists'
|
||||
}
|
||||
throw error
|
||||
}
|
||||
bestEffortFsyncDirectorySync(dirname(path))
|
||||
return 'published'
|
||||
} catch (error) {
|
||||
return `its key record ${path} could not be written (${error instanceof Error ? error.message : String(error)})`
|
||||
} finally {
|
||||
rmSync(staging, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function withdrawRecord(path: string, check: string): void {
|
||||
try {
|
||||
if (parseStoredState(readFileSync(path, 'utf8'))?.check === check) {
|
||||
rmSync(path, { force: true })
|
||||
}
|
||||
} catch {
|
||||
// Left in place, the next start reports the missing keyring item instead of losing data.
|
||||
}
|
||||
}
|
||||
|
||||
export async function restrictExistingRecord(path: string): Promise<boolean> {
|
||||
try {
|
||||
// Why verify on Windows: the hardening helper does not report a failed ACL restriction.
|
||||
if (process.platform === 'win32') {
|
||||
return await restrictWindowsFile(path)
|
||||
}
|
||||
hardenExistingSecureFile(path)
|
||||
return (statSync(path).mode & 0o077) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,50 @@
|
||||
// Why per platform: macOS and Windows desktops do have an OS keychain; orcad just does not use
|
||||
// it, so "this host has no keyring" sent users looking for one they already had.
|
||||
export function describeOrcadSecretProtectionGap(platform: NodeJS.Platform): string {
|
||||
const keychain =
|
||||
platform === 'darwin'
|
||||
? 'the macOS Keychain'
|
||||
: platform === 'win32'
|
||||
? 'Windows credential protection'
|
||||
: 'the OS keyring'
|
||||
return `Orca's headless server does not use ${keychain}, so credentials are stored unencrypted on this host. Pair from a desktop to manage secrets.`
|
||||
/** What orcad tells a user about how its credentials are protected at rest. */
|
||||
|
||||
export const ORCAD_PLAINTEXT_SECRETS_ENV = 'ORCA_HEADLESS_SECRET_STORE'
|
||||
|
||||
function keychainName(platform: NodeJS.Platform): string {
|
||||
return platform === 'darwin'
|
||||
? 'the macOS Keychain'
|
||||
: platform === 'win32'
|
||||
? 'Windows credential protection'
|
||||
: 'a Secret Service keyring'
|
||||
}
|
||||
|
||||
export function describeOrcadPlaintextOptOut(): string {
|
||||
return (
|
||||
`${ORCAD_PLAINTEXT_SECRETS_ENV}=plaintext is set, so Orca's headless server stores ` +
|
||||
'credentials unencrypted on this host.'
|
||||
)
|
||||
}
|
||||
|
||||
export function describeOrcadDisposableProfile(): string {
|
||||
return (
|
||||
"Orca's headless server is running from a temporary profile, so it stores credentials " +
|
||||
'unencrypted and leaves the saved credential key and existing credentials untouched.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why not null: the machine ID is not a secret, so a key kept beside the credentials only stops
|
||||
* a lone copied file from opening elsewhere — not someone with this account's files or a backup.
|
||||
*/
|
||||
export function describeOrcadHostKeyGap(platform: NodeJS.Platform, hostBound: boolean): string {
|
||||
const why =
|
||||
platform === 'win32'
|
||||
? `does not use ${keychainName(platform)}`
|
||||
: `could not reach ${keychainName(platform)} when it created its key`
|
||||
const binding = hostBound ? ', mixed with this machine ID' : ''
|
||||
return (
|
||||
`Orca's headless server ${why}, so it seals credentials with a key kept beside them in its ` +
|
||||
`private credential directory${binding}. Anyone who can read this account's files on this ` +
|
||||
'host, or a backup of them, can unseal them.'
|
||||
)
|
||||
}
|
||||
|
||||
export function describeOrcadSealingUnavailable(reason: string, stateFile: string): string {
|
||||
return (
|
||||
`Orca's headless server cannot unseal its credentials: ${reason}. Saved credentials are kept ` +
|
||||
`as they are and new ones are refused until this is fixed and the server restarts. To start ` +
|
||||
`over and reconnect every integration, delete ${stateFile}.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** AES-256-GCM sealing for orcad's SecretStore; the key comes from orcad-secret-store.ts. */
|
||||
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from 'node:crypto'
|
||||
|
||||
// Why a leading NUL: classifyUnenvelopedCredential reports control bytes as 'sealed', so a
|
||||
// sealed file can never be mistaken for a legacy plaintext token.
|
||||
const MAGIC = Buffer.from([0x00, 0x4f, 0x48, 0x01])
|
||||
const IV_BYTES = 12
|
||||
const TAG_BYTES = 16
|
||||
const KEY_INFO = 'orca-headless-secret-store/v1'
|
||||
|
||||
export const ORCAD_MASTER_KEY_BYTES = 32
|
||||
|
||||
/** Binds the stored master key to `hostBinding` so a copied profile cannot unseal elsewhere. */
|
||||
export function deriveOrcadSealingKey(masterKey: Buffer, hostBinding: string): Buffer {
|
||||
return Buffer.from(hkdfSync('sha256', masterKey, hostBinding, KEY_INFO, ORCAD_MASTER_KEY_BYTES))
|
||||
}
|
||||
|
||||
/** Detects a wrong key (changed machine identity, replaced keyring item) without a sealed sample. */
|
||||
export function orcadSealingKeyCheck(key: Buffer): string {
|
||||
return createHmac('sha256', key).update('orca-headless-secret-store/check').digest('hex')
|
||||
}
|
||||
|
||||
export function sealOrcadSecret(key: Buffer, plainText: string): Buffer {
|
||||
const iv = randomBytes(IV_BYTES)
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv)
|
||||
cipher.setAAD(MAGIC)
|
||||
const body = Buffer.concat([cipher.update(plainText, 'utf8'), cipher.final()])
|
||||
return Buffer.concat([MAGIC, iv, cipher.getAuthTag(), body])
|
||||
}
|
||||
|
||||
export function openOrcadSecret(key: Buffer, sealed: Buffer): string {
|
||||
const header = MAGIC.length + IV_BYTES + TAG_BYTES
|
||||
if (sealed.length < header || !sealed.subarray(0, MAGIC.length).equals(MAGIC)) {
|
||||
throw new Error('orcad_secret_not_sealed_by_this_host')
|
||||
}
|
||||
const decipher = createDecipheriv(
|
||||
'aes-256-gcm',
|
||||
key,
|
||||
sealed.subarray(MAGIC.length, MAGIC.length + IV_BYTES)
|
||||
)
|
||||
decipher.setAAD(MAGIC)
|
||||
decipher.setAuthTag(sealed.subarray(MAGIC.length + IV_BYTES, header))
|
||||
return Buffer.concat([decipher.update(sealed.subarray(header)), decipher.final()]).toString(
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
chmodSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { classifyUnenvelopedCredential } from '../../shared/secret-at-rest-protection'
|
||||
import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store'
|
||||
import { readStoredCredentialToken, writeEncryptedCredential } from '../integration-credential-file'
|
||||
import type { OrcadKeyringLookup, OrcadOsKeyring } from './orcad-os-keyring'
|
||||
import { orcadSecretStateFileName } from './orcad-secret-key-record'
|
||||
import { createOrcadSecretStore } from './orcad-secret-store'
|
||||
|
||||
const MACHINE_A = '0123456789abcdef0123456789abcdef'
|
||||
const MACHINE_B = 'fedcba9876543210fedcba9876543210'
|
||||
const STATE_A = orcadSecretStateFileName(MACHINE_A)
|
||||
|
||||
type FakeKeyring = OrcadOsKeyring & {
|
||||
items: Map<string, string>
|
||||
failure: OrcadKeyringLookup | null
|
||||
storeWorks: boolean
|
||||
}
|
||||
|
||||
function fakeKeyring(): FakeKeyring {
|
||||
const items = new Map<string, string>()
|
||||
const keyring: FakeKeyring = {
|
||||
label: 'the test keyring',
|
||||
items,
|
||||
failure: null,
|
||||
storeWorks: true,
|
||||
async lookup(account: string): Promise<OrcadKeyringLookup> {
|
||||
if (keyring.failure) {
|
||||
return keyring.failure
|
||||
}
|
||||
const value = items.get(account)
|
||||
return value ? { kind: 'found', value } : { kind: 'missing' }
|
||||
},
|
||||
async store(account: string, value: string): Promise<boolean> {
|
||||
if (keyring.storeWorks) {
|
||||
items.set(account, value)
|
||||
}
|
||||
return keyring.storeWorks
|
||||
}
|
||||
}
|
||||
return keyring
|
||||
}
|
||||
|
||||
describe('createOrcadSecretStore', () => {
|
||||
let keyDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
keyDir = mkdtempSync(join(tmpdir(), 'orcad-secret-store-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetSecretStoreForTests()
|
||||
rmSync(keyDir, { recursive: true, force: true })
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const prepare = async (
|
||||
options: {
|
||||
keyring?: OrcadOsKeyring | null
|
||||
machine?: string | null
|
||||
env?: NodeJS.ProcessEnv
|
||||
temporaryProfile?: boolean
|
||||
claimSettleMs?: number
|
||||
} = {}
|
||||
) => {
|
||||
const store = createOrcadSecretStore()
|
||||
const ready = await store.prepare({
|
||||
keyDir,
|
||||
platform: 'linux',
|
||||
env: options.env ?? {},
|
||||
keyring: options.keyring ?? null,
|
||||
machineIdentity: () => (options.machine === undefined ? MACHINE_A : options.machine),
|
||||
isTemporaryKeyDir: () => options.temporaryProfile ?? false,
|
||||
claimSettleMs: options.claimSettleMs ?? 0
|
||||
})
|
||||
return { store, ready }
|
||||
}
|
||||
|
||||
it('seals with a host-bound profile key when no keyring is reachable', async () => {
|
||||
const { store, ready } = await prepare()
|
||||
expect(ready).toBe(true)
|
||||
expect(store.isEncryptionAvailable()).toBe(true)
|
||||
const sealed = store.encryptString('lin_api_secret')
|
||||
expect(sealed.includes(Buffer.from('lin_api_secret'))).toBe(false)
|
||||
expect(classifyUnenvelopedCredential(sealed)).toBe('sealed')
|
||||
expect(store.decryptString(sealed)).toBe('lin_api_secret')
|
||||
expect(store.describeProtectionGap()).toContain('mixed with this machine ID')
|
||||
|
||||
const statePath = join(keyDir, STATE_A)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(statSync(statePath).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
const reopened = (await prepare()).store
|
||||
expect(reopened.decryptString(sealed)).toBe('lin_api_secret')
|
||||
})
|
||||
|
||||
it('gives another machine sharing the home its own key, leaving this one intact', async () => {
|
||||
const sealed = (await prepare()).store.encryptString('lin_api_secret')
|
||||
const stateBefore = readFileSync(join(keyDir, STATE_A))
|
||||
|
||||
const { store, ready } = await prepare({ machine: MACHINE_B })
|
||||
expect(ready).toBe(true)
|
||||
expect(() => store.decryptString(sealed)).toThrow()
|
||||
expect(readFileSync(join(keyDir, STATE_A))).toEqual(stateBefore)
|
||||
expect((await prepare()).store.decryptString(sealed)).toBe('lin_api_secret')
|
||||
})
|
||||
|
||||
it('refuses to seal or unseal when its record was bound to a different machine ID', async () => {
|
||||
const sealed = (await prepare()).store.encryptString('lin_api_secret')
|
||||
// A record copied in under this machine's name from another host.
|
||||
renameSync(join(keyDir, STATE_A), join(keyDir, orcadSecretStateFileName(MACHINE_B)))
|
||||
const moved = await prepare({ machine: MACHINE_B })
|
||||
expect(moved.ready).toBe(false)
|
||||
expect(() => moved.store.encryptString('new')).toThrow(/different machine ID/)
|
||||
expect(() => moved.store.decryptString(sealed)).toThrow(/different machine ID/)
|
||||
expect(moved.store.isEncryptionAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the master key in the OS keyring when one is reachable', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
const { store, ready } = await prepare({ keyring })
|
||||
expect(ready).toBe(true)
|
||||
expect(store.describeProtectionGap()).toBeNull()
|
||||
expect(keyring.items.size).toBe(1)
|
||||
const [masterKey] = [...keyring.items.values()]
|
||||
expect(readFileSync(join(keyDir, STATE_A), 'utf8')).not.toContain(masterKey)
|
||||
const sealed = store.encryptString('lin_api_secret')
|
||||
expect((await prepare({ keyring })).store.decryptString(sealed)).toBe('lin_api_secret')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['locked', { kind: 'unreachable', reason: 'exited 1: Cannot unlock' }],
|
||||
['timing out', { kind: 'unreachable', reason: 'timed out after 5s' }],
|
||||
['missing its item', { kind: 'missing' }]
|
||||
] as const)('fails closed when the recorded keyring is %s', async (_label, failure) => {
|
||||
const keyring = fakeKeyring()
|
||||
const sealed = (await prepare({ keyring })).store.encryptString('lin_api_secret')
|
||||
keyring.failure = failure
|
||||
|
||||
const { store, ready } = await prepare({ keyring })
|
||||
expect(ready).toBe(false)
|
||||
expect(() => store.encryptString('x')).toThrow(/cannot unseal/)
|
||||
expect(() => store.decryptString(sealed)).toThrow(/cannot unseal/)
|
||||
expect(store.describeProtectionGap()).toContain('test keyring')
|
||||
|
||||
keyring.failure = null
|
||||
expect((await prepare({ keyring })).store.decryptString(sealed)).toBe('lin_api_secret')
|
||||
})
|
||||
|
||||
it('never creates a keyring item for a temporary profile or when told to use the file', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
const temporary = await prepare({ keyring, temporaryProfile: true })
|
||||
expect(temporary.store.describeProtectionGap()).toContain('private credential directory')
|
||||
expect(keyring.items.size).toBe(0)
|
||||
rmSync(join(keyDir, STATE_A))
|
||||
await prepare({ keyring, env: { ORCA_HEADLESS_SECRET_STORE: 'file' } })
|
||||
expect(keyring.items.size).toBe(0)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'treats /tmp and the OS temp dir as disposable, by either spelling',
|
||||
async () => {
|
||||
const keyring = fakeKeyring()
|
||||
for (const dir of [
|
||||
mkdtempSync('/tmp/orcad-key-'),
|
||||
realpathSync(mkdtempSync('/tmp/orcad-key-')),
|
||||
keyDir
|
||||
]) {
|
||||
await createOrcadSecretStore().prepare({
|
||||
keyDir: dir,
|
||||
env: {},
|
||||
keyring,
|
||||
machineIdentity: () => MACHINE_A
|
||||
})
|
||||
if (dir !== keyDir) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
expect(keyring.items.size).toBe(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps its claim and fails closed when the keyring store result is uncertain', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
const realStore = keyring.store.bind(keyring)
|
||||
// Stores the value, then reports a timeout; the read-back is unreachable too.
|
||||
keyring.store = async (account, value) => {
|
||||
await realStore(account, value)
|
||||
keyring.failure = { kind: 'unreachable', reason: 'timed out after 5s' }
|
||||
return false
|
||||
}
|
||||
const uncertain = await prepare({ keyring })
|
||||
expect(uncertain.ready).toBe(false)
|
||||
expect(keyring.items.size).toBe(1)
|
||||
|
||||
keyring.failure = null
|
||||
const recovered = await prepare({ keyring })
|
||||
expect(recovered.ready).toBe(true)
|
||||
expect(recovered.store.describeProtectionGap()).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the profile key when the keyring cannot store one', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
keyring.storeWorks = false
|
||||
const { store, ready } = await prepare({ keyring })
|
||||
expect(ready).toBe(true)
|
||||
expect(store.describeProtectionGap()).toContain('private credential directory')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)(
|
||||
'treats a record it cannot read as unavailable, never as missing',
|
||||
async () => {
|
||||
const sealed = (await prepare()).store.encryptString('lin_api_secret')
|
||||
const statePath = join(keyDir, STATE_A)
|
||||
const before = readFileSync(statePath)
|
||||
chmodSync(statePath, 0o000)
|
||||
try {
|
||||
const { store, ready } = await prepare()
|
||||
expect(ready).toBe(false)
|
||||
expect(() => store.decryptString(sealed)).toThrow(/could not be read/)
|
||||
} finally {
|
||||
chmodSync(statePath, 0o600)
|
||||
}
|
||||
expect(readFileSync(statePath)).toEqual(before)
|
||||
}
|
||||
)
|
||||
|
||||
it('agrees on one key when two servers start against the same home at once', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
const [first, second] = await Promise.all([prepare({ keyring }), prepare({ keyring })])
|
||||
expect(first.ready && second.ready).toBe(true)
|
||||
expect(keyring.items.size).toBe(1)
|
||||
expect(second.store.decryptString(first.store.encryptString('a'))).toBe('a')
|
||||
expect((await prepare({ keyring })).store.decryptString(second.store.encryptString('b'))).toBe(
|
||||
'b'
|
||||
)
|
||||
|
||||
// A slow keyring write: the losing start must wait for it rather than give up.
|
||||
rmSync(join(keyDir, STATE_A))
|
||||
keyring.items.clear()
|
||||
const slowStore = keyring.store.bind(keyring)
|
||||
keyring.store = async (account, value) => {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 1_500))
|
||||
return slowStore(account, value)
|
||||
}
|
||||
const slow = await Promise.all([
|
||||
prepare({ keyring, claimSettleMs: 12_000 }),
|
||||
prepare({ keyring, claimSettleMs: 12_000 })
|
||||
])
|
||||
expect(slow.map(({ ready }) => ready)).toEqual([true, true])
|
||||
expect(slow[1].store.decryptString(slow[0].store.encryptString('s'))).toBe('s')
|
||||
|
||||
const [fileA, fileB] = await Promise.all([
|
||||
prepare({ machine: MACHINE_B }),
|
||||
prepare({ machine: MACHINE_B })
|
||||
])
|
||||
expect(fileB.store.decryptString(fileA.store.encryptString('c'))).toBe('c')
|
||||
})
|
||||
|
||||
it('never regenerates over an unreadable key record', async () => {
|
||||
const statePath = join(keyDir, STATE_A)
|
||||
writeFileSync(statePath, '{"version":1,"backend":"host-file"', 'utf8')
|
||||
const { store, ready } = await prepare()
|
||||
expect(ready).toBe(false)
|
||||
expect(() => store.encryptString('x')).toThrow(/not valid/)
|
||||
expect(readFileSync(statePath, 'utf8')).toBe('{"version":1,"backend":"host-file"')
|
||||
})
|
||||
|
||||
it('records an unbound key when the host has no machine ID', async () => {
|
||||
const { store } = await prepare({ machine: null })
|
||||
expect(store.describeProtectionGap()).not.toContain('machine ID')
|
||||
const sealed = store.encryptString('t')
|
||||
expect((await prepare({ machine: null })).store.decryptString(sealed)).toBe('t')
|
||||
})
|
||||
|
||||
it('leaves the persistent credential home alone for a temporary profile', async () => {
|
||||
const keyring = fakeKeyring()
|
||||
const store = createOrcadSecretStore()
|
||||
const ready = await store.prepare({
|
||||
keyDir,
|
||||
userDataPath: '/tmp/orcad-smoke-profile',
|
||||
env: {},
|
||||
keyring,
|
||||
machineIdentity: () => MACHINE_A
|
||||
})
|
||||
expect(ready).toBe(false)
|
||||
expect(store.isEncryptionAvailable()).toBe(false)
|
||||
expect(store.describeProtectionGap()).toContain('temporary profile')
|
||||
expect(keyring.items.size).toBe(0)
|
||||
expect(readdirSync(keyDir)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the explicit plaintext opt-out for shared homes', async () => {
|
||||
const { store, ready } = await prepare({ env: { ORCA_HEADLESS_SECRET_STORE: 'plaintext' } })
|
||||
expect(ready).toBe(false)
|
||||
expect(store.isEncryptionAvailable()).toBe(false)
|
||||
expect(store.describeProtectionGap()).toContain('ORCA_HEADLESS_SECRET_STORE=plaintext')
|
||||
})
|
||||
|
||||
it('makes integration credential saves seal, and still reads legacy plaintext', async () => {
|
||||
const { store } = await prepare()
|
||||
setSecretStore(store)
|
||||
const legacy = join(keyDir, 'legacy.enc')
|
||||
writeFileSync(legacy, 'lin_api_legacy', 'utf8')
|
||||
expect(readStoredCredentialToken('Linear', readFileSync(legacy))).toBe('lin_api_legacy')
|
||||
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const saved = join(keyDir, 'saved.enc')
|
||||
writeEncryptedCredential('Linear', saved, 'lin_api_new')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
expect(readFileSync(saved).includes(Buffer.from('lin_api_new'))).toBe(false)
|
||||
expect(readStoredCredentialToken('Linear', readFileSync(saved))).toBe('lin_api_new')
|
||||
})
|
||||
|
||||
it('fails a save closed when the key cannot load, keeping the sealed file', async () => {
|
||||
const saved = join(keyDir, 'saved.enc')
|
||||
const keyring = fakeKeyring()
|
||||
setSecretStore((await prepare({ keyring })).store)
|
||||
writeEncryptedCredential('Linear', saved, 'lin_api_original')
|
||||
const before = readFileSync(saved)
|
||||
|
||||
keyring.failure = { kind: 'unreachable', reason: 'locked' }
|
||||
setSecretStore((await prepare({ keyring })).store)
|
||||
expect(() => writeEncryptedCredential('Linear', saved, 'lin_api_replacement')).toThrow()
|
||||
expect(readFileSync(saved)).toEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* orcad's SecretStore: credentials are sealed on the execution host, with a master key the
|
||||
* host owns — in the OS keyring when one is reachable when the key is first created, else in
|
||||
* a private file beside the credentials in `~/.orca`, mixed with the machine ID.
|
||||
*
|
||||
* Why always "available": answering false makes every credential writer fall back to plaintext.
|
||||
* When the recorded key cannot be loaded (locked keyring, changed machine), seal and unseal
|
||||
* throw instead, so saves fail closed and existing sealed files are left untouched.
|
||||
*/
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import process from 'node:process'
|
||||
import type { SecretStore } from '../../shared/secret-store'
|
||||
import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity'
|
||||
import { resolveOrcadOsKeyring, type OrcadOsKeyring } from './orcad-os-keyring'
|
||||
import {
|
||||
decodeMasterKey,
|
||||
isTemporaryKeyDir,
|
||||
keyringAccount,
|
||||
orcadSecretStateFileName,
|
||||
publishRecordExclusively,
|
||||
readStoredRecord,
|
||||
restrictExistingRecord,
|
||||
withdrawRecord,
|
||||
type StoredKeyState
|
||||
} from './orcad-secret-key-record'
|
||||
import {
|
||||
describeOrcadHostKeyGap,
|
||||
describeOrcadDisposableProfile,
|
||||
describeOrcadPlaintextOptOut,
|
||||
describeOrcadSealingUnavailable,
|
||||
ORCAD_PLAINTEXT_SECRETS_ENV
|
||||
} from './orcad-secret-protection-gap'
|
||||
import {
|
||||
deriveOrcadSealingKey,
|
||||
openOrcadSecret,
|
||||
ORCAD_MASTER_KEY_BYTES,
|
||||
orcadSealingKeyCheck,
|
||||
sealOrcadSecret
|
||||
} from './orcad-secret-sealing'
|
||||
|
||||
type StoreState =
|
||||
| { kind: 'pending' }
|
||||
| { kind: 'plaintext'; reason: string }
|
||||
| { kind: 'ready'; key: Buffer; gap: string | null }
|
||||
| { kind: 'unavailable'; message: string }
|
||||
|
||||
export type OrcadSecretStorePrepareOptions = {
|
||||
/** Orca's credential directory (`~/.orca`), so the key lives exactly as long as what it seals. */
|
||||
keyDir: string
|
||||
/** A temporary profile keeps the legacy behavior and leaves `keyDir` untouched. */
|
||||
userDataPath?: string
|
||||
platform?: NodeJS.Platform
|
||||
env?: NodeJS.ProcessEnv
|
||||
keyring?: OrcadOsKeyring | null
|
||||
machineIdentity?: () => string | null
|
||||
isTemporaryKeyDir?: (keyDir: string) => boolean
|
||||
/** How long to wait for a concurrent first start to finish storing its keyring key. */
|
||||
claimSettleMs?: number
|
||||
}
|
||||
|
||||
export type OrcadSecretStore = SecretStore & {
|
||||
/** Resolves whether sealing works now, i.e. the key loaded. */
|
||||
prepare(options: OrcadSecretStorePrepareOptions): Promise<boolean>
|
||||
}
|
||||
|
||||
// A claimant's keyring store and read-back can each take the full 5s keyring timeout.
|
||||
const CLAIM_SETTLE_MS = 12_000
|
||||
|
||||
type Paths = { statePath: string; unavailable: (reason: string) => StoreState }
|
||||
|
||||
type KeyContext = {
|
||||
platform: NodeJS.Platform
|
||||
keyring: OrcadOsKeyring | null
|
||||
machineId: string | null
|
||||
claimSettleMs?: number
|
||||
}
|
||||
|
||||
async function loadStoredRecord(
|
||||
stored: StoredKeyState,
|
||||
paths: Paths,
|
||||
context: KeyContext
|
||||
): Promise<StoreState | 'record-changed'> {
|
||||
const { unavailable } = paths
|
||||
if (stored.backend === 'os-keyring') {
|
||||
const { keyring } = context
|
||||
if (!keyring) {
|
||||
return unavailable('the OS keyring that holds its key is not reachable from this session')
|
||||
}
|
||||
let found = await keyring.lookup(stored.account)
|
||||
// Why wait: a concurrent first start may have claimed the record and still be storing the key
|
||||
// (one keyring store plus one lookup), or may withdraw the claim and switch to the file.
|
||||
const deadline = Date.now() + (context.claimSettleMs ?? CLAIM_SETTLE_MS)
|
||||
while (found.kind === 'missing' && Date.now() < deadline) {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
|
||||
const current = readStoredRecord(paths.statePath)
|
||||
if (current.kind !== 'record' || current.stored.check !== stored.check) {
|
||||
return 'record-changed'
|
||||
}
|
||||
found = await keyring.lookup(stored.account)
|
||||
}
|
||||
if (found.kind !== 'found') {
|
||||
return unavailable(
|
||||
found.kind === 'missing'
|
||||
? `its key is missing from ${keyring.label}`
|
||||
: `${keyring.label} is locked or unreachable (${found.reason}); unlock it`
|
||||
)
|
||||
}
|
||||
const master = decodeMasterKey(found.value)
|
||||
const key = master ? deriveOrcadSealingKey(master, '') : null
|
||||
if (!key || orcadSealingKeyCheck(key) !== stored.check) {
|
||||
return unavailable(`the key in ${keyring.label} does not match the one it sealed with`)
|
||||
}
|
||||
return { kind: 'ready', key, gap: null }
|
||||
}
|
||||
if (!(await restrictExistingRecord(paths.statePath))) {
|
||||
return unavailable(`its key record ${paths.statePath} could not be restricted to this user`)
|
||||
}
|
||||
const master = decodeMasterKey(stored.key)
|
||||
const binding = stored.hostBound ? context.machineId : ''
|
||||
if (!master || binding === null) {
|
||||
return unavailable('this machine no longer reports the machine ID its key is bound to')
|
||||
}
|
||||
const key = deriveOrcadSealingKey(master, binding)
|
||||
if (orcadSealingKeyCheck(key) !== stored.check) {
|
||||
return unavailable('its key is bound to a different machine ID')
|
||||
}
|
||||
return { kind: 'ready', key, gap: describeOrcadHostKeyGap(context.platform, stored.hostBound) }
|
||||
}
|
||||
|
||||
async function resolveState(options: OrcadSecretStorePrepareOptions): Promise<StoreState> {
|
||||
const platform = options.platform ?? process.platform
|
||||
const env = options.env ?? process.env
|
||||
if (env[ORCAD_PLAINTEXT_SECRETS_ENV] === 'plaintext') {
|
||||
return { kind: 'plaintext', reason: describeOrcadPlaintextOptOut() }
|
||||
}
|
||||
// Why: a disposable run (tests, smoke launchers) must not create keys in, or re-seal, the
|
||||
// user's persistent ~/.orca; credentials it saves would outlive the run unreadable by others.
|
||||
if (
|
||||
options.userDataPath &&
|
||||
(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.userDataPath)
|
||||
) {
|
||||
return { kind: 'plaintext', reason: describeOrcadDisposableProfile() }
|
||||
}
|
||||
const keyring =
|
||||
options.keyring === undefined ? resolveOrcadOsKeyring(platform, env) : options.keyring
|
||||
const machineId = (options.machineIdentity ?? profileStateAccessMachineIdentity)()
|
||||
const statePath = join(options.keyDir, orcadSecretStateFileName(machineId))
|
||||
const paths: Paths = {
|
||||
statePath,
|
||||
unavailable: (reason) => ({
|
||||
kind: 'unavailable',
|
||||
message: describeOrcadSealingUnavailable(reason, statePath)
|
||||
})
|
||||
}
|
||||
const context: KeyContext = {
|
||||
platform,
|
||||
keyring,
|
||||
machineId,
|
||||
claimSettleMs: options.claimSettleMs
|
||||
}
|
||||
// Why loop: losing an exclusive-create race means another start's record is the one to load.
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const existing = readStoredRecord(paths.statePath)
|
||||
if (existing.kind === 'record') {
|
||||
const loaded = await loadStoredRecord(existing.stored, paths, context)
|
||||
if (loaded !== 'record-changed') {
|
||||
return loaded
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (existing.kind === 'unreadable') {
|
||||
return paths.unavailable(existing.reason)
|
||||
}
|
||||
const created = await createKey(options, paths, context, env)
|
||||
if (created !== 'lost-race') {
|
||||
return created
|
||||
}
|
||||
}
|
||||
return paths.unavailable('another server kept replacing its key record')
|
||||
}
|
||||
|
||||
async function createKey(
|
||||
options: OrcadSecretStorePrepareOptions,
|
||||
paths: Paths,
|
||||
context: KeyContext,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<StoreState | 'lost-race'> {
|
||||
const { keyring, machineId, platform } = context
|
||||
const mayUseKeyring =
|
||||
env[ORCAD_PLAINTEXT_SECRETS_ENV] !== 'file' &&
|
||||
!(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.keyDir)
|
||||
if (keyring && mayUseKeyring) {
|
||||
const account = keyringAccount(options.keyDir, machineId)
|
||||
const found = await keyring.lookup(account)
|
||||
const adopted = found.kind === 'found' ? decodeMasterKey(found.value) : null
|
||||
if (adopted || found.kind === 'missing') {
|
||||
const master = adopted ?? randomBytes(ORCAD_MASTER_KEY_BYTES)
|
||||
const key = deriveOrcadSealingKey(master, '')
|
||||
const record: StoredKeyState = {
|
||||
version: 1,
|
||||
backend: 'os-keyring',
|
||||
account,
|
||||
check: orcadSealingKeyCheck(key)
|
||||
}
|
||||
// Why claim the record first: only the start that wins the claim may write the keyring.
|
||||
const claim = publishRecordExclusively(paths.statePath, record)
|
||||
if (claim !== 'published') {
|
||||
return claim === 'exists' ? 'lost-race' : paths.unavailable(claim)
|
||||
}
|
||||
if (adopted) {
|
||||
return { kind: 'ready', key, gap: null }
|
||||
}
|
||||
const encoded = master.toString('base64')
|
||||
// Why read back: `security -i` exits 0 even when its command failed.
|
||||
await keyring.store(account, encoded)
|
||||
// Why trust only the read-back: a store can time out after it succeeded, and a concurrent
|
||||
// start may already have adopted the claimed key.
|
||||
const stored = await keyring.lookup(account)
|
||||
if (stored.kind === 'found' && stored.value === encoded) {
|
||||
return { kind: 'ready', key, gap: null }
|
||||
}
|
||||
if (stored.kind !== 'missing') {
|
||||
// Uncertain: the key may be in the keyring and in use, so keep the claim and fail closed.
|
||||
return paths.unavailable(`${keyring.label} did not confirm its new key; restart to retry`)
|
||||
}
|
||||
// Definitely never stored, so nobody can have adopted it: withdraw and use the file.
|
||||
withdrawRecord(paths.statePath, record.check)
|
||||
}
|
||||
}
|
||||
|
||||
const master = randomBytes(ORCAD_MASTER_KEY_BYTES)
|
||||
const key = deriveOrcadSealingKey(master, machineId ?? '')
|
||||
const record: StoredKeyState = {
|
||||
version: 1,
|
||||
backend: 'host-file',
|
||||
key: master.toString('base64'),
|
||||
hostBound: machineId !== null,
|
||||
check: orcadSealingKeyCheck(key)
|
||||
}
|
||||
// Why fail closed: sealing with a key that did not reach disk loses every credential on restart.
|
||||
const published = publishRecordExclusively(paths.statePath, record)
|
||||
if (published !== 'published') {
|
||||
return published === 'exists' ? 'lost-race' : paths.unavailable(published)
|
||||
}
|
||||
return { kind: 'ready', key, gap: describeOrcadHostKeyGap(platform, record.hostBound) }
|
||||
}
|
||||
|
||||
export function createOrcadSecretStore(): OrcadSecretStore {
|
||||
let state: StoreState = { kind: 'pending' }
|
||||
const readyKey = (): Buffer => {
|
||||
if (state.kind === 'ready') {
|
||||
return state.key
|
||||
}
|
||||
throw new Error(
|
||||
state.kind === 'unavailable'
|
||||
? state.message
|
||||
: 'orcad_secret_store_not_prepared — the headless secret store has not loaded its key yet'
|
||||
)
|
||||
}
|
||||
return {
|
||||
isEncryptionAvailable: () => state.kind !== 'plaintext',
|
||||
encryptString: (plainText) => sealOrcadSecret(readyKey(), plainText),
|
||||
decryptString: (cipher) => openOrcadSecret(readyKey(), cipher),
|
||||
describeProtectionGap: () => {
|
||||
switch (state.kind) {
|
||||
case 'ready':
|
||||
return state.gap
|
||||
case 'unavailable':
|
||||
return state.message
|
||||
case 'plaintext':
|
||||
return state.reason
|
||||
case 'pending':
|
||||
return "Orca's headless server has not loaded its credential key yet."
|
||||
}
|
||||
},
|
||||
async prepare(options) {
|
||||
state = await resolveState(options)
|
||||
return state.kind === 'ready'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getSecretStore } from '../../shared/secret-store'
|
||||
import { readCredentialFileProtection } from '../credential-file-protection'
|
||||
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
||||
import {
|
||||
classifyUnenvelopedCredential,
|
||||
type SecretAtRestProtection
|
||||
} from '../../shared/secret-at-rest-protection'
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -23,7 +26,7 @@ function ensureOrcaDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenAiKeyPath(): string {
|
||||
export function getOpenAiKeyPath(): string {
|
||||
return join(getOrcaDir(), OPENAI_SPEECH_TOKEN_FILE)
|
||||
}
|
||||
|
||||
@@ -94,7 +97,7 @@ export function readOpenAiSpeechApiKey(): string {
|
||||
return cachedOpenAiSpeechApiKey
|
||||
}
|
||||
cachedOpenAiSpeechApiKey = getSecretStore().isEncryptionAvailable()
|
||||
? getSecretStore().decryptString(raw)
|
||||
? decryptOrLegacyPlaintext(raw)
|
||||
: raw.toString('utf8')
|
||||
return cachedOpenAiSpeechApiKey
|
||||
} catch {
|
||||
@@ -102,6 +105,18 @@ export function readOpenAiSpeechApiKey(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a key saved before sealing became available stays plaintext until it is migrated or re-saved.
|
||||
function decryptOrLegacyPlaintext(raw: Buffer): string {
|
||||
try {
|
||||
return getSecretStore().decryptString(raw)
|
||||
} catch (error) {
|
||||
if (classifyUnenvelopedCredential(raw) === 'plaintext') {
|
||||
return raw.toString('utf8')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function clearOpenAiSpeechApiKey(): void {
|
||||
cachedOpenAiSpeechApiKey = null
|
||||
rmSync(getOpenAiKeyPath(), { force: true })
|
||||
|
||||
@@ -6,7 +6,7 @@ import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-f
|
||||
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
||||
|
||||
const ZCODE_PLAN_API_KEY_FILE = 'zcode-plan-api-key.enc'
|
||||
const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:'
|
||||
export const API_KEY_ENVELOPE_PREFIX = 'orca-zcode-plan-api-key:v1:'
|
||||
let cachedZcodePlanApiKey: string | null = null
|
||||
let warnedZcodePlanApiKeyStatusHardenFailure = false
|
||||
|
||||
@@ -15,7 +15,7 @@ type ZcodePlanApiKeyEnvelope = {
|
||||
payload: Buffer
|
||||
}
|
||||
|
||||
function getZcodePlanApiKeyPath(): string {
|
||||
export function getZcodePlanApiKeyPath(): string {
|
||||
return join(homedir(), '.orca', ZCODE_PLAN_API_KEY_FILE)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,12 @@ async function restrictAsync(
|
||||
return verifyAfterApply(security, targetPath, isDirectory)
|
||||
}
|
||||
|
||||
/** Restricts one file on the thread pool; never rejects, resolves whether the DACL holds. */
|
||||
export function restrictWindowsFile(filePath: string): Promise<boolean> {
|
||||
const security = securityFor(filePath)
|
||||
return security ? restrictAsync(security, filePath, false) : Promise.resolve(false)
|
||||
}
|
||||
|
||||
/** Verifies and, only when that fails, rewrites the DACL on the calling thread. */
|
||||
export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean): boolean {
|
||||
const security = securityFor(targetPath)
|
||||
@@ -59,7 +65,7 @@ export function restrictWindowsPathSync(targetPath: string, isDirectory: boolean
|
||||
* restriction holds. Without the addon the file is written plainly and this is false; with it, a
|
||||
* failed create throws like any other write failure.
|
||||
*/
|
||||
export function writeRestrictedWindowsFile(targetPath: string, contents: string): boolean {
|
||||
export function writeRestrictedWindowsFile(targetPath: string, contents: string | Buffer): boolean {
|
||||
const security = securityFor(targetPath)
|
||||
if (!security) {
|
||||
writeFileSync(targetPath, contents, { encoding: 'utf-8', mode: 0o600 })
|
||||
@@ -68,7 +74,7 @@ export function writeRestrictedWindowsFile(targetPath: string, contents: string)
|
||||
security.addon.createFileWithDacl(
|
||||
targetPath,
|
||||
hardenedDaclSddl(security.userSid, false),
|
||||
Buffer.from(contents, 'utf-8')
|
||||
typeof contents === 'string' ? Buffer.from(contents, 'utf-8') : contents
|
||||
)
|
||||
// Why read it back: FAT32 and some shares accept the descriptor and silently drop it.
|
||||
return verifyAfterApply(security, targetPath, false)
|
||||
|
||||
Reference in New Issue
Block a user