Files
MrTerm/src/main/store.js
T

202 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: [],
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
aiChats: [],
// 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',
dockerStacksDir: '/opt/stacks',
dockerTemplatesUrl: '',
aiUrl: 'http://localhost:11434',
aiModel: '',
},
};
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 };