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' +}