App-Sperre mit Passwort und/oder FIDO2-Sicherheitsschlüssel (PRF/hmac-secret): Vault zusätzlich verschlüsselt, Sperrbildschirm, Strg+Shift+L, automatische Sperre

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 18:54:17 +02:00
co-authored by Claude Opus 5.5
parent 618f5ad010
commit b33af709aa
8 changed files with 432 additions and 14 deletions
+60 -2
View File
@@ -31,16 +31,63 @@ const DEFAULTS = {
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 {
constructor() {
this.dir = app.getPath('userData');
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() {
@@ -64,12 +111,23 @@ class Store {
json = raw.toString('utf8');
}
const parsed = JSON.parse(json);
this.data = { ...structuredClone(DEFAULTS), ...parsed, settings: { ...DEFAULTS.settings, ...(parsed.settings || {}) } };
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() {
const json = JSON.stringify(this.data, null, 2);
if (this.sealed) return; // Inhalt noch nicht entschlüsselt – nichts überschreiben
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'), safeStorage.encryptString(json)]));