mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-06 22:05:50 +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:
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));
|
||||
}
|
||||
Reference in New Issue
Block a user