diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 3acf649e6d53c..0549f8cef28fa 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import { test } from 'vitest' @@ -12,9 +12,11 @@ import { DATA_URL_READ_DEFAULT_MAX_MB, dataUrlReadMaxBytesFromMb, DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret, readFileDataUrlForIpc, resolveDirectoryForIpc, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, @@ -103,6 +105,219 @@ test('encryptDesktopSecret stores safeStorage base64 payload', () => { }) }) +test('encryptDesktopSecret allows plain-text opt-in when encryption is unavailable', () => { + const secret = encryptDesktopSecret( + 'token', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { encoding: 'plain', value: 'token' }) +}) + +test('encryptDesktopSecret keeps encrypting when available even with the plain-text opt-in', () => { + const secret = encryptDesktopSecret( + 'token-123', + { isEncryptionAvailable: () => true, encryptString: value => Buffer.from(`enc:${value}`, 'utf8') }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('encryptDesktopSecret returns null for an empty value even with the plain-text opt-in', () => { + assert.equal( + encryptDesktopSecret( + '', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ), + null + ) +}) + +test('enableBasicPasswordStoreEncryption flips the flag once on linux with --password-store=basic', () => { + const calls: boolean[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: (value: boolean) => calls.push(value) + } + + const result = enableBasicPasswordStoreEncryption({ + platform: 'linux', + passwordStoreSwitch: 'basic', + safeStorageApi + }) + + assert.equal(result, true) + assert.deepEqual(calls, [true]) +}) + +test('enableBasicPasswordStoreEncryption ignores non-basic password-store values on linux', () => { + for (const passwordStoreSwitch of ['gnome-libsecret', '', undefined]) { + const calls: unknown[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: () => calls.push('called') + } + + const result = enableBasicPasswordStoreEncryption({ + platform: 'linux', + passwordStoreSwitch, + safeStorageApi + }) + + assert.equal(result, false, `value ${JSON.stringify(passwordStoreSwitch)} must not enable plain text`) + assert.deepEqual(calls, []) + } +}) + +test('enableBasicPasswordStoreEncryption never enables plain text off linux even with --password-store=basic', () => { + for (const platform of ['win32', 'darwin']) { + const calls: unknown[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: () => calls.push('called') + } + + const result = enableBasicPasswordStoreEncryption({ + platform, + passwordStoreSwitch: 'basic', + safeStorageApi + }) + + assert.equal(result, false, `platform ${platform} must not enable plain text`) + assert.deepEqual(calls, []) + } +}) + +test('enableBasicPasswordStoreEncryption tolerates a missing setUsePlainTextEncryption method', () => { + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi: {} }), + false + ) + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi: undefined }), + false + ) +}) + +test('enableBasicPasswordStoreEncryption swallows a throwing setUsePlainTextEncryption', () => { + const safeStorageApi = { + setUsePlainTextEncryption: () => { + throw new Error('backend not ready') + } + } + + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi }), + false + ) +}) + +test('resolvePersistedRemoteToken stores plain text end-to-end only with the explicit opt-in', () => { + const unavailableSafeStorage = { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) } + const encryptSecret = (value: string, options: any) => encryptDesktopSecret(value, unavailableSafeStorage, options) + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: true, + existingToken: undefined, + allowPlainText: true, + encryptSecret + }), + { encoding: 'plain', value: 'token' } + ) + + // Only strict boolean true opts in; undefined, false, and truthy-non-true + // values must all keep the secure-storage requirement (which throws when the + // keyring is unavailable). + for (const allowPlainText of [undefined, false, 1, 'yes']) { + assert.throws( + () => + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: true, + existingToken: undefined, + allowPlainText, + encryptSecret + }), + /Secure token storage is unavailable/, + `allowPlainText ${JSON.stringify(allowPlainText)} must not enable plain-text storage` + ) + } +}) + +test('resolvePersistedRemoteToken keeps encrypting when the keyring is available even with the opt-in', () => { + const availableSafeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf8') + } + + const encryptSecret = (value: string, options: any) => encryptDesktopSecret(value, availableSafeStorage, options) + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token-123', + persistToken: true, + existingToken: undefined, + allowPlainText: true, + encryptSecret + }), + { encoding: 'safeStorage', value: Buffer.from('enc:token-123', 'utf8').toString('base64') } + ) +}) + +test('resolvePersistedRemoteToken passes the token through untouched on the transient path', () => { + let called = false + + const encryptSecret = () => { + called = true + + return null + } + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: false, + existingToken: { encoding: 'safeStorage', value: 'stale' }, + allowPlainText: false, + encryptSecret + }), + { encoding: 'plain', value: 'token' } + ) + assert.equal(called, false, 'the transient test-connection path must not touch secure storage') +}) + +test('resolvePersistedRemoteToken keeps the existing token when no new token is supplied', () => { + let called = false + + const encryptSecret = () => { + called = true + + return null + } + + const existingToken = { encoding: 'safeStorage', value: 'kept' } + + assert.equal( + resolvePersistedRemoteToken({ + incomingToken: '', + persistToken: true, + existingToken, + allowPlainText: true, + encryptSecret + }), + existingToken + ) + assert.equal(called, false, 'an empty incoming token must not re-encrypt anything') +}) + test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) @@ -352,3 +567,95 @@ test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () fs.rmSync(tempDir, { recursive: true, force: true }) } }) + +// main.ts has no module.exports, so the wiring of the extracted keyring-less +// helpers into the main process follows the repo's source-assertion pattern +// (see windows-hermes-resolution.test.ts). These pin the propagation the PR +// reviewer flagged as untested: the connection-config IPC path forwarding +// allowPlainTextToken through resolvePersistedRemoteToken, and the whenReady +// --password-store=basic startup branch. +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function readMain() { + return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') +} + +test('coerceDesktopConnectionConfig routes token persistence through resolvePersistedRemoteToken', () => { + const source = readMain() + const fnStart = source.indexOf('function coerceDesktopConnectionConfig(') + assert.notEqual(fnStart, -1, 'coerceDesktopConnectionConfig must exist in main.ts') + const fnEnd = source.indexOf('\nfunction ', fnStart + 1) + const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) + + assert.match( + body, + /const nextToken = resolvePersistedRemoteToken\(\{/, + 'the persist decision must go through the shared hardening helper' + ) + // The opt-in must be forwarded RAW (no `=== true` at the call site): the + // helper owns the strict coercion so it is asserted in exactly one place. + assert.match( + body, + /allowPlainText: input\.allowPlainTextToken\b/, + 'allowPlainTextToken must reach the helper so the IPC opt-in propagates' + ) + assert.doesNotMatch( + body, + /allowPlainText: input\.allowPlainTextToken === true/, + 'the strict coercion must live in the helper, not be duplicated at the call site' + ) + assert.match(body, /encryptSecret: encryptDesktopSecret\b/, 'the helper must encrypt via encryptDesktopSecret') +}) + +test('connection-config save and apply IPC handlers route payloads through coerceDesktopConnectionConfig', () => { + const source = readMain() + + for (const channel of ['hermes:connection-config:save', 'hermes:connection-config:apply']) { + const handlerStart = source.indexOf(`ipcMain.handle('${channel}'`) + assert.notEqual(handlerStart, -1, `${channel} handler must exist`) + const handlerBody = source.slice(handlerStart, handlerStart + 400) + assert.match( + handlerBody, + /coerceDesktopConnectionConfig\(payload\)/, + `${channel} must coerce its payload (the propagation seam) before persisting` + ) + } +}) + +test('whenReady enables basic password-store encryption before createWindow', () => { + const source = readMain() + const enableIndex = source.indexOf('enableBasicPasswordStoreEncryption({') + assert.notEqual(enableIndex, -1, 'whenReady must call enableBasicPasswordStoreEncryption') + + const call = source.slice(enableIndex, enableIndex + 240) + assert.match(call, /platform: process\.platform/, 'the real platform must be forwarded') + assert.match( + call, + /passwordStoreSwitch: app\.commandLine\.getSwitchValue\('password-store'\)/, + 'the real --password-store switch value must be forwarded' + ) + assert.match(call, /safeStorageApi: safeStorage/, 'the real safeStorage must be forwarded') + + // Ordering matters: the switch must take effect before anything touches + // safeStorage, so the enable call must precede the first createWindow(). + const createWindowIndex = source.indexOf('createWindow()', enableIndex) + assert.notEqual(createWindowIndex, -1, 'whenReady must call createWindow after enabling encryption') + assert.ok( + enableIndex < createWindowIndex, + 'enableBasicPasswordStoreEncryption must run before createWindow() so the switch is applied first' + ) +}) + +test('sanitizeDesktopConnectionConfig exposes secureTokenStorage and remoteTokenPlainText', () => { + const source = readMain() + const fnStart = source.indexOf('async function sanitizeDesktopConnectionConfig(') + assert.notEqual(fnStart, -1, 'sanitizeDesktopConnectionConfig must exist in main.ts') + const fnEnd = source.indexOf('\nfunction ', fnStart + 1) + const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) + + const returnIndex = body.indexOf('return {') + assert.notEqual(returnIndex, -1, 'sanitizeDesktopConnectionConfig must return a sanitized object') + const returned = body.slice(returnIndex) + assert.match(returned, /\bsecureTokenStorage\b/, 'the renderer needs the secure-storage availability signal') + assert.match(returned, /\bremoteTokenPlainText\b/, 'the renderer needs the plain-text token signal') +}) diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index e671dc259b794..9df4fd3db4ec5 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -48,13 +48,18 @@ function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) { return fallback } -function encryptDesktopSecret(value, safeStorageApi) { +function encryptDesktopSecret(value, safeStorageApi, options: { allowPlainText?: boolean } = {}) { const raw = String(value || '') if (!raw) { return null } + // Opt-in escape hatch for keyring-less Linux (e.g. Hyprland/Sway with no + // GNOME Keyring or KWallet): the renderer sets this once the user confirms + // the plain-text storage prompt in Settings → Gateway. + const allowPlainText = options?.allowPlainText === true + let encryptionAvailable = false try { @@ -64,9 +69,18 @@ function encryptDesktopSecret(value, safeStorageApi) { } if (!encryptionAvailable) { + // Only downgrade to plain text when the user has explicitly opted in; + // decryptDesktopSecret returns the raw value for any non-'safeStorage' + // encoding, so this round-trips without any decrypt-side change. + if (allowPlainText) { + return { encoding: 'plain', value: raw } + } + throw new Error( - 'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' + - 'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.' + 'Secure token storage is unavailable (no OS keyring service was found), so Hermes Desktop cannot save remote gateway tokens. ' + + 'Either enable an OS keyring (e.g. GNOME Keyring or KWallet providing org.freedesktop.secrets) and try again, ' + + 'confirm the plain-text storage option when prompted in Settings → Gateway, ' + + 'or set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment.' ) } @@ -84,6 +98,69 @@ function encryptDesktopSecret(value, safeStorageApi) { } } +// Keyring-less Linux (e.g. Hyprland/Sway with no GNOME Keyring or KWallet): +// `--password-store=basic` selects Electron's built-in "basic" backend, but +// Electron only counts it as available once setUsePlainTextEncryption(true) is +// called. The caller runs this on whenReady, before createWindow() and anything +// that could touch safeStorage, so the switch takes effect for the whole run. +// +// Semantics are deliberately narrow: only linux, only the exact 'basic' switch +// value (never 'gnome-libsecret', 'kwallet', '', etc.), and only when the +// method exists (older/mocked safeStorage may lack it) and does not throw. +// Anything else is a no-op. Returns true only when it actually flipped the flag, +// so the caller (and tests) can distinguish "enabled" from "left untouched". +// Never throws: a failure here is non-fatal — encryption simply stays +// unavailable and the user can fall back to the plain-text opt-in or the +// HERMES_DESKTOP_REMOTE_* env vars. +function enableBasicPasswordStoreEncryption({ platform, passwordStoreSwitch, safeStorageApi }: any = {}) { + if (platform !== 'linux' || passwordStoreSwitch !== 'basic') { + return false + } + + try { + if (typeof safeStorageApi?.setUsePlainTextEncryption === 'function') { + safeStorageApi.setUsePlainTextEncryption(true) + + return true + } + } catch { + // Non-fatal: fall through and report that encryption was not enabled. + } + + return false +} + +// The token-persistence seam shared by the connection-config save/apply IPC +// path. Given the incoming edit, decide what token block to persist: +// - No incoming token: keep the existing block's token untouched (edits that +// don't retype the token must not clear it). +// - persistToken false (the transient test-connection path): store the raw +// value as a plain block WITHOUT touching secure storage — it is never +// written to disk, so there is nothing to protect. +// - Otherwise: run the incoming token through the injected encryptSecret +// (encryptDesktopSecret in production), forwarding the plain-text opt-in. +// +// The plain-text opt-in is coerced with `=== true` HERE so the strictness lives +// in one place: a truthy-but-not-true value (1, 'yes', etc.) must NOT silently +// enable plain-text storage. Callers pass `allowPlainText` through raw. +function resolvePersistedRemoteToken({ + incomingToken, + persistToken, + existingToken, + allowPlainText, + encryptSecret +}: any = {}) { + if (!incomingToken) { + return existingToken + } + + if (!persistToken) { + return { encoding: 'plain', value: incomingToken } + } + + return encryptSecret(incomingToken, { allowPlainText: allowPlainText === true }) +} + function sensitiveFileBlockReason(filePath) { const normalized = String(filePath || '') .replace(/\\/g, '/') @@ -354,10 +431,12 @@ export { DATA_URL_READ_MIN_MAX_MB, dataUrlReadMaxBytesFromMb, DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret, readFileDataUrlForIpc, rejectUnsafePathSyntax, resolveDirectoryForIpc, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 09a59287c09bb..7c294f3307d8e 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -129,8 +129,10 @@ import { DATA_URL_READ_DEFAULT_MAX_MB, dataUrlReadMaxBytesFromMb, DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret as encryptDesktopSecretStrict, readFileDataUrlForIpc, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, @@ -6637,8 +6639,8 @@ async function cloudAgentSilentSignIn(dashboardUrl) { return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) } } -function encryptDesktopSecret(value) { - return encryptDesktopSecretStrict(value, safeStorage) +function encryptDesktopSecret(value, options = {}) { + return encryptDesktopSecretStrict(value, safeStorage, options) } function decryptDesktopSecret(secret) { @@ -6849,6 +6851,23 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '') const mode = envOverride ? 'remote' : savedMode === 'ssh' ? 'ssh' : modeIsRemoteLike(savedMode) ? savedMode : 'local' + // 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 + } + + // 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 + // the saved block, so it never reports as plain text here. + const remoteTokenPlainText = !envOverride && block.token?.encoding === 'plain' + let remoteOauthConnected = false if (authMode === 'oauth' && remoteUrl) { @@ -6877,6 +6896,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon cloudOrg: mode === 'cloud' ? String(block.org || '') : '', remoteTokenPreview: tokenPreview(remoteToken), remoteTokenSet: Boolean(remoteToken), + // Whether the OS keyring can encrypt a token; drives the plain-text opt-in + // affordance in Settings → Gateway on keyring-less Linux. + secureTokenStorage, + // Whether the saved token is currently persisted in plain text. + remoteTokenPlainText, sshHost: (ssh || savedSsh)?.host || '', sshUser: (ssh || savedSsh)?.user || '', sshPort: (ssh || savedSsh)?.port || null, @@ -6945,11 +6969,18 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo const cloudOrg = mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : '' const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : '' - const nextToken = incomingToken - ? persistToken - ? encryptDesktopSecret(incomingToken) - : { encoding: 'plain', value: incomingToken } - : existingBlock.token + // Persist decision lives in hardening.resolvePersistedRemoteToken so the + // IPC-propagation seam (allowPlainTextToken → encryptDesktopSecret opt-in) is + // covered by a focused regression test. Pass allowPlainText through RAW — the + // helper coerces with `=== true`, so a truthy-non-true value never enables + // plain-text storage, and that strictness is asserted in exactly one place. + const nextToken = resolvePersistedRemoteToken({ + incomingToken, + persistToken, + existingToken: existingBlock.token, + allowPlainText: input.allowPlainTextToken, + encryptSecret: encryptDesktopSecret + }) if (mode === 'ssh') { const sshBlock = buildSshBlock(input, savedProfileSsh(existing, key) || rawExistingBlock) @@ -12319,6 +12350,15 @@ app.whenReady().then(() => { rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`) } + // Keyring-less Linux `--password-store=basic` support. This must run before + // createWindow() and anything that could touch safeStorage; the narrow + // platform/switch/guard semantics live in the extracted helper. + enableBasicPasswordStoreEncryption({ + platform: process.platform, + passwordStoreSwitch: app.commandLine.getSwitchValue('password-store'), + safeStorageApi: safeStorage + }) + if (IS_MAC) { Menu.setApplicationMenu(buildApplicationMenu()) } else { diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 23773ae525618..14aeb1aa71bc3 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useMemo, useRef, useState } from 'react' import { Button } from '@/components/ui/button' +import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Tip } from '@/components/ui/tooltip' @@ -24,7 +25,7 @@ import { import { coerceRemoteUrlScheme } from '@/lib/remote-url' import { selectableCardClass } from '@/lib/selectable-card' import { cn } from '@/lib/utils' -import { notify, notifyError } from '@/store/notifications' +import { notify, notifyError, readableError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' @@ -44,6 +45,12 @@ interface GatewaySettingsState { remoteOauthConnected: boolean remoteTokenPreview: string | null remoteTokenSet: boolean + // Whether OS-keychain-backed encryption (Electron safeStorage) is available. + // Default true so we never gate on a value we haven't hydrated yet. + secureTokenStorage: boolean + // Whether the currently-persisted remote token is stored as plain text on + // disk (opted-in on a machine without secure storage). Drives the warning banner. + remoteTokenPlainText: boolean remoteUrl: string cloudOrg: string sshHost: string @@ -63,6 +70,8 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: '', cloudOrg: '', sshHost: '', @@ -172,6 +181,11 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { setConnectedCloudUrl(savedCloudConnectionUrl(config)) } + // When set, the plain-text opt-in dialog is open; `apply` remembers whether + // the gated action was Save-for-restart (false) or Save-and-reconnect (true) + // so confirm resumes the right one. + const [plainTextConfirm, setPlainTextConfirm] = useState(null) + // --- Hermes Cloud (cloud mode) state --- // One portal session powers discovery + the silent per-agent cascade. These // track the cloud panel: whether we're signed in, the discovered agent list, @@ -440,7 +454,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { return Boolean(remoteToken.trim()) || state.remoteTokenSet }, [authMode, oauthConnected, remoteToken, state.remoteTokenSet, trimmedUrl]) - const payload = () => ({ + const payload = (allowPlainTextToken?: boolean) => ({ mode: state.mode, profile: scope ?? undefined, remoteAuthMode: authMode, @@ -453,28 +467,28 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { sshRemoteHermesPath: state.sshRemoteHermesPath.trim(), // Preserve an intentional blank so an existing remote-profile mapping can // be cleared instead of being mistaken for an omitted field. - sshRemoteProfile: state.sshRemoteProfile.trim() + sshRemoteProfile: state.sshRemoteProfile.trim(), + ...(allowPlainTextToken ? { allowPlainTextToken: true } : {}) }) - const save = async (apply: boolean) => { + // A pending Save/Apply would write a NEW token to disk in plain text when + // we're on a remote-like connection using token auth, the user typed a token, + // and this machine has no OS keyring (safeStorage unavailable). In that case + // we must get an explicit opt-in before persisting. + const wouldPersistPlainTextToken = + (state.mode === 'remote' || state.mode === 'cloud') && + authMode !== 'oauth' && + Boolean(remoteToken.trim()) && + state.secureTokenStorage === false + + const performSave = async (apply: boolean, allowPlainTextToken: boolean) => { const seq = ++saveSeq.current - - if (state.mode === 'remote' && !canUseRemote) { - notify({ - kind: 'warning', - title: g.incompleteTitle, - message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken - }) - - return - } - setSaving(true) try { const next = apply - ? await window.hermesDesktop.applyConnectionConfig(payload()) - : await window.hermesDesktop.saveConnectionConfig(payload()) + ? await window.hermesDesktop.applyConnectionConfig(payload(allowPlainTextToken)) + : await window.hermesDesktop.saveConnectionConfig(payload(allowPlainTextToken)) if (seq !== saveSeq.current) { return @@ -492,6 +506,13 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { return } + // The plain-text opt-in path runs inside ConfirmDialog's onConfirm, which + // keeps the dialog open with an inline error when it throws — rethrow a + // readable message there so a failed save can't play the success beat. + if (allowPlainTextToken) { + throw new Error(readableError(err, apply ? g.applyFailed : g.saveFailed).message) + } + const sshError = err && typeof err === 'object' && 'sshError' in err ? String(err.sshError) : '' const errors = { @@ -520,6 +541,27 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } } + const save = async (apply: boolean) => { + if (state.mode === 'remote' && !canUseRemote) { + notify({ + kind: 'warning', + title: g.incompleteTitle, + message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken + }) + + return + } + + // Defer to the opt-in dialog; confirm resumes with allowPlainTextToken. + if (wouldPersistPlainTextToken) { + setPlainTextConfirm({ apply }) + + return + } + + await performSave(apply, false) + } + // OAuth sign-in: persist the URL + oauth mode first (so the saved config has // the URL the login window needs), then open the gateway login window and // refresh the connection status from the saved config once it completes. @@ -1315,25 +1357,39 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { {/* Session-token gateways: keep the existing token entry box. */} {state.mode === 'remote' && authResolved && authMode === 'token' ? ( - setRemoteToken(event.target.value)} - placeholder={ - state.remoteTokenSet - ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) - : g.pasteSessionToken - } - type="password" - value={remoteToken} - /> - } - description={g.tokenDesc} - title={g.tokenTitle} - /> + <> + setRemoteToken(event.target.value)} + placeholder={ + state.remoteTokenSet + ? g.existingToken(state.remoteTokenPreview ?? g.savedToken) + : g.pasteSessionToken + } + type="password" + value={remoteToken} + /> + } + description={g.tokenDesc} + title={g.tokenTitle} + /> + + {/* The saved token is on disk in plain text (no OS keyring). Same + banner idiom as envOverride so it reads as a real warning. */} + {state.remoteTokenPlainText ? ( +
+ +
+
{g.plainTextStoredTitle}
+
{g.plainTextStoredDesc}
+
+
+ ) : null} + ) : null} ) : null} @@ -1516,6 +1572,24 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { /> )} + + {/* Plain-text token opt-in: gated when secure storage is unavailable and a + new token would be persisted. Confirm resumes the remembered save/apply. */} + setPlainTextConfirm(null)} + onConfirm={async () => { + if (!plainTextConfirm) { + return + } + + await performSave(plainTextConfirm.apply, true) + }} + open={plainTextConfirm !== null} + title={g.plainTextConfirmTitle} + /> ) } diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts index 78b58329ce577..202ecff0935f0 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -20,6 +20,8 @@ function config(overrides: Partial = {}): DesktopConnec remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: 'https://box:9119', cloudOrg: '', sshHost: '', diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 43116d0335cd5..7c52ed5c17917 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -569,6 +569,14 @@ export interface DesktopConnectionConfig { remoteOauthConnected: boolean remoteTokenPreview: string | null remoteTokenSet: boolean + // Whether OS-keychain-backed encryption (Electron safeStorage) is currently + // available on this machine. When false, a persisted remote token can only be + // stored as plain text on disk (with an explicit opt-in). + secureTokenStorage: boolean + // Whether the currently-persisted remote token is stored with encoding + // 'plain' (i.e. plain text on disk in connection.json), which happens when + // the user opted in on a machine without secure storage. + remoteTokenPlainText: boolean remoteUrl: string // For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the // connected instance was discovered under, so Settings → Gateway can reopen @@ -589,6 +597,10 @@ export interface DesktopConnectionConfigInput { profile?: null | string remoteAuthMode?: 'oauth' | 'token' remoteToken?: string + // When true and secure (OS-keychain) storage is unavailable, persist the + // remote token as plain text on disk instead of failing. Requires an explicit + // user opt-in from the renderer. + allowPlainTextToken?: boolean remoteUrl?: string // For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to // persist so Settings can reopen into it. Ignored for remote/local modes. diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index da7386f185fdd..1d7c819f275ee 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -693,6 +693,13 @@ export const en: Translations = { existingToken: value => `Existing token ${value}`, savedToken: 'saved', pasteSessionToken: 'Paste session token', + plainTextConfirmTitle: 'Store the gateway token in plain text?', + plainTextConfirmDesc: + 'No OS keyring service was found on this machine, so the token would be saved unencrypted in the app’s connection settings file, readable by any process running as this user. Install or enable GNOME Keyring or KWallet for encrypted storage.', + plainTextConfirmAction: 'Save as plain text', + 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.', testRemote: 'Test remote', saveForRestart: 'Save for next restart', saveAndReconnect: 'Save and reconnect', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 740fbb2bb47b9..03d8854aeba33 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -746,6 +746,13 @@ export const ja = defineLocale({ existingToken: value => `既存のトークン ${value}`, savedToken: '保存済み', pasteSessionToken: 'セッショントークンを貼り付け', + plainTextConfirmTitle: 'ゲートウェイトークンを平文で保存しますか?', + plainTextConfirmDesc: + 'このマシンで OS のキーリングサービスが見つからなかったため、トークンはアプリの接続設定ファイルに暗号化されずに保存され、このユーザーとして実行される任意のプロセスから読み取れる状態になります。暗号化して保存するには、GNOME Keyring または KWallet をインストールまたは有効化してください。', + plainTextConfirmAction: '平文で保存', + plainTextStoredTitle: 'トークンは平文で保存されています', + plainTextStoredDesc: + 'セキュアストレージが利用できないため、保存済みのトークンはこのマシンのアプリの接続設定ファイルに暗号化されずに保存されています。暗号化するには GNOME Keyring または KWallet をインストールまたは有効化してください。', testRemote: 'リモートをテスト', saveForRestart: '次回起動時のために保存', saveAndReconnect: '保存して再接続', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index d611086143091..3c7499ce5bcc2 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -579,6 +579,11 @@ export interface Translations { existingToken: (value: string) => string savedToken: string pasteSessionToken: string + plainTextConfirmTitle: string + plainTextConfirmDesc: string + plainTextConfirmAction: string + plainTextStoredTitle: string + plainTextStoredDesc: string testRemote: string saveForRestart: string saveAndReconnect: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index fb84840259e34..0076b4081dd79 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -722,6 +722,13 @@ export const zhHant = defineLocale({ existingToken: value => `現有 Token ${value}`, savedToken: '已儲存', pasteSessionToken: '貼上工作階段 Token', + plainTextConfirmTitle: '以純文字儲存閘道 Token?', + plainTextConfirmDesc: + '在此裝置上找不到作業系統的金鑰環服務,因此 Token 將以未加密的純文字儲存在應用程式的連線設定檔中,以該使用者身分執行的任何處理程序皆可讀取。請安裝或啟用 GNOME Keyring 或 KWallet 以進行加密儲存。', + plainTextConfirmAction: '以純文字儲存', + plainTextStoredTitle: 'Token 以純文字儲存', + plainTextStoredDesc: + '安全儲存無法使用,因此已儲存的 Token 以未加密方式儲存在此裝置上應用程式的連線設定檔中。請安裝或啟用 GNOME Keyring 或 KWallet 以將其加密。', testRemote: '測試遠端', saveForRestart: '儲存至下次重新啟動', saveAndReconnect: '儲存並重新連線', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 71eb545f70f01..0108834f3a74d 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -897,6 +897,13 @@ export const zh: Translations = { existingToken: value => `现有 token ${value}`, savedToken: '已保存', pasteSessionToken: '粘贴会话 token', + plainTextConfirmTitle: '以明文存储网关 token?', + plainTextConfirmDesc: + '在此设备上未找到操作系统的密钥环服务,因此 token 将以未加密的明文保存在应用的连接设置文件中,以该用户身份运行的任何进程都可读取。请安装或启用 GNOME Keyring 或 KWallet 以进行加密存储。', + plainTextConfirmAction: '以明文保存', + plainTextStoredTitle: 'Token 以明文存储', + plainTextStoredDesc: + '安全存储不可用,因此已保存的 token 以未加密方式存储在此设备上应用的连接设置文件中。请安装或启用 GNOME Keyring 或 KWallet 以对其加密。', testRemote: '测试远程', saveForRestart: '保存到下次重启', saveAndReconnect: '保存并重连', diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 3709b8fe6fe1e..50b3f36b546ff 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -142,7 +142,9 @@ function summarizeErrorMessage(message: string, fallback: string) { return message.length > 180 ? fallback : message || fallback } -function readableError(error: unknown, fallback: string): { message: string; detail?: string } { +// Exported so flows that surface errors inline (e.g. ConfirmDialog's onConfirm +// rethrow) can reuse the same IPC-unwrapping + summarizing as notifyError. +export function readableError(error: unknown, fallback: string): { message: string; detail?: string } { const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw const cleaned = cleanErrorText(unwrapped) diff --git a/contributors/emails/github.commits@widow.cc b/contributors/emails/github.commits@widow.cc new file mode 100644 index 0000000000000..a98ce53522de3 --- /dev/null +++ b/contributors/emails/github.commits@widow.cc @@ -0,0 +1 @@ +Zeus-Deus