App lock: unlock with security keys via the built-in CTAP2 client on Linux too (Chromium returned no PRF result, so adding a YubiKey failed with "does not support hmac-secret"); updates: show only one update dialog at a time (startup and manual check stacked two)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:22:34 +02:00
co-authored by Claude Opus 5.5
parent 10acdaeb0b
commit 7d4991c99c
5 changed files with 76 additions and 5 deletions
+45 -1
View File
@@ -210,4 +210,48 @@ async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
}
}
module.exports = { makeCredential, CtapError, CANCEL_CODES, PIN };
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
const rpId = 'localhost';
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
let cred = null;
for (const c of creds) {
try {
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
cred = c; break;
} catch (e) { if (e.code !== 0x2e) throw e; }
}
if (!cred) throw new CtapError(0x2e);
const { key, platformKey } = await sharedSecret(dev);
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
const saltEnc = aes('enc', key, salt);
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
ui.touch(() => dev.cancel());
timer = setTimeout(() => dev.cancel(), timeoutMs);
const r = await dev.cbor(0x02, new Map([
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
]));
const authData = r.get(2);
if (!(authData[32] & 0x80)) throw new CtapError(-1);
const ext = dec(authData, 37)[0].get('hmac-secret');
if (!ext) throw new CtapError(-1);
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
} finally {
clearTimeout(timer);
dev.close();
}
}
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
+24
View File
@@ -99,6 +99,26 @@ async function registerNative(parent) {
}
}
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
async function deriveNative(parent, creds, text) {
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
try {
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
return r && { credId: b64url(r.credId), secret: r.secret };
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
@@ -124,6 +144,10 @@ async function register(parent) {
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
// Liefert { credId, secret: Buffer(32) }
async function derive(parent, creds, text) {
if (process.platform === 'linux') {
const r = await deriveNative(parent, creds, text);
if (r) return r;
}
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) }]));
+4 -1
View File
@@ -1338,11 +1338,14 @@ async function importRdm() {
// ============================================================ Updates
let updateInfo = null;
let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
if (updateDialogOpen) return;
updateDialogOpen = true;
const r = await modal({
title: T('MrTerm {v} is available', { v: info.version }),
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
@@ -1350,7 +1353,7 @@ async function showUpdate(info) {
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
});
}).finally(() => { updateDialogOpen = false; });
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast(T('Downloading update …'));