Files
thzxx 4cd6e997b5
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m45s
CI / 全量测试 (Electron ABI) (push) Failing after 6m28s
CI / 产物编译验证 (push) Successful in 11m18s
feat: v0.8.2 安全纵深补全 · 协议保真 · 断链修复 — 图片SSRF/根MEMORY.md保护根治 · Anthropic thinking回传+pause_turn续传 · 2523 用例全量回归 + E2E 扩充
2026-09-08 14:30:27 +08:00

136 lines
5.1 KiB
TypeScript
Raw Permalink 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.
/**
* Secure Config — 敏感配置项加密存储(P0-1)
*
* 使用 Electron safeStorage(操作系统级密钥链:Windows DPAPI / macOS Keychain / Linux libsecret
* 对 API Key 等敏感配置值做静态加密,落盘前加密、读取时解密。
*
* 加密格式:`metona-enc:v1:<base64(safeStorage.encryptString(value))>`
*
* 兜底策略:
* - safeStorage 不可用(如部分 Linux 无 keyring)→ 明文存储并打 WARN(保持可用性)
* - 解密失败(跨机器拷贝配置/重装系统导致密钥失效)→ 返回空串,用户需重新录入
*/
import { safeStorage } from 'electron';
import log from 'electron-log';
/** 加密值前缀标记(版本化,便于未来算法升级) */
const ENCRYPTION_PREFIX = 'metona-enc:v1:';
/**
* v0.8.1 根治: 加密可用性探测(roundtrip probe)。
*
* isEncryptionAvailable()=true 并不保证加解密可实际往返(部分桌面/服务会话下
* DPAPI/keyring 返回的密文无法回解,写后读必失败 → API Key 被静默清空)。
* 现在进程内首次使用时做一次 encrypt→decrypt 回环校验:
* - 通过 → 正常加密存储;
* - 失败 → 本次会话降级为明文存储(与 isEncryptionAvailable=false 同语义),
* 保持功能可用并 WARN 留痕;读取侧对"本会话明文"无感知(无前缀原样返回)。
*/
let encryptionUsable: boolean | null = null;
function isEncryptionUsable(): boolean {
if (encryptionUsable !== null) return encryptionUsable;
try {
if (!safeStorage.isEncryptionAvailable()) {
encryptionUsable = false;
return false;
}
const probe = 'metona-probe-0123456789abcdef';
const cipher = safeStorage.encryptString(probe);
const roundtrip = safeStorage.decryptString(cipher);
encryptionUsable = roundtrip === probe;
if (!encryptionUsable) {
log.warn(
'[SecureConfig] safeStorage roundtrip probe failed — falling back to plaintext storage for this session',
);
}
} catch (err) {
log.warn(
'[SecureConfig] safeStorage probe threw — falling back to plaintext:',
(err as Error).message,
);
encryptionUsable = false;
}
return encryptionUsable;
}
/** 测试专用:清除 roundtrip 探测缓存(生产代码不得调用) */
export function resetEncryptionUsableForTests(): void {
encryptionUsable = null;
}
/** 敏感配置 key 匹配模式(与 IPC 层审计脱敏规则保持一致)
* v0.8.2 P1-5: 补 authorization / authkey / credential —— 审计 args 深度脱敏
* 复用本表,HTTP 标准鉴权头(Authorization)此前不命中导致明文入库 */
const SENSITIVE_KEY_PATTERNS = [
'apikey',
'api_key',
'apitoken',
'token',
'secret',
'password',
'auth_key',
'authkey',
'authorization',
'credential',
];
/**
* v0.7.4 P2-5 根治: 判断配置 key 是否为敏感项(需要加密存储)。
*
* 旧实现直接 `key.toLowerCase().includes(pattern)` —— `searxng.authKey` 小写为
* 'searxng.authkey',与模式 'auth_key'(含下划线)不匹配 → 该 key 既不加密落盘、
* 也不在导出脱敏/审计脱敏中掩码,API Key 可经 data:export 明文泄露。
* 现改为"去分隔符归一化"匹配:key 小写后移除 `_`/`-`/`.` 再与模式(同样归一化)
* 比较,`authKey`/`auth-key`/`auth.key` 均命中 'authkey'。
*/
export function isSensitiveConfigKey(key: string): boolean {
const normalized = key.toLowerCase().replace(/[_.-]/g, '');
return SENSITIVE_KEY_PATTERNS.some((p) => normalized.includes(p.replace(/[_.-]/g, '')));
}
/** 判断值是否已是加密格式 */
export function isEncryptedValue(value: unknown): value is string {
return typeof value === 'string' && value.startsWith(ENCRYPTION_PREFIX);
}
/**
* 加密配置值(写入持久层前调用)
*
* 仅对非空字符串生效;其他类型(number/boolean/null)原样返回。
* safeStorage 不可用时降级为明文(记录 WARN)。
*/
export function encryptConfigValue(value: unknown): unknown {
if (typeof value !== 'string' || value.length === 0) return value;
if (isEncryptedValue(value)) return value; // 已加密,幂等
try {
if (!isEncryptionUsable()) {
log.warn('[SecureConfig] safeStorage 不可用,敏感配置将以明文存储');
return value;
}
const encrypted = safeStorage.encryptString(value);
return ENCRYPTION_PREFIX + encrypted.toString('base64');
} catch (err) {
log.error('[SecureConfig] 加密失败,回退明文存储:', err);
return value;
}
}
/**
* 解密配置值(从持久层读取后调用)
*
* 非加密格式原样返回;解密失败返回空串(密钥链变更场景,
* 返回空串使 createAdapter 判定"未配置",引导用户重新录入而非崩溃)。
*/
export function decryptConfigValue(value: unknown): unknown {
if (!isEncryptedValue(value)) return value;
try {
const buf = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), 'base64');
return safeStorage.decryptString(buf);
} catch (err) {
log.error('[SecureConfig] 解密失败(可能因系统密钥链变更),请重新录入 API Key:', err);
return '';
}
}