Files
Notes/main/secret-store.js
2026-10-16 16:34:15 +08:00

168 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// API Key 等敏感字段的加密存储层(audit P0-1)
// ============================================================================
//
// 背景:原来 aiApiKey 直接以明文写入 userData/config.json。任何能拿到
// userData 目录副本的人 / 备份脚本 / 同步盘历史版本都拿得到明文 Key。
//
// 修复:用 electron.safeStorage(DPAPI / Keychain / libsecret)做对称加密,
// 磁盘仅留包络(enc:v1:<base64 ciphertext>)。运行时内存需要时再解密
// 给 fetch 用 —— 主进程栈上才有明文窗口。
//
// 包络格式:
// - 'enc:v1:' 前缀 + base64 编码的密文 Buffer
// - 没有前缀 = 旧版明文(升级前配置),按明文处理(懒迁移见下)
// - 前缀之后是 v1 版本号:未来升级算法 / 改用非对称 / 换 key 时 bump 到 v2
//
// 降级(isEncryptionAvailable() === false,典型 Linux 无 gnome-keyring/kwallet):
// - encrypt 返回明文 + console.warn 一次(每进程)
// - decrypt 遇到包络但 keyring 不可用 → 返回 ''(AI 配置临时不可用,UI 提示重输)
//
// 迁移(懒迁移,audit P0-1 要求"启动时一次性加密" → 这里更轻量):
// - 老配置 plaintext 在 loadConfig 时进内存仍是明文
// - 任意 settingsStore.update 触发 saveConfig → 写入前自动加密
// - 不在 loadConfig 里主动写盘:避免「启动 + 配置没改也要做一次 IO」
// - 透明:上层读 appConfig.aiApiKey 永远拿到明文(loadConfig decrypt 一次,
// saveConfig 只在写盘路径加密,merged 内存对象保持明文)
//
// 测试注入:_setSafeStorage(mock) 替换底层 safeStorage,_reset() 还原。
// 单测里 vi.mock('electron') 拦不住 CJS require(与 main/config-store.js
// 同模式),用本模块自己的 setter。
'use strict';
const ENVELOPE_PREFIX = 'enc:v1:';
let _warnedUnavailable = false;
let _safeStorageOverride = null;
/**
* 取当前生效的 safeStorage(生产 = electron.safeStorage,单测 = 注入的 mock)。
* 模块未在 Electron 内运行时(极少见,e.g. 脚本化测试)返回 null。
* @returns {object|null}
*/
function getSafeStorage() {
if (_safeStorageOverride !== null) return _safeStorageOverride;
try {
const electron = require('electron');
return electron && electron.safeStorage ? electron.safeStorage : null;
} catch {
return null;
}
}
/**
* 系统密钥库是否可用。不可用时(典型 Linux 无 keyring)→ 降级到明文存储。
* @returns {boolean}
*/
function isAvailable() {
const ss = getSafeStorage();
if (!ss || typeof ss.isEncryptionAvailable !== 'function') return false;
try {
return !!ss.isEncryptionAvailable();
} catch {
return false;
}
}
/**
* 「不可用」时一次性 warn。每个进程只 warn 一次,避免设置面板来回点
* 反复触发;用户看到的也是控制台一次性提示。
* @param {string} reason
*/
function warnOnceDegraded(reason) {
if (_warnedUnavailable) return;
_warnedUnavailable = true;
console.warn(
`[secret-store] safeStorage 不可用 (${reason}); API Key 以明文写入 config.json(降级安全)。` +
'Linux 上安装 gnome-keyring / kwallet 后重启即可恢复加密。'
);
}
/**
* 加密一段明文 → 包络字符串(可写盘)。
* 输入:任意 string(空串、null、undefined 都安全处理)。
* 输出:
* - 空 / null / undefined → ''(不写盘也无所谓;saveConfig 会跳过空 aiApiKey)
* - 已是包络(enc:v1:...)→ 原样返回(幂等,防止重复加密变套娃)
* - 明文 + safeStorage 可用 → 'enc:v1:' + base64(ciphertext)
* - 明文 + safeStorage 不可用 → 原明文 + warn 一次(降级)
* @param {string|null|undefined} plain
* @returns {string}
*/
function encrypt(plain) {
if (plain == null) return '';
const s = String(plain);
if (s === '') return '';
if (s.startsWith(ENVELOPE_PREFIX)) return s;
const ss = getSafeStorage();
if (!ss || !isAvailable()) {
warnOnceDegraded('isEncryptionAvailable() === false');
return s;
}
let buf;
try {
buf = ss.encryptString(s);
} catch (e) {
warnOnceDegraded(`encryptString 抛错: ${e && e.message ? e.message : 'unknown'}`);
return s;
}
return ENVELOPE_PREFIX + buf.toString('base64');
}
/**
* 解密包络 → 明文。
* 输入:任意 string(空 / null / undefined 安全)。
* 输出:
* - 空 / null / undefined → ''
* - 没有 enc:v1: 前缀 → 原样返回(旧版明文 passthrough;懒迁移关键)
* - 包络 + safeStorage 可用 → 解密后的明文
* - 包络 + safeStorage 不可用 → ''(AI 临时不可用,UI 应引导重输)
* - 包络 + decryptString 抛错(损坏 / 跨用户 / 改 key 后)→ ''(同不可用)
* @param {string|null|undefined} stored
* @returns {string}
*/
function decrypt(stored) {
if (stored == null) return '';
const s = String(stored);
if (s === '') return '';
if (!s.startsWith(ENVELOPE_PREFIX)) return s;
const ss = getSafeStorage();
if (!ss || !isAvailable()) return '';
try {
const b64 = s.slice(ENVELOPE_PREFIX.length);
const buf = Buffer.from(b64, 'base64');
return ss.decryptString(buf);
} catch {
return '';
}
}
// ---- 测试注入点(生产代码不应调用) ----
/**
* 替换底层 safeStorage。传 null 还原到 require('electron') 路径。
* @param {object|null} mock
*/
function _setSafeStorage(mock) {
_safeStorageOverride = mock;
// 重置 warn 标志 —— 测试之间不串
_warnedUnavailable = false;
}
/**
* 重置模块状态(safeStorage 注入 + warn 标志)回默认。
* 测试 beforeEach 用。
*/
function _reset() {
_safeStorageOverride = null;
_warnedUnavailable = false;
}
module.exports = {
encrypt,
decrypt,
isAvailable,
ENVELOPE_PREFIX,
_setSafeStorage,
_reset,
};