87 lines
5.2 KiB
JavaScript
87 lines
5.2 KiB
JavaScript
// 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 };
|