feat: 会话导出加密(.metona),导入仅支持.metona格式
This commit is contained in:
+1
-1
@@ -194,7 +194,7 @@
|
|||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
<button class="btn btn-outline" id="btnExportAll">📦 导出全部会话</button>
|
<button class="btn btn-outline" id="btnExportAll">📦 导出全部会话</button>
|
||||||
<button class="btn btn-outline" id="btnImportSessions">📥 导入会话</button>
|
<button class="btn btn-outline" id="btnImportSessions">📥 导入会话</button>
|
||||||
<input type="file" id="importFileInput" accept=".json" style="display:none;">
|
<input type="file" id="importFileInput" accept=".metona" style="display:none;">
|
||||||
<button class="btn btn-danger" id="btnClearAllHistory">清空所有历史</button>
|
<button class="btn btn-danger" id="btnClearAllHistory">清空所有历史</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted" style="margin-top:6px;font-size:11px;">导出格式为 JSON,可备份后在其他设备导入恢复。</p>
|
<p class="text-muted" style="margin-top:6px;font-size:11px;">导出格式为 JSON,可备份后在其他设备导入恢复。</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { state, KEYS } from '../state.js';
|
import { state, KEYS } from '../state.js';
|
||||||
import { debounce, formatSize } from '../utils.js';
|
import { debounce, formatSize } from '../utils.js';
|
||||||
|
import { encryptData, decryptData, isMetonaFile } from '../crypto.js';
|
||||||
import { updateConnectionInfo, updateRunningModels } from './header.js';
|
import { updateConnectionInfo, updateRunningModels } from './header.js';
|
||||||
import { loadModels } from './model-bar.js';
|
import { loadModels } from './model-bar.js';
|
||||||
import { showToast } from './toast.js';
|
import { showToast } from './toast.js';
|
||||||
@@ -127,36 +128,40 @@ async function exportAllSessions() {
|
|||||||
sessions
|
sessions
|
||||||
};
|
};
|
||||||
|
|
||||||
const json = JSON.stringify(backup, null, 2);
|
try {
|
||||||
|
const blob = await encryptData(backup);
|
||||||
const ts = new Date().toISOString().slice(0, 10);
|
const ts = new Date().toISOString().slice(0, 10);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json;charset=utf-8' }));
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `metona-backup-${ts}.json`;
|
a.download = `metona-backup-${ts}.metona`;
|
||||||
a.click();
|
a.click();
|
||||||
showToast(`已导出 ${sessions.length} 个会话`, 'success');
|
showToast(`已导出 ${sessions.length} 个会话(已加密)`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Settings] 导出失败:', err);
|
||||||
|
showToast(`导出失败: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSessions(file) {
|
async function importSessions(file) {
|
||||||
const db = state.get(KEYS.DB);
|
const db = state.get(KEYS.DB);
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|
||||||
try {
|
if (!isMetonaFile(file)) {
|
||||||
const text = await file.text();
|
showToast('仅支持导入 .metona 格式文件', 'error');
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
showToast('文件格式错误:不是有效的 JSON', 'error');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const data = await decryptData(buffer);
|
||||||
|
|
||||||
let sessions;
|
let sessions;
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
sessions = data;
|
sessions = data;
|
||||||
} else if (data.sessions && Array.isArray(data.sessions)) {
|
} else if (data.sessions && Array.isArray(data.sessions)) {
|
||||||
sessions = data.sessions;
|
sessions = data.sessions;
|
||||||
} else {
|
} else {
|
||||||
showToast('文件格式错误:未找到会话数据', 'error');
|
showToast('文件内容格式错误:未找到会话数据', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user