mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
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.
71 lines
2.5 KiB
TypeScript
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
|
|
}
|