1// A software authenticator for scenarios: what a browser and a passkey do2// together, over HTTP, against lib/private-server.ts. It makes an ES256 key3// (or, with --alg ml-dsa-44, a post-quantum one, on Node 24.7 and later),4// attests with "none", keeps the key in a JSON file, and signs in with it5// the way a discoverable credential does (the user handle comes along).6// What it prints on success is the session cookie's value.7//8// node test/authenticator.ts enroll <server> <code> <key.json>9// node test/authenticator.ts login <server> <key.json>10//11// <server> is where the requests go; the origin they claim (the Origin12// header and the client data) is FORGE_BASE_URL. To try what must fail:13// --origin <o> claim another origin, header and client data alike14// --client-origin <o> only the client data says it15// --rp <id> sign for another RP ID16// --no-uv no user verification17// --counter <n> the signature counter to send (default: one more than last time)18// --replay send the same signed response a second time (prints the second answer)19// --other-browser send the signed response from another browser: without20// the challenge cookie the options came with21// --flood <n> before signing in, n other clients (each its own22// address) ask for sign-in options23// --alg ml-dsa-44 enroll an ML-DSA-44 key instead of ES25624import fs from 'node:fs';25import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, sign, type KeyObject } from 'node:crypto';26import { isoCBOR } from '@simplewebauthn/server/helpers';2728interface Key {29 id: string;30 alg?: string;31 privateKey: string;32 userHandle: string | null;33 counter: number;34}3536const args = process.argv.slice(2);37const flag = (name: string): string | null => {38 const i = args.indexOf(name);39 if (i < 0) return null;40 const [, v] = args.splice(i, 2);41 return v;42};43const has = (name: string): boolean => {44 const i = args.indexOf(name);45 if (i >= 0) args.splice(i, 1);46 return i >= 0;47};48const origin = flag('--origin') ?? new URL(process.env.FORGE_BASE_URL || 'http://localhost').origin;49const clientOrigin = flag('--client-origin') ?? origin;50const rpOverride = flag('--rp');51const counterArg = flag('--counter');52const noUv = has('--no-uv');53const replay = has('--replay');54const otherBrowser = has('--other-browser');55const flood = Number(flag('--flood') ?? 0);56const alg = flag('--alg') ?? 'es256';57const [mode, server, ...rest] = args;5859const b64 = (b: Uint8Array): string => Buffer.from(b).toString('base64url');60const sha256 = (b: Uint8Array | string): Buffer => createHash('sha256').update(b).digest();61const u32 = (n: number): Buffer => {62 const b = Buffer.alloc(4);63 b.writeUInt32BE(n);64 return b;65};6667// The challenge cookie the options came with, sent back as a browser would.68let jar = '';69async function call(path: string, body: unknown): Promise<{ status: number; data: Record<string, unknown>; cookie: string | null }> {70 const headers: Record<string, string> = { 'Content-Type': 'application/json', Origin: origin, 'User-Agent': 'test-authenticator' };71 if (jar && !(otherBrowser && /\/verify$/.test(path))) headers.Cookie = jar;72 const r = await fetch(server.replace(/\/$/, '') + path, { method: 'POST', headers, body: JSON.stringify(body) });73 const text = await r.text();74 let data: Record<string, unknown> = {};75 try {76 data = JSON.parse(text);77 } catch {78 data = { error: text };79 }80 const set = r.headers.get('set-cookie') ?? '';81 const c = /__Host-forge-challenge=([^;]*)/.exec(set);82 if (c) jar = `__Host-forge-challenge=${c[1]}`;83 const m = /__Host-forge=([^;]*)/.exec(set);84 return { status: r.status, data, cookie: m ? m[1] : null };85}8687function done(r: { status: number; data: Record<string, unknown>; cookie: string | null }): void {88 if (r.status !== 200 || !r.cookie) {89 console.error(`HTTP ${r.status}: ${JSON.stringify(r.data)}`);90 process.exit(1);91 }92 console.log(r.cookie);93}9495// Flags: user present, user verified (unless --no-uv), backup eligible and96// backed up (a synced passkey), and attested credential data when registering.97const flags = (attested: boolean): number => 0x01 | (noUv ? 0 : 0x04) | 0x08 | 0x10 | (attested ? 0x40 : 0);9899if (mode === 'enroll' && rest.length === 2) {100 const [code, file] = rest;101 const opts = await call('/login/enroll/options', { code });102 if (opts.status !== 200) done(opts);103 const o = opts.data as { challenge: string; rp: { id: string }; user: { id: string } };104 // COSE keys: EC2 P-256 for ES256 (-7), AKP for ML-DSA-44 (-48).105 const keyPair = generateKeyPairSync as (type: string, opts?: object) => { privateKey: KeyObject; publicKey: KeyObject };106 const { privateKey, publicKey } = alg === 'ml-dsa-44' ? keyPair('ml-dsa-44') : keyPair('ec', { namedCurve: 'P-256' });107 const jwk = publicKey.export({ format: 'jwk' }) as { x?: string; y?: string; pub?: string };108 const cose = isoCBOR.encode(alg === 'ml-dsa-44'109 ? new Map<number, number | Uint8Array>([[1, 7], [3, -48], [-1, Buffer.from(jwk.pub!, 'base64url')]])110 : new Map<number, number | Uint8Array>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));111 const id = randomBytes(16);112 const len = Buffer.alloc(2);113 len.writeUInt16BE(id.length);114 const authData = Buffer.concat([sha256(rpOverride ?? o.rp.id), Buffer.from([flags(true)]), u32(0), Buffer.alloc(16), len, id, cose]);115 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));116 const attestationObject = isoCBOR.encode(new Map<string, string | Map<string, never> | Uint8Array>([['fmt', 'none'], ['attStmt', new Map<string, never>()], ['authData', authData]]));117 const response = {118 id: b64(id), rawId: b64(id), type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},119 response: { clientDataJSON: b64(clientDataJSON), attestationObject: b64(attestationObject), transports: ['internal'] },120 };121 const r = await call('/login/enroll/verify', { code, response });122 if (r.status === 200) {123 const key: Key = { id: b64(id), alg, privateKey: privateKey.export({ format: 'pem', type: 'pkcs8' }) as string, userHandle: o.user.id, counter: 0 };124 fs.writeFileSync(file, JSON.stringify(key, null, 1) + '\n');125 }126 if (replay) done(await call('/login/enroll/verify', { code, response }));127 done(r);128} else if (mode === 'login' && rest.length === 1) {129 const [file] = rest;130 const key = JSON.parse(fs.readFileSync(file, 'utf8')) as Key;131 const opts = await call('/login/options', {});132 if (opts.status !== 200) done(opts);133 const o = opts.data as { challenge: string; rpId: string };134 for (let i = 0; i < flood; i++) {135 const r = await fetch(server.replace(/\/$/, '') + '/login/options', {136 method: 'POST', headers: { 'Content-Type': 'application/json', Origin: origin, 'X-Forge-Client-IP': `10.${(i >> 16) & 255}.${(i >> 8) & 255}.${i & 255}` }, body: '{}',137 });138 if (r.status !== 200) done({ status: r.status, data: { error: `flood request ${i}` }, cookie: null });139 }140 const counter = counterArg != null ? Number(counterArg) : key.counter + 1;141 const authData = Buffer.concat([sha256(rpOverride ?? o.rpId), Buffer.from([flags(false)]), u32(counter)]);142 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: o.challenge, origin: clientOrigin, crossOrigin: false }));143 // ES256 signs a SHA-256 of the data (DER); ML-DSA signs the data itself.144 const signature = sign(key.alg === 'ml-dsa-44' ? null : 'sha256', Buffer.concat([authData, sha256(clientDataJSON)]), createPrivateKey(key.privateKey));145 const response = {146 id: key.id, rawId: key.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},147 response: { clientDataJSON: b64(clientDataJSON), authenticatorData: b64(authData), signature: b64(signature), userHandle: key.userHandle ?? undefined },148 };149 const r = await call('/login/verify', response);150 if (r.status === 200) fs.writeFileSync(file, JSON.stringify({ ...key, counter }, null, 1) + '\n');151 if (replay) done(await call('/login/verify', response));152 done(r);153} else {154 console.error('usage: node test/authenticator.ts enroll <server> <code> <key.json> | login <server> <key.json> [--origin o] [--client-origin o] [--rp id] [--no-uv] [--counter n] [--replay] [--other-browser] [--flood n]');155 process.exit(2);156}