App lock: fix registering security keys with a FIDO2 PIN on Linux (Electron has no WebAuthn PIN prompt, so Chromium failed with NotAllowedError after touching the key); register via a built-in CTAP2 client over hidraw with PIN entry in the key window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 13:53:38 +02:00
co-authored by Claude Opus 5.5
parent 66d4350221
commit daff240aa6
3 changed files with 281 additions and 8 deletions
+213
View File
@@ -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<string|null>, 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 };