feat(desktop): OS-keychain encryption for stored secrets is now opt-in — no more macOS Keychain password prompt on every launch
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
This commit is contained in:
@@ -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
|
||||
|
||||
+273
-17
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 : (
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ToggleRow
|
||||
checked={keychainEncryption}
|
||||
description={g.keychainEncryptionDesc}
|
||||
disabled={keychainEncryptionBusy}
|
||||
label={g.keychainEncryptionTitle}
|
||||
onChange={on => void setKeychainEncryption(on)}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
|
||||
|
||||
Vendored
+5
@@ -145,6 +145,11 @@ declare global {
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
applyConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
testConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionTestResult>
|
||||
// Opt-in OS-keychain encryption for stored gateway secrets (default
|
||||
// off). `get` never touches the OS keychain; `set` re-encodes stored
|
||||
// secrets and can throw when the keychain is unusable.
|
||||
getSecretStorageEncryption: () => Promise<{ on: boolean }>
|
||||
setSecretStorageEncryption: (on: boolean) => Promise<{ on: boolean }>
|
||||
// v2 multi-connection registry: named agent sources, all persisted
|
||||
// together (local + any number of remote/cloud/ssh instances).
|
||||
connections: {
|
||||
|
||||
@@ -766,6 +766,10 @@ export const ar = defineLocale({
|
||||
existingToken: value => `رمز موجود ${value}`,
|
||||
savedToken: 'محفوظ',
|
||||
pasteSessionToken: 'ألصق رمز الجلسة',
|
||||
keychainEncryptionTitle: 'تشفير الأسرار المحفوظة باستخدام سلسلة مفاتيح النظام',
|
||||
keychainEncryptionDesc:
|
||||
'معطّل افتراضياً. عند التفعيل، تُشفَّر رموز البوابة وبيانات تسجيل الدخول باستخدام سلسلة مفاتيح النظام (Keychain Access أو GNOME Keyring أو Windows DPAPI) — وقد يطلب النظام إذناً أو كلمة مرور. عند التعطيل، تُخزَّن كملفات عادية لا يقرؤها سوى حساب المستخدم الحالي.',
|
||||
keychainEncryptionFailed: 'تعذّر تغيير إعداد تشفير الأسرار',
|
||||
testRemote: 'اختبار البعيد',
|
||||
saveForRestart: 'حفظ للتشغيل القادم',
|
||||
saveAndReconnect: 'حفظ وإعادة الاتصال',
|
||||
|
||||
@@ -887,6 +887,10 @@ export const en: Translations = {
|
||||
plainTextStoredTitle: 'Token stored in plain text',
|
||||
plainTextStoredDesc:
|
||||
'Secure storage is unavailable, so the saved token is stored unencrypted in the app’s connection settings file on this machine. Install or enable GNOME Keyring or KWallet to encrypt it.',
|
||||
keychainEncryptionTitle: 'Encrypt saved secrets with the OS keychain',
|
||||
keychainEncryptionDesc:
|
||||
'Off by default. When on, gateway tokens and sign-in credentials are encrypted with your system keychain (Keychain Access, GNOME Keyring, or Windows DPAPI) — your system may ask for permission or a password. When off, they are stored as plain files readable only by your user account.',
|
||||
keychainEncryptionFailed: 'Could not change secret encryption',
|
||||
testRemote: 'Test remote',
|
||||
saveForRestart: 'Save for next restart',
|
||||
saveAndReconnect: 'Save and reconnect',
|
||||
|
||||
@@ -835,6 +835,10 @@ export const ja = defineLocale({
|
||||
plainTextStoredTitle: 'トークンは平文で保存されています',
|
||||
plainTextStoredDesc:
|
||||
'セキュアストレージが利用できないため、保存済みのトークンはこのマシンのアプリの接続設定ファイルに暗号化されずに保存されています。暗号化するには GNOME Keyring または KWallet をインストールまたは有効化してください。',
|
||||
keychainEncryptionTitle: 'OS キーチェーンで保存済みのシークレットを暗号化',
|
||||
keychainEncryptionDesc:
|
||||
'デフォルトはオフです。オンにすると、ゲートウェイのトークンとサインイン資格情報がシステムのキーチェーン(Keychain Access、GNOME Keyring、Windows DPAPI)で暗号化されます。システムから許可やパスワードを求められる場合があります。オフの場合は、現在のユーザーのみが読める通常ファイルとして保存されます。',
|
||||
keychainEncryptionFailed: 'シークレット暗号化の設定を変更できませんでした',
|
||||
testRemote: 'リモートをテスト',
|
||||
saveForRestart: '次回起動時のために保存',
|
||||
saveAndReconnect: '保存して再接続',
|
||||
|
||||
@@ -754,6 +754,9 @@ export interface Translations {
|
||||
plainTextConfirmAction: string
|
||||
plainTextStoredTitle: string
|
||||
plainTextStoredDesc: string
|
||||
keychainEncryptionTitle: string
|
||||
keychainEncryptionDesc: string
|
||||
keychainEncryptionFailed: string
|
||||
testRemote: string
|
||||
saveForRestart: string
|
||||
saveAndReconnect: string
|
||||
|
||||
@@ -809,6 +809,10 @@ export const zhHant = defineLocale({
|
||||
plainTextStoredTitle: 'Token 以純文字儲存',
|
||||
plainTextStoredDesc:
|
||||
'安全儲存無法使用,因此已儲存的 Token 以未加密方式儲存在此裝置上應用程式的連線設定檔中。請安裝或啟用 GNOME Keyring 或 KWallet 以將其加密。',
|
||||
keychainEncryptionTitle: '使用系統鑰匙圈加密已儲存的機密',
|
||||
keychainEncryptionDesc:
|
||||
'預設關閉。開啟後,閘道 Token 與登入憑證將使用系統鑰匙圈(Keychain Access、GNOME Keyring 或 Windows DPAPI)加密——系統可能會要求授權或密碼。關閉時,它們以僅目前使用者可讀的一般檔案形式儲存。',
|
||||
keychainEncryptionFailed: '無法變更機密加密設定',
|
||||
testRemote: '測試遠端',
|
||||
saveForRestart: '儲存至下次重新啟動',
|
||||
saveAndReconnect: '儲存並重新連線',
|
||||
|
||||
@@ -1085,6 +1085,10 @@ export const zh: Translations = {
|
||||
plainTextStoredTitle: 'Token 以明文存储',
|
||||
plainTextStoredDesc:
|
||||
'安全存储不可用,因此已保存的 token 以未加密方式存储在此设备上应用的连接设置文件中。请安装或启用 GNOME Keyring 或 KWallet 以对其加密。',
|
||||
keychainEncryptionTitle: '使用系统钥匙串加密已保存的机密',
|
||||
keychainEncryptionDesc:
|
||||
'默认关闭。开启后,网关 token 和登录凭据将使用系统钥匙串(Keychain Access、GNOME Keyring 或 Windows DPAPI)加密——系统可能会请求授权或密码。关闭时,它们以仅当前用户可读的普通文件形式存储。',
|
||||
keychainEncryptionFailed: '无法更改机密加密设置',
|
||||
testRemote: '测试远程',
|
||||
saveForRestart: '保存到下次重启',
|
||||
saveAndReconnect: '保存并重连',
|
||||
|
||||
@@ -12,8 +12,10 @@ ways:
|
||||
|
||||
1. **Native sign-in (RFC 8252)** — the app opens your **real system browser**,
|
||||
you approve in the browser you already trust, and the app receives tokens it
|
||||
stores in your OS keychain. **No embedded webview, no browser session
|
||||
cookies.** This is the default whenever the gateway supports it.
|
||||
stores as owner-only files in its user-data directory (optionally encrypted
|
||||
with your OS keychain — Settings → Gateway). **No embedded webview, no
|
||||
browser session cookies.** This is the default whenever the gateway
|
||||
supports it.
|
||||
2. **Embedded sign-in (legacy fallback)** — the app opens a small in-app
|
||||
browser window and captures the gateway's session cookie. Used automatically
|
||||
when the gateway is an older build that doesn't advertise native sign-in.
|
||||
@@ -37,9 +39,10 @@ For Hermes specifically, native sign-in means:
|
||||
Firefox / Edge — whatever you use — with your logins, extensions, and
|
||||
passkeys intact.
|
||||
- **No session cookies.** The app holds an OAuth **access token** (short-lived)
|
||||
and **refresh token**, encrypted at rest via your OS keychain (Electron
|
||||
`safeStorage`). REST calls and WebSocket tickets are authenticated with an
|
||||
`Authorization: Bearer` header, not a cookie jar.
|
||||
and **refresh token**, stored as owner-only files — encrypted at rest via
|
||||
your OS keychain (Electron `safeStorage`) when the opt-in keychain toggle in
|
||||
Settings → Gateway is on. REST calls and WebSocket tickets are authenticated
|
||||
with an `Authorization: Bearer` header, not a cookie jar.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -53,7 +56,7 @@ Desktop app Gateway (/auth/native/*) Nous Portal (IDP)
|
||||
│ ◄─ 302 127.0.0.1/cb?code=… ─┘
|
||||
│ 4. POST /auth/native/token (code + PKCE verifier)
|
||||
│ ◄─ 5. { access_token, refresh_token, expires_at } ───────┘
|
||||
│ 6. store in OS keychain; use Bearer for REST + WS tickets
|
||||
│ 6. store in local token store; use Bearer for REST + WS tickets
|
||||
```
|
||||
|
||||
The gateway **brokers** the flow: it is the authorization server *to the
|
||||
@@ -88,11 +91,11 @@ tool blocks the loopback listener, or you close the browser tab — the app
|
||||
every REST call and when minting a WebSocket ticket.
|
||||
- **Refresh token**: longer-lived, rotating. When the access token is near
|
||||
expiry the app calls `/auth/native/refresh` to rotate both tokens, then
|
||||
updates the keychain.
|
||||
updates its token store.
|
||||
- **Terminal expiry**: if the refresh token is dead (expired / revoked /
|
||||
reuse-detected), the app clears its stored tokens and prompts a fresh
|
||||
sign-in.
|
||||
- **Sign out**: clears both the native tokens (keychain) and any legacy session
|
||||
- **Sign out**: clears both the stored native tokens and any legacy session
|
||||
cookie for that gateway.
|
||||
|
||||
## For gateway operators
|
||||
|
||||
@@ -247,16 +247,18 @@ the desktop app itself last. See
|
||||
|
||||
## Security notes
|
||||
|
||||
- **Where tokens live.** Remote-gateway session tokens are encrypted at rest
|
||||
with Electron's `safeStorage` (the OS keychain — Keychain on macOS, DPAPI
|
||||
on Windows, the session keyring backend on Linux) and stay in the Electron
|
||||
main process; the renderer and plugins never see token bytes. OAuth tokens
|
||||
for native sign-in are stored the same way, keyed by gateway base URL, and
|
||||
refreshed automatically before expiry.
|
||||
- **Keyring-less Linux.** On a Linux session without a usable keychain the
|
||||
app cannot encrypt the token; saving one raises an explicit opt-in dialog
|
||||
before it will store the
|
||||
token in plain text.
|
||||
- **Where tokens live.** Remote-gateway session tokens (and native sign-in
|
||||
OAuth tokens, keyed by gateway base URL) are stored in the app's user-data
|
||||
directory as owner-only (0600) files, in the Electron main process; the
|
||||
renderer and plugins never see token bytes.
|
||||
- **Optional keychain encryption.** By default the tokens are **not** run
|
||||
through the OS keychain — on macOS in particular, Electron's `safeStorage`
|
||||
parks a per-app key in the login keychain, and a locked or broken keychain
|
||||
turns that into a password prompt on every launch. If you want at-rest
|
||||
encryption on top of the file permissions, turn on **Settings → Gateway →
|
||||
"Encrypt saved secrets with the OS keychain"**; existing stored secrets are
|
||||
re-encrypted in place (Keychain on macOS, DPAPI on Windows, the session
|
||||
keyring backend on Linux). Turning it back off decrypts them again.
|
||||
- **The registry file** (`connections.json` under the app's user-data
|
||||
directory) holds labels, URLs, and hosts — secrets only ever appear inside
|
||||
encrypted envelopes.
|
||||
|
||||
Reference in New Issue
Block a user