From cacf07273a0998076328e983f641784f2a89c7c1 Mon Sep 17 00:00:00 2001 From: William Wang Date: Tue, 16 Dec 2025 21:53:57 +0800 Subject: [PATCH] fix: redact apiKey logs and encrypt persisted credentials --- .gitignore | 3 + src/main/ipc.ts | 3 + src/main/services/AnthropicService.ts | 9 +- src/main/services/BackupManager.ts | 102 +++++- src/main/services/CopilotService.ts | 51 ++- src/main/services/SearchService.ts | 5 +- src/main/services/WindowService.ts | 32 +- .../services/__tests__/CopilotService.test.ts | 84 +++++ .../mcp/oauth/__tests__/storage.test.ts | 67 ++++ src/main/services/mcp/oauth/storage.ts | 50 ++- .../utils/__tests__/backupEncryption.test.ts | 59 ++++ src/main/utils/backupEncryption.ts | 178 +++++++++++ src/preload/index.ts | 12 +- .../src/components/Popups/BackupPopup.tsx | 107 ++++++- src/renderer/src/i18n/index.ts | 14 +- src/renderer/src/i18n/label.ts | 2 + src/renderer/src/i18n/locales/en-us.json | 42 ++- src/renderer/src/i18n/locales/zh-cn.json | 42 ++- src/renderer/src/i18n/locales/zh-tw.json | 42 ++- .../pages/settings/CredentialIssuesBanner.tsx | 130 ++++++++ .../src/pages/settings/SettingsPage.tsx | 47 +-- src/renderer/src/services/BackupService.ts | 161 +++++++++- src/renderer/src/store/index.ts | 301 ++---------------- src/renderer/src/store/runtime.ts | 31 ++ src/renderer/src/types/index.ts | 3 + .../src/utils/__tests__/securePersist.test.ts | 137 ++++++++ .../src/utils/__tests__/secureStorage.test.ts | 51 +++ src/renderer/src/utils/securePersist.ts | 238 ++++++++++++++ src/renderer/src/utils/secureStorage.ts | 54 +++- tests/main.setup.ts | 39 ++- tests/renderer.setup.ts | 14 +- 31 files changed, 1732 insertions(+), 378 deletions(-) create mode 100644 src/main/services/__tests__/CopilotService.test.ts create mode 100644 src/main/services/mcp/oauth/__tests__/storage.test.ts create mode 100644 src/main/utils/__tests__/backupEncryption.test.ts create mode 100644 src/main/utils/backupEncryption.ts create mode 100644 src/renderer/src/pages/settings/CredentialIssuesBanner.tsx create mode 100644 src/renderer/src/utils/__tests__/securePersist.test.ts create mode 100644 src/renderer/src/utils/__tests__/secureStorage.test.ts create mode 100644 src/renderer/src/utils/securePersist.ts diff --git a/.gitignore b/.gitignore index a8107fa93e..d292c8b09a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Thumbs.db node_modules dist out +.tmp/ mcp_server stats.html .eslintcache @@ -56,6 +57,8 @@ local .claude-code-router/* .codebuddy/* .zed/* +.cache/ +.home/ CLAUDE.local.md # vitest diff --git a/src/main/ipc.ts b/src/main/ipc.ts index d7e82ff875..b269cc29bc 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -142,6 +142,9 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { notesPath: getNotesDir(), configPath: getConfigDir(), appDataPath: app.getPath('userData'), + documentsPath: app.getPath('documents'), + downloadsPath: app.getPath('downloads'), + desktopPath: app.getPath('desktop'), resourcesPath: getResourcePath(), logsPath: logger.getLogsDir(), arch: arch(), diff --git a/src/main/services/AnthropicService.ts b/src/main/services/AnthropicService.ts index 1aa9f61f9a..dc8138b71f 100644 --- a/src/main/services/AnthropicService.ts +++ b/src/main/services/AnthropicService.ts @@ -125,8 +125,13 @@ class AnthropicService extends Error { return } - const encrypted = safeStorage.encryptString(JSON.stringify(creds)) - await promises.writeFile(CREDS_PATH, encrypted) + try { + const encrypted = safeStorage.encryptString(JSON.stringify(creds)) + await promises.writeFile(CREDS_PATH, encrypted) + } catch (error) { + logger.warn('safeStorage encryptString failed; saving Anthropic OAuth credentials as plain JSON', error as Error) + await promises.writeFile(CREDS_PATH, JSON.stringify(creds, null, 2)) + } await promises.chmod(CREDS_PATH, 0o600) // Read/write for owner only } diff --git a/src/main/services/BackupManager.ts b/src/main/services/BackupManager.ts index f331254fdf..6f899cd1f9 100644 --- a/src/main/services/BackupManager.ts +++ b/src/main/services/BackupManager.ts @@ -11,12 +11,17 @@ import * as path from 'path' import type { CreateDirectoryOptions, FileStat } from 'webdav' import { getDataPath } from '../utils' +import { decryptBackupFile, encryptBackupFile, isEncryptedBackupFile } from '../utils/backupEncryption' import S3Storage from './S3Storage' import WebDav from './WebDav' import { windowService } from './WindowService' const logger = loggerService.withContext('BackupManager') +type BackupCryptoOptions = { + passphrase?: string +} + class BackupManager { private tempDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup', 'temp') private backupDir = path.join(app.getPath('temp'), 'cherry-studio', 'backup') @@ -195,14 +200,19 @@ class BackupManager { fileName: string, data: string, destinationPath: string = this.backupDir, - skipBackupFile: boolean = false + skipBackupFile: boolean = false, + options: BackupCryptoOptions = {} ): Promise { const mainWindow = windowService.getMainWindow() + const passphrase = + typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : null + let tempZipPath = '' + let backupedFilePath = '' const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.BackupProgress, processData) // 只在关键阶段记录日志:开始、结束和主要阶段转换点 - const logStages = ['preparing', 'writing_data', 'preparing_compression', 'completed'] + const logStages = ['preparing', 'writing_data', 'preparing_compression', 'encrypting', 'completed'] if (logStages.includes(processData.stage) || processData.progress === 100) { logger.debug('backup progress', processData) } @@ -210,6 +220,7 @@ class BackupManager { try { await fs.ensureDir(this.tempDir) + await fs.ensureDir(destinationPath) onProgress({ stage: 'preparing', progress: 0, total: 100 }) // 使用流的方式写入 data.json @@ -251,9 +262,11 @@ class BackupManager { await fs.promises.mkdir(path.join(this.tempDir, 'Data')) // 不创建空 Data 目录会导致 restore 失败 } + backupedFilePath = path.join(destinationPath, fileName) + tempZipPath = passphrase ? path.join(this.backupDir, `cherry-studio.${Date.now()}.zip`) : backupedFilePath + // 创建输出文件流 - const backupedFilePath = path.join(destinationPath, fileName) - const output = fs.createWriteStream(backupedFilePath) + const output = fs.createWriteStream(tempZipPath) // 创建 archiver 实例,启用 ZIP64 支持 const archive = archiver('zip', { @@ -261,6 +274,7 @@ class BackupManager { zip64: true // 启用 ZIP64 支持以处理大文件 }) + const compressMax = passphrase ? 95 : 99 let lastProgress = 50 let totalEntries = 0 let processedEntries = 0 @@ -305,7 +319,10 @@ class BackupManager { archive.on('data', (chunk) => { processedBytes += chunk.length if (totalBytes > 0) { - const progressPercent = Math.min(99, 55 + Math.floor((processedBytes / totalBytes) * 44)) + const progressPercent = Math.min( + compressMax, + 55 + Math.floor((processedBytes / totalBytes) * (compressMax - 55)) + ) if (progressPercent > lastProgress) { lastProgress = progressPercent onProgress({ stage: 'compressing', progress: progressPercent, total: 100 }) @@ -316,7 +333,7 @@ class BackupManager { // 使用 Promise 等待压缩完成 await new Promise((resolve, reject) => { output.on('close', () => { - onProgress({ stage: 'compressing', progress: 100, total: 100 }) + onProgress({ stage: 'compressing', progress: compressMax, total: 100 }) resolve() }) archive.on('error', reject) @@ -336,6 +353,26 @@ class BackupManager { archive.finalize() }) + if (passphrase) { + const encryptStart = compressMax + const encryptSpan = 99 - encryptStart + onProgress({ stage: 'encrypting', progress: encryptStart, total: 100 }) + + await encryptBackupFile(tempZipPath, backupedFilePath, { + passphrase, + onProgress: (processed, total) => { + if (total <= 0) return + const progressPercent = Math.min(99, encryptStart + Math.floor((processed / total) * encryptSpan)) + if (progressPercent > lastProgress) { + lastProgress = progressPercent + onProgress({ stage: 'encrypting', progress: progressPercent, total: 100 }) + } + } + }) + + await fs.remove(tempZipPath).catch(() => {}) + } + // 清理临时目录 await fs.remove(this.tempDir) onProgress({ stage: 'completed', progress: 100, total: 100 }) @@ -346,17 +383,30 @@ class BackupManager { logger.error('[BackupManager] Backup failed:', error as Error) // 确保清理临时目录 await fs.remove(this.tempDir).catch(() => {}) + if (tempZipPath) { + await fs.remove(tempZipPath).catch(() => {}) + } + if (backupedFilePath) { + await fs.remove(backupedFilePath).catch(() => {}) + } throw error } } - async restore(_: Electron.IpcMainInvokeEvent, backupPath: string): Promise { + async restore( + _: Electron.IpcMainInvokeEvent, + backupPath: string, + options: BackupCryptoOptions = {} + ): Promise { const mainWindow = windowService.getMainWindow() + const passphrase = + typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : null + let decryptedZipPath: string | null = null const onProgress = (processData: { stage: string; progress: number; total: number }) => { mainWindow?.webContents.send(IpcChannel.RestoreProgress, processData) // 只在关键阶段记录日志 - const logStages = ['preparing', 'extracting', 'extracted', 'reading_data', 'completed'] + const logStages = ['preparing', 'decrypting', 'extracting', 'extracted', 'reading_data', 'completed'] if (logStages.includes(processData.stage) || processData.progress === 100) { logger.debug('restore progress', processData) } @@ -367,9 +417,37 @@ class BackupManager { await fs.ensureDir(this.tempDir) onProgress({ stage: 'preparing', progress: 0, total: 100 }) + let zipPath = backupPath + + const encrypted = await isEncryptedBackupFile(backupPath) + if (encrypted) { + if (!passphrase) { + throw new Error('Backup passphrase required') + } + + decryptedZipPath = path.join(this.backupDir, `cherry-studio.restore.${Date.now()}.zip`) + onProgress({ stage: 'decrypting', progress: 10, total: 100 }) + + try { + await decryptBackupFile(backupPath, decryptedZipPath, { + passphrase, + onProgress: (processed, total) => { + if (total <= 0) return + const progress = Math.min(14, 10 + Math.floor((processed / total) * 4)) + onProgress({ stage: 'decrypting', progress, total: 100 }) + } + }) + } catch (error) { + await fs.remove(decryptedZipPath).catch(() => {}) + throw new Error('Invalid backup passphrase') + } + + zipPath = decryptedZipPath + } + logger.debug(`step 1: unzip backup file: ${this.tempDir}`) - const zip = new StreamZip.async({ file: backupPath }) + const zip = new StreamZip.async({ file: zipPath }) onProgress({ stage: 'extracting', progress: 15, total: 100 }) await zip.extract(null, this.tempDir) onProgress({ stage: 'extracted', progress: 25, total: 100 }) @@ -410,6 +488,9 @@ class BackupManager { // 清理临时目录 await this.setWritableRecursive(this.tempDir) await fs.remove(this.tempDir) + if (decryptedZipPath) { + await fs.remove(decryptedZipPath).catch(() => {}) + } onProgress({ stage: 'completed', progress: 100, total: 100 }) logger.debug('step 5: Restore completed successfully') @@ -418,6 +499,9 @@ class BackupManager { } catch (error) { logger.error('Restore failed:', error as Error) await fs.remove(this.tempDir).catch(() => {}) + if (decryptedZipPath) { + await fs.remove(decryptedZipPath).catch(() => {}) + } throw error } } diff --git a/src/main/services/CopilotService.ts b/src/main/services/CopilotService.ts index 3efaafa737..f1f755e97e 100644 --- a/src/main/services/CopilotService.ts +++ b/src/main/services/CopilotService.ts @@ -85,6 +85,13 @@ class CopilotService { return path.join(getConfigDir(), CONFIG.TOKEN_FILE_NAME) } + private isLikelyToken = (token: string): boolean => { + if (!token) return false + if (token !== token.trim()) return false + if (token.length < 10) return false + return /^[\x21-\x7E]+$/.test(token) + } + /** * 设置自定义请求头 */ @@ -218,14 +225,25 @@ class CopilotService { */ public saveCopilotToken = async (_: Electron.IpcMainInvokeEvent, token: string): Promise => { try { - const encryptedToken = safeStorage.encryptString(token) + let payload: Buffer | string = token + if (safeStorage.isEncryptionAvailable()) { + try { + payload = safeStorage.encryptString(token) + } catch (error) { + logger.warn('safeStorage encryptString failed; saving Copilot token as plain text', error as Error) + payload = token + } + } else { + logger.warn('safeStorage encryption is not available; saving Copilot token as plain text') + } // 确保目录存在 const dir = path.dirname(this.tokenFilePath) if (!fs.existsSync(dir)) { await fs.promises.mkdir(dir, { recursive: true }) } - await fs.promises.writeFile(this.tokenFilePath, encryptedToken) + await fs.promises.writeFile(this.tokenFilePath, payload) + await fs.promises.chmod(this.tokenFilePath, 0o600).catch(() => {}) } catch (error) { logger.error('Failed to save token:', error as Error) throw new CopilotServiceError('无法保存访问令牌', error) @@ -242,8 +260,33 @@ class CopilotService { try { this.updateHeaders(headers) - const encryptedToken = await fs.promises.readFile(this.tokenFilePath) - const access_token = safeStorage.decryptString(Buffer.from(encryptedToken)) + const raw = await fs.promises.readFile(this.tokenFilePath) + + let access_token: string | undefined + if (safeStorage.isEncryptionAvailable()) { + try { + access_token = safeStorage.decryptString(raw) + } catch { + // Fall back to legacy plain token (pre-encryption), and migrate on success. + } + } + + if (!access_token) { + const legacy = raw.toString('utf-8') + if (!this.isLikelyToken(legacy)) { + await fs.promises.unlink(this.tokenFilePath).catch(() => {}) + throw new CopilotServiceError('无法读取已保存的访问令牌,请重新授权') + } + access_token = legacy + if (safeStorage.isEncryptionAvailable()) { + await this.saveCopilotToken(_, legacy) + } + } + + if (!this.isLikelyToken(access_token)) { + await fs.promises.unlink(this.tokenFilePath).catch(() => {}) + throw new CopilotServiceError('无法读取已保存的访问令牌,请重新授权') + } const response = await net.fetch(CONFIG.API_URLS.COPILOT_TOKEN, { method: 'GET', diff --git a/src/main/services/SearchService.ts b/src/main/services/SearchService.ts index 8a4e42099a..4598a651c5 100644 --- a/src/main/services/SearchService.ts +++ b/src/main/services/SearchService.ts @@ -24,8 +24,9 @@ export class SearchService { height: 600, show: false, webPreferences: { - nodeIntegration: true, - contextIsolation: false, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, devTools: is.dev } }) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 0322c3bd70..9d69fc12b4 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -3,7 +3,7 @@ import './ThemeService' import { is } from '@electron-toolkit/utils' import { loggerService } from '@logger' -import { isDev, isLinux, isMac, isWin } from '@main/constant' +import { isLinux, isMac, isWin } from '@main/constant' import { getFilesDir } from '@main/utils/file' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import { IpcChannel } from '@shared/IpcChannel' @@ -82,8 +82,11 @@ export class WindowService { ...(isLinux ? { icon } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), + contextIsolation: true, + nodeIntegration: false, sandbox: false, webSecurity: false, + allowRunningInsecureContent: false, webviewTag: true, zoomFactor: configManager.getZoomFactor(), backgroundThrottling: false @@ -163,12 +166,15 @@ export class WindowService { contextMenu.contextMenu(webContents) }) - // Dangerous API - if (isDev) { - mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => { - webPreferences.preload = join(__dirname, '../preload/index.js') - }) - } + mainWindow.webContents.on('will-attach-webview', (_, webPreferences) => { + // Never expose the app preload API surface to arbitrary content. + delete (webPreferences as any).preload + webPreferences.nodeIntegration = false + webPreferences.contextIsolation = true + webPreferences.sandbox = true + webPreferences.webSecurity = true + webPreferences.allowRunningInsecureContent = false + }) } private setupWindowEvents(mainWindow: BrowserWindow) { @@ -283,7 +289,10 @@ export class WindowService { action: 'allow', overrideBrowserWindowOptions: { webPreferences: { - partition: 'persist:webview' + partition: 'persist:webview', + contextIsolation: true, + nodeIntegration: false, + sandbox: true } } } @@ -484,9 +493,12 @@ export class WindowService { fullscreenable: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), - sandbox: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, webSecurity: false, - webviewTag: true + allowRunningInsecureContent: false, + webviewTag: false } }) diff --git a/src/main/services/__tests__/CopilotService.test.ts b/src/main/services/__tests__/CopilotService.test.ts new file mode 100644 index 0000000000..ab5a254316 --- /dev/null +++ b/src/main/services/__tests__/CopilotService.test.ts @@ -0,0 +1,84 @@ +import { net, safeStorage } from 'electron' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fsMock = vi.hoisted(() => ({ + existsSync: vi.fn(() => false), + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn(), + access: vi.fn() + } +})) + +vi.mock('fs', () => ({ default: fsMock, ...fsMock })) + +import copilotService from '../CopilotService' + +describe('CopilotService token storage', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true) + vi.mocked(fsMock.promises.mkdir).mockResolvedValue(undefined) + vi.mocked(fsMock.promises.writeFile).mockResolvedValue(undefined) + vi.mocked(fsMock.promises.chmod).mockResolvedValue(undefined) + vi.mocked(fsMock.promises.unlink).mockResolvedValue(undefined) + vi.mocked(net.fetch).mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ token: 'copilot-token' }) + } as any) + }) + + it('saves Copilot token encrypted when safeStorage is available', async () => { + const ciphertext = Buffer.from('ciphertext', 'utf-8') + vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext) + + await copilotService.saveCopilotToken({} as any, 'ghp_abcdefghijklmnopqrstuvwxyz1234567890') + + expect(fsMock.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), ciphertext) + expect(fsMock.promises.chmod).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), 0o600) + }) + + it('reads encrypted Copilot token and uses it for authorization', async () => { + vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from('encrypted:anything', 'utf-8')) + vi.mocked(safeStorage.decryptString).mockReturnValue('ghp_abcdefghijklmnopqrstuvwxyz1234567890') + + await copilotService.getToken({} as any) + + expect(net.fetch).toHaveBeenCalledWith( + expect.stringContaining('copilot_internal'), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: 'token ghp_abcdefghijklmnopqrstuvwxyz1234567890' + }) + }) + ) + }) + + it('falls back to legacy plaintext token and migrates it to encrypted storage', async () => { + const legacyToken = 'ghp_abcdefghijklmnopqrstuvwxyz1234567890' + vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from(legacyToken, 'utf-8')) + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('Unable to decrypt') + }) + + const ciphertext = Buffer.from('ciphertext', 'utf-8') + vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext) + + await copilotService.getToken({} as any) + + expect(fsMock.promises.writeFile).toHaveBeenCalledWith(expect.stringContaining('.copilot_token'), ciphertext) + }) + + it('clears unreadable token files when decryption fails and plaintext fallback is invalid', async () => { + vi.mocked(fsMock.promises.readFile).mockResolvedValue(Buffer.from('not a token', 'utf-8')) + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('Key unavailable') + }) + + await expect(copilotService.getToken({} as any)).rejects.toThrow('无法获取Copilot令牌') + expect(fsMock.promises.unlink).toHaveBeenCalledWith(expect.stringContaining('.copilot_token')) + }) +}) diff --git a/src/main/services/mcp/oauth/__tests__/storage.test.ts b/src/main/services/mcp/oauth/__tests__/storage.test.ts new file mode 100644 index 0000000000..bef104fb7b --- /dev/null +++ b/src/main/services/mcp/oauth/__tests__/storage.test.ts @@ -0,0 +1,67 @@ +import { safeStorage } from 'electron' +import fs from 'fs/promises' +import path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { JsonFileStorage } from '../storage' + +vi.mock('fs/promises', () => { + const api = { + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: vi.fn(), + rename: vi.fn(), + chmod: vi.fn(), + unlink: vi.fn() + } + return { default: api, ...api } +}) + +describe('MCP OAuth JsonFileStorage', () => { + const configDir = '/mock/config' + const serverUrlHash = 'serverHash' + const filePath = path.join(configDir, `${serverUrlHash}_oauth.json`) + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(safeStorage.isEncryptionAvailable).mockReturnValue(true) + }) + + it('migrates legacy plaintext JSON to encrypted storage when available', async () => { + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from(JSON.stringify({ lastUpdated: 1 }), 'utf-8')) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.chmod).mockResolvedValue(undefined) + + const ciphertext = Buffer.from('ciphertext', 'utf-8') + vi.mocked(safeStorage.encryptString).mockReturnValue(ciphertext) + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('Unable to decrypt') + }) + + const storage = new JsonFileStorage(serverUrlHash, configDir) + await storage.getTokens() + + expect(fs.writeFile).toHaveBeenCalledWith(`${filePath}.tmp`, ciphertext) + expect(fs.rename).toHaveBeenCalledWith(`${filePath}.tmp`, filePath) + expect(fs.chmod).toHaveBeenCalledWith(filePath, 0o600) + }) + + it('quarantines unreadable storage and resets to an empty state', async () => { + vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('not-json', 'utf-8')) + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.chmod).mockResolvedValue(undefined) + vi.mocked(safeStorage.decryptString).mockImplementation(() => { + throw new Error('Key unavailable') + }) + + const storage = new JsonFileStorage(serverUrlHash, configDir) + await expect(storage.getTokens()).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(filePath, expect.stringContaining(`${filePath}.unreadable.`)) + expect(fs.writeFile).toHaveBeenCalled() + }) +}) diff --git a/src/main/services/mcp/oauth/storage.ts b/src/main/services/mcp/oauth/storage.ts index 569ef25348..07506eff4f 100644 --- a/src/main/services/mcp/oauth/storage.ts +++ b/src/main/services/mcp/oauth/storage.ts @@ -24,6 +24,17 @@ export class JsonFileStorage implements IOAuthStorage { this.filePath = path.join(configDir, `${serverUrlHash}_oauth.json`) } + private async quarantineUnreadableFile(reason: string): Promise { + try { + const timestamp = new Date().toISOString().replaceAll(':', '-') + const quarantinePath = `${this.filePath}.unreadable.${timestamp}` + await fs.rename(this.filePath, quarantinePath) + logger.warn(`OAuth storage was unreadable and has been quarantined (${reason})`, { quarantinePath }) + } catch (error) { + logger.warn(`Failed to quarantine unreadable OAuth storage (${reason})`, error as Error) + } + } + private async readStorage(): Promise { if (this.cache) { return this.cache @@ -32,24 +43,36 @@ export class JsonFileStorage implements IOAuthStorage { try { const raw = await fs.readFile(this.filePath) - let storageJson: string | undefined - let usedEncryptedPayload = false - + const candidates: Array<{ source: 'encrypted' | 'plain'; payload: string }> = [] if (safeStorage.isEncryptionAvailable()) { try { - storageJson = safeStorage.decryptString(raw) - usedEncryptedPayload = true + candidates.push({ source: 'encrypted', payload: safeStorage.decryptString(raw) }) } catch { - // Fall back to legacy plain JSON (pre-encryption), and migrate on success. + // Decryption failure can happen when the OS keychain entry is unavailable (e.g., different OS user, reinstall). + } + } + candidates.push({ source: 'plain', payload: raw.toString('utf-8') }) + + let validated: OAuthStorageData | null = null + let usedEncryptedPayload = false + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate.payload) + validated = OAuthStorageSchema.parse(parsed) + usedEncryptedPayload = candidate.source === 'encrypted' + break + } catch { + // Keep trying the next candidate. } } - if (storageJson === undefined) { - storageJson = raw.toString('utf-8') + if (!validated) { + await this.quarantineUnreadableFile('invalid_json_or_decryption_failed') + const initial: OAuthStorageData = { lastUpdated: Date.now() } + await this.writeStorage(initial) + return initial } - const parsed = JSON.parse(storageJson) - const validated = OAuthStorageSchema.parse(parsed) this.cache = validated if (safeStorage.isEncryptionAvailable() && !usedEncryptedPayload) { @@ -81,7 +104,12 @@ export class JsonFileStorage implements IOAuthStorage { const tempPath = `${this.filePath}.tmp` const payload = JSON.stringify(data, null, 2) if (safeStorage.isEncryptionAvailable()) { - await fs.writeFile(tempPath, safeStorage.encryptString(payload)) + try { + await fs.writeFile(tempPath, safeStorage.encryptString(payload)) + } catch (error) { + logger.warn('safeStorage encryptString failed; saving MCP OAuth storage as plain JSON', error as Error) + await fs.writeFile(tempPath, payload) + } } else { logger.warn('safeStorage encryption is not available; saving MCP OAuth storage as plain JSON') await fs.writeFile(tempPath, payload) diff --git a/src/main/utils/__tests__/backupEncryption.test.ts b/src/main/utils/__tests__/backupEncryption.test.ts new file mode 100644 index 0000000000..fe24be008a --- /dev/null +++ b/src/main/utils/__tests__/backupEncryption.test.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs-extra' +import * as path from 'path' +import { describe, expect, it } from 'vitest' + +import { decryptBackupFile, encryptBackupFile, isEncryptedBackupFile } from '../backupEncryption' + +describe('backupEncryption', () => { + it('encrypts and decrypts backup files', async () => { + const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-')) + try { + const inputPath = path.join(tempDir, 'plain.zip') + const encryptedPath = path.join(tempDir, 'backup.csbackup') + const outputPath = path.join(tempDir, 'decrypted.zip') + + await fs.writeFile(inputPath, Buffer.from('hello world', 'utf-8')) + + await encryptBackupFile(inputPath, encryptedPath, { passphrase: 'test-passphrase', iterations: 1000 }) + expect(await isEncryptedBackupFile(encryptedPath)).toBe(true) + + await decryptBackupFile(encryptedPath, outputPath, { passphrase: 'test-passphrase' }) + const decrypted = await fs.readFile(outputPath, 'utf-8') + expect(decrypted).toBe('hello world') + } finally { + await fs.remove(tempDir) + } + }) + + it('fails to decrypt with a wrong passphrase', async () => { + const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-')) + try { + const inputPath = path.join(tempDir, 'plain.zip') + const encryptedPath = path.join(tempDir, 'backup.csbackup') + const outputPath = path.join(tempDir, 'decrypted.zip') + + await fs.writeFile(inputPath, Buffer.from('hello world', 'utf-8')) + await encryptBackupFile(inputPath, encryptedPath, { passphrase: 'correct', iterations: 1000 }) + + await expect(decryptBackupFile(encryptedPath, outputPath, { passphrase: 'wrong' })).rejects.toBeTruthy() + } finally { + await fs.remove(tempDir) + } + }) + + it('detects encrypted backup file magic', async () => { + const tempDir = await fs.mkdtemp(path.join(process.cwd(), 'tmp-backup-encryption-')) + try { + const plainPath = path.join(tempDir, 'plain.zip') + const encryptedPath = path.join(tempDir, 'backup.csbackup') + + await fs.writeFile(plainPath, Buffer.from('plain', 'utf-8')) + await encryptBackupFile(plainPath, encryptedPath, { passphrase: 'test', iterations: 1000 }) + + expect(await isEncryptedBackupFile(plainPath)).toBe(false) + expect(await isEncryptedBackupFile(encryptedPath)).toBe(true) + } finally { + await fs.remove(tempDir) + } + }) +}) diff --git a/src/main/utils/backupEncryption.ts b/src/main/utils/backupEncryption.ts new file mode 100644 index 0000000000..5559619a5b --- /dev/null +++ b/src/main/utils/backupEncryption.ts @@ -0,0 +1,178 @@ +import { open as openFile } from 'node:fs/promises' +import { Transform } from 'node:stream' + +import * as crypto from 'crypto' +import * as fs from 'fs-extra' +import * as path from 'path' +import { pipeline } from 'stream/promises' + +const MAGIC = Buffer.from('CSBACKUP', 'utf-8') +const VERSION = 1 +const DEFAULT_ITERATIONS = 200_000 +const SALT_LENGTH = 16 +const IV_LENGTH = 12 +const TAG_LENGTH = 16 + +const headerLength = MAGIC.length + 1 + 4 + SALT_LENGTH + IV_LENGTH + +const deriveKey = (passphrase: string, salt: Buffer, iterations: number): Promise => + new Promise((resolve, reject) => { + crypto.pbkdf2(passphrase, salt, iterations, 32, 'sha256', (error, derivedKey) => { + if (error) { + reject(error) + return + } + resolve(derivedKey) + }) + }) + +const buildHeader = (salt: Buffer, iv: Buffer, iterations: number): Buffer => { + const header = Buffer.alloc(headerLength) + let offset = 0 + MAGIC.copy(header, offset) + offset += MAGIC.length + header.writeUInt8(VERSION, offset) + offset += 1 + header.writeUInt32BE(iterations, offset) + offset += 4 + salt.copy(header, offset) + offset += SALT_LENGTH + iv.copy(header, offset) + return header +} + +export const isEncryptedBackupFile = async (filePath: string): Promise => { + try { + const fd = await openFile(filePath, 'r') + try { + const magic = Buffer.alloc(MAGIC.length) + const { bytesRead } = await fd.read(magic, 0, MAGIC.length, 0) + if (bytesRead !== MAGIC.length) { + return false + } + return magic.equals(MAGIC) + } finally { + await fd.close() + } + } catch { + return false + } +} + +export type BackupEncryptionOptions = { + passphrase: string + iterations?: number + onProgress?: (processedBytes: number, totalBytes: number) => void +} + +export const encryptBackupFile = async ( + inputPath: string, + outputPath: string, + options: BackupEncryptionOptions +): Promise => { + const iterations = options.iterations ?? DEFAULT_ITERATIONS + const salt = crypto.randomBytes(SALT_LENGTH) + const iv = crypto.randomBytes(IV_LENGTH) + const header = buildHeader(salt, iv, iterations) + const key = await deriveKey(options.passphrase, salt, iterations) + + await fs.ensureDir(path.dirname(outputPath)) + + const totalBytes = (await fs.stat(inputPath)).size + let processedBytes = 0 + + const progressStream = new Transform({ + transform(chunk, _encoding, callback) { + processedBytes += chunk.length + options.onProgress?.(processedBytes, totalBytes) + callback(null, chunk) + } + }) + + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + cipher.setAAD(header) + + await fs.writeFile(outputPath, header) + await pipeline( + fs.createReadStream(inputPath), + progressStream, + cipher, + fs.createWriteStream(outputPath, { flags: 'a' }) + ) + + const tag = cipher.getAuthTag() + await fs.appendFile(outputPath, tag) +} + +export const decryptBackupFile = async ( + inputPath: string, + outputPath: string, + options: BackupEncryptionOptions +): Promise => { + const fileStat = await fs.stat(inputPath) + const fileSize = fileStat.size + + if (fileSize <= headerLength + TAG_LENGTH) { + throw new Error('Invalid encrypted backup file') + } + + const fd = await openFile(inputPath, 'r') + let header: Buffer + let tag: Buffer + try { + header = Buffer.alloc(headerLength) + const headerRead = await fd.read(header, 0, headerLength, 0) + if (headerRead.bytesRead !== headerLength) { + throw new Error('Invalid encrypted backup header') + } + const magic = header.subarray(0, MAGIC.length) + if (!magic.equals(MAGIC)) { + throw new Error('Unsupported backup file') + } + + const version = header.readUInt8(MAGIC.length) + if (version !== VERSION) { + throw new Error('Unsupported encrypted backup version') + } + + tag = Buffer.alloc(TAG_LENGTH) + const tagRead = await fd.read(tag, 0, TAG_LENGTH, fileSize - TAG_LENGTH) + if (tagRead.bytesRead !== TAG_LENGTH) { + throw new Error('Invalid encrypted backup tag') + } + } finally { + await fd.close() + } + + const iterations = header.readUInt32BE(MAGIC.length + 1) + const saltStart = MAGIC.length + 1 + 4 + const salt = header.subarray(saltStart, saltStart + SALT_LENGTH) + const iv = header.subarray(saltStart + SALT_LENGTH, saltStart + SALT_LENGTH + IV_LENGTH) + const key = await deriveKey(options.passphrase, salt, iterations) + + await fs.ensureDir(path.dirname(outputPath)) + + const cipherTextStart = headerLength + const cipherTextEnd = fileSize - TAG_LENGTH - 1 + const totalBytes = cipherTextEnd - cipherTextStart + 1 + let processedBytes = 0 + + const progressStream = new Transform({ + transform(chunk, _encoding, callback) { + processedBytes += chunk.length + options.onProgress?.(processedBytes, totalBytes) + callback(null, chunk) + } + }) + + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAAD(header) + decipher.setAuthTag(tag) + + await pipeline( + fs.createReadStream(inputPath, { start: cipherTextStart, end: cipherTextEnd }), + progressStream, + decipher, + fs.createWriteStream(outputPath) + ) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 38c9361b28..3a83880563 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -137,9 +137,15 @@ const api = { decompress: (text: Buffer) => ipcRenderer.invoke(IpcChannel.Zip_Decompress, text) }, backup: { - backup: (filename: string, content: string, path: string, skipBackupFile: boolean) => - ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile), - restore: (path: string) => ipcRenderer.invoke(IpcChannel.Backup_Restore, path), + backup: ( + filename: string, + content: string, + path: string, + skipBackupFile: boolean, + options?: { passphrase?: string } + ) => ipcRenderer.invoke(IpcChannel.Backup_Backup, filename, content, path, skipBackupFile, options), + restore: (path: string, options?: { passphrase?: string }) => + ipcRenderer.invoke(IpcChannel.Backup_Restore, path, options), backupToWebdav: (data: string, webdavConfig: WebDavConfig) => ipcRenderer.invoke(IpcChannel.Backup_BackupToWebdav, data, webdavConfig), restoreFromWebdav: (webdavConfig: WebDavConfig) => diff --git a/src/renderer/src/components/Popups/BackupPopup.tsx b/src/renderer/src/components/Popups/BackupPopup.tsx index c16cafa70a..56c6ab40b2 100644 --- a/src/renderer/src/components/Popups/BackupPopup.tsx +++ b/src/renderer/src/components/Popups/BackupPopup.tsx @@ -1,9 +1,9 @@ import { loggerService } from '@logger' import { getBackupProgressLabel } from '@renderer/i18n/label' -import { backup } from '@renderer/services/BackupService' +import { backupWithOptions } from '@renderer/services/BackupService' import store from '@renderer/store' import { IpcChannel } from '@shared/IpcChannel' -import { Modal, Progress } from 'antd' +import { Alert, Checkbox, Input, Modal, Progress, Radio, Space, Typography } from 'antd' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -15,7 +15,14 @@ interface Props { resolve: (data: any) => void } -type ProgressStageType = 'reading_data' | 'preparing' | 'extracting' | 'extracted' | 'copying_files' | 'completed' +type ProgressStageType = + | 'preparing' + | 'writing_data' + | 'copying_files' + | 'preparing_compression' + | 'compressing' + | 'encrypting' + | 'completed' interface ProgressData { stage: ProgressStageType @@ -26,6 +33,12 @@ interface ProgressData { const PopupContainer: React.FC = ({ resolve }) => { const [open, setOpen] = useState(true) const [progressData, setProgressData] = useState() + const [isRunning, setIsRunning] = useState(false) + const [includeSecrets, setIncludeSecrets] = useState(false) + const [encryptBackup, setEncryptBackup] = useState(true) + const [secretsAcknowledged, setSecretsAcknowledged] = useState(false) + const [passphrase, setPassphrase] = useState('') + const [confirmPassphrase, setConfirmPassphrase] = useState('') const { t } = useTranslation() const skipBackupFile = store.getState().settings.skipBackupFile @@ -41,8 +54,22 @@ const PopupContainer: React.FC = ({ resolve }) => { const onOk = async () => { logger.debug(`skipBackupFile: ${skipBackupFile}`) - await backup(skipBackupFile) - setOpen(false) + setIsRunning(true) + try { + const completed = await backupWithOptions(skipBackupFile, { + includeSecrets, + passphrase: includeSecrets && encryptBackup ? passphrase : undefined + }) + if (completed) { + setOpen(false) + } + } catch (error) { + logger.error('Backup failed:', error as Error) + window.toast.error(t('message.backup.failed')) + setProgressData(undefined) + } finally { + setIsRunning(false) + } } const onCancel = () => { @@ -66,7 +93,14 @@ const PopupContainer: React.FC = ({ resolve }) => { BackupPopup.hide = onCancel - const isDisabled = progressData ? progressData.stage !== 'completed' : false + const needsPassphrase = includeSecrets && encryptBackup + const passphraseValid = !needsPassphrase || (passphrase.length > 0 && passphrase === confirmPassphrase) + const canStart = !includeSecrets || (secretsAcknowledged && passphraseValid) + + const isProgressLocked = progressData ? progressData.stage !== 'completed' : false + const isBusy = isRunning || isProgressLocked + const okDisabled = isBusy || !canStart + const cancelDisabled = isBusy return ( = ({ resolve }) => { onOk={onOk} onCancel={onCancel} afterClose={onClose} - okButtonProps={{ disabled: isDisabled }} - cancelButtonProps={{ disabled: isDisabled }} + okButtonProps={{ disabled: okDisabled }} + cancelButtonProps={{ disabled: cancelDisabled }} okText={t('backup.confirm.button')} maskClosable={false} transitionName="animation-move-down" centered> - {!progressData &&
{t('backup.content')}
} + {!progressData && ( + + {t('backup.content')} + { + const nextInclude = e.target.value === 'with_secrets' + setIncludeSecrets(nextInclude) + if (!nextInclude) { + setSecretsAcknowledged(false) + setPassphrase('') + setConfirmPassphrase('') + } + }}> + + {t('backup.options.without_secrets')} + {t('backup.options.with_secrets')} + + + + {includeSecrets && ( + + + setSecretsAcknowledged(e.target.checked)}> + {t('backup.options.secrets_ack')} + + setEncryptBackup(e.target.checked)}> + {t('backup.options.encrypt_with_passphrase')} + + + {encryptBackup && ( + + setPassphrase(e.target.value)} + placeholder={t('backup.options.passphrase')} + /> + setConfirmPassphrase(e.target.value)} + placeholder={t('backup.options.passphrase_confirm')} + status={confirmPassphrase.length > 0 && !passphraseValid ? 'error' : undefined} + /> + + )} + + {t('backup.options.path_tip')} + + )} + + )} {progressData && (
diff --git a/src/renderer/src/i18n/index.ts b/src/renderer/src/i18n/index.ts index f2e6e7424f..771564bb55 100644 --- a/src/renderer/src/i18n/index.ts +++ b/src/renderer/src/i18n/index.ts @@ -34,7 +34,19 @@ const resources = Object.fromEntries( ) export const getLanguage = () => { - return localStorage.getItem('language') || navigator.language || defaultLanguage + let savedLanguage: string | null = null + try { + savedLanguage = + typeof window !== 'undefined' && typeof window.localStorage?.getItem === 'function' + ? window.localStorage.getItem('language') + : null + } catch { + savedLanguage = null + } + + const browserLanguage = typeof navigator !== 'undefined' ? navigator.language : undefined + + return savedLanguage || browserLanguage || defaultLanguage } export const getLanguageCode = () => { diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 434fb415f9..bdc250316f 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -108,6 +108,7 @@ const backupProgressKeyMap = { completed: 'backup.progress.completed', compressing: 'backup.progress.compressing', copying_files: 'backup.progress.copying_files', + encrypting: 'backup.progress.encrypting', preparing_compression: 'backup.progress.preparing_compression', preparing: 'backup.progress.preparing', title: 'backup.progress.title', @@ -121,6 +122,7 @@ export const getBackupProgressLabel = (key: string): string => { const restoreProgressKeyMap = { completed: 'restore.progress.completed', copying_files: 'restore.progress.copying_files', + decrypting: 'restore.progress.decrypting', extracted: 'restore.progress.extracted', extracting: 'restore.progress.extracting', preparing: 'restore.progress.preparing', diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 86f3900329..aa610bf4fb 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -593,11 +593,28 @@ "button": "Select Backup Location", "label": "Are you sure you want to backup data?" }, - "content": "Backup all data, including chat history, settings, and knowledge base. Please note that the backup process may take some time, thank you for your patience.", + "content": "Backup app data, including chat history, settings, and knowledge base. By default, credentials are excluded. Please note that the backup process may take some time, thank you for your patience.", + "options": { + "encrypt_with_passphrase": "Encrypt backup with a passphrase (recommended)", + "passphrase": "Passphrase", + "passphrase_confirm": "Confirm passphrase", + "path_tip": "Tip: Avoid saving backups containing credentials to public or cloud-synced folders.", + "secrets_ack": "I understand the backup includes plaintext credentials.", + "secrets_warning": { + "description": "Including credentials exports them in a portable form. Store the backup securely and avoid public or cloud-synced folders.", + "title": "This backup includes credentials" + }, + "with_secrets": "Include credentials (portable across devices)", + "without_secrets": "Default (without credentials)" + }, + "path": { + "warning_public_dir": "You selected a potentially unsafe folder (public or cloud-synced). For backups containing credentials, avoid saving to public or cloud-synced folders. Continue?" + }, "progress": { "completed": "Backup completed", "compressing": "Compressing files...", "copying_files": "Copying files... {{progress}}%", + "encrypting": "Encrypting backup...", "preparing": "Preparing backup...", "preparing_compression": "Preparing compression...", "title": "Backup Progress", @@ -1246,7 +1263,8 @@ "availableProviders": "Available Providers", "availableTools": "Available Tools", "backup": { - "file_format": "Backup file format error" + "file_format": "Backup file format error", + "invalid_passphrase": "Invalid backup passphrase" }, "boundary": { "default": { @@ -1908,6 +1926,7 @@ }, "restore": { "failed": "Restore failed", + "secrets_migration_notice": "Some credentials may need to be re-authorized on this device", "success": "Restored successfully" }, "save": { @@ -2633,9 +2652,16 @@ "label": "Are you sure you want to restore data?" }, "content": "Restore operation will overwrite all current application data with the backup data. Please note that the restore process may take some time, thank you for your patience.", + "passphrase": { + "description": "Enter the passphrase to decrypt this backup.", + "empty": "Passphrase is required", + "placeholder": "Passphrase", + "title": "Backup Passphrase" + }, "progress": { "completed": "Restore completed", "copying_files": "Copying files... {{progress}}%", + "decrypting": "Decrypting backup...", "extracted": "Extraction successful", "extracting": "Extracting backup...", "preparing": "Preparing restore...", @@ -4596,6 +4622,18 @@ "titleLabel": "Title", "titlePlaceholder": "Please enter phrase title" }, + "security": { + "credentials_invalid": { + "action_data": "Go to Data settings", + "action_docprocess": "Go to Document processing", + "action_provider": "Go to Providers", + "action_websearch": "Go to Web search", + "affected": "Affected credentials: {{items}}", + "description": "Some credentials could not be decrypted or were lost (e.g. after reinstall or OS keychain changes). Please re-enter API keys or re-authorize.", + "dismiss": "Dismiss", + "title": "Credentials need re-authorization" + } + }, "shortcuts": { "action": "Action", "actions": "operation", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index a205408c47..78d6cd1304 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -593,11 +593,28 @@ "button": "选择备份位置", "label": "确定要备份数据吗?" }, - "content": "备份全部数据,包括聊天记录、设置、知识库等所有数据。请注意,备份过程可能需要一些时间,感谢您的耐心等待", + "content": "备份应用数据,包括聊天记录、设置、知识库等。默认不包含凭据(secrets)。请注意,备份过程可能需要一些时间,感谢您的耐心等待", + "options": { + "encrypt_with_passphrase": "使用口令加密备份文件(推荐)", + "passphrase": "口令", + "passphrase_confirm": "确认口令", + "path_tip": "提示:包含凭据的备份请避免保存到公共目录或网盘同步目录。", + "secrets_ack": "我理解备份包含明文凭据", + "secrets_warning": { + "description": "包含凭据会以可迁移形式导出(解密后的凭据)。请妥善保管备份文件,避免放在公共目录或网盘同步目录。", + "title": "此备份将包含凭据" + }, + "with_secrets": "包含凭据(可跨设备迁移)", + "without_secrets": "默认(不包含凭据)" + }, + "path": { + "warning_public_dir": "你选择了可能不安全的目录(公共目录/网盘同步目录)。对于包含凭据的备份,请避免保存到公共目录或网盘同步目录。仍要继续吗?" + }, "progress": { "completed": "备份完成", "compressing": "压缩文件...", "copying_files": "复制文件... {{progress}}%", + "encrypting": "加密备份...", "preparing": "准备备份...", "preparing_compression": "准备压缩...", "title": "备份进度", @@ -1246,7 +1263,8 @@ "availableProviders": "可用提供商", "availableTools": "可用工具", "backup": { - "file_format": "备份文件格式错误" + "file_format": "备份文件格式错误", + "invalid_passphrase": "备份口令错误" }, "boundary": { "default": { @@ -1908,6 +1926,7 @@ }, "restore": { "failed": "恢复失败", + "secrets_migration_notice": "部分凭据可能无法迁移到当前设备,请按需重新授权", "success": "恢复成功" }, "save": { @@ -2633,9 +2652,16 @@ "label": "确定要恢复数据吗?" }, "content": "恢复操作将使用备份数据覆盖当前所有应用数据。请注意,恢复过程可能需要一些时间,感谢您的耐心等待", + "passphrase": { + "description": "该备份已加密,请输入口令解密。", + "empty": "口令不能为空", + "placeholder": "口令", + "title": "输入备份口令" + }, "progress": { "completed": "恢复完成", "copying_files": "复制文件... {{progress}}%", + "decrypting": "解密备份...", "extracted": "解压成功", "extracting": "解压备份...", "preparing": "准备恢复...", @@ -4596,6 +4622,18 @@ "titleLabel": "标题", "titlePlaceholder": "请输入短语标题" }, + "security": { + "credentials_invalid": { + "action_data": "前往数据设置", + "action_docprocess": "前往文档处理设置", + "action_provider": "前往服务商设置", + "action_websearch": "前往网页搜索设置", + "affected": "受影响的凭据:{{items}}", + "description": "检测到部分凭据无法解密或已丢失(可能由于卸载重装、系统钥匙串变化等)。请到对应设置重新输入 API Key 或重新授权。", + "dismiss": "忽略", + "title": "凭据失效,需要重新授权" + } + }, "shortcuts": { "action": "操作", "actions": "操作", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 2896077fa4..19200a4336 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -593,11 +593,28 @@ "button": "選擇備份位置", "label": "確定要備份資料嗎?" }, - "content": "備份全部資料,包括聊天記錄、設定、知識庫等全部資料。請注意,備份過程可能需要一些時間,感謝您的耐心等待", + "content": "備份應用資料,包括聊天記錄、設定、知識庫等。預設不包含憑證(secrets)。請注意,備份過程可能需要一些時間,感謝您的耐心等待", + "options": { + "encrypt_with_passphrase": "使用口令加密備份檔案(推薦)", + "passphrase": "口令", + "passphrase_confirm": "確認口令", + "path_tip": "提示:包含憑證的備份請避免保存到公開目錄或雲端同步目錄。", + "secrets_ack": "我理解備份包含明文憑證", + "secrets_warning": { + "description": "包含憑證會以可遷移形式匯出(解密後的憑證)。請妥善保管備份檔案,避免放在公開目錄或雲端同步目錄。", + "title": "此備份將包含憑證" + }, + "with_secrets": "包含憑證(可跨裝置遷移)", + "without_secrets": "預設(不包含憑證)" + }, + "path": { + "warning_public_dir": "你選擇了可能不安全的目錄(公開目錄/雲端同步目錄)。對於包含憑證的備份,請避免保存到公開目錄或雲端同步目錄。仍要繼續嗎?" + }, "progress": { "completed": "備份完成", "compressing": "壓縮檔案...", "copying_files": "複製檔案... {{progress}}%", + "encrypting": "加密備份...", "preparing": "準備備份...", "preparing_compression": "準備壓縮...", "title": "備份進度", @@ -1246,7 +1263,8 @@ "availableProviders": "可用供應商", "availableTools": "可用工具", "backup": { - "file_format": "備份檔案格式錯誤" + "file_format": "備份檔案格式錯誤", + "invalid_passphrase": "備份口令錯誤" }, "boundary": { "default": { @@ -1908,6 +1926,7 @@ }, "restore": { "failed": "還原失敗", + "secrets_migration_notice": "部分憑證可能無法遷移到目前裝置,請視需要重新授權", "success": "還原成功" }, "save": { @@ -2633,9 +2652,16 @@ "label": "確定要復原資料嗎?" }, "content": "復原操作將使用備份資料覆蓋目前所有應用程式資料。請注意,復原過程可能需要一些時間,感謝您的耐心等待", + "passphrase": { + "description": "該備份已加密,請輸入口令解密。", + "empty": "口令不能為空", + "placeholder": "口令", + "title": "輸入備份口令" + }, "progress": { "completed": "復原完成", "copying_files": "複製檔案... {{progress}}%", + "decrypting": "解密備份...", "extracted": "解壓成功", "extracting": "解開備份...", "preparing": "準備復原...", @@ -4596,6 +4622,18 @@ "titleLabel": "標題", "titlePlaceholder": "請輸入短語標題" }, + "security": { + "credentials_invalid": { + "action_data": "前往資料設定", + "action_docprocess": "前往文件處理設定", + "action_provider": "前往服務商設定", + "action_websearch": "前往網頁搜尋設定", + "affected": "受影響的憑證:{{items}}", + "description": "偵測到部分憑證無法解密或已遺失(可能因為卸載重裝、系統鑰匙圈變更等)。請到對應設定重新輸入 API Key 或重新授權。", + "dismiss": "忽略", + "title": "憑證失效,需要重新授權" + } + }, "shortcuts": { "action": "操作", "actions": "操作", diff --git a/src/renderer/src/pages/settings/CredentialIssuesBanner.tsx b/src/renderer/src/pages/settings/CredentialIssuesBanner.tsx new file mode 100644 index 0000000000..f673d9b566 --- /dev/null +++ b/src/renderer/src/pages/settings/CredentialIssuesBanner.tsx @@ -0,0 +1,130 @@ +import { Alert, Button, Space } from 'antd' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +import { useAppDispatch, useAppSelector } from '../../store' +import { clearCredentialIssues } from '../../store/runtime' + +const CredentialIssuesBanner = () => { + const { t } = useTranslation() + const navigate = useNavigate() + const dispatch = useAppDispatch() + const issues = useAppSelector((state) => state.runtime.credentialIssues) + const llmProviders = useAppSelector((state) => state.llm.providers) + const preprocessProviders = useAppSelector((state) => state.preprocess.providers) + const webSearchProviders = useAppSelector((state) => state.websearch.providers) + + const affectedNames = useMemo(() => { + const names = new Set() + for (const issue of issues) { + const scope = issue.meta?.scope + const providerId = issue.meta?.providerId + if (typeof providerId !== 'string') continue + + if (scope === 'llm') { + const provider = llmProviders?.find((p: any) => p?.id === providerId) + names.add(provider?.name || providerId) + continue + } + + if (scope === 'preprocess') { + const provider = preprocessProviders?.find((p: any) => p?.id === providerId) + names.add(provider?.name || providerId) + continue + } + + if (scope === 'websearch') { + const provider = webSearchProviders?.find((p: any) => p?.id === providerId) + names.add(provider?.name || providerId) + continue + } + } + + return Array.from(names) + }, [issues, llmProviders, preprocessProviders, webSearchProviders]) + + const affectedPreview = useMemo(() => { + if (affectedNames.length === 0) return null + const preview = affectedNames.slice(0, 5).join(', ') + return affectedNames.length > 5 ? `${preview}…` : preview + }, [affectedNames]) + + const showProviderSettings = issues.some((issue) => issue.meta?.scope === 'llm') + const showWebSearchSettings = issues.some((issue) => issue.meta?.scope === 'websearch') + const showDocProcessSettings = issues.some((issue) => issue.meta?.scope === 'preprocess') + const showDataSettings = issues.some( + (issue) => + issue.meta?.scope === 'settings' || + issue.meta?.scope === 'nutstore' || + (typeof issue.id === 'string' && issue.id.startsWith('localStorage.')) + ) + + if (!issues.length) { + return null + } + + const actions = ( + + {showProviderSettings && ( + + )} + {showWebSearchSettings && ( + + )} + {showDocProcessSettings && ( + + )} + {showDataSettings && ( + + )} + + + ) + + return ( + + +
{t('settings.security.credentials_invalid.description')}
+ {affectedPreview ? ( + {t('settings.security.credentials_invalid.affected', { items: affectedPreview })} + ) : null} + + } + action={actions} + /> +
+ ) +} + +const Container = styled.div` + padding: 10px; +` + +const Description = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +` + +const Affected = styled.div` + opacity: 0.9; +` + +export default CredentialIssuesBanner diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index a14e10973d..9d408ac689 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -26,6 +26,7 @@ import { Link, Route, Routes, useLocation } from 'react-router-dom' import styled from 'styled-components' import AboutSettings from './AboutSettings' +import CredentialIssuesBanner from './CredentialIssuesBanner' import DataSettings from './DataSettings/DataSettings' import DisplaySettings from './DisplaySettings/DisplaySettings' import DocProcessSettings from './DocProcessSettings' @@ -156,24 +157,27 @@ const SettingsPage: FC = () => { - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + @@ -236,10 +240,17 @@ const MenuItem = styled.li` const SettingContent = styled.div` display: flex; + flex-direction: column; height: 100%; flex: 1; ` +const RoutesContainer = styled.div` + display: flex; + flex: 1; + overflow: hidden; +` + const Divider = styled(AntDivider)` margin: 3px 0; ` diff --git a/src/renderer/src/services/BackupService.ts b/src/renderer/src/services/BackupService.ts index 1c97ebd087..bb2ce0fb90 100644 --- a/src/renderer/src/services/BackupService.ts +++ b/src/renderer/src/services/BackupService.ts @@ -6,7 +6,10 @@ import store from '@renderer/store' import { setLocalBackupSyncState, setS3SyncState, setWebDAVSyncState } from '@renderer/store/backup' import type { S3Config, WebDavConfig } from '@renderer/types' import { uuid } from '@renderer/utils' +import { stripPersistedRootStateSecretsString, transformPersistedRootStateString } from '@renderer/utils/securePersist' +import { Input } from 'antd' import dayjs from 'dayjs' +import { createElement } from 'react' import { NotificationService } from './NotificationService' @@ -63,26 +66,92 @@ async function deleteWebdavFileWithRetry(fileName: string, webdavConfig: WebDavC } export async function backup(skipBackupFile: boolean) { - const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.zip` - const fileContnet = await getBackupData() - const selectFolder = await window.api.file.selectFolder() - if (selectFolder) { - await window.api.backup.backup(filename, fileContnet, selectFolder, skipBackupFile) - window.toast.success(i18n.t('message.backup.success')) + return backupWithOptions(skipBackupFile) +} + +export type BackupExportOptions = { + includeSecrets?: boolean + passphrase?: string +} + +export async function backupWithOptions(skipBackupFile: boolean, options: BackupExportOptions = {}): Promise { + const includeSecrets = Boolean(options.includeSecrets) + const passphrase = + typeof options.passphrase === 'string' && options.passphrase.length > 0 ? options.passphrase : undefined + + const extension = passphrase ? 'csbackup' : 'zip' + const filename = `cherry-studio.${dayjs().format('YYYYMMDDHHmm')}.${extension}` + const fileContnet = await getBackupData({ includeSecrets }) + + const appInfo = await window.api.getAppInfo() + const defaultPath = appInfo?.documentsPath || appInfo?.appDataPath + + const selectFolder = await window.api.file.selectFolder(defaultPath ? { defaultPath } : undefined) + if (!selectFolder) { + return false } + + if (includeSecrets) { + const downloadsPath = appInfo?.downloadsPath + const desktopPath = appInfo?.desktopPath + const isDownloads = downloadsPath ? await window.api.isPathInside(selectFolder, downloadsPath) : false + const isDesktop = desktopPath ? await window.api.isPathInside(selectFolder, desktopPath) : false + const isCloudSynced = isPossiblyCloudSyncedPath(selectFolder) + + if (isDownloads || isDesktop || isCloudSynced) { + const confirmed = await window.modal.confirm({ + title: i18n.t('common.warning'), + content: i18n.t('backup.path.warning_public_dir'), + centered: true + }) + + if (!confirmed) { + return false + } + } + } + + await window.api.backup.backup(filename, fileContnet, selectFolder, skipBackupFile, { passphrase }) + window.toast.success(i18n.t('message.backup.success')) + return true +} + +const isPossiblyCloudSyncedPath = (folderPath: string): boolean => { + const normalized = folderPath.replaceAll('\\', '/').toLowerCase().replaceAll(' ', '') + const hints = [ + 'onedrive', + 'dropbox', + 'googledrive', + 'google-drive', + 'icloud', + 'icloudrive', + 'box', + 'mega', + 'syncthing' + ] + + return hints.some((hint) => normalized.includes(hint)) } export async function restore() { const notificationService = NotificationService.getInstance() - const file = await window.api.file.open({ filters: [{ name: '备份文件', extensions: ['bak', 'zip'] }] }) + const file = await window.api.file.open({ filters: [{ name: '备份文件', extensions: ['bak', 'zip', 'csbackup'] }] }) if (file) { try { let data: Record = {} // zip backup file - if (file?.fileName.endsWith('.zip')) { - const restoreData = await window.api.backup.restore(file.filePath) + if (file?.fileName.endsWith('.zip') || file?.fileName.endsWith('.csbackup')) { + let passphrase: string | undefined + if (file.fileName.endsWith('.csbackup')) { + passphrase = await promptBackupPassphrase() + if (!passphrase) { + return + } + } + + const restoreData = await window.api.backup.restore(file.filePath, passphrase ? { passphrase } : undefined) data = JSON.parse(restoreData) } else { data = JSON.parse(await window.api.zip.decompress(file.content)) @@ -102,11 +171,45 @@ export async function restore() { }) } catch (error) { logger.error('restore: Error restoring backup file:', error as Error) - window.toast.error(i18n.t('error.backup.file_format')) + const message = (error as Error)?.message || '' + if (message.toLowerCase().includes('passphrase')) { + window.toast.error(i18n.t('error.backup.invalid_passphrase')) + } else { + window.toast.error(i18n.t('error.backup.file_format')) + } } } } +async function promptBackupPassphrase(): Promise { + let passphrase = '' + + const confirmed = await window.modal.confirm({ + title: i18n.t('restore.passphrase.title'), + content: createElement('div', null, [ + createElement('div', { key: 'desc', style: { marginBottom: 8 } }, i18n.t('restore.passphrase.description')), + createElement(Input.Password, { + key: 'input', + placeholder: i18n.t('restore.passphrase.placeholder'), + autoFocus: true, + autoComplete: 'new-password', + onChange: (event: any) => { + passphrase = event?.target?.value ?? '' + } + }) + ]), + centered: true, + onOk: async () => { + if (!passphrase) { + window.toast.error(i18n.t('restore.passphrase.empty')) + throw new Error('Passphrase required') + } + } + }) + + return confirmed ? passphrase : undefined +} + export async function reset() { window.modal.confirm({ title: i18n.t('common.warning'), @@ -820,11 +923,31 @@ export function stopAutoSync(type?: BackupType) { } } -export async function getBackupData() { +export async function getBackupData(options: { includeSecrets?: boolean } = {}) { + const localStorageSnapshot: Record = {} + for (let index = 0; index < localStorage.length; index++) { + const key = localStorage.key(index) + if (!key) { + continue + } + const value = localStorage.getItem(key) + if (value !== null) { + localStorageSnapshot[key] = value + } + } + + const persistedKey = 'persist:cherry-studio' + if (typeof localStorageSnapshot[persistedKey] === 'string') { + localStorageSnapshot[persistedKey] = options.includeSecrets + ? transformPersistedRootStateString(localStorageSnapshot[persistedKey], 'decrypt') + : stripPersistedRootStateSecretsString(localStorageSnapshot[persistedKey]) + } + return JSON.stringify({ time: new Date().getTime(), - version: 5, - localStorage, + version: 6, + includesSecrets: Boolean(options.includeSecrets), + localStorage: localStorageSnapshot, indexedDB: await backupDatabase() }) } @@ -850,7 +973,17 @@ export async function handleData(data: Record) { } if (data.version >= 2) { - localStorage.setItem('persist:cherry-studio', data.localStorage['persist:cherry-studio']) + const persistedKey = 'persist:cherry-studio' + const persistedValue = data?.localStorage?.[persistedKey] + if (typeof persistedValue === 'string') { + const decrypted = transformPersistedRootStateString(persistedValue, 'decrypt') + const reencrypted = transformPersistedRootStateString(decrypted, 'encrypt') + localStorage.setItem(persistedKey, reencrypted) + + if (data.version < 6 && persistedValue.includes('csenc:')) { + window.toast.warning(i18n.t('message.restore.secrets_migration_notice')) + } + } // remove notes_tree from indexedDB if (data.indexedDB['notes_tree']) { diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 8c0684e25f..0a6ba1cc89 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -15,7 +15,9 @@ import { import storage from 'redux-persist/lib/storage' import storeSyncService from '../services/StoreSyncService' -import { decryptSecret, encryptSecret } from '../utils/secureStorage' +import { decryptPersistSliceState, encryptPersistSliceState, SECURE_PERSIST_SLICE_KEYS } from '../utils/securePersist' +import type { CredentialIssue } from '../utils/secureStorage' +import { consumeCredentialIssues, CREDENTIAL_ISSUE_EVENT_NAME } from '../utils/secureStorage' import assistants from './assistants' import backup from './backup' import codeTools from './codeTools' @@ -35,7 +37,7 @@ import nutstore from './nutstore' import ocr from './ocr' import paintings from './paintings' import preprocess from './preprocess' -import runtime from './runtime' +import runtime, { addCredentialIssue, setCredentialIssues } from './runtime' import selectionStore from './selectionStore' import settings from './settings' import shortcuts from './shortcuts' @@ -45,280 +47,13 @@ import translate from './translate' import websearch from './websearch' const logger = loggerService.withContext('Store') +let credentialIssueListenerAttached = false const securePersistTransform = createTransform( - (inboundState: any, key) => { - if (!inboundState || typeof inboundState !== 'object') { - return inboundState - } - - if (key === 'llm') { - return { - ...inboundState, - providers: Array.isArray(inboundState.providers) - ? inboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey - })) - : inboundState.providers, - settings: { - ...inboundState.settings, - vertexai: inboundState.settings?.vertexai - ? { - ...inboundState.settings.vertexai, - serviceAccount: inboundState.settings.vertexai.serviceAccount - ? { - ...inboundState.settings.vertexai.serviceAccount, - privateKey: - typeof inboundState.settings.vertexai.serviceAccount.privateKey === 'string' - ? encryptSecret(inboundState.settings.vertexai.serviceAccount.privateKey) - : inboundState.settings.vertexai.serviceAccount.privateKey - } - : inboundState.settings.vertexai.serviceAccount - } - : inboundState.settings?.vertexai, - awsBedrock: inboundState.settings?.awsBedrock - ? { - ...inboundState.settings.awsBedrock, - accessKeyId: - typeof inboundState.settings.awsBedrock.accessKeyId === 'string' - ? encryptSecret(inboundState.settings.awsBedrock.accessKeyId) - : inboundState.settings.awsBedrock.accessKeyId, - secretAccessKey: - typeof inboundState.settings.awsBedrock.secretAccessKey === 'string' - ? encryptSecret(inboundState.settings.awsBedrock.secretAccessKey) - : inboundState.settings.awsBedrock.secretAccessKey, - apiKey: - typeof inboundState.settings.awsBedrock.apiKey === 'string' - ? encryptSecret(inboundState.settings.awsBedrock.apiKey) - : inboundState.settings.awsBedrock.apiKey - } - : inboundState.settings?.awsBedrock - } - } - } - - if (key === 'settings') { - return { - ...inboundState, - webdavPass: - typeof inboundState.webdavPass === 'string' - ? encryptSecret(inboundState.webdavPass) - : inboundState.webdavPass, - notionApiKey: - typeof inboundState.notionApiKey === 'string' - ? encryptSecret(inboundState.notionApiKey) - : inboundState.notionApiKey, - yuqueToken: - typeof inboundState.yuqueToken === 'string' - ? encryptSecret(inboundState.yuqueToken) - : inboundState.yuqueToken, - joplinToken: - typeof inboundState.joplinToken === 'string' - ? encryptSecret(inboundState.joplinToken) - : inboundState.joplinToken, - siyuanToken: - typeof inboundState.siyuanToken === 'string' - ? encryptSecret(inboundState.siyuanToken) - : inboundState.siyuanToken, - s3: inboundState.s3 - ? { - ...inboundState.s3, - accessKeyId: - typeof inboundState.s3.accessKeyId === 'string' - ? encryptSecret(inboundState.s3.accessKeyId) - : inboundState.s3.accessKeyId, - secretAccessKey: - typeof inboundState.s3.secretAccessKey === 'string' - ? encryptSecret(inboundState.s3.secretAccessKey) - : inboundState.s3.secretAccessKey - } - : inboundState.s3, - apiServer: inboundState.apiServer - ? { - ...inboundState.apiServer, - apiKey: - typeof inboundState.apiServer.apiKey === 'string' - ? encryptSecret(inboundState.apiServer.apiKey) - : inboundState.apiServer.apiKey - } - : inboundState.apiServer - } - } - - if (key === 'preprocess') { - return { - ...inboundState, - providers: Array.isArray(inboundState.providers) - ? inboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey - })) - : inboundState.providers - } - } - - if (key === 'websearch') { - return { - ...inboundState, - providers: Array.isArray(inboundState.providers) - ? inboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? encryptSecret(provider.apiKey) : provider.apiKey - })) - : inboundState.providers - } - } - - if (key === 'nutstore') { - return { - ...inboundState, - nutstoreToken: - typeof inboundState.nutstoreToken === 'string' - ? encryptSecret(inboundState.nutstoreToken) - : inboundState.nutstoreToken - } - } - - return inboundState - }, - (outboundState: any, key) => { - if (!outboundState || typeof outboundState !== 'object') { - return outboundState - } - - if (key === 'llm') { - return { - ...outboundState, - providers: Array.isArray(outboundState.providers) - ? outboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey - })) - : outboundState.providers, - settings: { - ...outboundState.settings, - vertexai: outboundState.settings?.vertexai - ? { - ...outboundState.settings.vertexai, - serviceAccount: outboundState.settings.vertexai.serviceAccount - ? { - ...outboundState.settings.vertexai.serviceAccount, - privateKey: - typeof outboundState.settings.vertexai.serviceAccount.privateKey === 'string' - ? decryptSecret(outboundState.settings.vertexai.serviceAccount.privateKey) - : outboundState.settings.vertexai.serviceAccount.privateKey - } - : outboundState.settings.vertexai.serviceAccount - } - : outboundState.settings?.vertexai, - awsBedrock: outboundState.settings?.awsBedrock - ? { - ...outboundState.settings.awsBedrock, - accessKeyId: - typeof outboundState.settings.awsBedrock.accessKeyId === 'string' - ? decryptSecret(outboundState.settings.awsBedrock.accessKeyId) - : outboundState.settings.awsBedrock.accessKeyId, - secretAccessKey: - typeof outboundState.settings.awsBedrock.secretAccessKey === 'string' - ? decryptSecret(outboundState.settings.awsBedrock.secretAccessKey) - : outboundState.settings.awsBedrock.secretAccessKey, - apiKey: - typeof outboundState.settings.awsBedrock.apiKey === 'string' - ? decryptSecret(outboundState.settings.awsBedrock.apiKey) - : outboundState.settings.awsBedrock.apiKey - } - : outboundState.settings?.awsBedrock - } - } - } - - if (key === 'settings') { - return { - ...outboundState, - webdavPass: - typeof outboundState.webdavPass === 'string' - ? decryptSecret(outboundState.webdavPass) - : outboundState.webdavPass, - notionApiKey: - typeof outboundState.notionApiKey === 'string' - ? decryptSecret(outboundState.notionApiKey) - : outboundState.notionApiKey, - yuqueToken: - typeof outboundState.yuqueToken === 'string' - ? decryptSecret(outboundState.yuqueToken) - : outboundState.yuqueToken, - joplinToken: - typeof outboundState.joplinToken === 'string' - ? decryptSecret(outboundState.joplinToken) - : outboundState.joplinToken, - siyuanToken: - typeof outboundState.siyuanToken === 'string' - ? decryptSecret(outboundState.siyuanToken) - : outboundState.siyuanToken, - s3: outboundState.s3 - ? { - ...outboundState.s3, - accessKeyId: - typeof outboundState.s3.accessKeyId === 'string' - ? decryptSecret(outboundState.s3.accessKeyId) - : outboundState.s3.accessKeyId, - secretAccessKey: - typeof outboundState.s3.secretAccessKey === 'string' - ? decryptSecret(outboundState.s3.secretAccessKey) - : outboundState.s3.secretAccessKey - } - : outboundState.s3, - apiServer: outboundState.apiServer - ? { - ...outboundState.apiServer, - apiKey: - typeof outboundState.apiServer.apiKey === 'string' - ? decryptSecret(outboundState.apiServer.apiKey) - : outboundState.apiServer.apiKey - } - : outboundState.apiServer - } - } - - if (key === 'preprocess') { - return { - ...outboundState, - providers: Array.isArray(outboundState.providers) - ? outboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey - })) - : outboundState.providers - } - } - - if (key === 'websearch') { - return { - ...outboundState, - providers: Array.isArray(outboundState.providers) - ? outboundState.providers.map((provider: any) => ({ - ...provider, - apiKey: typeof provider.apiKey === 'string' ? decryptSecret(provider.apiKey) : provider.apiKey - })) - : outboundState.providers - } - } - - if (key === 'nutstore') { - return { - ...outboundState, - nutstoreToken: - typeof outboundState.nutstoreToken === 'string' - ? decryptSecret(outboundState.nutstoreToken) - : outboundState.nutstoreToken - } - } - - return outboundState - }, + (inboundState: any, key) => encryptPersistSliceState(String(key), inboundState), + (outboundState: any, key) => decryptPersistSliceState(String(key), outboundState), { - whitelist: ['llm', 'settings', 'preprocess', 'websearch', 'nutstore'] + whitelist: [...SECURE_PERSIST_SLICE_KEYS] } ) @@ -418,6 +153,26 @@ export const persistor = persistStore(store, undefined, () => { persistor.flush().catch(() => {}) }, 0) } + + const issues = consumeCredentialIssues() + if (issues.length > 0) { + store.dispatch(setCredentialIssues(issues)) + } + + if ( + !credentialIssueListenerAttached && + typeof window !== 'undefined' && + typeof window.addEventListener === 'function' + ) { + credentialIssueListenerAttached = true + window.addEventListener(CREDENTIAL_ISSUE_EVENT_NAME, ((event: Event) => { + const issue = (event as CustomEvent).detail as CredentialIssue | undefined + if (!issue || typeof issue.id !== 'string' || typeof issue.reason !== 'string') { + return + } + store.dispatch(addCredentialIssue(issue)) + }) as EventListener) + } }) export const useAppDispatch = useDispatch.withTypes() diff --git a/src/renderer/src/store/runtime.ts b/src/renderer/src/store/runtime.ts index 2ee7719469..d5882ef426 100644 --- a/src/renderer/src/store/runtime.ts +++ b/src/renderer/src/store/runtime.ts @@ -2,6 +2,7 @@ import type { PayloadAction } from '@reduxjs/toolkit' import { createSlice } from '@reduxjs/toolkit' import { AppLogo, UserAvatar } from '@renderer/config/env' import type { MinAppType, Topic, WebSearchStatus } from '@renderer/types' +import type { CredentialIssue } from '@renderer/utils/secureStorage' import type { UpdateInfo } from 'builder-util-runtime' export interface ChatState { @@ -39,6 +40,7 @@ export interface UpdateState { export interface RuntimeState { avatar: string + credentialIssues: CredentialIssue[] generating: boolean translating: boolean translateAbortKey?: string @@ -65,6 +67,7 @@ export interface ExportState { const initialState: RuntimeState = { avatar: UserAvatar, + credentialIssues: [], generating: false, translating: false, minappShow: false, @@ -109,6 +112,30 @@ const runtimeSlice = createSlice({ setAvatar: (state, action: PayloadAction) => { state.avatar = action.payload || AppLogo }, + addCredentialIssue: (state, action: PayloadAction) => { + const key = `${action.payload.reason}:${action.payload.id}` + const exists = state.credentialIssues.some((issue) => `${issue.reason}:${issue.id}` === key) + if (!exists) { + state.credentialIssues.push(action.payload) + } + }, + clearCredentialIssues: (state) => { + state.credentialIssues = [] + }, + dismissCredentialIssue: (state, action: PayloadAction) => { + state.credentialIssues = state.credentialIssues.filter((issue) => issue.id !== action.payload) + }, + setCredentialIssues: (state, action: PayloadAction) => { + const nextIssues: CredentialIssue[] = [] + const seen = new Set() + for (const issue of action.payload) { + const key = `${issue.reason}:${issue.id}` + if (seen.has(key)) continue + seen.add(key) + nextIssues.push(issue) + } + state.credentialIssues = nextIssues + }, setGenerating: (state, action: PayloadAction) => { state.generating = action.payload }, @@ -195,6 +222,10 @@ const runtimeSlice = createSlice({ export const { setAvatar, + addCredentialIssue, + clearCredentialIssues, + dismissCredentialIssue, + setCredentialIssues, setGenerating, setTranslating, setTranslateAbortKey, diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 6e7e4e41e0..b6f42059b4 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -488,6 +488,9 @@ export type AppInfo = { appPath: string configPath: string appDataPath: string + documentsPath?: string + downloadsPath?: string + desktopPath?: string resourcesPath: string filesPath: string logsPath: string diff --git a/src/renderer/src/utils/__tests__/securePersist.test.ts b/src/renderer/src/utils/__tests__/securePersist.test.ts new file mode 100644 index 0000000000..9033cc0cf6 --- /dev/null +++ b/src/renderer/src/utils/__tests__/securePersist.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { stripPersistedRootStateSecretsString, transformPersistedRootStateString } from '../securePersist' +import { decryptSecret, encryptSecret } from '../secureStorage' + +describe('securePersist', () => { + beforeEach(() => { + const safeStorage = { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((plainText: string) => { + if (plainText.startsWith('csenc:')) return plainText + return `csenc:${Buffer.from(plainText, 'utf-8').toString('base64')}` + }), + decryptString: vi.fn((value: string) => { + const prefix = 'csenc:' + if (!value.startsWith(prefix)) return value + try { + return Buffer.from(value.slice(prefix.length), 'base64').toString('utf-8') + } catch { + return value + } + }) + } + + ;(window as any).api = { ...(window as any).api, safeStorage } + }) + + it('decrypts persisted secrets to plaintext for portability', () => { + const persisted = JSON.stringify({ + llm: JSON.stringify({ + providers: [{ id: 'p1', apiKey: encryptSecret('k1') }], + settings: { + awsBedrock: { + accessKeyId: encryptSecret('ak'), + secretAccessKey: encryptSecret('sk'), + apiKey: encryptSecret('bk') + }, + vertexai: { + serviceAccount: { + privateKey: encryptSecret('pk') + } + } + } + }), + settings: JSON.stringify({ + webdavPass: encryptSecret('pass'), + s3: { accessKeyId: encryptSecret('s3ak'), secretAccessKey: encryptSecret('s3sk') }, + apiServer: { apiKey: encryptSecret('api') } + }), + preprocess: JSON.stringify({ providers: [{ apiKey: encryptSecret('pre') }] }), + websearch: JSON.stringify({ providers: [{ apiKey: encryptSecret('ws') }] }), + nutstore: JSON.stringify({ nutstoreToken: encryptSecret('nut') }), + _persist: JSON.stringify({ version: 183, rehydrated: true }) + }) + + const decrypted = transformPersistedRootStateString(persisted, 'decrypt') + const root = JSON.parse(decrypted) as Record + + const llm = JSON.parse(root.llm) + expect(llm.providers[0].apiKey).toBe('k1') + expect(llm.settings.awsBedrock.accessKeyId).toBe('ak') + expect(llm.settings.awsBedrock.secretAccessKey).toBe('sk') + expect(llm.settings.awsBedrock.apiKey).toBe('bk') + expect(llm.settings.vertexai.serviceAccount.privateKey).toBe('pk') + + const settings = JSON.parse(root.settings) + expect(settings.webdavPass).toBe('pass') + expect(settings.s3.accessKeyId).toBe('s3ak') + expect(settings.s3.secretAccessKey).toBe('s3sk') + expect(settings.apiServer.apiKey).toBe('api') + + const preprocess = JSON.parse(root.preprocess) + expect(preprocess.providers[0].apiKey).toBe('pre') + + const websearch = JSON.parse(root.websearch) + expect(websearch.providers[0].apiKey).toBe('ws') + + const nutstore = JSON.parse(root.nutstore) + expect(nutstore.nutstoreToken).toBe('nut') + }) + + it('re-encrypts plaintext secrets for the current device', () => { + const plaintext = JSON.stringify({ + llm: JSON.stringify({ providers: [{ apiKey: 'k1' }], settings: { awsBedrock: { accessKeyId: 'ak' } } }), + settings: JSON.stringify({ webdavPass: 'pass' }), + _persist: JSON.stringify({ version: 183, rehydrated: true }) + }) + + const encrypted = transformPersistedRootStateString(plaintext, 'encrypt') + const root = JSON.parse(encrypted) as Record + const llm = JSON.parse(root.llm) + const settings = JSON.parse(root.settings) + + expect(typeof llm.providers[0].apiKey).toBe('string') + expect(llm.providers[0].apiKey.startsWith('csenc:')).toBe(true) + expect(decryptSecret(llm.providers[0].apiKey)).toBe('k1') + + expect(typeof settings.webdavPass).toBe('string') + expect(settings.webdavPass.startsWith('csenc:')).toBe(true) + expect(decryptSecret(settings.webdavPass)).toBe('pass') + }) + + it('clears encrypted secrets when decryption fails', () => { + ;(window as any).api.safeStorage.decryptString = vi.fn((value: string) => value) + + const encrypted = JSON.stringify({ + settings: JSON.stringify({ webdavPass: encryptSecret('pass') }), + _persist: JSON.stringify({ version: 183, rehydrated: true }) + }) + + const decrypted = transformPersistedRootStateString(encrypted, 'decrypt') + const root = JSON.parse(decrypted) as Record + const settings = JSON.parse(root.settings) + expect(settings.webdavPass).toBe('') + }) + + it('strips secrets without requiring decryption', () => { + const persisted = JSON.stringify({ + llm: JSON.stringify({ + providers: [{ id: 'p1', apiKey: encryptSecret('k1') }], + settings: { awsBedrock: { secretAccessKey: encryptSecret('sk') } } + }), + settings: JSON.stringify({ webdavPass: encryptSecret('pass'), yuqueToken: 'keep-non-encrypted?' }), + _persist: JSON.stringify({ version: 183, rehydrated: true }) + }) + + const stripped = stripPersistedRootStateSecretsString(persisted) + const root = JSON.parse(stripped) as Record + const llm = JSON.parse(root.llm) + const settings = JSON.parse(root.settings) + + expect(llm.providers[0].apiKey).toBe('') + expect(llm.settings.awsBedrock.secretAccessKey).toBe('') + expect(settings.webdavPass).toBe('') + expect(settings.yuqueToken).toBe('') + }) +}) diff --git a/src/renderer/src/utils/__tests__/secureStorage.test.ts b/src/renderer/src/utils/__tests__/secureStorage.test.ts new file mode 100644 index 0000000000..388ec7a5d7 --- /dev/null +++ b/src/renderer/src/utils/__tests__/secureStorage.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getDecryptedLocalStorageItem, setEncryptedLocalStorageItem } from '../secureStorage' + +describe('secureStorage', () => { + beforeEach(() => { + localStorage.clear() + + const safeStorage = { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((plainText: string) => { + if (plainText.startsWith('csenc:')) return plainText + return `csenc:${Buffer.from(plainText, 'utf-8').toString('base64')}` + }), + decryptString: vi.fn((value: string) => { + const prefix = 'csenc:' + if (!value.startsWith(prefix)) return value + try { + return Buffer.from(value.slice(prefix.length), 'base64').toString('utf-8') + } catch { + return value + } + }) + } + + ;(window as any).api = { ...(window as any).api, safeStorage } + }) + + it('migrates legacy plaintext localStorage values to encrypted-at-rest storage', () => { + localStorage.setItem('plain', 'value') + + expect(getDecryptedLocalStorageItem('plain')).toBe('value') + expect(localStorage.getItem('plain')).toMatch(/^csenc:/) + }) + + it('stores encrypted values via setEncryptedLocalStorageItem and reads them back', () => { + setEncryptedLocalStorageItem('token', 'secret-token') + expect(localStorage.getItem('token')).toMatch(/^csenc:/) + expect(getDecryptedLocalStorageItem('token')).toBe('secret-token') + }) + + it('removes encrypted localStorage values that can no longer be decrypted', () => { + setEncryptedLocalStorageItem('token', 'secret-token') + expect(localStorage.getItem('token')).toMatch(/^csenc:/) + + ;(window as any).api.safeStorage.decryptString = vi.fn((value: string) => value) + + expect(getDecryptedLocalStorageItem('token')).toBeNull() + expect(localStorage.getItem('token')).toBeNull() + }) +}) diff --git a/src/renderer/src/utils/securePersist.ts b/src/renderer/src/utils/securePersist.ts new file mode 100644 index 0000000000..7373b2b626 --- /dev/null +++ b/src/renderer/src/utils/securePersist.ts @@ -0,0 +1,238 @@ +import { decryptSecretWithIssue, encryptSecret } from './secureStorage' + +export const SECURE_PERSIST_SLICE_KEYS = ['llm', 'settings', 'preprocess', 'websearch', 'nutstore'] as const +export type SecurePersistSliceKey = (typeof SECURE_PERSIST_SLICE_KEYS)[number] + +type TransformSecret = (value: string, context?: { id: string; meta?: Record }) => string + +const transformProvidersApiKeys = (sliceKey: string, state: any, transformSecret: TransformSecret) => { + return { + ...state, + providers: Array.isArray(state.providers) + ? state.providers.map((provider: any, index: number) => { + const providerId = typeof provider.id === 'string' ? provider.id : String(index) + return { + ...provider, + apiKey: + typeof provider.apiKey === 'string' + ? transformSecret(provider.apiKey, { + id: `${sliceKey}.providers.${providerId}.apiKey`, + meta: + typeof provider.id === 'string' + ? { scope: sliceKey, providerId: provider.id } + : { scope: sliceKey } + }) + : provider.apiKey + } + }) + : state.providers + } +} + +export const transformPersistSliceState = (key: string, state: any, transformSecret: TransformSecret) => { + if (!state || typeof state !== 'object') { + return state + } + + if (key === 'llm') { + return { + ...transformProvidersApiKeys('llm', state, transformSecret), + settings: { + ...state.settings, + vertexai: state.settings?.vertexai + ? { + ...state.settings.vertexai, + serviceAccount: state.settings.vertexai.serviceAccount + ? { + ...state.settings.vertexai.serviceAccount, + privateKey: + typeof state.settings.vertexai.serviceAccount.privateKey === 'string' + ? transformSecret(state.settings.vertexai.serviceAccount.privateKey, { + id: 'llm.settings.vertexai.serviceAccount.privateKey', + meta: { scope: 'llm', providerId: 'vertexai' } + }) + : state.settings.vertexai.serviceAccount.privateKey + } + : state.settings.vertexai.serviceAccount + } + : state.settings?.vertexai, + awsBedrock: state.settings?.awsBedrock + ? { + ...state.settings.awsBedrock, + accessKeyId: + typeof state.settings.awsBedrock.accessKeyId === 'string' + ? transformSecret(state.settings.awsBedrock.accessKeyId, { + id: 'llm.settings.awsBedrock.accessKeyId', + meta: { scope: 'llm', providerId: 'awsBedrock' } + }) + : state.settings.awsBedrock.accessKeyId, + secretAccessKey: + typeof state.settings.awsBedrock.secretAccessKey === 'string' + ? transformSecret(state.settings.awsBedrock.secretAccessKey, { + id: 'llm.settings.awsBedrock.secretAccessKey', + meta: { scope: 'llm', providerId: 'awsBedrock' } + }) + : state.settings.awsBedrock.secretAccessKey, + apiKey: + typeof state.settings.awsBedrock.apiKey === 'string' + ? transformSecret(state.settings.awsBedrock.apiKey, { + id: 'llm.settings.awsBedrock.apiKey', + meta: { scope: 'llm', providerId: 'awsBedrock' } + }) + : state.settings.awsBedrock.apiKey + } + : state.settings?.awsBedrock + } + } + } + + if (key === 'settings') { + return { + ...state, + webdavPass: + typeof state.webdavPass === 'string' + ? transformSecret(state.webdavPass, { id: 'settings.webdavPass', meta: { scope: 'settings' } }) + : state.webdavPass, + notionApiKey: + typeof state.notionApiKey === 'string' + ? transformSecret(state.notionApiKey, { id: 'settings.notionApiKey', meta: { scope: 'settings' } }) + : state.notionApiKey, + yuqueToken: + typeof state.yuqueToken === 'string' + ? transformSecret(state.yuqueToken, { id: 'settings.yuqueToken', meta: { scope: 'settings' } }) + : state.yuqueToken, + joplinToken: + typeof state.joplinToken === 'string' + ? transformSecret(state.joplinToken, { id: 'settings.joplinToken', meta: { scope: 'settings' } }) + : state.joplinToken, + siyuanToken: + typeof state.siyuanToken === 'string' + ? transformSecret(state.siyuanToken, { id: 'settings.siyuanToken', meta: { scope: 'settings' } }) + : state.siyuanToken, + s3: state.s3 + ? { + ...state.s3, + accessKeyId: + typeof state.s3.accessKeyId === 'string' + ? transformSecret(state.s3.accessKeyId, { id: 'settings.s3.accessKeyId', meta: { scope: 'settings' } }) + : state.s3.accessKeyId, + secretAccessKey: + typeof state.s3.secretAccessKey === 'string' + ? transformSecret(state.s3.secretAccessKey, { + id: 'settings.s3.secretAccessKey', + meta: { scope: 'settings' } + }) + : state.s3.secretAccessKey + } + : state.s3, + apiServer: state.apiServer + ? { + ...state.apiServer, + apiKey: + typeof state.apiServer.apiKey === 'string' + ? transformSecret(state.apiServer.apiKey, { + id: 'settings.apiServer.apiKey', + meta: { scope: 'settings' } + }) + : state.apiServer.apiKey + } + : state.apiServer + } + } + + if (key === 'preprocess' || key === 'websearch') { + return transformProvidersApiKeys(key, state, transformSecret) + } + + if (key === 'nutstore') { + return { + ...state, + nutstoreToken: + typeof state.nutstoreToken === 'string' + ? transformSecret(state.nutstoreToken, { id: 'nutstore.nutstoreToken', meta: { scope: 'nutstore' } }) + : state.nutstoreToken + } + } + + return state +} + +const encryptSecretTransform: TransformSecret = (value) => encryptSecret(value) +const decryptSecretTransform: TransformSecret = (value, context) => + decryptSecretWithIssue(value, context?.id, context?.meta) + +export const encryptPersistSliceState = (key: string, state: any) => + transformPersistSliceState(key, state, encryptSecretTransform) +export const decryptPersistSliceState = (key: string, state: any) => + transformPersistSliceState(key, state, decryptSecretTransform) +export const stripPersistSliceSecrets = (key: string, state: any) => transformPersistSliceState(key, state, () => '') + +export type PersistedRootState = Record + +export const transformPersistedRootStateString = (persistedValue: string, direction: 'encrypt' | 'decrypt'): string => { + const transformSlice = direction === 'encrypt' ? encryptPersistSliceState : decryptPersistSliceState + + try { + const root = JSON.parse(persistedValue) as PersistedRootState + if (!root || typeof root !== 'object') { + return persistedValue + } + + let changed = false + for (const sliceKey of SECURE_PERSIST_SLICE_KEYS) { + const rawSlice = root[sliceKey] + if (typeof rawSlice !== 'string') { + continue + } + + try { + const parsedSlice = JSON.parse(rawSlice) + const transformedSlice = transformSlice(sliceKey, parsedSlice) + const nextRawSlice = JSON.stringify(transformedSlice) + if (nextRawSlice !== rawSlice) { + root[sliceKey] = nextRawSlice + changed = true + } + } catch { + // Ignore malformed slice payloads and keep original. + } + } + + return changed ? JSON.stringify(root) : persistedValue + } catch { + return persistedValue + } +} + +export const stripPersistedRootStateSecretsString = (persistedValue: string): string => { + try { + const root = JSON.parse(persistedValue) as PersistedRootState + if (!root || typeof root !== 'object') { + return persistedValue + } + + let changed = false + for (const sliceKey of SECURE_PERSIST_SLICE_KEYS) { + const rawSlice = root[sliceKey] + if (typeof rawSlice !== 'string') { + continue + } + + try { + const parsedSlice = JSON.parse(rawSlice) + const strippedSlice = stripPersistSliceSecrets(sliceKey, parsedSlice) + const nextRawSlice = JSON.stringify(strippedSlice) + if (nextRawSlice !== rawSlice) { + root[sliceKey] = nextRawSlice + changed = true + } + } catch { + // Ignore malformed slice payloads and keep original. + } + } + + return changed ? JSON.stringify(root) : persistedValue + } catch { + return persistedValue + } +} diff --git a/src/renderer/src/utils/secureStorage.ts b/src/renderer/src/utils/secureStorage.ts index 14589d194d..8a128e77c0 100644 --- a/src/renderer/src/utils/secureStorage.ts +++ b/src/renderer/src/utils/secureStorage.ts @@ -1,5 +1,49 @@ const ENCRYPTION_PREFIX = 'csenc:' +export const CREDENTIAL_ISSUE_EVENT_NAME = 'cherrystudio:credential-issue' + +export type CredentialIssueReason = 'decrypt_failed' + +export interface CredentialIssue { + id: string + reason: CredentialIssueReason + timestamp: number + meta?: Record +} + +const credentialIssueQueue: CredentialIssue[] = [] +const credentialIssueKeys = new Set() + +const publishCredentialIssue = (issue: CredentialIssue): void => { + const key = `${issue.reason}:${issue.id}` + if (credentialIssueKeys.has(key)) return + credentialIssueKeys.add(key) + credentialIssueQueue.push(issue) + + try { + if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { + window.dispatchEvent(new CustomEvent(CREDENTIAL_ISSUE_EVENT_NAME, { detail: issue })) + } + } catch { + // Ignore. + } +} + +export const reportCredentialDecryptFailure = (id: string, meta?: Record): void => { + publishCredentialIssue({ + id, + reason: 'decrypt_failed', + timestamp: Date.now(), + meta + }) +} + +export const consumeCredentialIssues = (): CredentialIssue[] => { + const issues = credentialIssueQueue.splice(0, credentialIssueQueue.length) + credentialIssueKeys.clear() + return issues +} + const isEncryptionAvailable = (): boolean => { try { return Boolean(window.api?.safeStorage?.isEncryptionAvailable?.()) @@ -28,6 +72,14 @@ export const decryptSecret = (value: string): string => { } } +export const decryptSecretWithIssue = (value: string, issueId?: string, meta?: Record): string => { + const decrypted = decryptSecret(value) + if (issueId && value.startsWith(ENCRYPTION_PREFIX) && decrypted === '') { + reportCredentialDecryptFailure(issueId, meta) + } + return decrypted +} + export const setEncryptedLocalStorageItem = (key: string, value: string): void => { localStorage.setItem(key, encryptSecret(value)) } @@ -38,7 +90,7 @@ export const getDecryptedLocalStorageItem = (key: string): string | null => { return null } - const decrypted = decryptSecret(raw) + const decrypted = decryptSecretWithIssue(raw, `localStorage.${key}`, { scope: 'localStorage', storageKey: key }) if (raw.startsWith(ENCRYPTION_PREFIX) && decrypted === '') { localStorage.removeItem(key) diff --git a/tests/main.setup.ts b/tests/main.setup.ts index 5cadb89d02..a74bec5e2d 100644 --- a/tests/main.setup.ts +++ b/tests/main.setup.ts @@ -16,6 +16,12 @@ vi.mock('electron', () => ({ switch (key) { case 'userData': return '/mock/userData' + case 'documents': + return '/mock/documents' + case 'downloads': + return '/mock/downloads' + case 'desktop': + return '/mock/desktop' case 'temp': return '/mock/temp' case 'logs': @@ -26,6 +32,20 @@ vi.mock('electron', () => ({ }), getVersion: vi.fn(() => '1.0.0') }, + net: { + fetch: vi.fn() + }, + safeStorage: { + isEncryptionAvailable: vi.fn(() => true), + encryptString: vi.fn((plainText: string) => Buffer.from(`encrypted:${plainText}`, 'utf-8')), + decryptString: vi.fn((encrypted: Buffer) => { + const value = encrypted.toString('utf-8') + if (!value.startsWith('encrypted:')) { + throw new Error('Unable to decrypt') + } + return value.slice('encrypted:'.length) + }) + }, ipcMain: { handle: vi.fn(), on: vi.fn(), @@ -98,13 +118,18 @@ vi.mock('winston-daily-rotate-file', () => { }) // Mock Node.js modules -vi.mock('node:os', () => ({ - platform: vi.fn(() => 'darwin'), - arch: vi.fn(() => 'x64'), - version: vi.fn(() => '20.0.0'), - cpus: vi.fn(() => [{ model: 'Mock CPU' }]), - totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB -})) +vi.mock('node:os', () => { + const api = { + platform: vi.fn(() => 'darwin'), + arch: vi.fn(() => 'x64'), + version: vi.fn(() => '20.0.0'), + homedir: vi.fn(() => '/mock/home'), + cpus: vi.fn(() => [{ model: 'Mock CPU' }]), + totalmem: vi.fn(() => 8 * 1024 * 1024 * 1024) // 8GB + } + + return { ...api, default: api } +}) vi.mock('node:path', async () => { const actual = await vi.importActual('node:path') diff --git a/tests/renderer.setup.ts b/tests/renderer.setup.ts index 55ab9d1a71..beb041dafa 100644 --- a/tests/renderer.setup.ts +++ b/tests/renderer.setup.ts @@ -79,11 +79,9 @@ const createStorageMock = () => { } } -if (typeof globalThis.localStorage?.getItem !== 'function') { - const storage = typeof window?.localStorage?.getItem === 'function' ? window.localStorage : createStorageMock() - vi.stubGlobal('localStorage', storage) -} -if (typeof globalThis.sessionStorage?.getItem !== 'function') { - const storage = typeof window?.sessionStorage?.getItem === 'function' ? window.sessionStorage : createStorageMock() - vi.stubGlobal('sessionStorage', storage) -} +const localStorageImpl = typeof window?.localStorage?.getItem === 'function' ? window.localStorage : createStorageMock() +vi.stubGlobal('localStorage', localStorageImpl) + +const sessionStorageImpl = + typeof window?.sessionStorage?.getItem === 'function' ? window.sessionStorage : createStorageMock() +vi.stubGlobal('sessionStorage', sessionStorageImpl)