diff --git a/src/i18n.js b/src/i18n.js index d8b6bd9..02142f5 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -351,6 +351,11 @@ '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.', + 'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …', + 'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.', + 'Wrong PIN.': 'Falsche PIN.', + '{n} attempts left.': 'Noch {n} Versuche.', + 'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.', // VPN '— No VPN —': '— Kein VPN —', diff --git a/src/main/ctap2.js b/src/main/ctap2.js new file mode 100644 index 0000000..c76eaf9 --- /dev/null +++ b/src/main/ctap2.js @@ -0,0 +1,213 @@ +// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module). +// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN +// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann +// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1). +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ---- +function head(major, n) { + if (n < 24) return Buffer.from([(major << 5) | n]); + if (n < 0x100) return Buffer.from([(major << 5) | 24, n]); + if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; } + const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b; +} +function enc(v) { + if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v); + if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]); + if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); } + if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]); + if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]); + if (v instanceof Map) { + const items = [...v].map(([k, x]) => [enc(k), enc(x)]) + .sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b)); + return Buffer.concat([head(5, items.length), ...items.flat()]); + } + throw new Error('CBOR: unsupported value'); +} +function dec(buf, pos = 0) { + const ib = buf[pos++], major = ib >> 5, ai = ib & 31; + let n = ai; + if (ai === 24) n = buf[pos++]; + else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; } + else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; } + else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; } + switch (major) { + case 0: return [n, pos]; + case 1: return [-1 - n, pos]; + case 2: return [buf.subarray(pos, pos + n), pos + n]; + case 3: return [buf.toString('utf8', pos, pos + n), pos + n]; + case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; } + case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; } + case 6: return dec(buf, pos); + default: return [ai === 20 ? false : ai === 21 ? true : null, pos]; + } +} + +// ---- CTAPHID ---- +const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f }; + +class CtapError extends Error { + constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; } +} + +function findDevice() { + const base = '/sys/class/hidraw'; + let names = []; + try { names = fs.readdirSync(base); } catch { return null; } + for (const n of names) { + try { + const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor')); + if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO) + } catch { /* ignorieren */ } + } + return null; +} + +class Device { + static open() { + const p = findDevice(); + if (!p) return null; + const d = new Device(); + d.fd = fs.openSync(p, 'r+'); + d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]); + return d; + } + close() { + this.closed = true; + try { fs.closeSync(this.fd); } catch { /* ignorieren */ } + } + // Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool + readPacket() { + const b = Buffer.alloc(64); + return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n))))); + } + write(cmd, data) { + const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); }; + const first = Buffer.alloc(64); + this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5); + data.copy(first, 7, 0, 57); pkt(first); + for (let off = 57, seq = 0; off < data.length; off += 59, seq++) { + const c = Buffer.alloc(64); + this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c); + } + } + async transact(cmd, data) { + this.write(cmd, data); + for (;;) { + const r = await this.readPacket(); + if (!r.subarray(0, 4).equals(this.cid)) continue; + const rcmd = r[4] & 0x7f; + if (rcmd === CMD.KEEPALIVE) continue; + const len = r.readUInt16BE(5); + const parts = [r.subarray(7)]; + let got = r.length - 7; + while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; } + const body = Buffer.concat(parts).subarray(0, len); + if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`); + return body; + } + } + async init() { + const nonce = crypto.randomBytes(8); + for (;;) { + const r = await this.transact(CMD.INIT, nonce); + if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; } + } + } + cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } } + async cbor(cmd, params) { + const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)])); + if (r[0] !== 0) throw new CtapError(r[0]); + return r.length > 1 ? dec(r, 1)[0] : new Map(); + } +} + +// ---- PIN-Protokoll 1 ---- +const aes = (mode, key, data) => { + const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16)); + c.setAutoPadding(false); + return Buffer.concat([c.update(data), c.final()]); +}; + +async function sharedSecret(dev) { + const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1); + const ecdh = crypto.createECDH('prime256v1'); + ecdh.generateKeys(); + const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)])); + const pub = ecdh.getPublicKey(); + const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]); + return { key: crypto.createHash('sha256').update(z).digest(), platformKey }; +} + +async function pinToken(dev, pin) { + const { key, platformKey } = await sharedSecret(dev); + const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16); + const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]])); + return aes('dec', key, r.get(2)); +} + +async function pinRetries(dev) { + try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; } +} + +// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen +const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]); +const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 }; + +// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur +// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise, touch() } +// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist. +async function makeCredential(ui, { timeoutMs = 60000 } = {}) { + let dev; + try { dev = Device.open(); } catch { return null; } + if (!dev) return null; + let timer; + const abort = () => dev.cancel(); + try { + await dev.init(); + const info = await dev.cbor(0x04); + if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null; + if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1); + const opts = info.get(4) || new Map(); + + const params = new Map([ + [2, new Map([['id', 'localhost'], ['name', 'MrTerm']])], + [3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])], + [4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))], + [6, new Map([['hmac-secret', true]])], + ]); + const cdh = crypto.randomBytes(32); + params.set(1, cdh); + + if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) { + let wrong = false; + for (;;) { + const pin = await ui.askPin(await pinRetries(dev), wrong); + if (pin == null) throw new CtapError(0x2d); + try { + const tok = await pinToken(dev, pin); + params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16)); + params.set(9, 1); + break; + } catch (e) { + if (e.code === PIN.INVALID) { wrong = true; continue; } + throw e; + } + } + } + + ui.touch(abort); + timer = setTimeout(abort, timeoutMs); + const r = await dev.cbor(0x01, params); + const authData = r.get(2); + const idLen = authData.readUInt16BE(53); + return Buffer.from(authData.subarray(55, 55 + idLen)); + } finally { + clearTimeout(timer); + dev.close(); + } +} + +module.exports = { makeCredential, CtapError, CANCEL_CODES, PIN }; diff --git a/src/main/fido.js b/src/main/fido.js index 3fa7e4b..d7338e1 100644 --- a/src/main/fido.js +++ b/src/main/fido.js @@ -4,8 +4,11 @@ // 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). +// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und +// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern. const { BrowserWindow, session } = require('electron'); const i18n = require('../i18n'); +const ctap2 = require('./ctap2'); const PAGE = `
🔑
`; + form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none} + input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px} + .row{display:flex;gap:8px} +
🔑
`; let fidoSession; function getSession() { @@ -29,17 +35,23 @@ const HELPERS = ` const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)); `; -async function ceremony(parent, text, script) { +async function openWindow(parent, text) { const w = new BrowserWindow({ - parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false, + parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false, backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true }, }); + await w.loadURL('http://localhost/'); + await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)}; + document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))}; + const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`); + w.show(); + w.focus(); + return w; +} + +async function ceremony(parent, text, script) { + const w = await openWindow(parent, text); 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.')); @@ -50,8 +62,51 @@ async function ceremony(parent, text, script) { } } +// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein +// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium). +async function registerNative(parent) { + const w = await openWindow(parent, i18n.t('Connecting to security key …')); + let abort = () => {}; + let cancelled = false; + w.once('closed', () => { cancelled = true; abort(); }); + const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null)); + const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`); + const ui = { + async askPin(retries, wrong) { + let t = i18n.t('Enter the PIN of your security key.'); + if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t; + if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries }); + await setText(t); + const closed = new Promise((resolve) => w.once('closed', () => resolve(null))); + return Promise.race([closed, js(`new Promise((resolve) => { + const f = document.getElementById('f'), p = document.getElementById('p'); + f.hidden = false; p.value = ''; p.focus(); + f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); }; + })`)]); + }, + touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); }, + }; + try { + const id = await ctap2.makeCredential(ui); + return id && b64url(id); + } 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 === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.')); + 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) async function register(parent) { + if (process.platform === 'linux') { + const id = await registerNative(parent); + if (id) return id; + } 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)),