196 lines
7.3 KiB
JavaScript
196 lines
7.3 KiB
JavaScript
// 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 };
|