From e879d133da3cceff4e30a90caf317ea690edf605 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Fri, 10 Jul 2026 23:36:54 +0200 Subject: [PATCH 1/3] fix(desktop): allow remote gateway token storage on keyring-less Linux On Linux without a Secret Service keyring (e.g. Hyprland/Sway with no GNOME Keyring or KWallet), safeStorage.isEncryptionAvailable() is false, so saving a remote gateway session token from Settings -> Gateway failed hard with no in-app way forward. - encryptDesktopSecret gains an explicit allowPlainText opt-in: when secure storage is unavailable and the user confirmed the prompt, the token persists as { encoding: 'plain' } in connection.json (which decryptDesktopSecret already round-trips). - Settings -> Gateway now surfaces the opt-in: a destructive confirm dialog before persisting a token in plain text, and a persistent warning banner while the saved token is stored unencrypted. Localized in en/ja/zh/zh-hant. - The connection-config IPC response reports secureTokenStorage and remoteTokenPlainText so the renderer can drive both affordances. - Launching with --password-store=basic now works: on Linux the app calls safeStorage.setUsePlainTextEncryption(true) at startup when the switch is set, which Electron requires for the basic backend to count as available. - The no-opt-in error now spells out all three remedies (enable an OS keyring, confirm plain-text storage, or use HERMES_DESKTOP_REMOTE_URL/ HERMES_DESKTOP_REMOTE_TOKEN). Fixes #62294 --- apps/desktop/electron/hardening.test.ts | 34 ++++ apps/desktop/electron/hardening.ts | 20 ++- apps/desktop/electron/main.ts | 44 +++++- .../src/app/settings/gateway-settings.tsx | 145 +++++++++++++----- .../components/boot-failure-reauth.test.ts | 2 + apps/desktop/src/global.d.ts | 12 ++ apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/ja.ts | 7 + apps/desktop/src/i18n/types.ts | 5 + apps/desktop/src/i18n/zh-hant.ts | 7 + apps/desktop/src/i18n/zh.ts | 7 + apps/desktop/src/store/notifications.ts | 4 +- 12 files changed, 252 insertions(+), 42 deletions(-) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 1a5852f720af3..4aa6e650768a0 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -55,6 +55,40 @@ 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('sensitiveFileBlockReason blocks obvious secret file patterns', () => { assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) diff --git a/apps/desktop/electron/hardening.ts b/apps/desktop/electron/hardening.ts index 2d6b533100184..9673f314f01ba 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -23,13 +23,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 { @@ -39,9 +44,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.' ) } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 646eefc6eb2d9..bdf88007be258 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5909,8 +5909,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) { @@ -6098,6 +6098,23 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon const savedMode = key ? scoped?.mode : config.mode const mode = envOverride ? 'remote' : 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) { @@ -6124,6 +6141,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, // The env override only forces the global/primary connection; a per-profile // scope is never overridden by HERMES_DESKTOP_REMOTE_URL. envOverride @@ -6187,7 +6209,7 @@ function coerceDesktopConnectionConfig(input: any = {}, existing = readDesktopCo const nextToken = incomingToken ? persistToken - ? encryptDesktopSecret(incomingToken) + ? encryptDesktopSecret(incomingToken, { allowPlainText: input.allowPlainTextToken === true }) : { encoding: 'plain', value: incomingToken } : existingBlock.token @@ -9541,6 +9563,22 @@ app.whenReady().then(() => { rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`) } + // 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. Do this before createWindow() and anything that could touch + // safeStorage. Older/mocked safeStorage may lack the method, so guard + wrap. + if (process.platform === 'linux' && app.commandLine.getSwitchValue('password-store') === 'basic') { + try { + if (typeof safeStorage.setUsePlainTextEncryption === 'function') { + safeStorage.setUsePlainTextEncryption(true) + } + } catch { + // 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. + } + } + 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 ca7ce2384f9c0..a70f1eceb360d 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 { Tip } from '@/components/ui/tooltip' import type { DesktopAuthProvider, DesktopCloudAgent, DesktopCloudOrg, DesktopConnectionProbeResult } from '@/global' @@ -10,7 +11,7 @@ import { ExternalLink } from '@/lib/external-link' import { AlertCircle, Check, Cloud, FileText, Globe, HelpCircle, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons' 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' @@ -29,6 +30,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 } @@ -40,6 +47,8 @@ const EMPTY_STATE: GatewaySettingsState = { remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: '', cloudOrg: '' } @@ -135,6 +144,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, @@ -351,31 +365,32 @@ 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, remoteToken: authMode === 'token' ? remoteToken.trim() || undefined : undefined, - remoteUrl: trimmedUrl + remoteUrl: trimmedUrl, + ...(allowPlainTextToken ? { allowPlainTextToken: true } : {}) }) - const save = async (apply: boolean) => { - if (state.mode === 'remote' && !canUseRemote) { - notify({ - kind: 'warning', - title: g.incompleteTitle, - message: authMode === 'oauth' ? g.incompleteSignIn : g.incompleteToken - }) - - return - } - + // 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) => { 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)) acceptSavedConfig(next) setRemoteToken('') @@ -385,12 +400,40 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { message: apply ? g.restartingMessage : g.savedMessage }) } catch (err) { + // 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) + } + notifyError(err, apply ? g.applyFailed : g.saveFailed) } finally { setSaving(false) } } + 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. @@ -1010,25 +1053,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} @@ -1083,6 +1140,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 5d198c96e416f..f1a2163fc0d48 100644 --- a/apps/desktop/src/components/boot-failure-reauth.test.ts +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -19,6 +19,8 @@ function config(overrides: Partial = {}): DesktopConnec remoteOauthConnected: false, remoteTokenPreview: null, remoteTokenSet: false, + secureTokenStorage: true, + remoteTokenPlainText: false, remoteUrl: 'https://box:9119', cloudOrg: '', ...overrides diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb49c..dd731f9422f6c 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -429,6 +429,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 @@ -443,6 +451,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 7765c0f7468f5..85700a7756d98 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -617,6 +617,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 fcc27776201ef..811865b416cd6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -689,6 +689,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 40f224f2b242a..b1e576276a732 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -520,6 +520,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 4c543feb986a8..58a40802ae301 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -668,6 +668,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 07c01b7395e6a..7d64fdf274039 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -811,6 +811,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 82a67e973127e..d1213bfc18965 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -112,7 +112,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) From af400e1d1ca8ccfe20c0e44b16e95c9202accc7a Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Sat, 11 Jul 2026 22:13:12 +0200 Subject: [PATCH 2/3] test(desktop): pin the plain text opt-in propagation and the basic store startup Review feedback asked for regression coverage of the main process pieces: the connection-config save and apply IPC path that carries allowPlainTextToken down to encryptDesktopSecret, and the Linux --password-store=basic startup branch. main.ts has no exports, so both pieces now live as small injected helpers in hardening.ts next to encryptDesktopSecret. The whenReady block became enableBasicPasswordStoreEncryption, which only acts on linux with the exact basic switch value, tolerates a missing or throwing setUsePlainTextEncryption, and reports whether it actually flipped the flag. The token persistence ternary became resolvePersistedRemoteToken, which owns the strict opt-in coercion in one place: a truthy value that is not exactly true never enables plain text storage. main.ts passes the raw payload field through, so the strictness itself is what the tests pin. hardening.test.ts grows behavioral cases for both helpers, including the full path through the real encryptDesktopSecret for the opt-in, the never downgrade rule when the keyring is available, and the transient test connection passthrough. The wiring inside main.ts (save and apply routing through coerceDesktopConnectionConfig, the raw field handoff, the startup call ordered before createWindow, and the secureTokenStorage and remoteTokenPlainText fields in the sanitized response) is pinned with the repo's source assertion pattern. --- apps/desktop/electron/hardening.test.ts | 275 +++++++++++++++++++++++- apps/desktop/electron/hardening.ts | 65 ++++++ apps/desktop/electron/main.ts | 42 ++-- 3 files changed, 361 insertions(+), 21 deletions(-) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts index 4aa6e650768a0..be10a9e7ee38d 100644 --- a/apps/desktop/electron/hardening.test.ts +++ b/apps/desktop/electron/hardening.test.ts @@ -2,14 +2,16 @@ 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' import { DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret, resolveDirectoryForIpc, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, @@ -89,6 +91,185 @@ test('encryptDesktopSecret returns null for an empty value even with the plain-t ) }) +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) @@ -338,3 +519,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 9673f314f01ba..cd4512c8f161e 100644 --- a/apps/desktop/electron/hardening.ts +++ b/apps/desktop/electron/hardening.ts @@ -73,6 +73,69 @@ function encryptDesktopSecret(value, safeStorageApi, options: { allowPlainText?: } } +// 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, '/') @@ -320,9 +383,11 @@ async function resolveReadableFileForIpc( export { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret, rejectUnsafePathSyntax, resolveDirectoryForIpc, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index bdf88007be258..5a86665e25ba5 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -96,7 +96,9 @@ import { import { DATA_URL_READ_MAX_BYTES, DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, encryptDesktopSecret as encryptDesktopSecretStrict, + resolvePersistedRemoteToken, resolveReadableFileForIpc, resolveRequestedPathForIpc, resolveTimeoutMs, @@ -6207,11 +6209,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, { allowPlainText: input.allowPlainTextToken === true }) - : { 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 (key) { // Per-profile scope: a remote/cloud entry pins this profile to its own @@ -9563,21 +9572,14 @@ app.whenReady().then(() => { rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`) } - // 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. Do this before createWindow() and anything that could touch - // safeStorage. Older/mocked safeStorage may lack the method, so guard + wrap. - if (process.platform === 'linux' && app.commandLine.getSwitchValue('password-store') === 'basic') { - try { - if (typeof safeStorage.setUsePlainTextEncryption === 'function') { - safeStorage.setUsePlainTextEncryption(true) - } - } catch { - // 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. - } - } + // 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()) From 98c3edbd8b57cd96a52ff5968cedd4d74125139d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:40:20 -0700 Subject: [PATCH 3/3] chore: map github.commits@widow.cc -> Zeus-Deus for contributor attribution --- contributors/emails/github.commits@widow.cc | 1 + 1 file changed, 1 insertion(+) create mode 100644 contributors/emails/github.commits@widow.cc 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