258 lines
11 KiB
JavaScript
258 lines
11 KiB
JavaScript
// 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();
|
||
}
|
||
}
|
||
|
||
// 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 };
|