mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
feat(secrets): warn in Settings when a credential is stored unencrypted (#24048)
* 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.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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<SpeechModelManifest[]>
|
||||
getModelStates: () => Promise<SpeechModelState[]>
|
||||
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<void>
|
||||
|
||||
@@ -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<SpeechModelManifest[]> => ipcRenderer.invoke('speech:getCatalog'),
|
||||
getModelStates: (): Promise<SpeechModelState[]> => 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 }> =>
|
||||
|
||||
@@ -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<SecretAtRestProtection | null>(null)
|
||||
const [miniMaxConfigured, setMiniMaxConfigured] = useState(false)
|
||||
const [miniMaxCookieProtection, setMiniMaxCookieProtection] =
|
||||
useState<SecretAtRestProtection | null>(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
|
||||
|
||||
@@ -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<string, string>) =>
|
||||
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(<UnsealedCredentialNotice protection="plaintext" credentialName="MiniMax API key" />)
|
||||
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(<UnsealedCredentialNotice protection="sealed" credentialName="MiniMax API key" />)
|
||||
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(<UnsealedCredentialNotice protection={null} credentialName="MiniMax API key" />)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the credential so a pane with several is unambiguous', () => {
|
||||
render(
|
||||
<UnsealedCredentialNotice protection="plaintext" credentialName="MiniMax Session Cookie" />
|
||||
)
|
||||
expect(screen.getByRole('alert').textContent).toContain('MiniMax Session Cookie')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2.5 rounded-lg border border-status-warning-border bg-status-warning-background px-3 py-2 text-status-warning"
|
||||
>
|
||||
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
|
||||
<p className="min-w-0 text-xs leading-snug">
|
||||
{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 }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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<SecretAtRestProtection | null>(
|
||||
null
|
||||
)
|
||||
const [pendingCloudModelId, setPendingCloudModelId] = useState<string | null>(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 && (
|
||||
<>
|
||||
<Separator />
|
||||
<UnsealedCredentialNotice
|
||||
protection={openAiKeyProtection}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.VoicePane.openAiKeyName',
|
||||
'Your OpenAI transcription key'
|
||||
)}
|
||||
/>
|
||||
<OpenAiTranscriptionSettingsRow
|
||||
configured={voiceSettings.openAiApiKeyConfigured}
|
||||
disabled={openAiKeyPending}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { FeatureInteractionId } from '../../../../shared/feature-interaction-catalog'
|
||||
import type { SecretAtRestProtection } from '../../../../shared/secret-at-rest-protection'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
@@ -7,9 +8,11 @@ type MiniMaxCredentialActionContext = {
|
||||
miniMaxApiKeyDraft: string
|
||||
setMiniMaxApiKeyDraft: Dispatch<SetStateAction<string>>
|
||||
setMiniMaxApiKeyConfigured: Dispatch<SetStateAction<boolean>>
|
||||
setMiniMaxApiKeyProtection: Dispatch<SetStateAction<SecretAtRestProtection | null>>
|
||||
miniMaxCookieDraft: string
|
||||
setMiniMaxCookieDraft: Dispatch<SetStateAction<string>>
|
||||
setMiniMaxConfigured: Dispatch<SetStateAction<boolean>>
|
||||
setMiniMaxCookieProtection: Dispatch<SetStateAction<SecretAtRestProtection | null>>
|
||||
setMiniMaxCredentialBusy: Dispatch<SetStateAction<boolean>>
|
||||
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) {
|
||||
|
||||
@@ -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({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<UnsealedCredentialNotice
|
||||
protection={miniMaxCookieProtection}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.AccountsPane.21d6eb141e',
|
||||
'MiniMax Session Cookie'
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="minimax-cookie"
|
||||
@@ -226,6 +236,13 @@ export function MiniMaxCredentials({
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<UnsealedCredentialNotice
|
||||
protection={miniMaxApiKeyProtection}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.AccountsPane.83b6a1f7c4',
|
||||
'MiniMax API key'
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="minimax-api-key"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SecretAtRestProtection } from '../../../../shared/secret-at-rest-protection'
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import type {
|
||||
@@ -108,11 +109,13 @@ export type AccountsPaneSectionModel = {
|
||||
miniMaxApiKeyDraft: string
|
||||
setMiniMaxApiKeyDraft: Dispatch<SetStateAction<string>>
|
||||
miniMaxApiKeyConfigured: boolean
|
||||
miniMaxApiKeyProtection: SecretAtRestProtection | null
|
||||
saveMiniMaxApiKey: () => Promise<void>
|
||||
clearMiniMaxApiKey: () => Promise<void>
|
||||
miniMaxCookieDraft: string
|
||||
setMiniMaxCookieDraft: Dispatch<SetStateAction<string>>
|
||||
miniMaxConfigured: boolean
|
||||
miniMaxCookieProtection: SecretAtRestProtection | null
|
||||
miniMaxCredentialBusy: boolean
|
||||
saveMiniMaxCookie: () => Promise<void>
|
||||
clearMiniMaxCookie: () => Promise<void>
|
||||
|
||||
@@ -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' ? (
|
||||
<IntegrationCardDetails>
|
||||
<UnsealedCredentialNotice
|
||||
protection={connection?.credentialProtection ?? null}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.bitbucket.integration.card.credentialName',
|
||||
'Your Bitbucket credential'
|
||||
)}
|
||||
/>
|
||||
{connected ? (
|
||||
<div className={subordinateRowClass}>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
>
|
||||
<IntegrationCardDetails>
|
||||
<UnsealedCredentialNotice
|
||||
protection={jiraStatus.credentialProtection ?? null}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.jira.integration.card.jiraTokenName',
|
||||
'Your Jira API token'
|
||||
)}
|
||||
/>
|
||||
<ProviderHostScopeControl
|
||||
labelPrefix={translate(
|
||||
'auto.components.settings.task.tracker.integration.cards.account_scope_prefix',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UnsealedCredentialNotice } from './UnsealedCredentialNotice'
|
||||
import { useState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
|
||||
import { LinearIcon } from '@/components/icons/LinearIcon'
|
||||
@@ -124,6 +125,13 @@ export function LinearIntegrationCard(): React.JSX.Element {
|
||||
>
|
||||
<IntegrationCardDetails>
|
||||
<ProviderAccountScopeRow scope={accountScope} />
|
||||
<UnsealedCredentialNotice
|
||||
protection={linearStatus.credentialProtection ?? null}
|
||||
credentialName={translate(
|
||||
'auto.components.settings.task.tracker.integration.cards.linearTokenName',
|
||||
'Your Linear API token'
|
||||
)}
|
||||
/>
|
||||
{connected ? (
|
||||
<div className="space-y-2">
|
||||
{workspaces.map((workspace) => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { PreloadApi } from '../../../../preload/api-types'
|
||||
export function createMiniMaxCredentialsApi(): NonNullable<
|
||||
Partial<PreloadApi>['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),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user