// Persistenter Vault: Hosts, Keys, Snippets, Forwards, Settings. // Wird mit Electron safeStorage (DPAPI unter Windows, libsecret/kwallet unter Linux) verschlüsselt. // Im Web-Server gibt es kein Electron: dort übergibt der Aufrufer Verzeichnis und Verschlüsselung (opts) const electron = (() => { try { const e = require('electron'); return typeof e === 'object' ? e : {}; } catch { return {}; } })(); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const DEFAULTS = { version: 1, groups: [], hosts: [], keys: [], snippets: [], forwards: [], vpns: [], knownHosts: {}, history: [], // Löschvermerke für die Synchronisation: { c: Collection, id, at } tombstones: [], // LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert) sync: { enabled: false, deviceId: '', deviceName: '', peers: [], share: { keys: [], hosts: [], vpns: [] }, asked: false }, settings: { terminalTheme: 'mrterm', fontFamily: 'Cascadia Code, JetBrains Mono, Fira Code, Consolas, monospace', fontSize: 14, cursorStyle: 'block', cursorBlink: true, scrollback: 10000, copyOnSelect: true, keepAlive: 30, rdpClientLinux: 'auto', rdpEmbed: true, appTheme: 'midnight', accent: '', updateUrl: 'https://git.mrblake.cc/MrBlake/MrTerm', updateToken: '', updateAutoCheck: true, updatePrerelease: false, autoLock: 0, language: 'auto', }, }; const merge = (parsed) => ({ ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } }); // AES-256-GCM; Ergebnis als base64-Felder für JSON function box(key, plain) { const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', key, iv); const ct = Buffer.concat([c.update(plain), c.final()]); return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ct: ct.toString('base64') }; } function unbox(key, b) { const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(b.iv, 'base64')); d.setAuthTag(Buffer.from(b.tag, 'base64')); return Buffer.concat([d.update(Buffer.from(b.ct, 'base64')), d.final()]); } class Store { // opts: { dir, crypto } – crypto hat die Schnittstelle von Electrons safeStorage constructor(opts = {}) { this.dir = opts.dir || electron.app.getPath('userData'); this.crypto = opts.crypto || electron.safeStorage; this.file = path.join(this.dir, 'vault.dat'); this.data = structuredClone(DEFAULTS); this.encrypted = false; // App-Sperre: Inhalt zusätzlich mit zufälligem Datenschlüssel (DEK, AES-256-GCM) verschlüsselt. // Der DEK liegt je Entsperrmethode verpackt vor: Passwort (scrypt) und/oder FIDO2-Schlüssel (PRF/hmac-secret). this.lock = null; // { password: { salt, N, wrap } | null, fido: [{ id, label, credId, prfSalt, wrap }] } this.dek = null; this.sealed = null; // verschlüsselter Inhalt, solange nach dem Start noch nicht entsperrt this.locked = false; } get lockEnabled() { return !!(this.lock && (this.lock.password || this.lock.fido.length)); } // Entsperren mit einem Schlüssel, der den DEK verpackt hat (wirft bei falschem Schlüssel) unlockWith(kek, wrap) { const dek = unbox(kek, wrap); if (this.sealed) { const parsed = JSON.parse(unbox(dek, this.sealed).toString('utf8')); this.data = merge(parsed); this.sealed = null; } this.dek = dek; this.locked = false; } // Neue Entsperrmethode: verpackt den (ggf. neu erzeugten) DEK mit kek wrapDek(kek) { if (!this.dek) this.dek = crypto.randomBytes(32); return box(kek, this.dek); } // Letzte Methode entfernt → Sperre aus, Inhalt wieder nur per Betriebssystem verschlüsselt dropLockIfEmpty() { if (!this.lockEnabled) { this.lock = null; this.dek = null; } } canEncrypt() { try { if (!this.crypto.isEncryptionAvailable()) return false; // Unter Linux ohne Keyring fällt Electron auf "basic_text" zurück – das ist keine echte Verschlüsselung. if (process.platform === 'linux' && this.crypto.getSelectedStorageBackend?.() === 'basic_text') return false; return true; } catch { return false; } } load() { fs.mkdirSync(this.dir, { recursive: true }); if (!fs.existsSync(this.file)) { this.save(); return this.data; } const raw = fs.readFileSync(this.file); let json; if (raw.slice(0, 4).toString() === 'ENC1') { json = this.crypto.decryptString(raw.slice(4)); this.encrypted = true; } else { json = raw.toString('utf8'); } const parsed = JSON.parse(json); if (parsed.mrtermLock) { // Gesperrt: nur Darstellungs-Einstellungen (meta) sind bis zum Entsperren bekannt this.lock = parsed.lock; this.sealed = parsed.data; this.locked = true; this.data = merge({ settings: parsed.meta || {} }); } else this.data = merge(parsed); return this.data; } save() { if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben if (!this.muted) this.onChange?.(); let json = JSON.stringify(this.data, null, 2); if (this.lockEnabled && this.dek) { const { language, appTheme, accent } = this.data.settings; json = JSON.stringify({ mrtermLock: 1, meta: { language, appTheme, accent }, lock: this.lock, data: box(this.dek, Buffer.from(json)) }); } const tmp = this.file + '.tmp'; if (this.canEncrypt()) { fs.writeFileSync(tmp, Buffer.concat([Buffer.from('ENC1'), this.crypto.encryptString(json)])); this.encrypted = true; } else { fs.writeFileSync(tmp, json, { mode: 0o600 }); this.encrypted = false; } fs.renameSync(tmp, this.file); } get() { return this.data; } // Generisches Upsert für Collections (hosts, groups, keys, snippets, forwards) upsert(collection, item) { const list = this.data[collection]; if (!Array.isArray(list)) throw new Error('Unbekannte Collection: ' + collection); if (!item.id) item.id = crypto.randomUUID(); const i = list.findIndex((x) => x.id === item.id); if (i >= 0) list[i] = { ...list[i], ...item, updatedAt: Date.now() }; else list.push({ ...item, createdAt: Date.now(), updatedAt: Date.now() }); this.save(); return item; } remove(collection, id) { this.data[collection] = this.data[collection].filter((x) => x.id !== id); if (collection === 'groups') this.data.hosts.forEach((h) => { if (h.groupId === id) { h.groupId = null; h.updatedAt = Date.now(); } }); this.tombstone(collection, id); this.save(); } // Löschung für andere Geräte vermerken (180 Tage aufbewahren) tombstone(c, id) { const now = Date.now(); this.data.tombstones = [...(this.data.tombstones || []).filter((t) => !(t.c === c && t.id === id) && now - t.at < 180 * 864e5), { c, id, at: now }]; } forgetKnownHost(id) { delete this.data.knownHosts[id]; this.tombstone('knownHosts', id); this.save(); } setSettings(s) { this.data.settings = { ...this.data.settings, ...s }; this.save(); } addHistory(entry) { this.data.history = [entry, ...this.data.history.filter((h) => h.hostId !== entry.hostId)].slice(0, 30); this.save(); } resolveHost(id) { return this.data.hosts.find((h) => h.id === id); } resolveKey(id) { return this.data.keys.find((k) => k.id === id); } } module.exports = { Store };