feat: add API config system, FilePipe backend, and ConfigManager

- ConfigManager: route sensitive keys (API keys) to localStorage,
  migrate existing values out of extension_settings on startup
- ApiKeyStore: local/cloud storage modes with RSA+AES hybrid encryption
- ApiProfileManager: named connection profiles (chat/embedding/rerank)
  with per-slot type-validated assignments
- FilePipe: complete IndexedDB backend (read/write/delete/list/stat)
- Amily2Bus: inject FilePipe via forPlugin() capability token
- UI: api-config-panel with profile CRUD and slot assignment
- TableSystemService: initial service layer scaffold
- logger.js: XSS fix
This commit is contained in:
2026-03-10 22:07:15 +08:00
parent ed3f52a568
commit 0be6a86e94
17 changed files with 1970 additions and 110 deletions

View File

@@ -0,0 +1,359 @@
/**
* ApiKeyStore — 专用 API 凭证管理模块
*
* 存储策略(用户可选):
*
* 'local'(默认)
* API Key 明文存储在 localStorage前缀 amily2_secure_
* 不随 ST 设置上传,绝对安全,但换设备需重新填写。
*
* 'cloud'
* API Key 使用混合加密RSA-OAEP + AES-256-GCM后存入 extension_settings。
* 私钥仅保存在本设备 localStorage服务端只能看到密文。
* 换设备时密文跟着走,但需要在新设备上重新生成密钥对并重新输入 Key。
* 可大幅提升技术攻击成本(被动读取 settings.json 完全无效)。
*
* 注意API URL 不是凭证,始终存储在 extension_settings云同步方便多端
*
* Bus 注册名:'ApiKeyStore'
*
* 公开接口query('ApiKeyStore')
* getKey(field) — 读取指定凭证(自动按模式解密)
* setKey(field, value) — 写入指定凭证(自动按模式加密)
* getMode() — 返回当前存储模式 'local' | 'cloud'
* setMode(mode) — 切换存储模式(会迁移现有数据)
* isCloudReady() — cloud 模式下密钥对是否已就绪
* generateKeyPair() — 生成新密钥对(会清除旧加密数据)
* getPublicKeyInfo() — 返回公钥摘要字符串(用于 UI 展示)
* exportEncryptedBackup() — 导出加密备份JSON含密文+公钥,不含私钥)
*/
import { extension_settings } from "/scripts/extensions.js";
import { saveSettingsDebounced } from "/script.js";
import { extensionName } from "../../settings.js";
import { SENSITIVE_KEYS } from "../sensitive-keys.js";
import {
generateKeyPair,
serializeKeyPair,
importPublicKey,
importPrivateKey,
encrypt,
decrypt,
} from "./crypto-utils.js";
// ── 存储 key 常量 ─────────────────────────────────────────────────────────────
const LS_MODE_KEY = 'amily2_keystore_mode'; // 'local' | 'cloud'
const LS_PRIVATE_KEY = 'amily2_keypair_private'; // JWK 字符串
const LS_PLAIN_PREFIX = 'amily2_secure_'; // local 模式明文前缀
const EXT_PUBKEY = 'amily2_pubkey'; // extension_settings 中的公钥
const EXT_ENC_PREFIX = 'amily2_enc_'; // extension_settings 中的密文前缀
// ── ApiKeyStore ───────────────────────────────────────────────────────────────
class ApiKeyStore {
constructor() {
this._publicKey = null; // CryptoKey运行时缓存
this._privateKey = null; // CryptoKey运行时缓存
this._keyReady = false;
this._initPromise = null;
}
// ── 初始化 ──────────────────────────────────────────────────────────────
/**
* 异步初始化:若为 cloud 模式则加载密钥对到内存缓存。
* 由 Bus 注册后自动调用,也可手动 await。
*/
async init() {
if (this.getMode() === 'cloud') {
await this._loadKeyPair();
}
}
// ── 公开 API ────────────────────────────────────────────────────────────
/** 读取指定凭证字段SENSITIVE_KEYS 内的字段) */
async getKey(field) {
if (!SENSITIVE_KEYS.has(field)) {
console.warn(`[ApiKeyStore] "${field}" 不是凭证字段,请用 configManager.get() 读取普通配置。`);
return undefined;
}
if (this.getMode() === 'cloud') {
return this._getCloud(field);
}
return this._getLocal(field);
}
/** 写入指定凭证字段 */
async setKey(field, value) {
if (!SENSITIVE_KEYS.has(field)) {
console.warn(`[ApiKeyStore] "${field}" 不是凭证字段。`);
return;
}
if (this.getMode() === 'cloud') {
await this._setCloud(field, value);
} else {
this._setLocal(field, value);
}
}
/** 当前存储模式 */
getMode() {
return localStorage.getItem(LS_MODE_KEY) || 'local';
}
/**
* 切换存储模式并迁移现有数据。
* local → cloud读出明文 → 加密 → 写入 extension_settings → 清除 localStorage 明文
* cloud → local解密 → 写入 localStorage → 清除 extension_settings 密文
* @param {'local'|'cloud'} mode
*/
async setMode(mode) {
const current = this.getMode();
if (current === mode) return;
if (mode === 'cloud') {
if (!this._keyReady) {
await this.generateKeyPair(); // 首次切换自动生成密钥对
}
await this._migrateLocalToCloud();
} else {
await this._migrateCloudToLocal();
}
localStorage.setItem(LS_MODE_KEY, mode);
console.info(`[ApiKeyStore] 存储模式已切换为 "${mode}"。`);
}
/** cloud 模式下密钥对是否已就绪 */
isCloudReady() {
return this._keyReady;
}
/**
* 按任意 ID 存储凭证(供 ApiProfileManager 使用key = `profile_<id>`)。
* 走与 setKey 相同的加密路由。
*/
async storeById(id, value) {
const field = `profile_${id}`;
if (this.getMode() === 'cloud') {
await this._setCloud(field, value);
} else {
this._setLocal(field, value);
}
}
/**
* 按任意 ID 读取凭证(供 ApiProfileManager 使用)。
*/
async retrieveById(id) {
const field = `profile_${id}`;
if (this.getMode() === 'cloud') {
return this._getCloud(field);
}
return this._getLocal(field);
}
/**
* 删除指定 ID 的凭证Profile 删除时调用)。
*/
deleteById(id) {
const field = `profile_${id}`;
localStorage.removeItem(LS_PLAIN_PREFIX + field);
const settings = extension_settings[extensionName];
if (settings?.[EXT_ENC_PREFIX + field]) {
delete settings[EXT_ENC_PREFIX + field];
saveSettingsDebounced();
}
}
/**
* 生成新的 RSA 密钥对。
* 警告:会清除所有已加密的 cloud 模式凭证(旧私钥无法解密)。
*/
async generateKeyPair() {
const keyPair = await generateKeyPair();
const { publicJwk, privateJwk } = await serializeKeyPair(keyPair);
// 清除旧密文(旧私钥无法解密新密钥对加密的内容)
this._clearAllCloudCiphers();
// 私钥存 localStorage公钥存 extension_settings公钥不需要保密
localStorage.setItem(LS_PRIVATE_KEY, privateJwk);
const settings = extension_settings[extensionName] || {};
settings[EXT_PUBKEY] = publicJwk;
saveSettingsDebounced();
// 更新运行时缓存
this._publicKey = await importPublicKey(publicJwk);
this._privateKey = await importPrivateKey(privateJwk);
this._keyReady = true;
console.info('[ApiKeyStore] 新密钥对已生成。请重新输入所有 API Key。');
}
/**
* 返回公钥的简短指纹SHA-256 前 8 字节Base64用于 UI 展示。
* @returns {Promise<string>}
*/
async getPublicKeyInfo() {
const jwkStr = extension_settings[extensionName]?.[EXT_PUBKEY];
if (!jwkStr) return '(未生成)';
const jwk = JSON.parse(jwkStr);
const raw = new TextEncoder().encode(jwk.n); // RSA modulus
const hash = await crypto.subtle.digest('SHA-256', raw);
const hex = Array.from(new Uint8Array(hash)).slice(0, 8)
.map(b => b.toString(16).padStart(2, '0')).join(':');
return `RSA-2048 · ${hex}`;
}
/**
* 导出可备份的加密摘要(包含公钥 + 所有密文,不含私钥)。
* 仅供参考,不能用于在新设备上恢复(因为私钥不在其中)。
* @returns {Object}
*/
exportEncryptedBackup() {
const settings = extension_settings[extensionName] || {};
const backup = { publicKey: settings[EXT_PUBKEY], encrypted: {} };
for (const field of SENSITIVE_KEYS) {
const cipher = settings[EXT_ENC_PREFIX + field];
if (cipher) backup.encrypted[field] = cipher;
}
return backup;
}
// ── 内部local 模式 ────────────────────────────────────────────────────
_getLocal(field) {
return localStorage.getItem(LS_PLAIN_PREFIX + field) ?? '';
}
_setLocal(field, value) {
if (value !== null && value !== undefined && value !== '') {
localStorage.setItem(LS_PLAIN_PREFIX + field, value);
} else {
localStorage.removeItem(LS_PLAIN_PREFIX + field);
}
}
// ── 内部cloud 模式 ────────────────────────────────────────────────────
async _getCloud(field) {
if (!this._keyReady) {
console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法解密。');
return '';
}
const cipher = extension_settings[extensionName]?.[EXT_ENC_PREFIX + field];
if (!cipher) return '';
try {
return await decrypt(this._privateKey, cipher);
} catch (e) {
console.error(`[ApiKeyStore] 解密 "${field}" 失败(私钥不匹配?):`, e);
return '';
}
}
async _setCloud(field, value) {
if (!this._keyReady) {
console.warn('[ApiKeyStore] cloud 模式密钥未就绪,无法加密。');
return;
}
const settings = extension_settings[extensionName] || {};
if (value !== null && value !== undefined && value !== '') {
settings[EXT_ENC_PREFIX + field] = await encrypt(this._publicKey, value);
} else {
delete settings[EXT_ENC_PREFIX + field];
}
saveSettingsDebounced();
}
// ── 内部:迁移 ──────────────────────────────────────────────────────────
async _migrateLocalToCloud() {
for (const field of SENSITIVE_KEYS) {
const plain = this._getLocal(field);
if (plain) {
await this._setCloud(field, plain);
localStorage.removeItem(LS_PLAIN_PREFIX + field);
console.info(`[ApiKeyStore] "${field}" 已加密迁移至云同步。`);
}
}
}
async _migrateCloudToLocal() {
for (const field of SENSITIVE_KEYS) {
const plain = await this._getCloud(field);
if (plain) {
this._setLocal(field, plain);
console.info(`[ApiKeyStore] "${field}" 已解密迁移至本地存储。`);
}
}
this._clearAllCloudCiphers();
}
_clearAllCloudCiphers() {
const settings = extension_settings[extensionName];
if (!settings) return;
let changed = false;
for (const field of SENSITIVE_KEYS) {
if (settings[EXT_ENC_PREFIX + field]) {
delete settings[EXT_ENC_PREFIX + field];
changed = true;
}
}
if (changed) saveSettingsDebounced();
}
// ── 内部:密钥加载 ──────────────────────────────────────────────────────
async _loadKeyPair() {
const privateJwk = localStorage.getItem(LS_PRIVATE_KEY);
const publicJwk = extension_settings[extensionName]?.[EXT_PUBKEY];
if (!privateJwk || !publicJwk) {
console.warn('[ApiKeyStore] cloud 模式:本地未找到密钥对,请生成新密钥对。');
this._keyReady = false;
return;
}
try {
this._privateKey = await importPrivateKey(privateJwk);
this._publicKey = await importPublicKey(publicJwk);
this._keyReady = true;
console.info('[ApiKeyStore] cloud 模式密钥对已加载。');
} catch (e) {
console.error('[ApiKeyStore] 密钥对加载失败(数据损坏?):', e);
this._keyReady = false;
}
}
}
// ── 单例导出 ─────────────────────────────────────────────────────────────────
export const apiKeyStore = new ApiKeyStore();
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
setTimeout(async () => {
try {
// 先初始化cloud 模式下加载密钥)
await apiKeyStore.init();
const _ctx = window.Amily2Bus?.register('ApiKeyStore');
if (!_ctx) {
console.warn('[ApiKeyStore] Amily2Bus 尚未就绪,注册跳过。');
return;
}
_ctx.expose({
getKey: (field) => apiKeyStore.getKey(field),
setKey: (field, value) => apiKeyStore.setKey(field, value),
getMode: () => apiKeyStore.getMode(),
setMode: (mode) => apiKeyStore.setMode(mode),
isCloudReady: () => apiKeyStore.isCloudReady(),
generateKeyPair: () => apiKeyStore.generateKeyPair(),
getPublicKeyInfo: () => apiKeyStore.getPublicKeyInfo(),
exportEncryptedBackup: () => apiKeyStore.exportEncryptedBackup(),
});
_ctx.log('ApiKeyStore', 'info', 'ApiKeyStore 服务已注册到 Bus。');
} catch (e) {
console.error('[ApiKeyStore] 初始化失败:', e);
}
}, 0);

