diff --git a/docs/reference/headless-secret-storage.md b/docs/reference/headless-secret-storage.md new file mode 100644 index 00000000000..dfc5092c88e --- /dev/null +++ b/docs/reference/headless-secret-storage.md @@ -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-.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. diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts index 34797bb516b..8650f839990 100644 --- a/src/main/bitbucket/credential-store.test.ts +++ b/src/main/bitbucket/credential-store.test.ts @@ -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()), + 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 () => { diff --git a/src/main/bitbucket/credential-store.ts b/src/main/bitbucket/credential-store.ts index 1930a56de66..0ac9407825c 100644 --- a/src/main/bitbucket/credential-store.ts +++ b/src/main/bitbucket/credential-store.ts @@ -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') } diff --git a/src/main/credentials/encrypted-api-key-file-store.ts b/src/main/credentials/encrypted-api-key-file-store.ts index bc6d985f284..2038ac52367 100644 --- a/src/main/credentials/encrypted-api-key-file-store.ts +++ b/src/main/credentials/encrypted-api-key-file-store.ts @@ -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 }) + } } diff --git a/src/main/integration-credential-file-windows-acl.test.ts b/src/main/integration-credential-file-windows-acl.test.ts new file mode 100644 index 00000000000..8cd4eff4f2f --- /dev/null +++ b/src/main/integration-credential-file-windows-acl.test.ts @@ -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 }) + } + }) +}) diff --git a/src/main/integration-credential-file.ts b/src/main/integration-credential-file.ts index 01a85c28b51..3925dfabc71 100644 --- a/src/main/integration-credential-file.ts +++ b/src/main/integration-credential-file.ts @@ -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) diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 382303bd221..4fbc2a833bb 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -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) } diff --git a/src/main/linear/linear-credential-paths.ts b/src/main/linear/linear-credential-paths.ts index 9c842a4c78b..529e4a23bad 100644 --- a/src/main/linear/linear-credential-paths.ts +++ b/src/main/linear/linear-credential-paths.ts @@ -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') } diff --git a/src/main/linear/linear-token-store.ts b/src/main/linear/linear-token-store.ts index 06f62a6bb44..cbaab020f62 100644 --- a/src/main/linear/linear-token-store.ts +++ b/src/main/linear/linear-token-store.ts @@ -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) } diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts index ac18c8822b3..4a083ff33c8 100644 --- a/src/main/minimax/minimax-api-key-store.ts +++ b/src/main/minimax/minimax-api-key-store.ts @@ -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 diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index f75655b0e16..4e0bbb0765c 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -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) } diff --git a/src/main/opencode/opencode-go-api-key-store.ts b/src/main/opencode/opencode-go-api-key-store.ts index 212f9166255..fb3d946ae92 100644 --- a/src/main/opencode/opencode-go-api-key-store.ts +++ b/src/main/opencode/opencode-go-api-key-store.ts @@ -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 diff --git a/src/main/orcad/orcad-data-root-privacy.ts b/src/main/orcad/orcad-data-root-privacy.ts index 7d0ec24b3c3..ea2385a3b6c 100644 --- a/src/main/orcad/orcad-data-root-privacy.ts +++ b/src/main/orcad/orcad-data-root-privacy.ts @@ -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.' ) } diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 841a68629d9..e70866f8544 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -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 { + 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() diff --git a/src/main/orcad/orcad-host-adapters.ts b/src/main/orcad/orcad-host-adapters.ts index d375cb77088..0490c73f88f 100644 --- a/src/main/orcad/orcad-host-adapters.ts +++ b/src/main/orcad/orcad-host-adapters.ts @@ -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 { + 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) } diff --git a/src/main/orcad/orcad-os-keyring.test.ts b/src/main/orcad/orcad-os-keyring.test.ts new file mode 100644 index 00000000000..65a8a29a94c --- /dev/null +++ b/src/main/orcad/orcad-os-keyring.test.ts @@ -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 { + 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>() + 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>() + 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' + }) + }) +}) diff --git a/src/main/orcad/orcad-os-keyring.ts b/src/main/orcad/orcad-os-keyring.ts new file mode 100644 index 00000000000..e6d8b7a41d7 --- /dev/null +++ b/src/main/orcad/orcad-os-keyring.ts @@ -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 + +/** `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 + /** Resolves true only when the keyring reports success; callers still read the item back. */ + store(account: string, value: string): Promise +} + +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 { + 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 ` 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 +} diff --git a/src/main/orcad/orcad-plaintext-credential-migration.test.ts b/src/main/orcad/orcad-plaintext-credential-migration.test.ts new file mode 100644 index 00000000000..7df279843dc --- /dev/null +++ b/src/main/orcad/orcad-plaintext-credential-migration.test.ts @@ -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') + }) +}) diff --git a/src/main/orcad/orcad-plaintext-credential-migration.ts b/src/main/orcad/orcad-plaintext-credential-migration.ts new file mode 100644 index 00000000000..0be71d4c0f2 --- /dev/null +++ b/src/main/orcad/orcad-plaintext-credential-migration.ts @@ -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 + /** `plaintext:` becomes `encrypted:`. */ + 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 +} diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 36f8f0e71c5..3958818196c 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -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', () => ({ diff --git a/src/main/orcad/orcad-secret-key-record.ts b/src/main/orcad/orcad-secret-key-record.ts new file mode 100644 index 00000000000..9dfc37f229f --- /dev/null +++ b/src/main/orcad/orcad-secret-key-record.ts @@ -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 = { ...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 { + 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 + } +} diff --git a/src/main/orcad/orcad-secret-protection-gap.ts b/src/main/orcad/orcad-secret-protection-gap.ts index 46b411ebdc7..bb39707bfe4 100644 --- a/src/main/orcad/orcad-secret-protection-gap.ts +++ b/src/main/orcad/orcad-secret-protection-gap.ts @@ -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}.` + ) } diff --git a/src/main/orcad/orcad-secret-sealing.ts b/src/main/orcad/orcad-secret-sealing.ts new file mode 100644 index 00000000000..09598dbef8c --- /dev/null +++ b/src/main/orcad/orcad-secret-sealing.ts @@ -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' + ) +} diff --git a/src/main/orcad/orcad-secret-store.test.ts b/src/main/orcad/orcad-secret-store.test.ts new file mode 100644 index 00000000000..f20838e17b6 --- /dev/null +++ b/src/main/orcad/orcad-secret-store.test.ts @@ -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 + failure: OrcadKeyringLookup | null + storeWorks: boolean +} + +function fakeKeyring(): FakeKeyring { + const items = new Map() + const keyring: FakeKeyring = { + label: 'the test keyring', + items, + failure: null, + storeWorks: true, + async lookup(account: string): Promise { + 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 { + 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) + }) +}) diff --git a/src/main/orcad/orcad-secret-store.ts b/src/main/orcad/orcad-secret-store.ts new file mode 100644 index 00000000000..e0eca322f25 --- /dev/null +++ b/src/main/orcad/orcad-secret-store.ts @@ -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 +} + +// 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 { + 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 { + 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 { + 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' + } + } +} diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index c21bf554cd6..1c334b7b5be 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -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 }) diff --git a/src/main/zcode/zcode-plan-api-key-store.ts b/src/main/zcode/zcode-plan-api-key-store.ts index 320a1b3f794..523038bc64c 100644 --- a/src/main/zcode/zcode-plan-api-key-store.ts +++ b/src/main/zcode/zcode-plan-api-key-store.ts @@ -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) } diff --git a/src/shared/secure-path-windows-acl.ts b/src/shared/secure-path-windows-acl.ts index 4d595ff0bdf..955ffc60018 100644 --- a/src/shared/secure-path-windows-acl.ts +++ b/src/shared/secure-path-windows-acl.ts @@ -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 { + 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)