Backup: export everything (hosts, groups, keys, passwords, snippets, forwards, VPNs, known hosts, history, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import asks for the password, can merge or replace existing data and optionally restore settings; old backups still import; shared module for desktop, web and Android (src/core/backup.js)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:45:31 +02:00
co-authored by Claude Opus 5.5
parent 408b4bd9d0
commit 93d7130bfa
7 changed files with 197 additions and 21 deletions
+99
View File
@@ -0,0 +1,99 @@
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
const crypto = require('crypto');
const FORMAT = 'mrterm-backup';
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
const KDF = { N: 1 << 16, r: 8, p: 1 };
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
function snapshot(store, { settings = true } = {}) {
const d = store.get();
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
for (const c of COLLECTIONS) out[c] = d[c] || [];
if (settings) out.settings = d.settings;
return out;
}
// Liefert den Dateiinhalt (JSON-Text)
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
const data = snapshot(store, { settings });
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
const salt = crypto.randomBytes(16);
const key = scrypt(password, salt, KDF);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
c.setAAD(Buffer.from(FORMAT));
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
return JSON.stringify({
...head, encrypted: true,
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
ct: ct.toString('base64'),
}, null, 2);
}
function parse(content) {
let j;
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
if (!j || typeof j !== 'object') throw new Error('INVALID');
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
if (j.format !== FORMAT) {
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
return { encrypted: false, data: j, createdAt: null, app: '' };
}
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
}
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
// Vorabinfo für die Oberfläche (ohne Passwort)
function inspectBackup(content) {
const b = parse(content);
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
}
function decrypt(content, password) {
const b = parse(content);
if (!b.encrypted) return b.data;
if (!password) throw new Error('PASSWORD_REQUIRED');
const { kdf, cipher, ct } = b.raw;
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
try {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
d.setAAD(Buffer.from(FORMAT));
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
} catch { throw new Error('WRONG_PASSWORD'); }
}
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
const data = decrypt(content, password);
const d = store.get();
store.muted = true;
try {
for (const c of COLLECTIONS) {
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
if (replace) {
const keep = new Set(items.map((x) => x.id).filter(Boolean));
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
}
for (const item of items) store.upsert(c, { ...item });
}
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
if (Array.isArray(data.history)) {
const seen = new Set();
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
}
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
} finally {
store.muted = false;
}
store.save();
return counts(data);
}
module.exports = { createBackup, inspectBackup, importBackup };