View File

@@ -0,0 +1,174 @@
/**
* crypto-utils.js — Web Crypto API 封装
*
* 使用混合加密方案Hybrid Encryption
* - RSA-OAEP 2048 负责密钥交换(加密 AES 密钥)
* - AES-256-GCM 负责实际数据加密
*
* 优势:
* - RSA 部分无明文长度限制AES 密钥固定 32 字节,远小于 RSA 上限)
* - AES-GCM 提供认证加密AEAD防止密文篡改
* - 全程使用 Web Crypto API密钥操作不经过 JS 内存SubtleCrypto 内部实现)
*/
// ── 密钥对生成与导入导出 ────────────────────────────────────────────────────
/**
* 生成 RSA-OAEP 2048 密钥对。
* 返回 { publicKey, privateKey }(均为 CryptoKey 对象)
*/
export async function generateKeyPair() {
return crypto.subtle.generateKey(
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
hash: 'SHA-256',
},
true, // extractable = true以便序列化存储
['encrypt', 'decrypt']
);
}
/**
* 将密钥对序列化为 JWK 字符串,以便存储。
* @param {CryptoKeyPair} keyPair
* @returns {Promise<{ publicJwk: string, privateJwk: string }>}
*/
export async function serializeKeyPair(keyPair) {
const [publicJwk, privateJwk] = await Promise.all([
crypto.subtle.exportKey('jwk', keyPair.publicKey),
crypto.subtle.exportKey('jwk', keyPair.privateKey),
]);
return {
publicJwk: JSON.stringify(publicJwk),
privateJwk: JSON.stringify(privateJwk),
};
}
/**
* 从 JWK 字符串恢复公钥(用于加密)。
* @param {string} jwkString
* @returns {Promise<CryptoKey>}
*/
export async function importPublicKey(jwkString) {
return crypto.subtle.importKey(
'jwk',
JSON.parse(jwkString),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false, // 不需要再次导出
['encrypt']
);
}
/**
* 从 JWK 字符串恢复私钥(用于解密)。
* @param {string} jwkString
* @returns {Promise<CryptoKey>}
*/
export async function importPrivateKey(jwkString) {
return crypto.subtle.importKey(
'jwk',
JSON.parse(jwkString),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['decrypt']
);
}
// ── 混合加密 / 解密 ──────────────────────────────────────────────────────────
/**
* 混合加密RSA-OAEP 包装 AES-256-GCM 密钥AES-GCM 加密明文。
*
* 返回的密文包 JSON 结构:
* {
* wrappedKey: "<base64>", // RSA 加密的 AES 密钥
* iv: "<base64>", // AES-GCM 随机 IV12 字节)
* ciphertext: "<base64>", // AES-GCM 密文(含 GCM tag
* }
*
* @param {CryptoKey} publicKey RSA 公钥
* @param {string} plaintext 明文字符串
* @returns {Promise<string>} 序列化的密文包JSON 字符串)
*/
export async function encrypt(publicKey, plaintext) {
// 1. 生成一次性 AES-256-GCM 密钥
const aesKey = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt']
);
// 2. 生成随机 IV12 字节是 GCM 的推荐长度)
const iv = crypto.getRandomValues(new Uint8Array(12));
// 3. 用 AES-GCM 加密明文
const plainBytes = new TextEncoder().encode(plaintext);
const ciphertextBuffer = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
plainBytes
);
// 4. 导出 AES 原始密钥字节,用 RSA 公钥包装
const rawAesKey = await crypto.subtle.exportKey('raw', aesKey);
const wrappedKeyBuffer = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
publicKey,
rawAesKey
);
// 5. 序列化为 base64 JSON 包
return JSON.stringify({
wrappedKey: bufToBase64(wrappedKeyBuffer),
iv: bufToBase64(iv),
ciphertext: bufToBase64(ciphertextBuffer),
});
}
/**
* 混合解密:用 RSA 私钥解出 AES 密钥,再用 AES-GCM 解密密文。
*
* @param {CryptoKey} privateKey RSA 私钥
* @param {string} payload encrypt() 返回的 JSON 字符串
* @returns {Promise<string>} 原始明文字符串
*/
export async function decrypt(privateKey, payload) {
const { wrappedKey, iv, ciphertext } = JSON.parse(payload);
// 1. RSA 解出 AES 密钥字节
const rawAesKey = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
base64ToBuf(wrappedKey)
);
// 2. 恢复 AES 密钥对象(只用于解密)
const aesKey = await crypto.subtle.importKey(
'raw',
rawAesKey,
{ name: 'AES-GCM' },
false,
['decrypt']
);
// 3. AES-GCM 解密
const plainBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: base64ToBuf(iv) },
aesKey,
base64ToBuf(ciphertext)
);
return new TextDecoder().decode(plainBuffer);
}
// ── 工具函数 ─────────────────────────────────────────────────────────────────
function bufToBase64(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)));
}
function base64ToBuf(base64) {
return Uint8Array.from(atob(base64), c => c.charCodeAt(0));
}