From 6a6e16fa5d94e84a4112ec23bd5dc95e933087ba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:00:53 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20OS-keychain=20encryption=20for?= =?UTF-8?q?=20stored=20secrets=20is=20now=20opt-in=20=E2=80=94=20no=20more?= =?UTF-8?q?=20macOS=20Keychain=20password=20prompt=20on=20every=20launch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Electron safeStorage parks a per-app key ('Hermes Key') in the macOS login keychain; on machines with a locked/missing/corrupted default keychain that turned every Hermes Desktop launch into a blocking 'Keychain Not Found' / password dialog. Keychain-backed encryption is now an explicit opt-in: - electron/secret-storage-policy.ts: standalone policy seam (default OFF, strict === true coercion, one-shot migration flag) + unit tests - default path never calls any safeStorage API (including isEncryptionAvailable, which itself touches the keychain) - one-shot legacy migration decrypts existing safeStorage blobs to plain 0600 files at first launch; undecryptable blobs are kept but read as absent afterward (classify 'drop') so a dead keychain prompts at most once - Settings -> Gateway toggle (all 5 locales) re-encodes every stored secret store in place when flipped (v1 connection.json, v2 connections.json, native-oauth-tokens.json) - e2e: at-rest spec now covers both postures (opted-in unchanged contract, default saves without secure storage, owner-only bits, restart round-trip) - docs: multi-connection-desktop + desktop-native-signin updated --- .../e2e/at-rest-connection-token.spec.ts | 68 +++- apps/desktop/electron/main.ts | 290 +++++++++++++++++- apps/desktop/electron/preload.ts | 4 + .../electron/secret-storage-policy.test.ts | 103 +++++++ .../desktop/electron/secret-storage-policy.ts | 102 ++++++ .../src/app/settings/gateway-settings.tsx | 49 ++- apps/desktop/src/global.d.ts | 5 + apps/desktop/src/i18n/ar.ts | 4 + apps/desktop/src/i18n/en.ts | 4 + apps/desktop/src/i18n/ja.ts | 4 + apps/desktop/src/i18n/types.ts | 3 + apps/desktop/src/i18n/zh-hant.ts | 4 + apps/desktop/src/i18n/zh.ts | 4 + website/docs/guides/desktop-native-signin.md | 19 +- .../user-guide/multi-connection-desktop.md | 22 +- 15 files changed, 648 insertions(+), 37 deletions(-) create mode 100644 apps/desktop/electron/secret-storage-policy.test.ts create mode 100644 apps/desktop/electron/secret-storage-policy.ts diff --git a/apps/desktop/e2e/at-rest-connection-token.spec.ts b/apps/desktop/e2e/at-rest-connection-token.spec.ts index 5ebf3ac662..8fc8914d48 100644 --- a/apps/desktop/e2e/at-rest-connection-token.spec.ts +++ b/apps/desktop/e2e/at-rest-connection-token.spec.ts @@ -523,10 +523,19 @@ test.afterEach(async () => { }) test.describe('remote gateway session token at rest', () => { - test('a newly configured token is never written to userData in plaintext, and still works after restart', async () => { + test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => { const fake = gateway! sandbox = createSandbox('at-rest-fresh') + // Keychain-backed encryption is opt-in (default OFF — see + // electron/secret-storage-policy.ts). This test covers the opted-IN + // posture, so seed the policy the way the Settings toggle writes it. + fs.writeFileSync( + path.join(sandbox.userDataDir, 'secure-token-storage.json'), + JSON.stringify({ migrated: true, on: true }), + 'utf8', + ) + const first = await launchAgainst(sandbox) app = first.app @@ -648,6 +657,63 @@ test.describe('remote gateway session token at rest', () => { ).toContain(SENTINEL_TOKEN) }) + /** + * The DEFAULT posture: keychain encryption opted out (no policy file at + * all). Saving a token must (a) succeed without ever touching safeStorage + * — this is the whole point of the opt-in: no macOS Keychain dialog on + * machines with a broken login keychain — (b) store the token with a + * non-safeStorage encoding at 0600, and (c) round-trip it across a + * restart. The plaintext-on-disk trade-off is the user's chosen (default) + * mode; owner-only file bits remain the at-rest boundary. + */ + test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => { + const fake = gateway! + sandbox = createSandbox('at-rest-default') + + const first = await launchAgainst(sandbox) + app = first.app + + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + + // Must succeed regardless of host keyring state — the default policy + // never consults safeStorage, so "no keyring" cannot refuse the save. + const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN) + + expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull() + expect(saved.config?.remoteTokenSet).toBe(true) + + // Not a safeStorage blob, and owner-only on disk. + expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage') + expectOwnerOnlyMode( + connectionFile, + 'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage', + ) + + // Round trip across a restart, same witness as the opted-in test. + await app.close().catch(() => undefined) + app = null + + const second = await launchAgainst(sandbox) + app = second.app + + const reread = await second.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true) + + const before = fake.sessionTokens.length + await exerciseStoredToken(second.page, fake.url) + + expect( + fake.sessionTokens.slice(before), + 'the app must send the exact stored token to the gateway after a restart', + ).toContain(SENTINEL_TOKEN) + }) + /** * The read side of the same contract: an install written BEFORE the file was * owner-only keeps its 0644 bits until something chmods it, and the write diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e9ac3b25e4..55408bb3d6 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -202,6 +202,13 @@ import { tightenSecretFileMode, writeSecretFileAtomic } from './hardening' +import { + classifyStoredSecret, + readSecretStoragePolicy, + SECRET_STORAGE_POLICY_FILE, + type SecretStoragePolicy, + writeSecretStoragePolicy +} from './secret-storage-policy' import { cursorPointInWindow } from './hud-cursor' import { startHudGameOverlayWatch } from './hud-game-overlay' import { applyHudResetBounds, defaultHudBounds } from './hud-geometry' @@ -8285,7 +8292,245 @@ async function cloudAgentSilentSignIn(dashboardUrl) { return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } } +// --------------------------------------------------------------------------- +// Opt-in keychain encryption (secret-storage-policy.ts owns the decision). +// Default OFF: no safeStorage call is ever made, so a broken/locked macOS +// login keychain can never throw its password dialog on launch. Settings → +// Gateway exposes the toggle; flipping it re-encrypts (or decrypts) the +// stored secrets in place. +// --------------------------------------------------------------------------- +const SECRET_STORAGE_POLICY_PATH = path.join(app.getPath('userData'), SECRET_STORAGE_POLICY_FILE) + +const _secretStoragePolicyIo = { + readText: () => fs.readFileSync(SECRET_STORAGE_POLICY_PATH, 'utf8'), + writeText: (text: string) => writeSecretFileAtomic(SECRET_STORAGE_POLICY_PATH, text, { encoding: 'utf8' }) +} + +let _secretStoragePolicy: SecretStoragePolicy | null = null + +function secretStoragePolicy(): SecretStoragePolicy { + if (!_secretStoragePolicy) { + _secretStoragePolicy = readSecretStoragePolicy(_secretStoragePolicyIo) + } + + return _secretStoragePolicy +} + +function setSecretStoragePolicy(next: SecretStoragePolicy) { + _secretStoragePolicy = { on: next.on === true, migrated: next.migrated === true } + writeSecretStoragePolicy(_secretStoragePolicy, _secretStoragePolicyIo) +} + +/** + * Keychain availability as the renderer should see it. With encryption + * opted out this must NOT probe safeStorage — isEncryptionAvailable() is + * itself a keychain touch that raises the macOS dialog this feature exists + * to avoid. We report `true` so no plain-text warning banners fire: storing + * plaintext is the user's chosen (default) mode, not a degraded state. + */ +function probeSecureTokenStorage(): boolean { + if (!secretStoragePolicy().on) { + return true + } + + try { + return Boolean(safeStorage.isEncryptionAvailable()) + } catch { + return false + } +} + +/** + * Rewrite every stored desktop secret (v1 connection.json token/headers + + * per-profile overrides, v2 registry connections, native OAuth token store) + * through `reencode`. Returns true when any store was rewritten. Shared by + * the one-shot legacy migration and the Settings encryption toggle. + */ +function rewriteAllStoredSecrets(shouldRewrite: (secret: any) => boolean, reencode: (secret: any) => any): boolean { + let touched = false + + const rewriteBlock = (block: any) => { + if (!block || typeof block !== 'object') { + return block + } + + const next = { ...block, ...(block.token ? { token: reencode(block.token) } : {}) } + + if (block.headers && typeof block.headers === 'object') { + next.headers = Object.fromEntries(Object.entries(block.headers).map(([k, v]) => [k, reencode(v)])) + } + + return next + } + + const blockNeedsRewrite = (o: any) => + shouldRewrite(o?.token) || + Object.values(o?.headers && typeof o.headers === 'object' ? o.headers : {}).some(shouldRewrite) + + // v1 connection.json. + const config = readDesktopConnectionConfig() + + if (blockNeedsRewrite(config.remote) || Object.values(config.profiles || {}).some(blockNeedsRewrite)) { + touched = true + writeDesktopConnectionConfig({ + ...config, + remote: rewriteBlock(config.remote), + profiles: Object.fromEntries(Object.entries(config.profiles || {}).map(([k, v]) => [k, rewriteBlock(v)])) + }) + } + + // v2 connections.json registry. + const registry = readDesktopConnectionsRegistry() + + if (registry.connections?.some(blockNeedsRewrite)) { + touched = true + writeDesktopConnectionsRegistry({ ...registry, connections: registry.connections.map(rewriteBlock) }) + } + + // Native OAuth token store: baseUrl → blob. + const io = _nativeTokenStoreIo() + + try { + const store = JSON.parse(io.readStoreText()) + + if (store && typeof store === 'object' && !Array.isArray(store)) { + const entries = Object.entries(store) + + if (entries.some(([, v]) => shouldRewrite(v))) { + touched = true + io.writeStoreText(JSON.stringify(Object.fromEntries(entries.map(([k, v]) => [k, reencode(v)])))) + } + } + } catch { + // Missing/corrupt native token store: nothing to rewrite. + } + + return touched +} + +/** + * One-shot legacy migration: builds before the opt-in policy wrote every + * secret as a safeStorage blob. With encryption now defaulting OFF, decrypt + * each stored blob once and rewrite it as plain so no future launch touches + * the keychain. Marked `migrated` whether or not every blob decrypts — a + * broken keychain costs at most ONE prompt (this pass), never one per + * launch; blobs that would not decrypt are left in place and simply read as + * absent from then on (classifyStoredSecret → 'drop'), so opting encryption + * back ON later can still recover them on a healthy keychain. + * + * Runs before createWindow() so every later read sees the final encodings. + */ +function migrateLegacyEncryptedSecretsOnce() { + const policy = secretStoragePolicy() + + if (policy.on || policy.migrated) { + return + } + + const needsMigration = (secret: any) => classifyStoredSecret(secret, policy) === 'migrate' + const reencode = (secret: any) => { + if (!needsMigration(secret)) { + return secret + } + + const plaintext = decryptDesktopSecret(secret) + + // Undecryptable now (locked/absent keychain): keep the blob for a + // potential future opt-in, but post-migration reads treat it as unset. + return plaintext ? { encoding: 'plain', value: plaintext } : secret + } + + let touchedKeychain = false + + try { + touchedKeychain = rewriteAllStoredSecrets(needsMigration, reencode) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + + rememberLog(`[secret-storage] legacy migration pass failed: ${detail}`) + } + + setSecretStoragePolicy({ on: false, migrated: true }) + + if (touchedKeychain) { + rememberLog('[secret-storage] migrated legacy keychain-encrypted secrets to opt-out storage (one-shot pass)') + } +} + +/** + * Settings → Gateway toggle: flip keychain-backed encryption and re-encode + * every stored secret to match. Turning ON encrypts plain blobs through + * strict safeStorage (throws loudly when the keychain is unusable — the + * toggle stays off and the renderer shows the error). Turning OFF decrypts + * back to plain; this is user-initiated, so a keychain prompt here is + * expected and acceptable. + */ +function applySecretStorageEncryption(on: boolean) { + const enable = on === true + + if (secretStoragePolicy().on === enable) { + return { on: enable } + } + + if (enable) { + const needsEncrypt = (secret: any) => secret?.encoding === 'plain' && Boolean(secret.value) + + // Probe FIRST so an unusable keychain fails before any store is touched. + if (!(() => { + try { + return Boolean(safeStorage.isEncryptionAvailable()) + } catch { + return false + } + })()) { + throw new Error( + 'OS keychain encryption is unavailable on this machine, so stored gateway secrets cannot be encrypted.' + ) + } + + setSecretStoragePolicy({ on: true, migrated: true }) + + try { + rewriteAllStoredSecrets(needsEncrypt, secret => + needsEncrypt(secret) ? encryptDesktopSecretStrict(String(secret.value), safeStorage) : secret + ) + } catch (error) { + // Encryption failed midway: revert the policy so reads keep working + // against whatever encodings are on disk (mixed stores read fine — + // decryptDesktopSecret handles both encodings under either policy). + setSecretStoragePolicy({ on: false, migrated: true }) + throw error + } + + return { on: true } + } + + // Turning OFF: decrypt everything back to plain while the keychain is + // still readable, then flip the policy. + const needsDecrypt = (secret: any) => secret?.encoding === SAFE_STORAGE_ENCODING + + rewriteAllStoredSecrets(needsDecrypt, (secret: any) => { + if (!needsDecrypt(secret)) { + return secret + } + + const plaintext = decryptDesktopSecret(secret) + + return plaintext ? { encoding: 'plain', value: plaintext } : secret + }) + + setSecretStoragePolicy({ on: false, migrated: true }) + + return { on: false } +} + function encryptDesktopSecret(value, options = {}) { + if (!secretStoragePolicy().on) { + const raw = String(value || '') + + return raw ? { encoding: 'plain', value: raw } : null + } + return encryptDesktopSecretStrict(value, safeStorage, options) } @@ -8301,6 +8546,14 @@ function decryptDesktopSecret(secret) { } if (secret.encoding === SAFE_STORAGE_ENCODING) { + // Legacy blob under an opted-out policy: once the one-shot migration pass + // has run, never touch safeStorage again — a dead keychain would otherwise + // prompt on every read. Before that pass, decryption is allowed so the + // migration itself (and this launch's reads) can recover the value. + if (classifyStoredSecret(secret, secretStoragePolicy()) === 'drop') { + return '' + } + try { return safeStorage.decryptString(Buffer.from(value, 'base64')) } catch { @@ -8728,15 +8981,10 @@ function sanitizeRegistryConnection(entry) { } function sanitizeConnectionsRegistry(registry = readDesktopConnectionsRegistry()) { - // Same keyring probe the v1 sanitize exposes: lets the Connections panel + // Same keyring signal the v1 sanitize exposes: lets the Connections panel // offer the plain-text opt-in on keyring-less Linux instead of failing. - let secureTokenStorage = false - - try { - secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable()) - } catch { - secureTokenStorage = false - } + // Policy-aware: never touches safeStorage while encryption is opted out. + const secureTokenStorage = probeSecureTokenStorage() return { version: registry.version, @@ -8860,15 +9108,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // Whether the OS keyring (safeStorage) can encrypt the saved token. When // false the renderer knows to offer the plain-text opt-in in Settings → - // Gateway. safeStorage.isEncryptionAvailable can throw on some platforms, so - // treat any failure as "not available". - let secureTokenStorage = false - - try { - secureTokenStorage = Boolean(safeStorage.isEncryptionAvailable()) - } catch { - secureTokenStorage = false - } + // Gateway. With keychain encryption opted out (the default) this reports + // true WITHOUT touching safeStorage — probing is itself a keychain touch + // that raises the macOS password dialog (see probeSecureTokenStorage). + const secureTokenStorage = probeSecureTokenStorage() // Whether the currently saved token is stored in plain text (the keyring-less // opt-in path). The env override supplies its token from the environment, not @@ -12949,6 +13192,12 @@ ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => { }) ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload)) +// ── Opt-in keychain encryption for stored secrets ─────────────────────────── +// get returns the current policy without touching safeStorage; set flips it +// and re-encodes every stored secret (see applySecretStorageEncryption). +ipcMain.handle('hermes:secret-storage:get', async () => ({ on: secretStoragePolicy().on })) +ipcMain.handle('hermes:secret-storage:set', async (_event: any, on: any) => applySecretStorageEncryption(on === true)) + // ── v2 connection registry IPC (multi-source) ─────────────────────────────── // Storage-level CRUD for named agent sources. Routing/pooling consumption of // the registry lands separately; these handlers only manage the persisted @@ -15469,6 +15718,13 @@ app.whenReady().then(() => { safeStorageApi: safeStorage }) + // Keychain encryption is opt-in (default OFF). One-shot: rewrite any + // legacy safeStorage-encrypted secrets as plain so no later launch ever + // touches the OS keychain unless the user turns encryption on in + // Settings → Gateway. Must run before createWindow() and the first + // connection resolution. + migrateLegacyEncryptedSecretsOnce() + if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index c438221032..143b1e74a7 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -165,6 +165,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', { saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload), testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload), + // Opt-in OS-keychain encryption for stored gateway secrets (default off — + // see secret-storage-policy.ts). get never touches the OS keychain. + getSecretStorageEncryption: () => ipcRenderer.invoke('hermes:secret-storage:get'), + setSecretStorageEncryption: (on: boolean) => ipcRenderer.invoke('hermes:secret-storage:set', on), // v2 multi-connection registry: named agent sources (local / remote / cloud / ssh). connections: { list: () => ipcRenderer.invoke('hermes:connections:list'), diff --git a/apps/desktop/electron/secret-storage-policy.test.ts b/apps/desktop/electron/secret-storage-policy.test.ts new file mode 100644 index 0000000000..f6a3f5ef38 --- /dev/null +++ b/apps/desktop/electron/secret-storage-policy.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for electron/secret-storage-policy.ts — the "is OS-keychain + * encryption enabled at all?" decision seam. + * + * The behavior this file pins: keychain-backed encryption is OPT-IN + * (default OFF), and once the one-shot legacy migration has run, a + * safeStorage blob under an opted-out policy reads as 'drop' — i.e. the + * caller must treat it as absent WITHOUT touching safeStorage, so a broken + * macOS login keychain can never raise its password dialog on launch. + * + * (Wired into the vitest `electron` project via electron/**\/*.test.ts.) + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + classifyStoredSecret, + readSecretStoragePolicy, + type SecretStoragePolicyIo, + writeSecretStoragePolicy +} from './secret-storage-policy' + +function fakeIo(initial: string | null = null): SecretStoragePolicyIo & { fileText: () => string | null } { + let text = initial + + return { + readText: () => { + if (text === null) { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + } + + return text + }, + writeText: (next: string) => { + text = next + }, + fileText: () => text + } +} + +// ── defaults ──────────────────────────────────────────────────────────────── + +test('missing policy file defaults to encryption OFF, not migrated', () => { + const policy = readSecretStoragePolicy(fakeIo()) + + assert.deepEqual(policy, { on: false, migrated: false }) +}) + +test('corrupt or non-object policy file reads as the default', () => { + for (const bad of ['not-json', '[]', '"on"', 'null', '123']) { + assert.deepEqual(readSecretStoragePolicy(fakeIo(bad)), { on: false, migrated: false }) + } +}) + +test('truthy-but-not-true values do NOT enable encryption', () => { + // Strict === true coercion: a hand-edited "on": 1 or "yes" must not turn + // keychain prompts back on. + for (const bad of ['{"on":1}', '{"on":"yes"}', '{"on":"true"}']) { + assert.equal(readSecretStoragePolicy(fakeIo(bad)).on, false) + } +}) + +test('round trip preserves both fields', () => { + const io = fakeIo() + + writeSecretStoragePolicy({ on: true, migrated: true }, io) + assert.deepEqual(readSecretStoragePolicy(io), { on: true, migrated: true }) + + writeSecretStoragePolicy({ on: false, migrated: true }, io) + assert.deepEqual(readSecretStoragePolicy(io), { on: false, migrated: true }) +}) + +// ── classification ────────────────────────────────────────────────────────── + +const SAFE_BLOB = { encoding: 'safeStorage', value: 'AAAA' } +const PLAIN_BLOB = { encoding: 'plain', value: 'tok' } + +test('non-safeStorage blobs are always keep, under every policy', () => { + for (const policy of [ + { on: false, migrated: false }, + { on: false, migrated: true }, + { on: true, migrated: true } + ]) { + assert.equal(classifyStoredSecret(PLAIN_BLOB, policy), 'keep') + assert.equal(classifyStoredSecret(null, policy), 'keep') + assert.equal(classifyStoredSecret(undefined, policy), 'keep') + assert.equal(classifyStoredSecret({} as any, policy), 'keep') + } +}) + +test('safeStorage blob with encryption ON is keep', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: true, migrated: true }), 'keep') +}) + +test('safeStorage blob, encryption OFF, pre-migration is migrate', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: false, migrated: false }), 'migrate') +}) + +test('safeStorage blob, encryption OFF, post-migration is drop — never touch the keychain again', () => { + assert.equal(classifyStoredSecret(SAFE_BLOB, { on: false, migrated: true }), 'drop') +}) diff --git a/apps/desktop/electron/secret-storage-policy.ts b/apps/desktop/electron/secret-storage-policy.ts new file mode 100644 index 0000000000..707dbb0d61 --- /dev/null +++ b/apps/desktop/electron/secret-storage-policy.ts @@ -0,0 +1,102 @@ +/** + * secret-storage-policy.ts + * + * Single owner of the "do we use the OS keychain at all?" decision for + * desktop-stored secrets (remote gateway tokens, CF Access headers, native + * OAuth token sets). + * + * Why this exists: Electron safeStorage on macOS parks a per-app key + * ("Hermes Key") in the login keychain. On machines with a locked, missing, + * or corrupted default keychain, ANY safeStorage touch — including + * isEncryptionAvailable() — makes macOS throw a blocking "Keychain Not + * Found" / password dialog on every launch. That is an unacceptable default + * for a chat app, so keychain-backed encryption is OPT-IN: + * + * - Setting OFF (default): secrets are written with encoding 'plain' and + * NO safeStorage API is ever called. decryptDesktopSecret already + * returns non-safeStorage encodings verbatim, so reads need no change. + * - Setting ON: the previous behavior — strict safeStorage encryption, + * loud failure when the keychain is unavailable, per-save plain-text + * confirm dialog as the escape hatch. + * + * Legacy blobs written before the flag existed are safeStorage-encoded on + * disk. With the setting OFF we attempt ONE migration pass (decrypt → + * rewrite as plain). The pass is recorded in the same settings file whether + * or not it succeeds, so a broken keychain costs at most one prompt on the + * first post-update launch — never one per launch. + * + * Kept standalone (no `import 'electron'`) so it unit-tests under the + * electron vitest project, same pattern as native-token-store.ts. main.ts + * injects the file path and fs. + */ + +export interface SecretStoragePolicy { + /** Keychain-backed encryption enabled (explicit user opt-in). */ + on: boolean + /** One-shot legacy-blob migration already attempted. */ + migrated: boolean +} + +export const SECRET_STORAGE_POLICY_FILE = 'secure-token-storage.json' + +export interface SecretStoragePolicyIo { + readText: () => string + writeText: (text: string) => void +} + +/** + * Normalize whatever is on disk into a policy. Anything unreadable, + * unparseable, or hand-mangled is the default: encryption OFF, migration + * not yet attempted. `on` uses strict `=== true` — a truthy-but-not-true + * value must not silently enable keychain prompts (mirrors the + * allowPlainText coercion rule in hardening.ts). + */ +export function readSecretStoragePolicy(io: SecretStoragePolicyIo): SecretStoragePolicy { + try { + const parsed = JSON.parse(io.readText()) + + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { on: parsed.on === true, migrated: parsed.migrated === true } + } + } catch { + // fall through to default + } + + return { on: false, migrated: false } +} + +export function writeSecretStoragePolicy(policy: SecretStoragePolicy, io: SecretStoragePolicyIo): void { + io.writeText(JSON.stringify({ on: policy.on === true, migrated: policy.migrated === true })) +} + +/** One stored secret blob as it appears on disk. */ +interface StoredSecret { + encoding?: string + value?: string +} + +/** + * Decide what to do with one stored blob under the current policy. + * + * - 'keep' — blob is fine as-is under this policy. + * - 'migrate' — safeStorage blob while encryption is OFF and migration has + * not run: caller should decrypt once and rewrite as plain. + * - 'drop' — safeStorage blob while encryption is OFF and the migration + * pass already ran (i.e. it could not be decrypted last + * time): treat as absent WITHOUT touching safeStorage, so a + * dead keychain never prompts again. + */ +export function classifyStoredSecret( + secret: StoredSecret | null | undefined, + policy: SecretStoragePolicy +): 'keep' | 'migrate' | 'drop' { + if (!secret || typeof secret !== 'object' || secret.encoding !== 'safeStorage') { + return 'keep' + } + + if (policy.on) { + return 'keep' + } + + return policy.migrated ? 'drop' : 'migrate' +} diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 3f923a6607..4a384d4eb1 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -28,7 +28,7 @@ import { notify, notifyError, readableError } from '@/store/notifications' import { ConnectionsRegistrySection } from './connections-registry' import { CONTROL_TEXT } from './constants' -import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives' +import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives' import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' type Mode = 'local' | 'remote' | 'cloud' | 'ssh' @@ -158,6 +158,46 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { const contextSeq = useRef(0) const [connectedCloudUrl, setConnectedCloudUrl] = useState('') + // Opt-in OS-keychain encryption for stored gateway secrets. Read lazily via + // IPC (never touches the keychain); flipping it re-encodes stored secrets + // in the main process and can legitimately prompt for keychain access. + const [keychainEncryption, setKeychainEncryptionState] = useState(false) + const [keychainEncryptionBusy, setKeychainEncryptionBusy] = useState(false) + + useEffect(() => { + let cancelled = false + + void window.hermesDesktop + ?.getSecretStorageEncryption?.() + .then(res => { + if (!cancelled && res) { + setKeychainEncryptionState(res.on === true) + } + }) + .catch(() => {}) + + return () => { + cancelled = true + } + }, []) + + const setKeychainEncryption = async (on: boolean) => { + setKeychainEncryptionBusy(true) + // Optimistic paint; the IPC result (or a failure rollback) gets the last word. + setKeychainEncryptionState(on) + + try { + const res = await window.hermesDesktop.setSecretStorageEncryption(on) + + setKeychainEncryptionState(res?.on === true) + } catch (err) { + setKeychainEncryptionState(!on) + notifyError(err, g.keychainEncryptionFailed) + } finally { + setKeychainEncryptionBusy(false) + } + } + const acceptSavedConfig = (config: GatewaySettingsState) => { setState(config) setConnectedCloudUrl(savedCloudConnectionUrl(config)) @@ -1483,6 +1523,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {embedded ? null : (