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
+129 -8
View File
@@ -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);
+1
View File
@@ -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>
+17
View File
@@ -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; }