From 5fa290fa507403cd417012a6b7a9f2c42366548c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 30 Sep 2026 02:13:03 -0700 Subject: [PATCH] feat(secrets): warn in Settings when a credential is stored unencrypted (#24048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(secrets): warn in Settings when a credential is stored unencrypted When no OS keyring is usable, the MiniMax stores write the credential as a plaintext envelope and say so with a console.warn nobody reads. The users this affects are exactly the ones who never see a main-process log, so in practice they were told nothing (#21827). Report it where the credential is managed instead. Each store gains a protection reader, the status IPC carries it, and Settings renders a warning next to the credential it applies to. Keyed on the stored bytes, not isEncryptionAvailable(): a credential saved before a keyring existed stays plaintext until it is saved again, so reporting current capability would call it protected while the file says otherwise. The readers parse the envelope kind without decrypting, so opening Settings cannot provoke a keychain prompt. The console.warn stays. It carries no secret material, and it is still the only signal on a headless host with no Settings window. * feat(secrets): extend the unsealed-credential warning to every affected store The speech key, Linear tokens, Jira tokens and the Bitbucket credential have the same plaintext fallback the MiniMax stores do, and the same console-only warning nobody reads. Add a shared `readCredentialFileProtection` for the four stores that write bare ciphertext with no envelope, classifying with the same printable-UTF-8 test `readStoredCredentialToken` already uses — so the reporter cannot drift into disagreeing with the reader about the same bytes. Linear and Jira report across every stored workspace/site rather than the active one: sealing is a host-wide property, so a second workspace stored while the keyring was missing is exposed even when the active one is sealed. Both fields are optional, so an older remote host that omits them reads as unknown rather than as sealed. Bitbucket reports null for env-supplied auth, where Orca stores nothing and has no claim to make. Also fixes the credential-connection test double, whose identity-function `encryptString` wrote a readable token — faithful enough for a round-trip assertion, but it made the suite assert that a sealed credential was exposed. * chore(i18n): extract the unsealed-credential notice strings CI's localization-extraction gate requires every translate() key to exist in the primary catalog. Inserted in place rather than re-sorting the file, which is not fully sorted and would have produced a 17k-line diff. * test(web): pin the null protection fields on the desktop-only MiniMax bridge The web bridge reports no protection because it stores nothing; the shape assertions had to move with it. --- .../bitbucket/credential-connection.test.ts | 9 +++- src/main/bitbucket/credential-connection.ts | 4 ++ src/main/bitbucket/credential-store.ts | 7 +++ src/main/credential-file-protection.test.ts | 48 +++++++++++++++++++ src/main/credential-file-protection.ts | 29 +++++++++++ src/main/ipc/minimax-credentials.test.ts | 26 +++++++++- src/main/ipc/minimax-credentials.ts | 10 +++- src/main/ipc/speech.ts | 7 +-- src/main/jira/client.ts | 11 ++++- src/main/jira/site-credential-store.ts | 7 +++ src/main/linear/client.ts | 13 ++++- src/main/linear/linear-token-store.ts | 7 +++ .../minimax/minimax-api-key-store.test.ts | 40 ++++++++++++++++ src/main/minimax/minimax-api-key-store.ts | 20 ++++++++ src/main/minimax/minimax-cookie-store.test.ts | 46 ++++++++++++++++++ src/main/minimax/minimax-cookie-store.ts | 25 ++++++++++ src/main/speech/openai-api-key-store.ts | 11 +++++ src/preload/api/agent-account-api.ts | 23 +++++++-- src/preload/api/minimax-credentials-bridge.ts | 23 ++++++--- src/preload/api/speech-api.ts | 6 ++- src/preload/api/speech-bridge.ts | 7 ++- .../src/components/settings/AccountsPane.tsx | 11 +++++ .../UnsealedCredentialNotice.test.tsx | 42 ++++++++++++++++ .../settings/UnsealedCredentialNotice.tsx | 43 +++++++++++++++++ .../components/settings/VoicePane.test.tsx | 5 +- .../src/components/settings/VoicePane.tsx | 18 ++++++- .../settings/accounts-pane-minimax-actions.ts | 9 ++++ .../accounts-pane-minimax-credentials.tsx | 17 +++++++ .../settings/accounts-pane-types.ts | 3 ++ .../settings/bitbucket-integration-card.tsx | 8 ++++ .../settings/jira-integration-card.tsx | 8 ++++ .../task-tracker-integration-cards.tsx | 8 ++++ src/renderer/src/i18n/locales/en.json | 19 ++++++-- .../web/preload-api/web-agent-accounts-api.ts | 10 +++- .../web-preload-api-agent-providers.test.ts | 15 ++++-- src/shared/bitbucket-credentials.ts | 7 +++ src/shared/jira-types.ts | 4 ++ src/shared/linear/workspace-types.ts | 4 ++ src/shared/secret-at-rest-protection.test.ts | 37 ++++++++++++++ src/shared/secret-at-rest-protection.ts | 37 ++++++++++++++ 40 files changed, 648 insertions(+), 36 deletions(-) create mode 100644 src/main/credential-file-protection.test.ts create mode 100644 src/main/credential-file-protection.ts create mode 100644 src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx create mode 100644 src/renderer/src/components/settings/UnsealedCredentialNotice.tsx create mode 100644 src/shared/secret-at-rest-protection.test.ts create mode 100644 src/shared/secret-at-rest-protection.ts diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts index ea07cf6a96c..9811db05833 100644 --- a/src/main/bitbucket/credential-connection.test.ts +++ b/src/main/bitbucket/credential-connection.test.ts @@ -13,8 +13,12 @@ async function loadModule() { const { setSecretStore } = await import('../../shared/secret-store') setSecretStore({ isEncryptionAvailable: () => true, - encryptString: (value) => Buffer.from(value), - decryptString: (value) => value.toString('utf-8'), + // Why a binary prefix and not an identity function: real safeStorage ciphertext is + // not printable UTF-8, and the at-rest protection reporter distinguishes sealed from + // plaintext by exactly that. An identity double writes a readable token and would + // make this suite assert that a sealed credential is exposed. + encryptString: (value) => Buffer.concat([Buffer.from([0x00]), Buffer.from(value)]), + decryptString: (value) => value.subarray(1).toString('utf-8'), describeProtectionGap: () => null }) vi.doMock('node:os', async () => { @@ -60,6 +64,7 @@ describe('Bitbucket credential connection', () => { expect(conn.getBitbucketConnectionStatus()).toEqual({ configured: true, source: 'stored', + credentialProtection: 'sealed', account: 'ada', authMode: 'basic', email: 'ada@example.com', diff --git a/src/main/bitbucket/credential-connection.ts b/src/main/bitbucket/credential-connection.ts index c36980e8e79..217e4ad9ca7 100644 --- a/src/main/bitbucket/credential-connection.ts +++ b/src/main/bitbucket/credential-connection.ts @@ -9,6 +9,7 @@ import { accountNameFromUser, fetchBitbucketUserResult } from './user-request' import { clearStoredBitbucketCredential, getStoredBitbucketMetadata, + getBitbucketCredentialProtection, hasStoredBitbucketCredential, saveBitbucketCredential } from './credential-store' @@ -87,6 +88,7 @@ export function getBitbucketConnectionStatus(): BitbucketConnectionStatus { return { configured: true, source: 'environment', + credentialProtection: null, account: null, authMode: env.accessToken ? 'token' : 'basic', email: env.email, @@ -98,6 +100,7 @@ export function getBitbucketConnectionStatus(): BitbucketConnectionStatus { return { configured: true, source: 'stored', + credentialProtection: getBitbucketCredentialProtection(), account: metadata?.account ?? null, authMode: metadata?.authMode ?? null, email: metadata?.email ?? null, @@ -107,6 +110,7 @@ export function getBitbucketConnectionStatus(): BitbucketConnectionStatus { return { configured: false, source: 'none', + credentialProtection: null, account: null, authMode: null, email: null, diff --git a/src/main/bitbucket/credential-store.ts b/src/main/bitbucket/credential-store.ts index 4bf4544ae7c..1930a56de66 100644 --- a/src/main/bitbucket/credential-store.ts +++ b/src/main/bitbucket/credential-store.ts @@ -9,6 +9,8 @@ import { writeEncryptedCredential } from '../integration-credential-file' import type { BitbucketAuthMode } from '../../shared/bitbucket-credentials' +import { readCredentialFileProtection } from '../credential-file-protection' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' // Why: the secret stays encrypted via safeStorage while this metadata stays // plaintext, so status reads render the connected account without decrypting — @@ -156,6 +158,11 @@ export function loadStoredBitbucketSecret( } } +/** How the stored Bitbucket secret sits on disk, or null when none is stored. */ +export function getBitbucketCredentialProtection(): SecretAtRestProtection | null { + return readCredentialFileProtection(getSecretPath()) +} + export function saveBitbucketCredential(input: BitbucketCredentialSaveInput): void { ensureOrcaDir() const secret: BitbucketStoredSecret = { diff --git a/src/main/credential-file-protection.test.ts b/src/main/credential-file-protection.test.ts new file mode 100644 index 00000000000..1806e3d66ed --- /dev/null +++ b/src/main/credential-file-protection.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const existsSyncMock = vi.hoisted(() => vi.fn()) +const readFileSyncMock = vi.hoisted(() => vi.fn()) +vi.mock('node:fs', () => ({ existsSync: existsSyncMock, readFileSync: readFileSyncMock })) + +const { readCredentialFileProtection } = await import('./credential-file-protection') + +describe('readCredentialFileProtection', () => { + beforeEach(() => { + existsSyncMock.mockReset() + readFileSyncMock.mockReset() + }) + + it('reports null when the file does not exist', () => { + existsSyncMock.mockReturnValue(false) + expect(readCredentialFileProtection('/tokens/linear')).toBeNull() + expect(readFileSyncMock).not.toHaveBeenCalled() + }) + + // Why null and not 'plaintext': credentialFileHasContent already treats an empty + // file as "no credential saved", so warning about one would contradict the badge. + it('reports null for an empty file, which reads as no credential at all', () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.alloc(0)) + expect(readCredentialFileProtection('/tokens/linear')).toBeNull() + }) + + it('reports plaintext for a bare token', () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('lin_api_abcdef123456', 'utf8')) + expect(readCredentialFileProtection('/tokens/linear')).toBe('plaintext') + }) + + it('reports sealed for ciphertext', () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from([0x76, 0x31, 0x30, 0x00, 0x8f, 0x02, 0x1c])) + expect(readCredentialFileProtection('/tokens/linear')).toBe('sealed') + }) + + it('reports null when the file cannot be read rather than guessing', () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockImplementation(() => { + throw Object.assign(new Error('EACCES'), { code: 'EACCES' }) + }) + expect(readCredentialFileProtection('/tokens/linear')).toBeNull() + }) +}) diff --git a/src/main/credential-file-protection.ts b/src/main/credential-file-protection.ts new file mode 100644 index 00000000000..c8cd513640e --- /dev/null +++ b/src/main/credential-file-protection.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs' +import { + classifyUnenvelopedCredential, + type SecretAtRestProtection +} from '../shared/secret-at-rest-protection' + +/** + * How the credential file at `path` is protected, or null when there is nothing there. + * + * Why every unenveloped store shares this: speech, Linear, Jira and Bitbucket all write + * either `safeStorage` ciphertext or the bare token with nothing on disk to distinguish + * them, so each would otherwise grow its own sniff — and a reporter that disagrees with + * the reader about the same bytes is worse than no reporter. + * + * Never decrypts. Settings calls this on open, and a decrypt would put an OS keychain + * prompt in front of someone who only opened a settings pane. + */ +export function readCredentialFileProtection(path: string): SecretAtRestProtection | null { + if (!existsSync(path)) { + return null + } + try { + const raw = readFileSync(path) + return raw.length === 0 ? null : classifyUnenvelopedCredential(raw) + } catch { + // Unreadable is a read-time error to surface elsewhere, not a protection claim. + return null + } +} diff --git a/src/main/ipc/minimax-credentials.test.ts b/src/main/ipc/minimax-credentials.test.ts index e4d39e38a29..2c9a9d02b32 100644 --- a/src/main/ipc/minimax-credentials.test.ts +++ b/src/main/ipc/minimax-credentials.test.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const ipcState = vi.hoisted(() => ({ @@ -19,14 +20,22 @@ const clearMiniMaxSessionCookieJarMock = vi.hoisted(() => vi.fn(() => Promise.re const saveMiniMaxApiKeyMock = vi.hoisted(() => vi.fn()) const clearMiniMaxApiKeyMock = vi.hoisted(() => vi.fn()) const hasMiniMaxApiKeyMock = vi.hoisted(() => vi.fn(() => false)) +const getCookieProtectionMock = vi.hoisted(() => + vi.fn((): SecretAtRestProtection | null => 'sealed') +) +const getApiKeyProtectionMock = vi.hoisted(() => + vi.fn((): SecretAtRestProtection | null => 'sealed') +) vi.mock('../minimax/minimax-cookie-store', () => ({ + getMiniMaxSessionCookieProtection: getCookieProtectionMock, saveMiniMaxSessionCookie: saveMiniMaxSessionCookieMock, clearMiniMaxSessionCookie: clearMiniMaxSessionCookieMock, hasMiniMaxSessionCookie: hasMiniMaxSessionCookieMock })) vi.mock('../minimax/minimax-api-key-store', () => ({ + getMiniMaxApiKeyProtection: getApiKeyProtectionMock, saveMiniMaxApiKey: saveMiniMaxApiKeyMock, clearMiniMaxApiKey: clearMiniMaxApiKeyMock, hasMiniMaxApiKey: hasMiniMaxApiKeyMock @@ -101,7 +110,10 @@ describe('registerMiniMaxCredentialsHandlers', () => { expect(status).toEqual({ configured: true, cookieConfigured: true, - apiKeyConfigured: false + apiKeyConfigured: false, + cookieProtection: 'sealed', + // Null, not 'sealed': nothing is stored, so there is nothing to make a claim about. + apiKeyProtection: null }) }) @@ -116,7 +128,9 @@ describe('registerMiniMaxCredentialsHandlers', () => { expect(status).toEqual({ configured: true, cookieConfigured: false, - apiKeyConfigured: true + apiKeyConfigured: true, + cookieProtection: null, + apiKeyProtection: 'sealed' }) }) @@ -263,4 +277,12 @@ describe('registerMiniMaxCredentialsHandlers', () => { expect(status.cookieConfigured).toBe(true) expect(status.apiKeyConfigured).toBe(true) }) + + it('reports a plaintext key through the status so Settings can warn about it', async () => { + hasMiniMaxApiKeyMock.mockReturnValue(true) + getApiKeyProtectionMock.mockReturnValue('plaintext') + registerMiniMaxCredentialsHandlers(null) + const status = await invoke('minimaxCredentials:getStatus') + expect(status).toMatchObject({ apiKeyConfigured: true, apiKeyProtection: 'plaintext' }) + }) }) diff --git a/src/main/ipc/minimax-credentials.ts b/src/main/ipc/minimax-credentials.ts index 12138967f2c..cac2567db98 100644 --- a/src/main/ipc/minimax-credentials.ts +++ b/src/main/ipc/minimax-credentials.ts @@ -1,21 +1,27 @@ import { ipcMain } from 'electron' import { clearMiniMaxSessionCookie, + getMiniMaxSessionCookieProtection, hasMiniMaxSessionCookie, saveMiniMaxSessionCookie } from '../minimax/minimax-cookie-store' import { clearMiniMaxApiKey, + getMiniMaxApiKeyProtection, hasMiniMaxApiKey, saveMiniMaxApiKey } from '../minimax/minimax-api-key-store' import { clearMiniMaxSessionCookieJar } from '../rate-limits/minimax/minimax-request-context' import type { RateLimitService } from '../rate-limits/service' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' export type MiniMaxCredentialsStatus = { configured: boolean cookieConfigured: boolean apiKeyConfigured: boolean + /** How each stored credential sits on disk, so Settings can warn when it is unsealed. */ + cookieProtection: SecretAtRestProtection | null + apiKeyProtection: SecretAtRestProtection | null } function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { @@ -24,7 +30,9 @@ function getMiniMaxCredentialsStatus(): MiniMaxCredentialsStatus { return { configured: cookieConfigured || apiKeyConfigured, cookieConfigured, - apiKeyConfigured + apiKeyConfigured, + cookieProtection: cookieConfigured ? getMiniMaxSessionCookieProtection() : null, + apiKeyProtection: apiKeyConfigured ? getMiniMaxApiKeyProtection() : null } } diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index f89a2ab8b70..bd0ea494ea0 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -7,6 +7,7 @@ import { deleteLocalSpeechModel } from '../speech/speech-model-deletion' import { getSpeechModelManager, getSpeechSttService } from '../speech/speech-runtime-service' import { clearOpenAiSpeechApiKey, + getOpenAiSpeechApiKeyProtection, hasOpenAiSpeechApiKey, saveOpenAiSpeechApiKey } from '../speech/openai-api-key-store' @@ -22,17 +23,17 @@ export function registerSpeechHandlers(store: Store): void { }) ipcMain.handle('speech:getOpenAiApiKeyStatus', async () => { - return { configured: hasOpenAiSpeechApiKey() } + return { configured: hasOpenAiSpeechApiKey(), protection: getOpenAiSpeechApiKeyProtection() } }) ipcMain.handle('speech:saveOpenAiApiKey', async (_event, apiKey: string) => { saveOpenAiSpeechApiKey(apiKey) - return { configured: true } + return { configured: true, protection: getOpenAiSpeechApiKeyProtection() } }) ipcMain.handle('speech:clearOpenAiApiKey', async () => { clearOpenAiSpeechApiKey() - return { configured: false } + return { configured: false, protection: null } }) ipcMain.handle('speech:downloadModel', async (event, modelId: string) => { diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 3e4dcaf6e92..8505c7314c7 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -16,7 +16,8 @@ import { hasStoredToken, readToken, saveToken, - writeSiteFile + writeSiteFile, + getSiteTokenProtection } from './site-credential-store' import { apiBasePath, @@ -62,13 +63,19 @@ export function getStatus(): JiraConnectionStatus { const credentialError = sites .map((site) => credentialErrors.get(site.id)) .find((message) => message !== undefined) + // Why any-not-active: sealing is a host-wide property, so a second site stored while + // the keyring was missing is exposed even when the active one is sealed. + const credentialProtection = sites.some((site) => getSiteTokenProtection(site.id) === 'plaintext') + ? 'plaintext' + : null return { connected: sites.length > 0, viewer: siteToViewer(activeSite), sites, activeSiteId: activeSite?.id ?? null, selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null, - ...(credentialError ? { credentialError } : {}) + ...(credentialError ? { credentialError } : {}), + credentialProtection } } diff --git a/src/main/jira/site-credential-store.ts b/src/main/jira/site-credential-store.ts index 63c241ca62c..8be2bdb112e 100644 --- a/src/main/jira/site-credential-store.ts +++ b/src/main/jira/site-credential-store.ts @@ -2,6 +2,8 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from ' import { homedir } from 'node:os' import { join } from 'node:path' import { getSecretStore } from '../../shared/secret-store' +import { readCredentialFileProtection } from '../credential-file-protection' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import { CredentialDecryptionError, credentialFileHasContent, @@ -191,6 +193,11 @@ export function readToken(siteId: string): string | null { } } +/** How a site's stored token sits on disk, or null when none is stored. */ +export function getSiteTokenProtection(siteId: string): SecretAtRestProtection | null { + return readCredentialFileProtection(getTokenPath(siteId)) +} + export function saveToken(siteId: string, apiToken: string): void { ensureOrcaDir() ensureTokenDir() diff --git a/src/main/linear/client.ts b/src/main/linear/client.ts index 7e02938b54c..03752548fac 100644 --- a/src/main/linear/client.ts +++ b/src/main/linear/client.ts @@ -21,7 +21,8 @@ import { clearTokenFile, loadToken, replaceLegacyWorkspace, - saveWorkspaceToken + saveWorkspaceToken, + getWorkspaceTokenProtection } from './linear-token-store' import { CredentialDecryptionError } from '../integration-credential-file' import type { @@ -176,6 +177,13 @@ export function getStatus(): LinearConnectionStatus { const credentialError = state.workspaces .map((workspace) => getCredentialError(workspace.id)) .find((message) => message !== undefined) + // Why any-not-active: sealing is a host-wide property, so a second workspace stored + // while the keyring was missing is exposed even when the active one is sealed. + const credentialProtection = state.workspaces.some( + (workspace) => getWorkspaceTokenProtection(workspace.id) === 'plaintext' + ) + ? 'plaintext' + : null return { connected: state.workspaces.length > 0, @@ -183,7 +191,8 @@ export function getStatus(): LinearConnectionStatus { workspaces: state.workspaces, activeWorkspaceId: state.activeWorkspaceId, selectedWorkspaceId: state.selectedWorkspaceId, - ...(credentialError ? { credentialError } : {}) + ...(credentialError ? { credentialError } : {}), + credentialProtection } } diff --git a/src/main/linear/linear-token-store.ts b/src/main/linear/linear-token-store.ts index 7a2bf892213..16145b8b0b6 100644 --- a/src/main/linear/linear-token-store.ts +++ b/src/main/linear/linear-token-store.ts @@ -31,6 +31,8 @@ import { readStoredCredentialToken } from '../integration-credential-file' import type { LinearWorkspace } from '../../shared/linear/workspace-types' +import { readCredentialFileProtection } from '../credential-file-protection' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' function writeEncryptedToken(path: string, apiKey: string): void { if (getSecretStore().isEncryptionAvailable()) { @@ -92,6 +94,11 @@ export function loadToken(options: { force?: boolean; workspaceId?: string } = { } } +/** How a workspace's stored token sits on disk, or null when none is stored. */ +export function getWorkspaceTokenProtection(workspaceId: string): SecretAtRestProtection | null { + return readCredentialFileProtection(getWorkspaceTokenPath(workspaceId)) +} + export function clearTokenFile(workspaceId: string): void { forgetCachedToken(workspaceId) try { diff --git a/src/main/minimax/minimax-api-key-store.test.ts b/src/main/minimax/minimax-api-key-store.test.ts index 9dd3ebdea01..756ed9069b1 100644 --- a/src/main/minimax/minimax-api-key-store.test.ts +++ b/src/main/minimax/minimax-api-key-store.test.ts @@ -120,6 +120,46 @@ describe('minimax-api-key-store', () => { warn.mockRestore() }) + describe('getMiniMaxApiKeyProtection', () => { + it('reports null when nothing is stored', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.getMiniMaxApiKeyProtection()).toBeNull() + }) + + it('reports plaintext for a plaintext envelope', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'sk-test-1234567890'))) + const store = await loadStore() + expect(store.getMiniMaxApiKeyProtection()).toBe('plaintext') + }) + + it('reports sealed for an encrypted envelope', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'sk-test-1234567890'))) + const store = await loadStore() + expect(store.getMiniMaxApiKeyProtection()).toBe('sealed') + }) + + // Why it must not decrypt: Settings calls this on open, and a decrypt would put a + // macOS keychain prompt in front of a user who only opened a settings pane. + it('does not decrypt, so opening Settings cannot trigger a keychain prompt', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'sk-test-1234567890'))) + const store = await loadStore() + store.getMiniMaxApiKeyProtection() + expect(safeStorageMock.decryptString).not.toHaveBeenCalled() + expect(safeStorageMock.isEncryptionAvailable).not.toHaveBeenCalled() + }) + + it('reports null for an unreadable envelope rather than guessing', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('not-an-orca-envelope')) + const store = await loadStore() + expect(store.getMiniMaxApiKeyProtection()).toBeNull() + }) + }) + it('refuses empty keys', async () => { const store = await loadStore() expect(() => store.saveMiniMaxApiKey(' ')).toThrow(/required/) diff --git a/src/main/minimax/minimax-api-key-store.ts b/src/main/minimax/minimax-api-key-store.ts index efd0af65db9..c2fea05621f 100644 --- a/src/main/minimax/minimax-api-key-store.ts +++ b/src/main/minimax/minimax-api-key-store.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' const MINIMAX_API_KEY_FILE = 'minimax-api-key.enc' const API_KEY_ENVELOPE_PREFIX = 'orca-minimax-api-key:v1:' @@ -72,6 +73,25 @@ export function hasMiniMaxApiKey(): boolean { return true } +/** + * How the stored key is protected, or null when none is stored. + * + * Reads the envelope kind only — no decrypt, so this cannot trigger a keychain prompt + * and is safe to call from a status handler. + */ +export function getMiniMaxApiKeyProtection(): SecretAtRestProtection | null { + const keyPath = getMiniMaxApiKeyPath() + if (!existsSync(keyPath)) { + return null + } + try { + return decodeApiKeyEnvelope(readFileSync(keyPath)).kind === 'plaintext' ? 'plaintext' : 'sealed' + } catch { + // An undecodable envelope is a decrypt-time error to report, not a protection claim. + return null + } +} + export function saveMiniMaxApiKey(key: string): void { const trimmed = key.trim() if (!trimmed) { diff --git a/src/main/minimax/minimax-cookie-store.test.ts b/src/main/minimax/minimax-cookie-store.test.ts index 4362dd2df93..c7530b82031 100644 --- a/src/main/minimax/minimax-cookie-store.test.ts +++ b/src/main/minimax/minimax-cookie-store.test.ts @@ -205,4 +205,50 @@ describe('minimax-cookie-store', () => { expect(rmSyncMock).toHaveBeenCalledWith(storePath, { force: true }) expect(store.readMiniMaxSessionCookie()).toBeNull() }) + + describe('getMiniMaxSessionCookieProtection', () => { + it('reports null when nothing is stored', async () => { + existsSyncMock.mockReturnValue(false) + const store = await loadStore() + expect(store.getMiniMaxSessionCookieProtection()).toBeNull() + }) + + it('reports plaintext for a plaintext envelope', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('plaintext', 'sessionId=abc123'))) + const store = await loadStore() + expect(store.getMiniMaxSessionCookieProtection()).toBe('plaintext') + }) + + it('reports sealed for an encrypted envelope', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'sessionId=abc123'))) + const store = await loadStore() + expect(store.getMiniMaxSessionCookieProtection()).toBe('sealed') + }) + + // Pre-envelope files carry no kind, so the legacy sniff decides — the same one the + // reader uses, so the warning cannot disagree with how the bytes are interpreted. + it('reports a legacy pre-envelope cookie header as plaintext', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from('sessionId=abc123; other=1', 'utf8')) + const store = await loadStore() + expect(store.getMiniMaxSessionCookieProtection()).toBe('plaintext') + }) + + it('reports legacy sealed bytes as sealed', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from([0x76, 0x31, 0x30, 0x00, 0x8f, 0x02, 0x1c])) + const store = await loadStore() + expect(store.getMiniMaxSessionCookieProtection()).toBe('sealed') + }) + + it('does not decrypt, so opening Settings cannot trigger a keychain prompt', async () => { + existsSyncMock.mockReturnValue(true) + readFileSyncMock.mockReturnValue(Buffer.from(envelope('encrypted', 'sessionId=abc123'))) + const store = await loadStore() + store.getMiniMaxSessionCookieProtection() + expect(safeStorageMock.decryptString).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/main/minimax/minimax-cookie-store.ts b/src/main/minimax/minimax-cookie-store.ts index 1b98a1a81c6..28155411e8a 100644 --- a/src/main/minimax/minimax-cookie-store.ts +++ b/src/main/minimax/minimax-cookie-store.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { hardenExistingSecureFile, writeSecureFile } from '../../shared/secure-file' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' const MINIMAX_COOKIE_FILE = 'minimax-session-cookie.enc' const COOKIE_ENVELOPE_PREFIX = 'orca-minimax-cookie:v1:' @@ -96,6 +97,30 @@ function readLegacyCookie(raw: Buffer): string { throw new Error('MiniMax session cookie could not be decrypted') } +/** + * How the stored cookie is protected, or null when none is stored. + * + * Envelope kind where there is one; otherwise the same sniff the legacy reader uses, so + * a pre-envelope file is reported as what it actually is. No decrypt, so this is safe to + * call from a status handler without provoking a keychain prompt. + */ +export function getMiniMaxSessionCookieProtection(): SecretAtRestProtection | null { + const cookiePath = getMiniMaxCookiePath() + if (!existsSync(cookiePath)) { + return null + } + try { + const raw = readFileSync(cookiePath) + const envelope = decodeCookieEnvelope(raw) + if (envelope) { + return envelope.kind === 'plaintext' ? 'plaintext' : 'sealed' + } + return looksLikeCookieHeader(raw.toString('utf8')) ? 'plaintext' : 'sealed' + } catch { + return null + } +} + export function hasMiniMaxSessionCookie(): boolean { const keyPath = getMiniMaxCookiePath() if (!existsSync(keyPath)) { diff --git a/src/main/speech/openai-api-key-store.ts b/src/main/speech/openai-api-key-store.ts index b587c878f4d..c21bf554cd6 100644 --- a/src/main/speech/openai-api-key-store.ts +++ b/src/main/speech/openai-api-key-store.ts @@ -1,4 +1,6 @@ import { getSecretStore } from '../../shared/secret-store' +import { readCredentialFileProtection } from '../credential-file-protection' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -47,6 +49,15 @@ export function hasOpenAiSpeechApiKey(): boolean { return existsSync(getOpenAiKeyPath()) } +/** How the stored speech key sits on disk, or null when none is stored. */ +export function getOpenAiSpeechApiKeyProtection(): SecretAtRestProtection | null { + // The legacy JSON wrapper only ever held base64 ciphertext, so it is sealed by shape. + if (readLegacyJsonStoredOpenAiKey()) { + return 'sealed' + } + return readCredentialFileProtection(getOpenAiKeyPath()) +} + export function saveOpenAiSpeechApiKey(apiKey: string): void { const trimmed = apiKey.trim() if (!trimmed) { diff --git a/src/preload/api/agent-account-api.ts b/src/preload/api/agent-account-api.ts index e8d574c285b..9c8087f4b91 100644 --- a/src/preload/api/agent-account-api.ts +++ b/src/preload/api/agent-account-api.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState @@ -74,11 +75,25 @@ export type MinimaxCredentialsApi = { configured: boolean cookieConfigured: boolean apiKeyConfigured: boolean + cookieProtection: SecretAtRestProtection | null + apiKeyProtection: SecretAtRestProtection | null + }> + saveCookie: (cookie: string) => Promise<{ + cookieConfigured: boolean + cookieProtection: SecretAtRestProtection | null + }> + clearCookie: () => Promise<{ + cookieConfigured: boolean + cookieProtection: SecretAtRestProtection | null + }> + saveApiKey: (key: string) => Promise<{ + apiKeyConfigured: boolean + apiKeyProtection: SecretAtRestProtection | null + }> + clearApiKey: () => Promise<{ + apiKeyConfigured: boolean + apiKeyProtection: SecretAtRestProtection | null }> - saveCookie: (cookie: string) => Promise<{ cookieConfigured: boolean }> - clearCookie: () => Promise<{ cookieConfigured: boolean }> - saveApiKey: (key: string) => Promise<{ apiKeyConfigured: boolean }> - clearApiKey: () => Promise<{ apiKeyConfigured: boolean }> } export type CodexConfigSyncApi = { diff --git a/src/preload/api/minimax-credentials-bridge.ts b/src/preload/api/minimax-credentials-bridge.ts index f49e32d42ec..4c2196d14d1 100644 --- a/src/preload/api/minimax-credentials-bridge.ts +++ b/src/preload/api/minimax-credentials-bridge.ts @@ -1,18 +1,29 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' export const minimaxCredentialsApi = { getStatus: (): Promise<{ configured: boolean cookieConfigured: boolean apiKeyConfigured: boolean + cookieProtection: SecretAtRestProtection | null + apiKeyProtection: SecretAtRestProtection | null }> => ipcRenderer.invoke('minimaxCredentials:getStatus'), - saveCookie: (cookie: string): Promise<{ cookieConfigured: boolean }> => + saveCookie: ( + cookie: string + ): Promise<{ cookieConfigured: boolean; cookieProtection: SecretAtRestProtection | null }> => ipcRenderer.invoke('minimaxCredentials:saveCookie', cookie), - clearCookie: (): Promise<{ cookieConfigured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearCookie'), - saveApiKey: (key: string): Promise<{ apiKeyConfigured: boolean }> => + clearCookie: (): Promise<{ + cookieConfigured: boolean + cookieProtection: SecretAtRestProtection | null + }> => ipcRenderer.invoke('minimaxCredentials:clearCookie'), + saveApiKey: ( + key: string + ): Promise<{ apiKeyConfigured: boolean; apiKeyProtection: SecretAtRestProtection | null }> => ipcRenderer.invoke('minimaxCredentials:saveApiKey', key), - clearApiKey: (): Promise<{ apiKeyConfigured: boolean }> => - ipcRenderer.invoke('minimaxCredentials:clearApiKey') + clearApiKey: (): Promise<{ + apiKeyConfigured: boolean + apiKeyProtection: SecretAtRestProtection | null + }> => ipcRenderer.invoke('minimaxCredentials:clearApiKey') } satisfies PreloadApi['minimaxCredentials'] diff --git a/src/preload/api/speech-api.ts b/src/preload/api/speech-api.ts index fb866dace15..26e90c0f8f2 100644 --- a/src/preload/api/speech-api.ts +++ b/src/preload/api/speech-api.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -9,7 +10,10 @@ import type { export type SpeechApi = { getCatalog: () => Promise getModelStates: () => Promise - getOpenAiApiKeyStatus: () => Promise<{ configured: boolean }> + getOpenAiApiKeyStatus: () => Promise<{ + configured: boolean + protection: SecretAtRestProtection | null + }> saveOpenAiApiKey: (apiKey: string) => Promise<{ configured: boolean }> clearOpenAiApiKey: () => Promise<{ configured: boolean }> downloadModel: (modelId: string) => Promise diff --git a/src/preload/api/speech-bridge.ts b/src/preload/api/speech-bridge.ts index dbd7e0d26ab..86465ef42c6 100644 --- a/src/preload/api/speech-bridge.ts +++ b/src/preload/api/speech-bridge.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection' import { ipcRenderer } from 'electron' import type { SpeechErrorEvent, @@ -11,8 +12,10 @@ import type { PreloadApi } from '../api-types' export const speechApi = { getCatalog: (): Promise => ipcRenderer.invoke('speech:getCatalog'), getModelStates: (): Promise => ipcRenderer.invoke('speech:getModelStates'), - getOpenAiApiKeyStatus: (): Promise<{ configured: boolean }> => - ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), + getOpenAiApiKeyStatus: (): Promise<{ + configured: boolean + protection: SecretAtRestProtection | null + }> => ipcRenderer.invoke('speech:getOpenAiApiKeyStatus'), saveOpenAiApiKey: (apiKey: string): Promise<{ configured: boolean }> => ipcRenderer.invoke('speech:saveOpenAiApiKey', apiKey), clearOpenAiApiKey: (): Promise<{ configured: boolean }> => diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index a418aa9916b..1e479ab7e25 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../../../../shared/secret-at-rest-protection' import { useEffect, useRef, useState } from 'react' import type { ClaudeRateLimitAccountsState, @@ -86,7 +87,11 @@ export function AccountsPane({ const [miniMaxCookieDraft, setMiniMaxCookieDraft] = useState('') const [miniMaxApiKeyDraft, setMiniMaxApiKeyDraft] = useState('') const [miniMaxApiKeyConfigured, setMiniMaxApiKeyConfigured] = useState(false) + const [miniMaxApiKeyProtection, setMiniMaxApiKeyProtection] = + useState(null) const [miniMaxConfigured, setMiniMaxConfigured] = useState(false) + const [miniMaxCookieProtection, setMiniMaxCookieProtection] = + useState(null) const [miniMaxCredentialBusy, setMiniMaxCredentialBusy] = useState(false) const localAccountRuntime = getSelectedAccountRuntime( settings, @@ -230,6 +235,8 @@ export function AccountsPane({ const status = await window.api.minimaxCredentials.getStatus() setMiniMaxConfigured(status.cookieConfigured) setMiniMaxApiKeyConfigured(status.apiKeyConfigured) + setMiniMaxCookieProtection(status.cookieProtection) + setMiniMaxApiKeyProtection(status.apiKeyProtection) } catch (error) { console.error('Failed to load MiniMax credential status:', error) } @@ -241,7 +248,9 @@ export function AccountsPane({ miniMaxApiKeyDraft, setMiniMaxApiKeyDraft, setMiniMaxApiKeyConfigured, + setMiniMaxApiKeyProtection, setMiniMaxConfigured, + setMiniMaxCookieProtection, setMiniMaxCredentialBusy, recordFeatureInteraction }) @@ -349,11 +358,13 @@ export function AccountsPane({ miniMaxApiKeyDraft, setMiniMaxApiKeyDraft, miniMaxApiKeyConfigured, + miniMaxApiKeyProtection, saveMiniMaxApiKey, clearMiniMaxApiKey, miniMaxCookieDraft, setMiniMaxCookieDraft, miniMaxConfigured, + miniMaxCookieProtection, miniMaxCredentialBusy, saveMiniMaxCookie, clearMiniMaxCookie diff --git a/src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx b/src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx new file mode 100644 index 00000000000..df2d590aaaa --- /dev/null +++ b/src/renderer/src/components/settings/UnsealedCredentialNotice.test.tsx @@ -0,0 +1,42 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, params?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => params?.[name] ?? '') +})) + +const { UnsealedCredentialNotice } = await import('./UnsealedCredentialNotice') + +describe('UnsealedCredentialNotice', () => { + afterEach(cleanup) + + it('warns when the stored credential is plaintext', () => { + render() + const text = screen.getByRole('alert').textContent ?? '' + expect(text).toContain('MiniMax API key is stored unencrypted') + // The remedy has to be in the message; the console warning it replaces had none. + expect(text).toMatch(/gnome-keyring|kwallet/) + }) + + it('renders nothing when the credential is sealed', () => { + render() + expect(screen.queryByRole('alert')).toBeNull() + }) + + // Why null must stay silent: it means "nothing stored" or "envelope unreadable", and + // warning that an absent credential is exposed would be worse than saying nothing. + it('renders nothing when protection is unknown', () => { + render() + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('names the credential so a pane with several is unambiguous', () => { + render( + + ) + expect(screen.getByRole('alert').textContent).toContain('MiniMax Session Cookie') + }) +}) diff --git a/src/renderer/src/components/settings/UnsealedCredentialNotice.tsx b/src/renderer/src/components/settings/UnsealedCredentialNotice.tsx new file mode 100644 index 00000000000..cf2ea133d0b --- /dev/null +++ b/src/renderer/src/components/settings/UnsealedCredentialNotice.tsx @@ -0,0 +1,43 @@ +import { TriangleAlert } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { SecretAtRestProtection } from '../../../../shared/secret-at-rest-protection' + +/** + * Warn that a saved credential is sitting on disk unencrypted. + * + * Why in Settings and not only the log: the app has always been able to describe this + * and only ever wrote it to the main-process console, so the users it affects — a host + * with no usable OS keyring — were told nothing where they could see it (#21827). + * + * Why keyed on the stored bytes and not on whether sealing works now: a credential saved + * before a keyring existed stays plaintext until it is saved again, so capability would + * report it protected while the file says otherwise. + */ +export function UnsealedCredentialNotice({ + protection, + credentialName +}: { + /** Null when nothing is stored, or when the envelope could not be read. */ + protection: SecretAtRestProtection | null + /** Named so the warning is unambiguous when a pane shows several credentials. */ + credentialName: string +}): React.JSX.Element | null { + if (protection !== 'plaintext') { + return null + } + return ( +
+ +

+ {translate( + 'auto.components.settings.UnsealedCredentialNotice.body', + '{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it.', + { credential: credentialName } + )} +

+
+ ) +} diff --git a/src/renderer/src/components/settings/VoicePane.test.tsx b/src/renderer/src/components/settings/VoicePane.test.tsx index 1cf41781125..1f066c0380a 100644 --- a/src/renderer/src/components/settings/VoicePane.test.tsx +++ b/src/renderer/src/components/settings/VoicePane.test.tsx @@ -270,7 +270,10 @@ describe('VoicePane', () => { ) useShortcutLabelMock.mockReturnValue('Ctrl+Shift+Y') installWindowApi(vi.fn(async () => deniedMicrophoneResult)) - window.api.speech.getOpenAiApiKeyStatus = vi.fn(async () => ({ configured: true })) + window.api.speech.getOpenAiApiKeyStatus = vi.fn(async () => ({ + configured: true, + protection: 'sealed' as const + })) window.api.speech.clearOpenAiApiKey = vi.fn(() => clearing) const settingsWithKey = (enabled: boolean): GlobalSettings => diff --git a/src/renderer/src/components/settings/VoicePane.tsx b/src/renderer/src/components/settings/VoicePane.tsx index 7d4d938325f..15a3d7e37d6 100644 --- a/src/renderer/src/components/settings/VoicePane.tsx +++ b/src/renderer/src/components/settings/VoicePane.tsx @@ -1,3 +1,5 @@ +import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' +import type { SecretAtRestProtection } from '../../../../shared/secret-at-rest-protection' import { useCallback, useEffect, useRef, useState } from 'react' import type { GlobalSettings } from '../../../../shared/global-settings-types' import { getDefaultVoiceSettings } from '../../../../shared/constants' @@ -34,6 +36,9 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J const [openAiDialogOpen, setOpenAiDialogOpen] = useState(false) const [openAiApiKeyDraft, setOpenAiApiKeyDraft] = useState('') const [openAiKeyPending, setOpenAiKeyPending] = useState(false) + const [openAiKeyProtection, setOpenAiKeyProtection] = useState( + null + ) const [pendingCloudModelId, setPendingCloudModelId] = useState(null) const mountedRef = useRef(true) // Why: every write here is a read-modify-write of the whole voice object, and the @@ -76,7 +81,11 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J void window.api.speech .getOpenAiApiKeyStatus() .then((status) => { - if (!cancelled && status.configured !== voiceSettings.openAiApiKeyConfigured) { + if (cancelled) { + return + } + setOpenAiKeyProtection(status.protection) + if (status.configured !== voiceSettings.openAiApiKeyConfigured) { updateVoiceSettings({ openAiApiKeyConfigured: status.configured }) refreshModelStates() } @@ -230,6 +239,13 @@ export function VoicePane({ settings, updateSettings }: VoicePaneProps): React.J {showOpenAiSettingsRow && ( <> + > setMiniMaxApiKeyConfigured: Dispatch> + setMiniMaxApiKeyProtection: Dispatch> miniMaxCookieDraft: string setMiniMaxCookieDraft: Dispatch> setMiniMaxConfigured: Dispatch> + setMiniMaxCookieProtection: Dispatch> setMiniMaxCredentialBusy: Dispatch> recordFeatureInteraction: (featureId: FeatureInteractionId) => void } @@ -24,9 +27,11 @@ export function createMiniMaxCredentialActions(context: MiniMaxCredentialActionC miniMaxApiKeyDraft, setMiniMaxApiKeyDraft, setMiniMaxApiKeyConfigured, + setMiniMaxApiKeyProtection, miniMaxCookieDraft, setMiniMaxCookieDraft, setMiniMaxConfigured, + setMiniMaxCookieProtection, setMiniMaxCredentialBusy, recordFeatureInteraction } = context @@ -49,6 +54,7 @@ export function createMiniMaxCredentialActions(context: MiniMaxCredentialActionC ) } setMiniMaxConfigured(status.cookieConfigured) + setMiniMaxCookieProtection(status.cookieProtection) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') toast.success( @@ -72,6 +78,7 @@ export function createMiniMaxCredentialActions(context: MiniMaxCredentialActionC try { const status = await window.api.minimaxCredentials.clearCookie() setMiniMaxConfigured(status.cookieConfigured) + setMiniMaxCookieProtection(status.cookieProtection) setMiniMaxCookieDraft('') recordFeatureInteraction('usage-tracking') } catch (error) { @@ -109,6 +116,7 @@ export function createMiniMaxCredentialActions(context: MiniMaxCredentialActionC ) } setMiniMaxApiKeyConfigured(status.apiKeyConfigured) + setMiniMaxApiKeyProtection(status.apiKeyProtection) setMiniMaxApiKeyDraft('') recordFeatureInteraction('usage-tracking') toast.success( @@ -132,6 +140,7 @@ export function createMiniMaxCredentialActions(context: MiniMaxCredentialActionC try { const status = await window.api.minimaxCredentials.clearApiKey() setMiniMaxApiKeyConfigured(status.apiKeyConfigured) + setMiniMaxApiKeyProtection(status.apiKeyProtection) setMiniMaxApiKeyDraft('') recordFeatureInteraction('usage-tracking') } catch (error) { diff --git a/src/renderer/src/components/settings/accounts-pane-minimax-credentials.tsx b/src/renderer/src/components/settings/accounts-pane-minimax-credentials.tsx index 9fa2b3c35d0..bdf2334b3a3 100644 --- a/src/renderer/src/components/settings/accounts-pane-minimax-credentials.tsx +++ b/src/renderer/src/components/settings/accounts-pane-minimax-credentials.tsx @@ -8,6 +8,7 @@ import { Input } from '../ui/input' import { Label } from '../ui/label' import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover' import { SearchableSetting } from './SearchableSetting' +import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' import type { AccountsPaneSectionModel } from './accounts-pane-types' function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { @@ -74,6 +75,7 @@ export function MiniMaxCredentials({ miniMaxCookieDraft, setMiniMaxCookieDraft, miniMaxConfigured, + miniMaxCookieProtection, miniMaxCredentialBusy, miniMaxRateLimits, saveMiniMaxCookie, @@ -81,6 +83,7 @@ export function MiniMaxCredentials({ miniMaxApiKeyDraft, setMiniMaxApiKeyDraft, miniMaxApiKeyConfigured, + miniMaxApiKeyProtection, saveMiniMaxApiKey, clearMiniMaxApiKey } = model @@ -133,6 +136,13 @@ export function MiniMaxCredentials({ +
+
> miniMaxApiKeyConfigured: boolean + miniMaxApiKeyProtection: SecretAtRestProtection | null saveMiniMaxApiKey: () => Promise clearMiniMaxApiKey: () => Promise miniMaxCookieDraft: string setMiniMaxCookieDraft: Dispatch> miniMaxConfigured: boolean + miniMaxCookieProtection: SecretAtRestProtection | null miniMaxCredentialBusy: boolean saveMiniMaxCookie: () => Promise clearMiniMaxCookie: () => Promise diff --git a/src/renderer/src/components/settings/bitbucket-integration-card.tsx b/src/renderer/src/components/settings/bitbucket-integration-card.tsx index a2630e31f39..1247bcd6ac4 100644 --- a/src/renderer/src/components/settings/bitbucket-integration-card.tsx +++ b/src/renderer/src/components/settings/bitbucket-integration-card.tsx @@ -1,3 +1,4 @@ +import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' import { useCallback, useEffect, useRef, useState } from 'react' import { ExternalLink, GitPullRequestArrow, LoaderCircle, Unlink } from 'lucide-react' import type { BitbucketConnectionStatus } from '../../../../shared/bitbucket-credentials' @@ -155,6 +156,13 @@ export function BitbucketIntegrationCard(): React.JSX.Element { > {status !== 'checking' ? ( + {connected ? (
diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index e9f510628c6..a4e17e9847f 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -1,3 +1,4 @@ +import { UnsealedCredentialNotice } from './UnsealedCredentialNotice' import { useState } from 'react' import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react' import { JiraConnectDialog } from '@/components/jira-connect-dialog' @@ -128,6 +129,13 @@ export function JiraIntegrationCard(): React.JSX.Element { } > + + {connected ? (
{workspaces.map((workspace) => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1859cb84d74..25a3f2aaabf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3984,7 +3984,9 @@ } }, "tooltip": { - "zcode": { "mcp": "MCP" }, + "zcode": { + "mcp": "MCP" + }, "cedb7b99e3": "% used", "6d6df77f41": "No data available", "7f7f208060": "Monthly", @@ -8992,7 +8994,8 @@ "ad5d036ecc": "Could not request microphone permission. Voice dictation was not enabled.", "f9a9cf6928": "Microphone permission is required before enabling voice dictation.", "1eac933202": "Opened macOS Privacy & Security. Enable dictation again after granting access.", - "cd9fe37556": "Microphone permission granted" + "cd9fe37556": "Microphone permission granted", + "openAiKeyName": "Your OpenAI transcription key" }, "WorktreeSymlinksSection": { "1c1e35b219": "Remove {{value0}}", @@ -10075,7 +10078,8 @@ "9a9f8d4910": "Connect Jira Cloud to browse, create, and link issues.", "74f3063026": "{{value0}} site{{value1}} connected", "statusConnected": "Connected", - "statusNotConnected": "Not connected" + "statusNotConnected": "Not connected", + "jiraTokenName": "Your Jira API token" } } }, @@ -10983,7 +10987,8 @@ "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.", "statusConnected": "Connected", - "statusNotConnected": "Not connected" + "statusNotConnected": "Not connected", + "linearTokenName": "Your Linear API token" } } } @@ -12151,7 +12156,8 @@ "notConfigured": "Connect a Bitbucket Cloud account with an Atlassian API token or an access token. ORCA_BITBUCKET_* environment variables work too and take precedence.", "disconnectFailed": "Could not remove the saved Bitbucket credential.", "statusLoadFailed": "Could not check for a saved Bitbucket credential.", - "replaceCredentials": "Add or replace credentials" + "replaceCredentials": "Add or replace credentials", + "credentialName": "Your Bitbucket credential" } } }, @@ -12215,6 +12221,9 @@ "usageLabel": "Usage", "noAllowance": "Cursor reported no usage allowance for this account.", "staleUsage": "Last known usage — the latest refresh failed: {{reason}}" + }, + "UnsealedCredentialNotice": { + "body": "{{credential}} is stored unencrypted — this system has no OS keyring Orca can use. Anyone who can read your disk or a backup of it can read the credential. Install and unlock gnome-keyring or kwallet, then save it again to seal it." } }, "right": { diff --git a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts index 900757900a6..60b98274d31 100644 --- a/src/renderer/src/web/preload-api/web-agent-accounts-api.ts +++ b/src/renderer/src/web/preload-api/web-agent-accounts-api.ts @@ -4,7 +4,15 @@ import type { PreloadApi } from '../../../../preload/api-types' export function createMiniMaxCredentialsApi(): NonNullable< Partial['minimaxCredentials'] > { - const notConfigured = { configured: false, cookieConfigured: false, apiKeyConfigured: false } + // Nulls, not 'sealed': MiniMax credentials live on the desktop host, so this bridge + // stores nothing and has no protection to claim either way. + const notConfigured = { + configured: false, + cookieConfigured: false, + apiKeyConfigured: false, + cookieProtection: null, + apiKeyProtection: null + } const unsupportedError = new Error('MiniMax cookie storage is only available in the desktop app.') return { getStatus: () => Promise.resolve(notConfigured), diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index c5bfca8af71..8809047fba2 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -161,19 +161,28 @@ describe('web MiniMax preload API', () => { await expect(api.minimaxCredentials.getStatus()).resolves.toEqual({ configured: false, cookieConfigured: false, - apiKeyConfigured: false + apiKeyConfigured: false, + // Null, not 'sealed': this bridge stores nothing, so it has no protection to claim. + cookieProtection: null, + apiKeyProtection: null }) await expect(api.minimaxCredentials.saveCookie('_token=abc')).rejects.toThrow(/desktop app/i) await expect(api.minimaxCredentials.clearCookie()).resolves.toEqual({ configured: false, cookieConfigured: false, - apiKeyConfigured: false + apiKeyConfigured: false, + // Null, not 'sealed': this bridge stores nothing, so it has no protection to claim. + cookieProtection: null, + apiKeyProtection: null }) await expect(api.minimaxCredentials.saveApiKey('sk-test')).rejects.toThrow(/desktop app/i) await expect(api.minimaxCredentials.clearApiKey()).resolves.toEqual({ configured: false, cookieConfigured: false, - apiKeyConfigured: false + apiKeyConfigured: false, + // Null, not 'sealed': this bridge stores nothing, so it has no protection to claim. + cookieProtection: null, + apiKeyProtection: null }) }) }) diff --git a/src/shared/bitbucket-credentials.ts b/src/shared/bitbucket-credentials.ts index 90ae6223153..2e1e80739cf 100644 --- a/src/shared/bitbucket-credentials.ts +++ b/src/shared/bitbucket-credentials.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from './secret-at-rest-protection' export type BitbucketAuthMode = 'token' | 'basic' // Where the active credential comes from. Drives whether the UI offers @@ -16,6 +17,12 @@ export type BitbucketConnectArgs = { // the renderer. export type BitbucketConnectionStatus = { configured: boolean + /** + * How a `stored` credential sits on disk. Null for `environment` and `none`: Orca + * wrote nothing, so it has no claim to make. Optional so an older remote host that + * omits it reads as unknown rather than as sealed. + */ + credentialProtection?: SecretAtRestProtection | null source: BitbucketCredentialSource account: string | null authMode: BitbucketAuthMode | null diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 2b3600446cc..014a72298d9 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from './secret-at-rest-protection' // 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3). // 'server' = self-hosted Jira Server/Data Center (personal access token, // Bearer auth, REST v2). Older stored sites omit the field and mean 'cloud'. @@ -30,6 +31,9 @@ export type JiraConnectionStatus = { // Set when a stored token file exists but could not be decrypted, so the // UI can explain reads failing while the connection still looks saved. credentialError?: string + // 'plaintext' when any stored token is unsealed, so Settings can warn. Optional: + // an older remote host omits it, and absent must read as "unknown", not "sealed". + credentialProtection?: SecretAtRestProtection | null } export type JiraProject = { diff --git a/src/shared/linear/workspace-types.ts b/src/shared/linear/workspace-types.ts index 508fd980a1e..fad1ee6b817 100644 --- a/src/shared/linear/workspace-types.ts +++ b/src/shared/linear/workspace-types.ts @@ -1,3 +1,4 @@ +import type { SecretAtRestProtection } from '../secret-at-rest-protection' export type LinearViewer = { displayName: string email: string | null @@ -39,6 +40,9 @@ export type LinearConnectionStatus = { // Set when a stored token file exists but could not be decrypted, so the // UI can explain reads failing while the connection still looks saved. credentialError?: string + // 'plaintext' when any stored token is unsealed, so Settings can warn. Optional: + // an older remote host omits it, and absent must read as "unknown", not "sealed". + credentialProtection?: SecretAtRestProtection | null } /** diff --git a/src/shared/secret-at-rest-protection.test.ts b/src/shared/secret-at-rest-protection.test.ts new file mode 100644 index 00000000000..d69d068b729 --- /dev/null +++ b/src/shared/secret-at-rest-protection.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { classifyUnenvelopedCredential } from './secret-at-rest-protection' + +describe('classifyUnenvelopedCredential', () => { + it.each([ + ['a bare API key', 'sk-abcdef0123456789'], + ['a Bitbucket app password', 'ATBB3xYzQq_example-token'], + ['a JSON credential blob', '{"username":"me","password":"hunter2"}'], + ['a token with newlines a user pasted', 'sk-abc\ndef\n'] + ])('reports %s as plaintext', (_label, token) => { + expect(classifyUnenvelopedCredential(Buffer.from(token, 'utf8'))).toBe('plaintext') + }) + + it('reports a macOS v10 keychain blob as sealed', () => { + // Prefix Electron writes on macOS, followed by binary ciphertext. + const sealed = Buffer.concat([ + Buffer.from('v10', 'utf8'), + Buffer.from([0x00, 0x91, 0xf2, 0x1a, 0x7c, 0x03, 0xff, 0xfe]) + ]) + expect(classifyUnenvelopedCredential(sealed)).toBe('sealed') + }) + + it('reports arbitrary ciphertext bytes as sealed', () => { + const sealed = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x1f]) + expect(classifyUnenvelopedCredential(sealed)).toBe('sealed') + }) + + // Why this case: tabs and newlines are legal in a pasted token, so treating every + // control byte as ciphertext would warn on nothing and stay silent on real plaintext. + it('does not mistake tabs or CRLF in a token for ciphertext', () => { + expect(classifyUnenvelopedCredential(Buffer.from('token\tmore\r\n', 'utf8'))).toBe('plaintext') + }) + + it('reports empty bytes as plaintext rather than claiming protection', () => { + expect(classifyUnenvelopedCredential(Buffer.alloc(0))).toBe('plaintext') + }) +}) diff --git a/src/shared/secret-at-rest-protection.ts b/src/shared/secret-at-rest-protection.ts new file mode 100644 index 00000000000..e8b24c1d668 --- /dev/null +++ b/src/shared/secret-at-rest-protection.ts @@ -0,0 +1,37 @@ +/** + * How a credential Orca already wrote is protected on disk. + * + * Why this is about the stored bytes and not `isEncryptionAvailable()`: the two disagree + * exactly when it matters. A key saved on a host with no usable keyring stays plaintext + * forever, even after a keyring appears and sealing starts working — nothing rewrites it + * until the user saves again. Reporting current capability would tell that user their key + * is protected when the file on disk says otherwise. + */ +export type SecretAtRestProtection = + /** Sealed by the OS keyring via safeStorage. */ + | 'sealed' + /** Readable by anyone who can read the file, a backup of it, or the raw disk. */ + | 'plaintext' + +/** + * Classify a credential file that stores raw ciphertext with no envelope. + * + * Why a heuristic: these stores (speech, Linear, Jira, Bitbucket) write either + * `safeStorage` ciphertext or the bare token, with nothing on disk to tell them apart. + * This is the same test `readStoredCredentialToken` already uses to decide whether a + * file is a legacy plaintext token, kept in one place so the reader and the reporter + * cannot drift into disagreeing about the same bytes. + * + * Prefer an explicit envelope where one exists — the MiniMax stores record their own + * kind and do not need to guess. + */ +export function classifyUnenvelopedCredential(raw: Buffer): SecretAtRestProtection { + const text = raw.toString('utf8') + // Ciphertext (a macOS v10 blob, or Chromium's AES payload) is not valid printable + // UTF-8; a token is. Buffer.toString replaces invalid sequences with U+FFFD. + if (text.includes('�')) { + return 'sealed' + } + // oxlint-disable-next-line no-control-regex -- Sealed payloads are binary, so control bytes are the signal. + return /[\u0000-\u0008\u000E-\u001F]/.test(text) ? 'sealed' : 'plaintext' +}