Repository Public
1// A software WebAuthn authenticator for tests: real ES256 keys, real signatures, so the server-side verification2// under test is the production code path (attestation "none", user verified).3import { createHash, createSign, generateKeyPairSync, type KeyObject, randomBytes } from 'node:crypto';45const b64u = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64url');6// minimal CBOR encoder: unsigned/negative ints, byte strings, text strings, maps7function cbor(v: unknown): Buffer {8 const head = (major: number, n: number) => n < 24 ? Buffer.from([(major << 5) | n]) : n < 256 ? Buffer.from([(major << 5) | 24, n]) : Buffer.from([(major << 5) | 25, n >> 8, n & 255]);9 if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);10 if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);11 if (typeof v === 'string') { const b = Buffer.from(v); return Buffer.concat([head(3, b.length), b]); }12 if (v instanceof Map) return Buffer.concat([head(5, v.size), ...[...v].flatMap(([k, x]) => [cbor(k), cbor(x)])]);13 throw new Error('cbor: unsupported');14}15export class SoftAuthenticator {16 private creds = new Map<string, { key: KeyObject; userHandle: string; counter: number }>();17 constructor(private origin: string, private rpId: string) {}18 private authData(flags: number, counter: number, attested?: Buffer): Buffer {19 const c = Buffer.alloc(4); c.writeUInt32BE(counter);20 return Buffer.concat([createHash('sha256').update(this.rpId).digest(), Buffer.from([flags]), c, ...(attested ? [attested] : [])]);21 }22 /** Answer navigator.credentials.create(). */23 create(options: { challenge: string; user: { id: string } }) {24 const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });25 const jwk = publicKey.export({ format: 'jwk' });26 const id = randomBytes(32);27 const cose = cbor(new Map<number, unknown>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x!, 'base64url')], [-3, Buffer.from(jwk.y!, 'base64url')]]));28 const len = Buffer.alloc(2); len.writeUInt16BE(id.length);29 const authData = this.authData(0x45, 0, Buffer.concat([Buffer.alloc(16), len, id, cose])); // UP | UV | AT30 this.creds.set(b64u(id), { key: privateKey, userHandle: options.user.id, counter: 0 });31 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin: this.origin, crossOrigin: false }));32 return { id: b64u(id), rawId: b64u(id), type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform',33 response: { clientDataJSON: b64u(clientDataJSON), attestationObject: b64u(cbor(new Map<string, unknown>([['fmt', 'none'], ['attStmt', new Map()], ['authData', authData]]))), transports: ['internal'] } };34 }35 /** Answer navigator.credentials.get() with a discoverable credential (or the given one). */36 get(options: { challenge: string }, credentialId?: string, o: { origin?: string } = {}) {37 const id = credentialId ?? [...this.creds.keys()].pop()!;38 const c = this.creds.get(id)!;39 const authData = this.authData(0x05, ++c.counter);40 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: options.challenge, origin: o.origin ?? this.origin, crossOrigin: false }));41 const signature = createSign('sha256').update(Buffer.concat([authData, createHash('sha256').update(clientDataJSON).digest()])).sign(c.key);42 return { id, rawId: id, type: 'public-key', clientExtensionResults: {}, authenticatorAttachment: 'platform',43 response: { clientDataJSON: b64u(clientDataJSON), authenticatorData: b64u(authData), signature: b64u(signature), userHandle: c.userHandle } };44 }45}46