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:
+41
@@ -311,6 +311,47 @@
|
||||
'Actions': 'Aktionen',
|
||||
'Log in': 'Anmelden',
|
||||
|
||||
// App-Sperre
|
||||
'App lock': 'App-Sperre',
|
||||
'Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.': 'MrTerm mit Passwort und/oder FIDO2-Sicherheitsschlüssel (z. B. YubiKey) sperren. Der Vault wird dann zusätzlich verschlüsselt und lässt sich nur mit einer dieser Methoden öffnen.',
|
||||
'MrTerm is locked': 'MrTerm ist gesperrt',
|
||||
'MrTerm is locked.': 'MrTerm ist gesperrt.',
|
||||
'Unlock': 'Entsperren',
|
||||
'Unlock with security key': 'Mit Sicherheitsschlüssel entsperren',
|
||||
'Disable app lock?': 'App-Sperre deaktivieren?',
|
||||
'This is the last unlock method. MrTerm will no longer be locked.': 'Das ist die letzte Entsperrmethode. MrTerm wird danach nicht mehr gesperrt.',
|
||||
'Disable': 'Deaktivieren',
|
||||
'Set': 'Festgelegt',
|
||||
'Not set': 'Nicht festgelegt',
|
||||
'Change password': 'Passwort ändern',
|
||||
'Set password': 'Passwort festlegen',
|
||||
'New password': 'Neues Passwort',
|
||||
'Repeat password': 'Passwort wiederholen',
|
||||
'The passwords do not match.': 'Die Passwörter stimmen nicht überein.',
|
||||
'Password saved': 'Passwort gespeichert',
|
||||
'Security key': 'Sicherheitsschlüssel',
|
||||
'Security key (FIDO2)': 'Sicherheitsschlüssel (FIDO2)',
|
||||
'Remove security key?': 'Sicherheitsschlüssel entfernen?',
|
||||
'Add security key': 'Sicherheitsschlüssel hinzufügen',
|
||||
'Security key added': 'Sicherheitsschlüssel hinzugefügt',
|
||||
'Lock now': 'Jetzt sperren',
|
||||
'Lock (Ctrl+Shift+L)': 'Sperren (Strg+Shift+L)',
|
||||
'Lock automatically after inactivity': 'Automatisch sperren bei Inaktivität',
|
||||
'Never': 'Nie',
|
||||
'{n} minutes': '{n} Minuten',
|
||||
'If you forget the password and lose all security keys, the vault cannot be recovered.': 'Wenn du das Passwort vergisst und alle Sicherheitsschlüssel verlierst, lässt sich der Vault nicht wiederherstellen.',
|
||||
'No password set.': 'Kein Passwort festgelegt.',
|
||||
'Wrong password.': 'Falsches Passwort.',
|
||||
'No security key registered.': 'Kein Sicherheitsschlüssel registriert.',
|
||||
'Unknown security key.': 'Unbekannter Sicherheitsschlüssel.',
|
||||
'This security key could not unlock the vault.': 'Dieser Sicherheitsschlüssel konnte den Vault nicht entsperren.',
|
||||
'The password must be at least 6 characters long.': 'Das Passwort muss mindestens 6 Zeichen lang sein.',
|
||||
'Touch your security key to register it.': 'Berühre deinen Sicherheitsschlüssel, um ihn zu registrieren.',
|
||||
'Touch your security key again to finish.': 'Berühre deinen Sicherheitsschlüssel noch einmal zum Abschließen.',
|
||||
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
|
||||
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
|
||||
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
|
||||
|
||||
// Main-Prozess
|
||||
'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
|
||||
'Unknown host': 'Unbekannter Host',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// FIDO2/WebAuthn (YubiKey & Co.) für die App-Sperre.
|
||||
// Chromium erlaubt WebAuthn nur auf HTTPS oder http://localhost – nicht unter file://. Deshalb läuft die
|
||||
// Abfrage in einem kleinen eigenen Fenster, dessen http://localhost-Seite über eine eigene Session
|
||||
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
|
||||
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
|
||||
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
|
||||
const { BrowserWindow, session } = require('electron');
|
||||
const i18n = require('../i18n');
|
||||
|
||||
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
|
||||
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
|
||||
body{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;border:1px solid #2c3142;box-sizing:border-box;text-align:center;padding:16px}
|
||||
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
|
||||
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
|
||||
button:hover{background:#343a4f}
|
||||
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
|
||||
|
||||
let fidoSession;
|
||||
function getSession() {
|
||||
if (fidoSession) return fidoSession;
|
||||
fidoSession = session.fromPartition('mrterm-fido');
|
||||
fidoSession.protocol.handle('http', () => new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
||||
return fidoSession;
|
||||
}
|
||||
|
||||
// Gemeinsame Hilfsfunktionen im Fenster (base64url <-> Bytes)
|
||||
const HELPERS = `
|
||||
const b64 = (u) => btoa(String.fromCharCode(...new Uint8Array(u))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
||||
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
|
||||
`;
|
||||
|
||||
async function ceremony(parent, text, script) {
|
||||
const w = new BrowserWindow({
|
||||
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
|
||||
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
|
||||
});
|
||||
try {
|
||||
await w.loadURL('http://localhost/');
|
||||
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
|
||||
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
|
||||
w.show();
|
||||
w.focus();
|
||||
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
|
||||
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
|
||||
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
|
||||
if (r?.error) throw new Error(r.error);
|
||||
return r;
|
||||
} finally {
|
||||
if (!w.isDestroyed()) w.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
|
||||
async function register(parent) {
|
||||
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
|
||||
const cred = await navigator.credentials.create({ publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
||||
rp: { name: 'MrTerm', id: 'localhost' },
|
||||
user: { id: crypto.getRandomValues(new Uint8Array(16)), name: 'MrTerm', displayName: 'MrTerm' },
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }, { type: 'public-key', alg: -8 }, { type: 'public-key', alg: -257 }],
|
||||
authenticatorSelection: { userVerification: 'discouraged', residentKey: 'discouraged' },
|
||||
timeout: 60000, extensions: { prf: {} },
|
||||
} });
|
||||
return { credId: b64(cred.rawId), prf: cred.getClientExtensionResults().prf?.enabled };`);
|
||||
if (r.prf === false) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||
return r.credId;
|
||||
}
|
||||
|
||||
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
|
||||
// Liefert { credId, secret: Buffer(32) }
|
||||
async function derive(parent, creds, text) {
|
||||
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
|
||||
const creds = ${JSON.stringify(creds)};
|
||||
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
|
||||
const a = await navigator.credentials.get({ publicKey: {
|
||||
challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: 'localhost', timeout: 60000, userVerification: 'discouraged',
|
||||
allowCredentials: creds.map((c) => ({ type: 'public-key', id: unb64(c.credId) })),
|
||||
extensions: { prf: { evalByCredential } },
|
||||
} });
|
||||
const first = a.getClientExtensionResults().prf?.results?.first;
|
||||
return { credId: b64(a.rawId), secret: first ? b64(first) : null };`);
|
||||
if (!r.secret) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
|
||||
return { credId: r.credId, secret: Buffer.from(r.secret, 'base64url') };
|
||||
}
|
||||
|
||||
module.exports = { register, derive };
|
||||
+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.
|
||||
|
||||
+60
-2
@@ -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)]));
|
||||
|
||||
+129
-8
@@ -137,6 +137,9 @@ const S = {
|
||||
const settings = () => S.vault.settings;
|
||||
|
||||
async function reload() {
|
||||
S.lock = await call('lock:status');
|
||||
$('#lockBtn').style.display = S.lock.enabled ? '' : 'none';
|
||||
if (S.lock.locked) return showLock();
|
||||
S.vault = await call('vault:get');
|
||||
I18N.setLanguage(settings().language, navigator.language);
|
||||
applyStatic();
|
||||
@@ -838,6 +841,10 @@ async function viewSettings(page) {
|
||||
rdmBtn.onclick = importRdm;
|
||||
importCard.append(rdmBtn);
|
||||
|
||||
// ---- App-Sperre
|
||||
const secCard = h(`<div class="settings-card"><h3>${T('App lock')}</h3><p style="color:var(--muted);margin-top:0">${T('Lock MrTerm with a password and/or a FIDO2 security key (e.g. YubiKey). The vault is then additionally encrypted and can only be opened with one of these methods.')}</p><div class="lock-rows"></div></div>`);
|
||||
renderLockSettings($('.lock-rows', secCard));
|
||||
|
||||
// ---- Updates
|
||||
const updCard = h(`<div class="settings-card"><h3>Updates</h3><p class="upd-info" style="color:var(--muted);margin-top:0">${T('Installed version: {v}', { v: '…' })}</p></div>`);
|
||||
api.call('app:version').then((v) => { $('.upd-info', updCard).textContent = T('Installed version: {v}', { v }); });
|
||||
@@ -868,8 +875,121 @@ async function viewSettings(page) {
|
||||
<span>${C}+<kbd>1..9</kbd></span><span>${T('Switch to tab')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>C/V</kbd></span><span>${T('Copy / paste in terminal')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span></div></div>`);
|
||||
c.append(langCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
||||
c.append(langCard, secCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
||||
}
|
||||
|
||||
// ============================================================ App-Sperre
|
||||
function showLock() {
|
||||
if ($('.lock-screen')) return;
|
||||
const m = S.lock.meta || {};
|
||||
I18N.setLanguage(m.language, navigator.language);
|
||||
applyStatic();
|
||||
applyAppTheme(m);
|
||||
closeDrawer();
|
||||
$$('.ctx').forEach((x) => x.remove());
|
||||
$('.palette')?.closest('.modal-bg')?.remove();
|
||||
const el = h(`<div class="lock-screen"><div class="lock-box">
|
||||
<img class="lock-logo" src="logo.png" alt=""/><h2>${T('MrTerm is locked')}</h2>
|
||||
${S.lock.hasPassword ? `<form class="lock-pw"><input type="password" name="pw" placeholder="${esc(T('Password'))}" autocomplete="off"/><button class="btn primary">${ICONS.unlock}${T('Unlock')}</button></form>` : ''}
|
||||
${S.lock.fido.length ? `<button class="btn lock-fido">${ICONS.key}${T('Unlock with security key')}</button>` : ''}
|
||||
<div class="lock-err"></div></div></div>`);
|
||||
const err = $('.lock-err', el);
|
||||
const done = async () => { el.remove(); await reload(); };
|
||||
const form = $('.lock-pw', el);
|
||||
if (form) form.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const inp = $('input', form);
|
||||
err.textContent = '';
|
||||
try { await api.call('lock:unlockPassword', inp.value); done(); } catch (x) { err.textContent = x.message; inp.select(); }
|
||||
};
|
||||
const fb = $('.lock-fido', el);
|
||||
if (fb) fb.onclick = async () => {
|
||||
err.textContent = ''; fb.disabled = true;
|
||||
try { await api.call('lock:unlockFido'); done(); } catch (x) { err.textContent = x.message; }
|
||||
fb.disabled = false;
|
||||
};
|
||||
document.body.append(el);
|
||||
($('input', el) || fb)?.focus();
|
||||
}
|
||||
|
||||
async function lockNow() {
|
||||
if (!S.lock?.enabled) return;
|
||||
S.lock = await call('lock:lock');
|
||||
if (S.lock.locked) showLock();
|
||||
}
|
||||
$('#lockBtn').onclick = lockNow;
|
||||
|
||||
// Automatische Sperre nach Inaktivität (Einstellung autoLock in Minuten, 0 = aus)
|
||||
let lastActivity = Date.now();
|
||||
['keydown', 'mousedown', 'mousemove', 'wheel'].forEach((ev) => document.addEventListener(ev, () => { lastActivity = Date.now(); }, { capture: true, passive: true }));
|
||||
setInterval(() => {
|
||||
const min = Number(S.vault?.settings.autoLock) || 0;
|
||||
if (!min || !S.lock?.enabled || S.lock.locked || $('.lock-screen')) return;
|
||||
// In einem eingebetteten RDP-Fenster sieht MrTerm keine Eingaben – dort nicht automatisch sperren
|
||||
if (S.tabs.some((t) => t.id === S.active && t.kind === 'rdp')) { lastActivity = Date.now(); return; }
|
||||
if (Date.now() - lastActivity > min * 60000) lockNow();
|
||||
}, 15000);
|
||||
|
||||
function renderLockSettings(box) {
|
||||
const L = S.lock;
|
||||
box.innerHTML = '';
|
||||
const refresh = (st) => { S.lock = st; $('#lockBtn').style.display = st.enabled ? '' : 'none'; renderLockSettings(box); };
|
||||
const disableWarn = async () => (L.hasPassword ? 1 : 0) + L.fido.length > 1 || confirmBox(T('Disable app lock?'), T('This is the last unlock method. MrTerm will no longer be locked.'), T('Disable'));
|
||||
|
||||
// Passwort
|
||||
const pwRow = h(`<div class="lock-row"><div class="grow"><b>${T('Password')}</b><div class="sub">${L.hasPassword ? T('Set') : T('Not set')}</div></div></div>`);
|
||||
const pwBtn = h(`<button class="btn">${L.hasPassword ? T('Change password') : T('Set password')}</button>`);
|
||||
pwBtn.onclick = async () => {
|
||||
const r = await modal({ title: L.hasPassword ? T('Change password') : T('Set password'),
|
||||
body: `<div class="field"><label>${T('New password')}</label><input name="a" type="password" autocomplete="off"/></div><div class="field"><label>${T('Repeat password')}</label><input name="b" type="password" autocomplete="off"/></div>`,
|
||||
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Save'), value: 'form', cls: 'primary' }] });
|
||||
if (!r) return;
|
||||
if (r.a !== r.b) return toast(T('The passwords do not match.'), 'error');
|
||||
try { refresh(await call('lock:setPassword', r.a)); toast(T('Password saved'), 'ok'); } catch {}
|
||||
};
|
||||
pwRow.append(pwBtn);
|
||||
if (L.hasPassword) {
|
||||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||||
rm.onclick = async () => { if (await disableWarn()) refresh(await call('lock:removePassword')); };
|
||||
pwRow.append(rm);
|
||||
}
|
||||
box.append(pwRow);
|
||||
|
||||
// Sicherheitsschlüssel
|
||||
for (const k of L.fido) {
|
||||
const row = h(`<div class="lock-row"><div class="grow"><b>${ICONS.key}${esc(k.label)}</b><div class="sub">${T('Security key (FIDO2)')}</div></div></div>`);
|
||||
const rm = h(`<button class="btn ghost" title="${esc(T('Remove'))}">${ICONS.trash}</button>`);
|
||||
rm.onclick = async () => { if (await disableWarn() && await confirmBox(T('Remove security key?'), k.label, T('Remove'))) refresh(await call('lock:removeFido', k.id)); };
|
||||
row.append(rm);
|
||||
box.append(row);
|
||||
}
|
||||
const add = h(`<button class="btn">${ICONS.plus}${T('Add security key')}</button>`);
|
||||
add.onclick = async () => {
|
||||
const label = await promptBox(T('Add security key'), T('Name'), 'YubiKey');
|
||||
if (label == null) return;
|
||||
add.disabled = true;
|
||||
try { refresh(await call('lock:addFido', label)); toast(T('Security key added'), 'ok'); } catch {}
|
||||
add.disabled = false;
|
||||
};
|
||||
const actions = h('<div class="row" style="flex-wrap:wrap;margin-top:12px"></div>');
|
||||
actions.append(add);
|
||||
if (L.enabled) {
|
||||
const now = h(`<button class="btn primary">${ICONS.lock}${T('Lock now')}</button>`);
|
||||
now.onclick = lockNow;
|
||||
actions.append(now);
|
||||
}
|
||||
box.append(actions);
|
||||
|
||||
if (L.enabled) {
|
||||
const al = field(T('Lock automatically after inactivity'), 'autoLock', String(settings().autoLock || 0), { type: 'select',
|
||||
options: [['0', T('Never')], ...[1, 5, 10, 15, 30, 60].map((n) => [String(n), T('{n} minutes', { n })])] });
|
||||
al.style.marginTop = '14px';
|
||||
al.onchange = async () => { const v = Number($('select', al).value); await call('vault:settings', { autoLock: v }); S.vault.settings.autoLock = v; };
|
||||
box.append(al);
|
||||
box.append(h(`<div class="hint" style="color:var(--faint);font-size:11px;margin-top:10px">${T('If you forget the password and lose all security keys, the vault cannot be recovered.')}</div>`));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Design
|
||||
@@ -878,8 +998,7 @@ function termTheme() {
|
||||
if (id === 'auto') return TERM_THEMES[(APP_THEMES[settings().appTheme] || APP_THEMES.midnight).term] || TERM_THEMES.mrterm;
|
||||
return TERM_THEMES[id] || TERM_THEMES.mrterm;
|
||||
}
|
||||
function applyAppTheme() {
|
||||
const st = settings();
|
||||
function applyAppTheme(st = settings()) {
|
||||
const root = document.documentElement;
|
||||
if (st.appTheme && st.appTheme !== 'midnight') root.dataset.theme = st.appTheme; else delete root.dataset.theme;
|
||||
if (st.accent) root.style.setProperty('--accent', st.accent); else root.style.removeProperty('--accent');
|
||||
@@ -1028,7 +1147,7 @@ class TerminalSession {
|
||||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyV') { this.paste(); return false; }
|
||||
if (e.ctrlKey && e.shiftKey && e.code === 'KeyF') { this.toggleFind(true); return false; }
|
||||
// App-Kürzel im Terminal nur mit Strg+Shift, damit Strg+W/K/T (nano, bash, …) im Terminal ankommen
|
||||
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW'].includes(e.code)) return false;
|
||||
if (e.ctrlKey && e.shiftKey && ['KeyK', 'KeyT', 'KeyW', 'KeyL'].includes(e.code)) return false;
|
||||
if (e.ctrlKey && e.code === 'Tab') return false;
|
||||
if (e.ctrlKey && /^Digit[1-9]$/.test(e.code)) return false;
|
||||
if (e.ctrlKey && (e.key === '+' || e.key === '=' || e.key === '-' || e.key === '0')) { this.zoom(e.key); return false; }
|
||||
@@ -1154,7 +1273,7 @@ function openTerminal(host) { return new TerminalSession(host); }
|
||||
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
||||
S.covered = false;
|
||||
function updateCover() {
|
||||
const covered = !!document.querySelector('.modal-bg, .ctx');
|
||||
const covered = !!document.querySelector('.modal-bg, .ctx, .lock-screen');
|
||||
if (covered === S.covered) return;
|
||||
S.covered = covered;
|
||||
const t = S.tabs.find((x) => x.id === S.active && x.kind === 'rdp');
|
||||
@@ -1507,7 +1626,9 @@ if (api.platform === 'darwin') $('.win-ctrls').style.display = 'none';
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.ctrlKey) return;
|
||||
const inTerm = !!e.target.closest?.('.xterm');
|
||||
if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
|
||||
if ($('.lock-screen')) return;
|
||||
if (e.shiftKey && e.code === 'KeyL') { e.preventDefault(); lockNow(); }
|
||||
else if ((e.code === 'KeyK' || e.code === 'KeyT') && (e.shiftKey || !inTerm)) { e.preventDefault(); openPalette(); }
|
||||
else if (e.shiftKey && e.code === 'KeyW') { e.preventDefault(); if (S.active !== 'home') closeTab(S.active); }
|
||||
else if (e.code === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -1535,5 +1656,5 @@ api.on('fw:event', () => reload());
|
||||
api.on('toast', (m, t) => toast(m, t));
|
||||
api.on('win:state', (max) => { $('#winMax').innerHTML = max ? '<svg viewBox="0 0 12 12"><rect x="2" y="4" width="6" height="6"/><path d="M4 4V2h6v6H8"/></svg>' : '<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg>'; });
|
||||
|
||||
reload();
|
||||
reload(); // zeigt bei aktiver Sperre zuerst den Sperrbildschirm
|
||||
$('#updateBadge').onclick = () => updateInfo && showUpdate(updateInfo);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
||||
<div class="drag-fill"></div>
|
||||
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
||||
<div class="win-ctrls">
|
||||
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
|
||||
<button id="winMax" title="Maximize" data-i18n-title="Maximize"><svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8"/></svg></button>
|
||||
|
||||
@@ -343,3 +343,20 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
#sidebar.nav-collapsed .nav-menu { flex-direction: row; flex-wrap: wrap; gap: 2px; }
|
||||
#sidebar.nav-collapsed .nav-menu .nav { padding: 7px; flex: 0 0 auto; }
|
||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||
|
||||
/* App-Sperre */
|
||||
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
||||
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
|
||||
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
||||
.lock-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||
.lock-logo { width: 64px; height: 64px; }
|
||||
.lock-pw { display: flex; gap: 8px; width: 100%; }
|
||||
.lock-pw input { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; color: var(--text); outline: none; }
|
||||
.lock-pw input:focus { border-color: var(--accent); }
|
||||
.lock-fido { width: 100%; justify-content: center; }
|
||||
.lock-err { color: var(--red); min-height: 18px; font-size: 13px; }
|
||||
.lock-row { display: flex; align-items: center; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.lock-row .grow { flex: 1; min-width: 0; }
|
||||
.lock-row b { display: flex; align-items: center; gap: 6px; }
|
||||
.lock-row b svg { width: 14px; height: 14px; }
|
||||
.lock-row .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user