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:
+84
-4
@@ -10,6 +10,7 @@ const rdp = require('./rdp');
|
||||
const { Updater } = require('./updater');
|
||||
const { createEmbed, embedSupported } = require('./rdp-embed');
|
||||
const i18n = require('../i18n');
|
||||
const fido = require('./fido');
|
||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||
|
||||
let win;
|
||||
@@ -88,9 +89,14 @@ function createWindow() {
|
||||
win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; });
|
||||
}
|
||||
|
||||
// Solange MrTerm gesperrt ist, sind nur die Sperr-Kanäle erreichbar
|
||||
const OPEN_WHILE_LOCKED = new Set(['app:version']);
|
||||
function handle(channel, fn) {
|
||||
ipcMain.handle(channel, async (_e, ...args) => {
|
||||
try { return { ok: true, value: await fn(...args) }; }
|
||||
try {
|
||||
if (store.locked && !channel.startsWith('lock:') && !OPEN_WHILE_LOCKED.has(channel)) throw new Error(i18n.t('MrTerm is locked.'));
|
||||
return { ok: true, value: await fn(...args) };
|
||||
}
|
||||
catch (e) { return { ok: false, error: e.message || String(e) }; }
|
||||
});
|
||||
}
|
||||
@@ -100,6 +106,74 @@ ipcMain.on('win:min', () => win.minimize());
|
||||
ipcMain.on('win:max', () => (win.isMaximized() ? win.unmaximize() : win.maximize()));
|
||||
ipcMain.on('win:close', () => win.close());
|
||||
|
||||
// ---------- App-Sperre (Passwort / FIDO2) ----------
|
||||
const kdf = (pw, salt, N) => new Promise((resolve, reject) =>
|
||||
crypto.scrypt(String(pw), salt, 32, { N, r: 8, p: 1, maxmem: 256 * N * 8 }, (e, k) => (e ? reject(e) : resolve(k))));
|
||||
const fidoKek = (secret) => Buffer.from(crypto.hkdfSync('sha256', secret, Buffer.alloc(0), 'mrterm-fido-kek', 32));
|
||||
function lockStatus() {
|
||||
const { language, appTheme, accent } = store.get().settings;
|
||||
return {
|
||||
enabled: store.lockEnabled, locked: store.locked, hasPassword: !!store.lock?.password,
|
||||
fido: (store.lock?.fido || []).map(({ id, label }) => ({ id, label })), meta: { language, appTheme, accent },
|
||||
};
|
||||
}
|
||||
function requireUnlocked() { if (store.locked) throw new Error(i18n.t('MrTerm is locked.')); }
|
||||
const ensureLock = () => (store.lock = store.lock || { password: null, fido: [] });
|
||||
function afterUnlock() { applyLanguage(); scheduleUpdateCheck(); }
|
||||
|
||||
handle('lock:status', lockStatus);
|
||||
handle('lock:lock', () => { if (store.lockEnabled) store.locked = true; return lockStatus(); });
|
||||
handle('lock:unlockPassword', async (pw) => {
|
||||
const p = store.lock?.password;
|
||||
if (!p) throw new Error(i18n.t('No password set.'));
|
||||
const kek = await kdf(pw, Buffer.from(p.salt, 'base64'), p.N);
|
||||
try { store.unlockWith(kek, p.wrap); } catch { throw new Error(i18n.t('Wrong password.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:unlockFido', async () => {
|
||||
const list = store.lock?.fido || [];
|
||||
if (!list.length) throw new Error(i18n.t('No security key registered.'));
|
||||
const r = await fido.derive(win, list.map(({ credId, prfSalt }) => ({ credId, prfSalt })));
|
||||
const entry = list.find((f) => f.credId === r.credId);
|
||||
if (!entry) throw new Error(i18n.t('Unknown security key.'));
|
||||
try { store.unlockWith(fidoKek(r.secret), entry.wrap); } catch { throw new Error(i18n.t('This security key could not unlock the vault.')); }
|
||||
afterUnlock();
|
||||
return true;
|
||||
});
|
||||
handle('lock:setPassword', async (pw) => {
|
||||
requireUnlocked();
|
||||
if (!pw || String(pw).length < 6) throw new Error(i18n.t('The password must be at least 6 characters long.'));
|
||||
const salt = crypto.randomBytes(16), N = 2 ** 15;
|
||||
const kek = await kdf(pw, salt, N);
|
||||
ensureLock().password = { salt: salt.toString('base64'), N, wrap: store.wrapDek(kek) };
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removePassword', () => {
|
||||
requireUnlocked();
|
||||
if (store.lock) store.lock.password = null;
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:addFido', async (label) => {
|
||||
requireUnlocked();
|
||||
const credId = await fido.register(win);
|
||||
const prfSalt = crypto.randomBytes(32).toString('base64url');
|
||||
const r = await fido.derive(win, [{ credId, prfSalt }], i18n.t('Touch your security key again to finish.'));
|
||||
ensureLock().fido.push({ id: crypto.randomUUID(), label: label || i18n.t('Security key'), credId, prfSalt, wrap: store.wrapDek(fidoKek(r.secret)) });
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
handle('lock:removeFido', (id) => {
|
||||
requireUnlocked();
|
||||
if (store.lock) store.lock.fido = store.lock.fido.filter((f) => f.id !== id);
|
||||
store.dropLockIfEmpty();
|
||||
store.save();
|
||||
return lockStatus();
|
||||
});
|
||||
|
||||
// ---------- Vault ----------
|
||||
handle('vault:get', () => ({ ...store.get(), encrypted: store.encrypted, platform: process.platform }));
|
||||
handle('vault:upsert', (col, item) => store.upsert(col, item));
|
||||
@@ -369,10 +443,16 @@ app.whenReady().then(() => {
|
||||
store.load();
|
||||
applyLanguage();
|
||||
createWindow();
|
||||
if (store.get().settings.updateAutoCheck && app.isPackaged) {
|
||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||
}
|
||||
scheduleUpdateCheck();
|
||||
});
|
||||
|
||||
// Automatische Update-Prüfung – bei gesperrtem Vault erst nach dem Entsperren (Einstellungen sind verschlüsselt)
|
||||
let updateScheduled = false;
|
||||
function scheduleUpdateCheck() {
|
||||
if (updateScheduled || store.sealed || !store.get().settings.updateAutoCheck || !app.isPackaged) return;
|
||||
updateScheduled = true;
|
||||
setTimeout(() => updater.check().then((r) => { if (r.available) send('update:available', r); }).catch(() => {}), 6000);
|
||||
}
|
||||
app.on('window-all-closed', () => { ssh.closeAll(); app.quit(); });
|
||||
|
||||
// Smoke-Test: MRTERM_SMOKE=<pfad.png> startet, loggt Renderer-Meldungen, speichert einen Screenshot und beendet.
|
||||
|
||||
Reference in New Issue
Block a user