Files
MrTerm/src/main/ctap2.js
T

258 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 };