// 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 };