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:
@@ -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 };
|
||||
Reference in New Issue
Block a user