From c736dbbca45e6045d4e39e5f3e6f4c7c4b86dbd5 Mon Sep 17 00:00:00 2001 From: developer Date: Sat, 4 Apr 2026 22:22:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=8A=A0=E5=AF=86(.metona)=EF=BC=8C=E5=AF=BC=E5=85=A5=E4=BB=85?= =?UTF-8?q?=E6=94=AF=E6=8C=81.metona=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- js/components/settings-modal.js | 37 ++++++++------ js/crypto.js | 86 +++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 17 deletions(-) create mode 100644 js/crypto.js diff --git a/index.html b/index.html index bf34518..24229c4 100644 --- a/index.html +++ b/index.html @@ -194,7 +194,7 @@
- +

导出格式为 JSON,可备份后在其他设备导入恢复。

diff --git a/js/components/settings-modal.js b/js/components/settings-modal.js index c94139a..2850296 100644 --- a/js/components/settings-modal.js +++ b/js/components/settings-modal.js @@ -4,6 +4,7 @@ import { state, KEYS } from '../state.js'; import { debounce, formatSize } from '../utils.js'; +import { encryptData, decryptData, isMetonaFile } from '../crypto.js'; import { updateConnectionInfo, updateRunningModels } from './header.js'; import { loadModels } from './model-bar.js'; import { showToast } from './toast.js'; @@ -127,28 +128,32 @@ async function exportAllSessions() { sessions }; - const json = JSON.stringify(backup, null, 2); - const ts = new Date().toISOString().slice(0, 10); - const a = document.createElement('a'); - a.href = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' })); - a.download = `metona-backup-${ts}.json`; - a.click(); - showToast(`已导出 ${sessions.length} 个会话`, 'success'); + try { + const blob = await encryptData(backup); + const ts = new Date().toISOString().slice(0, 10); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `metona-backup-${ts}.metona`; + a.click(); + showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success'); + } catch (err) { + console.error('[Settings] 导出失败:', err); + showToast(`导出失败: ${err.message}`, 'error'); + } } async function importSessions(file) { const db = state.get(KEYS.DB); if (!db) return; + if (!isMetonaFile(file)) { + showToast('仅支持导入 .metona 格式文件', 'error'); + return; + } + try { - const text = await file.text(); - let data; - try { - data = JSON.parse(text); - } catch { - showToast('文件格式错误:不是有效的 JSON', 'error'); - return; - } + const buffer = await file.arrayBuffer(); + const data = await decryptData(buffer); let sessions; if (Array.isArray(data)) { @@ -156,7 +161,7 @@ async function importSessions(file) { } else if (data.sessions && Array.isArray(data.sessions)) { sessions = data.sessions; } else { - showToast('文件格式错误:未找到会话数据', 'error'); + showToast('文件内容格式错误:未找到会话数据', 'error'); return; } diff --git a/js/crypto.js b/js/crypto.js new file mode 100644 index 0000000..58adc92 --- /dev/null +++ b/js/crypto.js @@ -0,0 +1,86 @@ +/** + * Crypto - Metona 会话加密模块 + * + * 文件格式: [8B magic "METONA1\0"][16B salt][12B IV][AES-256-GCM ciphertext] + * 后缀: .metona + */ + +const MAGIC = new TextEncoder().encode('METONA1\0'); +const PASSPHRASE = 'metona-ollama-v2'; +const PBKDF2_ITERATIONS = 100000; + +/** + * 导入派生密钥 + */ +async function deriveKey(salt) { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(PASSPHRASE), + 'PBKDF2', + false, + ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * 加密 JSON 数据 → .metona 二进制格式 + * @param {object} data - 要加密的对象 + * @returns {Blob} .metona 文件内容 + */ +export async function encryptData(data) { + const json = new TextEncoder().encode(JSON.stringify(data)); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(salt); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, json); + + // 拼接: magic + salt + iv + ciphertext + const blob = new Blob([MAGIC, salt, iv, ciphertext]); + return blob; +} + +/** + * 解密 .metona 文件 → JSON 对象 + * @param {ArrayBuffer} buffer - .metona 文件内容 + * @returns {object} 解密后的对象 + */ +export async function decryptData(buffer) { + const data = new Uint8Array(buffer); + + // 校验 magic + const magic = data.slice(0, 8); + for (let i = 0; i < MAGIC.length; i++) { + if (magic[i] !== MAGIC[i]) { + throw new Error('不是有效的 .metona 文件'); + } + } + + const salt = data.slice(8, 24); + const iv = data.slice(24, 36); + const ciphertext = data.slice(36); + + const key = await deriveKey(salt); + let decrypted; + try { + decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + } catch { + throw new Error('文件已损坏或格式不正确'); + } + + const json = new TextDecoder().decode(decrypted); + return JSON.parse(json); +} + +/** + * 检查文件是否为 .metona 格式 + */ +export function isMetonaFile(file) { + return file.name.endsWith('.metona'); +}