mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 13:55:51 +00:00
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:
304
utils/config/ApiProfileManager.js
Normal file
304
utils/config/ApiProfileManager.js
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* ApiProfileManager — API 连接配置组管理
|
||||
*
|
||||
* Profile 是一组完整的 API 连接参数,按模型类型分为三类:
|
||||
* chat — 对话/补全模型(主 API、剧情优化、各子系统等)
|
||||
* embedding — 向量嵌入模型(RAG 向量化)
|
||||
* rerank — 重排序模型(RAG 精排)
|
||||
*
|
||||
* 存储分离:
|
||||
* Profile 元数据(name、type、provider、url、model、params)→ extension_settings.amily2_profiles
|
||||
* API Key → ApiKeyStore(local 或 cloud 加密)
|
||||
*
|
||||
* 功能分配(assignments):
|
||||
* 记录每个系统功能当前使用哪个 Profile ID,存于 extension_settings.amily2_profile_assignments
|
||||
* 选单会按功能对应的 Profile 类型进行过滤,防止类型错配。
|
||||
*
|
||||
* Bus 注册名:'ApiProfiles'
|
||||
*
|
||||
* 公开接口:
|
||||
* getProfiles(type?) — 获取全部或指定类型的 Profile 列表
|
||||
* getProfile(id) — 获取单个 Profile 元数据
|
||||
* createProfile(data) — 新建 Profile(返回新 ID)
|
||||
* updateProfile(id, data) — 更新 Profile 元数据
|
||||
* deleteProfile(id) — 删除 Profile(含清理 Key)
|
||||
* getKey(id) — 读取 Profile 的 API Key(异步,自动解密)
|
||||
* setKey(id, value) — 写入 Profile 的 API Key(异步,自动加密)
|
||||
* getAssignment(slot) — 获取功能槽当前分配的 Profile ID
|
||||
* setAssignment(slot, id) — 设置功能槽的 Profile
|
||||
* getAssignedProfile(slot) — 获取功能槽完整 Profile(含解密 Key)
|
||||
* SLOTS — 可用功能槽清单(静态)
|
||||
* PROFILE_TYPES — Profile 类型定义(静态)
|
||||
*/
|
||||
|
||||
import { extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { extensionName } from "../settings.js";
|
||||
import { apiKeyStore } from "./api-key-store/ApiKeyStore.js";
|
||||
|
||||
// ── 类型与功能槽定义 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Profile 类型定义 */
|
||||
export const PROFILE_TYPES = {
|
||||
chat: {
|
||||
label: '对话模型',
|
||||
icon: 'fa-comments',
|
||||
description: '用于文本生成、对话补全的模型(Chat / Completion)',
|
||||
params: ['maxTokens', 'temperature'],
|
||||
},
|
||||
embedding: {
|
||||
label: '向量嵌入',
|
||||
icon: 'fa-project-diagram',
|
||||
description: '将文本转换为向量的模型,用于 RAG 语义检索',
|
||||
params: ['dimensions', 'encodingFormat'],
|
||||
},
|
||||
rerank: {
|
||||
label: '重排序',
|
||||
icon: 'fa-sort-amount-down',
|
||||
description: '对检索结果重新打分排序的模型,用于 RAG 精排',
|
||||
params: ['topN', 'returnDocuments'],
|
||||
},
|
||||
};
|
||||
|
||||
/** 功能槽:每个系统功能需要的 Profile 类型 */
|
||||
export const SLOTS = {
|
||||
// Chat 槽
|
||||
main: { label: '主 API(正文优化)', type: 'chat' },
|
||||
plotOpt: { label: '剧情优化', type: 'chat' },
|
||||
plotOptConc: { label: '剧情优化(并发)', type: 'chat' },
|
||||
ngms: { label: 'NGMS 历史记录', type: 'chat' },
|
||||
nccs: { label: 'NCCS 并发', type: 'chat' },
|
||||
jqyh: { label: 'JQYH', type: 'chat' },
|
||||
cwb: { label: '角色卡编辑器', type: 'chat' },
|
||||
superMemory: { label: '超级记忆', type: 'chat' },
|
||||
// Embedding 槽
|
||||
ragEmbed: { label: 'RAG 向量化', type: 'embedding' },
|
||||
// Rerank 槽
|
||||
ragRerank: { label: 'RAG 重排序', type: 'rerank' },
|
||||
};
|
||||
|
||||
// extension_settings 存储 key
|
||||
const EXT_PROFILES = 'amily2_profiles';
|
||||
const EXT_ASSIGNMENTS = 'amily2_profile_assignments';
|
||||
|
||||
// ── ApiProfileManager ─────────────────────────────────────────────────────────
|
||||
|
||||
class ApiProfileManager {
|
||||
|
||||
// ── 内部工具 ────────────────────────────────────────────────────────────
|
||||
|
||||
_settings() {
|
||||
if (!extension_settings[extensionName]) extension_settings[extensionName] = {};
|
||||
return extension_settings[extensionName];
|
||||
}
|
||||
|
||||
_profiles() {
|
||||
const s = this._settings();
|
||||
if (!Array.isArray(s[EXT_PROFILES])) s[EXT_PROFILES] = [];
|
||||
return s[EXT_PROFILES];
|
||||
}
|
||||
|
||||
_assignments() {
|
||||
const s = this._settings();
|
||||
if (!s[EXT_ASSIGNMENTS] || typeof s[EXT_ASSIGNMENTS] !== 'object') {
|
||||
s[EXT_ASSIGNMENTS] = {};
|
||||
}
|
||||
return s[EXT_ASSIGNMENTS];
|
||||
}
|
||||
|
||||
_save() {
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
|
||||
_newId() {
|
||||
return `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
// ── Profile CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 获取 Profile 列表。
|
||||
* @param {'chat'|'embedding'|'rerank'} [type] 不传则返回全部
|
||||
* @returns {Array}
|
||||
*/
|
||||
getProfiles(type) {
|
||||
const all = this._profiles();
|
||||
return type ? all.filter(p => p.type === type) : [...all];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个 Profile 元数据(不含 Key)。
|
||||
*/
|
||||
getProfile(id) {
|
||||
return this._profiles().find(p => p.id === id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建 Profile。
|
||||
* @param {Object} data Profile 数据(不含 id、apiKey)
|
||||
* @returns {string} 新 Profile 的 id
|
||||
*/
|
||||
createProfile(data) {
|
||||
const id = this._newId();
|
||||
const profile = this._buildProfile(id, data);
|
||||
this._profiles().push(profile);
|
||||
this._save();
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Profile 元数据(不更新 Key,Key 用 setKey())。
|
||||
*/
|
||||
updateProfile(id, data) {
|
||||
const list = this._profiles();
|
||||
const idx = list.findIndex(p => p.id === id);
|
||||
if (idx === -1) return false;
|
||||
list[idx] = this._buildProfile(id, { ...list[idx], ...data });
|
||||
this._save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Profile(同时清理存储的 Key 和功能槽引用)。
|
||||
*/
|
||||
deleteProfile(id) {
|
||||
const s = this._settings();
|
||||
s[EXT_PROFILES] = this._profiles().filter(p => p.id !== id);
|
||||
|
||||
// 清理功能槽引用
|
||||
const asgn = this._assignments();
|
||||
for (const slot in asgn) {
|
||||
if (asgn[slot] === id) delete asgn[slot];
|
||||
}
|
||||
|
||||
// 清理 Key
|
||||
apiKeyStore.deleteById(id);
|
||||
|
||||
this._save();
|
||||
}
|
||||
|
||||
// ── Key 操作 ────────────────────────────────────────────────────────────
|
||||
|
||||
/** 读取 Profile 的 API Key(异步,自动解密) */
|
||||
async getKey(id) {
|
||||
return apiKeyStore.retrieveById(id);
|
||||
}
|
||||
|
||||
/** 写入 Profile 的 API Key(异步,自动加密) */
|
||||
async setKey(id, value) {
|
||||
return apiKeyStore.storeById(id, value);
|
||||
}
|
||||
|
||||
// ── 功能槽分配 ──────────────────────────────────────────────────────────
|
||||
|
||||
/** 获取功能槽当前分配的 Profile ID(null = 未分配) */
|
||||
getAssignment(slot) {
|
||||
return this._assignments()[slot] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置功能槽的 Profile。
|
||||
* 会校验 Profile 类型是否与槽类型匹配。
|
||||
*/
|
||||
setAssignment(slot, profileId) {
|
||||
if (!SLOTS[slot]) {
|
||||
console.warn(`[ApiProfiles] 未知功能槽 "${slot}"。`);
|
||||
return false;
|
||||
}
|
||||
if (profileId !== null) {
|
||||
const profile = this.getProfile(profileId);
|
||||
if (!profile) {
|
||||
console.warn(`[ApiProfiles] Profile "${profileId}" 不存在。`);
|
||||
return false;
|
||||
}
|
||||
if (profile.type !== SLOTS[slot].type) {
|
||||
console.warn(`[ApiProfiles] 类型不匹配:槽 "${slot}" 需要 ${SLOTS[slot].type},Profile 类型为 ${profile.type}。`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
this._assignments()[slot] = profileId;
|
||||
this._save();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取功能槽完整 Profile,包含解密后的 API Key。
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async getAssignedProfile(slot) {
|
||||
const id = this.getAssignment(slot);
|
||||
if (!id) return null;
|
||||
const profile = this.getProfile(id);
|
||||
if (!profile) return null;
|
||||
const apiKey = await this.getKey(id);
|
||||
return { ...profile, apiKey };
|
||||
}
|
||||
|
||||
// ── 内部:Profile 对象构造 ──────────────────────────────────────────────
|
||||
|
||||
_buildProfile(id, data) {
|
||||
const type = data.type || 'chat';
|
||||
const base = {
|
||||
id,
|
||||
name: data.name || '未命名配置',
|
||||
type,
|
||||
provider: data.provider || 'openai',
|
||||
apiUrl: data.apiUrl || '',
|
||||
model: data.model || '',
|
||||
};
|
||||
|
||||
if (type === 'chat') {
|
||||
return {
|
||||
...base,
|
||||
maxTokens: data.maxTokens ?? 65500,
|
||||
temperature: data.temperature ?? 1.0,
|
||||
};
|
||||
}
|
||||
if (type === 'embedding') {
|
||||
return {
|
||||
...base,
|
||||
dimensions: data.dimensions ?? null,
|
||||
encodingFormat: data.encodingFormat ?? 'float',
|
||||
};
|
||||
}
|
||||
if (type === 'rerank') {
|
||||
return {
|
||||
...base,
|
||||
topN: data.topN ?? 5,
|
||||
returnDocuments: data.returnDocuments ?? false,
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单例导出 ─────────────────────────────────────────────────────────────────
|
||||
export const apiProfileManager = new ApiProfileManager();
|
||||
|
||||
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const _ctx = window.Amily2Bus?.register('ApiProfiles');
|
||||
if (!_ctx) {
|
||||
console.warn('[ApiProfiles] Amily2Bus 尚未就绪,注册跳过。');
|
||||
return;
|
||||
}
|
||||
_ctx.expose({
|
||||
getProfiles: (type) => apiProfileManager.getProfiles(type),
|
||||
getProfile: (id) => apiProfileManager.getProfile(id),
|
||||
createProfile: (data) => apiProfileManager.createProfile(data),
|
||||
updateProfile: (id, data) => apiProfileManager.updateProfile(id, data),
|
||||
deleteProfile: (id) => apiProfileManager.deleteProfile(id),
|
||||
getKey: (id) => apiProfileManager.getKey(id),
|
||||
setKey: (id, val) => apiProfileManager.setKey(id, val),
|
||||
getAssignment: (slot) => apiProfileManager.getAssignment(slot),
|
||||
setAssignment: (slot, id) => apiProfileManager.setAssignment(slot, id),
|
||||
getAssignedProfile: (slot) => apiProfileManager.getAssignedProfile(slot),
|
||||
SLOTS: SLOTS,
|
||||
PROFILE_TYPES: PROFILE_TYPES,
|
||||
});
|
||||
_ctx.log('ApiProfiles', 'info', 'ApiProfiles 服务已注册到 Bus。');
|
||||
} catch (e) {
|
||||
console.error('[ApiProfiles] Bus 注册失败:', e);
|
||||
}
|
||||
}, 0);
|
||||
155
utils/config/ConfigManager.js
Normal file
155
utils/config/ConfigManager.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* ConfigManager — 独立配置持久化管理模块
|
||||
*
|
||||
* 解决的安全问题:
|
||||
* SillyTavern 的 extension_settings 会通过 saveSettingsDebounced() 上传到 ST
|
||||
* 服务端 settings.json。使用三方云服务商时,服务商可读取该文件,导致所有
|
||||
* API 密钥泄露。
|
||||
*
|
||||
* 解决方案:
|
||||
* 敏感字段(API Key / URL)→ localStorage(浏览器本地,绝不上传)
|
||||
* 非敏感字段 → extension_settings(维持原有行为)
|
||||
*
|
||||
* Bus 注册名:'Config'
|
||||
*
|
||||
* 公开接口(query('Config')):
|
||||
* get(key) — 读取配置项(自动路由)
|
||||
* set(key, value) — 写入配置项(自动路由 + 触发保存)
|
||||
* getSettings() — 返回完整配置对象(敏感字段从 localStorage 注入)
|
||||
* migrate() — 将 extension_settings 中残留的敏感字段迁移到 localStorage
|
||||
*/
|
||||
|
||||
import { extension_settings } from "/scripts/extensions.js";
|
||||
import { saveSettingsDebounced } from "/script.js";
|
||||
import { extensionName } from "../settings.js";
|
||||
import { SENSITIVE_KEYS } from "./sensitive-keys.js";
|
||||
|
||||
// localStorage key 前缀,避免与其他插件冲突
|
||||
const LS_PREFIX = 'amily2_secure_';
|
||||
|
||||
// ── ConfigManager ────────────────────────────────────────────────────────────
|
||||
|
||||
class ConfigManager {
|
||||
|
||||
/**
|
||||
* 读取配置项。
|
||||
* 敏感字段从 localStorage 读取,其余从 extension_settings 读取。
|
||||
* @param {string} key
|
||||
* @returns {*}
|
||||
*/
|
||||
get(key) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
return localStorage.getItem(LS_PREFIX + key) ?? '';
|
||||
}
|
||||
return extension_settings[extensionName]?.[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入配置项并持久化。
|
||||
* 敏感字段写入 localStorage(同时从 extension_settings 清除残留)。
|
||||
* 非敏感字段写入 extension_settings 并触发 saveSettingsDebounced。
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
*/
|
||||
set(key, value) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
localStorage.setItem(LS_PREFIX + key, value);
|
||||
} else {
|
||||
localStorage.removeItem(LS_PREFIX + key);
|
||||
}
|
||||
// 确保 extension_settings 中不保留该敏感字段
|
||||
const settings = extension_settings[extensionName];
|
||||
if (settings && Object.prototype.hasOwnProperty.call(settings, key)) {
|
||||
delete settings[key];
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
} else {
|
||||
if (!extension_settings[extensionName]) {
|
||||
extension_settings[extensionName] = {};
|
||||
}
|
||||
extension_settings[extensionName][key] = value;
|
||||
saveSettingsDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回完整配置对象(合并视图)。
|
||||
* 以 extension_settings 为基础,将 localStorage 中的敏感字段注入覆盖。
|
||||
*
|
||||
* 用途:替换现有 `const settings = extension_settings[extensionName]` 的读取点,
|
||||
* 使 API 调用模块能透明地获取到敏感字段,无需感知存储层差异。
|
||||
*
|
||||
* @returns {Object}
|
||||
*/
|
||||
getSettings() {
|
||||
const base = extension_settings[extensionName] ?? {};
|
||||
const result = { ...base };
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
const val = localStorage.getItem(LS_PREFIX + key);
|
||||
// null 表示 localStorage 中不存在,保留 base 中原值(如有)
|
||||
if (val !== null) {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移:将 extension_settings 中已存在的敏感字段移到 localStorage。
|
||||
*
|
||||
* 应在插件初始化阶段调用一次。
|
||||
* 逻辑:
|
||||
* - 若 extension_settings 有值 → 迁移到 localStorage(若 localStorage 已有值则跳过,保留用户上次输入)
|
||||
* - 从 extension_settings 删除该字段
|
||||
* - 最终触发一次 saveSettingsDebounced 清洗服务端
|
||||
*/
|
||||
migrate() {
|
||||
const settings = extension_settings[extensionName];
|
||||
if (!settings) return;
|
||||
|
||||
let needsSave = false;
|
||||
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
const settingsVal = settings[key];
|
||||
if (settingsVal !== undefined && settingsVal !== '') {
|
||||
// localStorage 中已有值时不覆盖(优先保留用户最新输入)
|
||||
if (!localStorage.getItem(LS_PREFIX + key)) {
|
||||
localStorage.setItem(LS_PREFIX + key, settingsVal);
|
||||
console.info(`[Amily2-Config] 已迁移敏感字段 "${key}" 到本地安全存储。`);
|
||||
}
|
||||
delete settings[key];
|
||||
needsSave = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsSave) {
|
||||
saveSettingsDebounced();
|
||||
console.info('[Amily2-Config] 敏感配置迁移完成,已从云同步配置中清除密钥。');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 单例导出 ─────────────────────────────────────────────────────────────────
|
||||
export const configManager = new ConfigManager();
|
||||
|
||||
// ── Bus 注册 ──────────────────────────────────────────────────────────────────
|
||||
// setTimeout 确保 window.Amily2Bus 在 Amily2Bus.js 模块体执行后已挂载
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const _ctx = window.Amily2Bus?.register('Config');
|
||||
if (!_ctx) {
|
||||
console.warn('[Config] Amily2Bus 尚未就绪,Config 服务注册跳过。');
|
||||
return;
|
||||
}
|
||||
_ctx.expose({
|
||||
get: (key) => configManager.get(key),
|
||||
set: (key, value) => configManager.set(key, value),
|
||||
getSettings: () => configManager.getSettings(),
|
||||
migrate: () => configManager.migrate(),
|
||||
});
|
||||
_ctx.log('ConfigManager', 'info', 'Config 服务已注册到 Bus。');
|
||||
} catch (e) {
|
||||
console.error('[Config] Bus 注册失败:', e);
|
||||
}
|
||||
}, 0);
|
||||
359
utils/config/api-key-store/ApiKeyStore.js
Normal file
359
utils/config/api-key-store/ApiKeyStore.js
Normal 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);
|
||||
174
utils/config/api-key-store/crypto-utils.js
Normal file
174
utils/config/api-key-store/crypto-utils.js
Normal 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 随机 IV(12 字节)
|
||||
* 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. 生成随机 IV(12 字节是 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));
|
||||
}
|
||||
17
utils/config/sensitive-keys.js
Normal file
17
utils/config/sensitive-keys.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 敏感配置字段清单(仅 API Key 类凭证)
|
||||
*
|
||||
* 只有真正的凭证(API Key)需要保护。
|
||||
* API URL 不是凭证——没有 Key 拿到 URL 也无法调用,且 URL 云同步方便多端使用。
|
||||
*
|
||||
* 这些字段将被 ConfigManager / ApiKeyStore 路由到安全存储,
|
||||
* 而不是 extension_settings(后者会被 saveSettingsDebounced 上传到 ST 服务端)。
|
||||
*/
|
||||
export const SENSITIVE_KEYS = new Set([
|
||||
'apiKey',
|
||||
'plotOpt_concurrentApiKey',
|
||||
'ngmsApiKey',
|
||||
'nccsApiKey',
|
||||
'jqyhApiKey',
|
||||
'cwb_api_key',
|
||||
]);
|
||||
Reference in New Issue
Block a user