morphit/apps/web/scripts/yubikey-enroll-verify-smoke.ts

193 lines
7.8 KiB
TypeScript

/**
* Morphit — YubiKey enroll-time fail-closed verification smoke (cp331).
*
* Enrollment now PROVES the device performs real HMAC-SHA1 challenge-
* response before committing a wrap (buildVerifiedYubikeyWrap /
* verifyYubikeyChallengeResponse). This guards the WebHID transport's
* most likely failure mode — challenge-INDEPENDENT output (see the
* diagnosis in transport.ts) — which a naive single-tap enroll would
* have silently committed as a CONSTANT / zero-entropy "2FA factor", a
* factor unlockable by a known constant (security theatre).
*
* To this layer a YubiKey is just a `YubikeyHmacFn`
* ((challenge:Uint8Array[64]) => Promise<Uint8Array[20]>), so simulated
* stubs exercise the entire gate without a physical device.
*
* Asserts:
* 1. A correct deterministic HMAC-SHA1 stub PASSES verify (yubikey wrap).
* 2. verify taps the device exactly twice.
* 3. The wrap built via verify round-trips (recoverCekFromYubikey
* recovers the exact CEK).
* 4. A CONSTANT-output stub is REJECTED by buildVerifiedYubikeyWrap...
* 5. ...and classifies as `enroll_verify_failed`.
* 6. The reject path still taps exactly twice (both challenges sent).
* 7. The legacy single-tap buildYubikeyWrap WOULD have accepted the
* constant stub — demonstrating exactly the hole the gate closes.
* 8. An all-zero-bytes stub is REJECTED...
* 9. ...and classifies as `enroll_verify_failed`.
* 10. A wrong-length stub is REJECTED...
* 11. ...and classifies as `protocol_violation`.
* 12. A throwing/dead stub propagates its error (no silent success)...
* 13. ...and classifies to its transport kind (`no_device`).
* 14. enrollYubikey END-TO-END rejects a constant device (the gate is
* wired into enrollment, not merely available as a helper)...
* 15. ...and that rejection classifies as `enroll_verify_failed`.
*/
import sodium from 'libsodium-wrappers-sumo';
import { createHmac } from 'node:crypto';
let passes = 0;
let failures = 0;
function ok(cond: boolean, msg: string): void {
if (cond) {
passes++;
console.log(`${msg}`);
} else {
failures++;
console.log(`${msg}`);
}
}
await sodium.ready;
// Dynamic import AFTER sodium.ready (keystore byte-length consts read libsodium at module-eval).
const { buildVerifiedYubikeyWrap, buildYubikeyWrap, verifyYubikeyChallengeResponse, recoverCekFromYubikey } =
await import('../src/lib/crypto/yubikey/wrap.ts');
const { generateCek, encryptIdentity } = await import('../src/lib/crypto/keystore.ts');
const { generateFullIdentity } = await import('../src/lib/crypto/keygen.ts');
const { enrollYubikey } = await import('../src/lib/crypto/keystoreYubikey.ts');
const { classifyYubikeyError } = await import('../src/lib/crypto/yubikeyErrors.ts');
const SLOT = 2 as 1 | 2;
/** Wrap an inner HMAC function with a tap counter. */
function countingStub(inner: (c: Uint8Array) => Uint8Array) {
let taps = 0;
const fn = async (challenge: Uint8Array): Promise<Uint8Array> => {
taps++;
return inner(challenge);
};
return { fn, taps: () => taps };
}
// A correct simulated slot: deterministic HMAC-SHA1(secret, challenge).
function realInner(secretHex: string): (c: Uint8Array) => Uint8Array {
const secret = Buffer.from(secretHex, 'hex');
return (challenge: Uint8Array) =>
new Uint8Array(createHmac('sha1', secret).update(Buffer.from(challenge)).digest());
}
async function freshCek(): Promise<Uint8Array> {
return generateCek();
}
// ── 1-3. Correct stub: passes, two taps, round-trips ──────────────────
{
const real = countingStub(realInner('00112233445566778899aabbccddeeff00112233'));
const cek = await freshCek();
const wrap = await buildVerifiedYubikeyWrap(cek, real.fn, SLOT, 'Test Key');
ok(wrap.kind === 'yubikey', 'correct stub: buildVerifiedYubikeyWrap returns a yubikey wrap');
ok(real.taps() === 2, 'correct stub: verify taps the device exactly twice');
// recoverCekFromYubikey re-derives via the stored challenge; the
// deterministic stub reproduces the response, so the CEK comes back.
const recovered = await recoverCekFromYubikey(wrap, real.fn);
ok(
recovered.length === cek.length && sodium.memcmp(recovered, cek),
'correct stub: wrap built via verify round-trips (exact CEK recovered)'
);
sodium.memzero(recovered);
sodium.memzero(cek);
}
// ── 4-7. Constant-output stub: rejected (theatre), legacy would accept ─
{
const constReject = countingStub(() => new Uint8Array(20).fill(0xab));
let rejected = false;
let kind: string | null = null;
try {
await buildVerifiedYubikeyWrap(await freshCek(), constReject.fn, SLOT, '');
} catch (e) {
rejected = true;
kind = classifyYubikeyError(e);
}
ok(rejected, 'constant-output stub: rejected by buildVerifiedYubikeyWrap');
ok(kind === 'enroll_verify_failed', 'constant-output stub: classifies as enroll_verify_failed');
ok(constReject.taps() === 2, 'constant-output stub: reject path still taps exactly twice');
// Demonstrate the gap the gate closes: the bare single-tap primitive
// commits a wrap around the constant without complaint.
const legacyConst = countingStub(() => new Uint8Array(20).fill(0xab));
const legacyWrap = await buildYubikeyWrap(await freshCek(), legacyConst.fn, SLOT, '');
ok(
legacyWrap.kind === 'yubikey' && legacyConst.taps() === 1,
'legacy single-tap buildYubikeyWrap WOULD have accepted the constant stub (the closed gap)'
);
}
// ── 8-9. All-zero stub: rejected ──────────────────────────────────────
{
const zero = countingStub(() => new Uint8Array(20)); // 20 zero bytes
let rejected = false;
let kind: string | null = null;
try {
await buildVerifiedYubikeyWrap(await freshCek(), zero.fn, SLOT, '');
} catch (e) {
rejected = true;
kind = classifyYubikeyError(e);
}
ok(rejected, 'zero-output stub: rejected by buildVerifiedYubikeyWrap');
ok(kind === 'enroll_verify_failed', 'zero-output stub: classifies as enroll_verify_failed');
}
// ── 10-11. Wrong-length stub: rejected as protocol_violation ──────────
{
const short = countingStub(() => new Uint8Array(16)); // not 20
let rejected = false;
let kind: string | null = null;
try {
await verifyYubikeyChallengeResponse(short.fn);
} catch (e) {
rejected = true;
kind = classifyYubikeyError(e);
}
ok(rejected, 'wrong-length stub: rejected by verify');
ok(kind === 'protocol_violation', 'wrong-length stub: classifies as protocol_violation');
}
// ── 12-13. Throwing/dead stub: propagates (never silently succeeds) ───
{
const dead = countingStub(() => {
throw new Error('no-device-selected');
});
let rejected = false;
let kind: string | null = null;
try {
await buildVerifiedYubikeyWrap(await freshCek(), dead.fn, SLOT, '');
} catch (e) {
rejected = true;
kind = classifyYubikeyError(e);
}
ok(rejected, 'dead stub: error propagates (no silent success)');
ok(kind === 'no_device', 'dead stub: classifies to its transport kind (no_device)');
}
// ── 14-15. enrollYubikey END-TO-END rejects a constant device ─────────
{
const PW = 'passphrase-1234';
const full = await generateFullIdentity();
const simpleEnv = await encryptIdentity(full, PW);
const constEnroll = countingStub(() => new Uint8Array(20).fill(0x7c));
let rejected = false;
let kind: string | null = null;
try {
await enrollYubikey(simpleEnv as never, PW, constEnroll.fn, SLOT, 'X');
} catch (e) {
rejected = true;
kind = classifyYubikeyError(e);
}
ok(rejected, 'enrollYubikey end-to-end: rejects a constant-output device (gate is wired into enrollment)');
ok(kind === 'enroll_verify_failed', 'enrollYubikey end-to-end: classifies as enroll_verify_failed');
}
console.log(`\n${passes} passed, ${failures} failed`);
if (failures > 0) process.exit(1);
console.log(`✓ all ${passes} yubikey-enroll-verify scenarios passed`);