Files
orca/src/shared/secret-store.ts
T
Neil 838f5bfb75 fix(secrets): tell Linux users when their secrets are only obfuscated (#16033)
On Linux with no keyring, Electron falls back to the `basic_text` backend, which
"encrypts" with a hardcoded password. `isEncryptionAvailable()` returns true for
it, so Orca reported those secrets as sealed. They are not.

The obvious fix — returning false for basic_text — is wrong and would have been a
credential regression: `decryptWithStatus()` skips decryption entirely when
encryption is unavailable, so every already-stored secret would read back empty.
Sealing genuinely works on basic_text and must keep working.

So capability and trust are now separate questions. `isEncryptionAvailable()`
still answers "can this host seal and unseal", and `describeProtectionGap()`
(renamed from `describeUnavailable`) answers "is my data actually protected",
covering both no-sealing and weak-sealing.

That method had no production caller — the port documented a promise nothing
kept. `reportSecretProtectionGap()` now reads it at startup. A user-visible
surface is follow-up; this at least stops the silence.

Adds a bootstrap wiring guard over all nine host port installs. The no-op
defaults are correct for a renderer-less host and silently wrong for the desktop,
and a dropped or reordered install fails no existing test. Verified in both
directions: it fails when an install is removed, and when one moves after the
runtime is constructed.
2026-08-22 22:30:11 -07:00

71 lines
2.5 KiB
TypeScript

/**
* SecretStore abstracts at-rest secret encryption that the desktop gets from
* Electron's `safeStorage` (OS keychain). A plain-Node host installs its own
* implementation so core modules never import `electron`.
*
* The contract mirrors safeStorage exactly, including the part that matters most:
* `isEncryptionAvailable()` may return false. A store that cannot seal must say so
* rather than throw; how a caller degrades is its own decision (persistence retains
* the prior sealed blob rather than writing plaintext). See `describeProtectionGap()`,
* which exists so the reason reaches the user, not a console warning nobody reads.
*/
export type SecretStore = {
isEncryptionAvailable(): boolean
encryptString(plainText: string): Buffer
decryptString(cipher: Buffer): string
/**
* Why separate from `isEncryptionAvailable()`: that answers "can this host seal and
* unseal at all", which must stay true for a backend that seals weakly — flipping it
* would stop `decryptWithStatus()` even attempting, and every already-stored
* credential would read back empty. This answers the different question a user cares
* about: "is my data actually protected at rest?"
*
* Non-null means the protection is not what a user would assume — either no sealing,
* or sealing with a backend that does not meaningfully protect. Null means sealed.
*/
describeProtectionGap(): string | null
}
/**
* Why a global symbol and not a module-level `let`: `vi.resetModules()` gives the
* re-imported graph a fresh copy of this module, so a store installed before the reset
* would silently read back as uninstalled — and `getSecretStore()` throws on that.
* Anchoring to the realm keeps one instance per process however often the module
* registry is rebuilt.
*/
const SLOT = Symbol.for('orca.host.secretStore')
type Slot = { [SLOT]?: SecretStore | null }
function slot(): Slot {
return globalThis as unknown as Slot
}
function read(): SecretStore | null {
return slot()[SLOT] ?? null
}
export function setSecretStore(store: SecretStore): void {
slot()[SLOT] = store
}
export function getSecretStore(): SecretStore {
const current = read()
if (!current) {
throw new Error(
'SecretStore not initialized — call setSecretStore() during startup before reading or writing secrets'
)
}
return current
}
export function hasSecretStore(): boolean {
return read() !== null
}
/** Test-only: drop the installed store so suites do not leak one across files. */
export function _resetSecretStoreForTests(): void {
slot()[SLOT] = null
}