// 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(); } } // 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 };