Files
MrTerm/src/main/fido.js
T

87 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };