Repository Public
1/**2 * Minimal software WebAuthn authenticator for tests: ES256 (P-256) credentials, `none`3 * attestation, real ECDSA signatures. Produces the JSON shapes a browser would send4 * (as @simplewebauthn/browser serialises them). Test-only: keys live in memory.5 */6import { createHash, createSign, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto';78const b64u = (b: Buffer): string => b.toString('base64url');9const sha256 = (b: Buffer | string): Buffer => createHash('sha256').update(b).digest();1011// ---- just enough CBOR (RFC 8949) for an attestation object and a COSE key ----12function head(major: number, n: number): Buffer {13 if (n < 24) return Buffer.from([(major << 5) | n]);14 if (n < 256) return Buffer.from([(major << 5) | 24, n]);15 return Buffer.from([(major << 5) | 25, n >> 8, n & 0xff]);16}17type Cbor = number | string | Buffer | Map<number | string, Cbor>;18function cbor(v: Cbor): Buffer {19 if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);20 if (typeof v === 'string') return Buffer.concat([head(3, Buffer.byteLength(v)), Buffer.from(v)]);21 if (Buffer.isBuffer(v)) return Buffer.concat([head(2, v.length), v]);22 const parts: Buffer[] = [head(5, v.size)];23 for (const [k, val] of v) parts.push(cbor(k), cbor(val));24 return Buffer.concat(parts);25}2627export interface SoftOptions {28 origin: string;29 rpId: string;30 /** Set the UV flag (biometric / PIN verified). Default true. */31 userVerified?: boolean;32}3334export class SoftAuthenticator {35 readonly credentialId = randomBytes(32);36 private readonly privateKey: KeyObject;37 private readonly cose: Buffer;38 signCount = 0;39 userHandle: string | null = null;4041 constructor() {42 const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });43 this.privateKey = privateKey;44 const jwk = publicKey.export({ format: 'jwk' }) as { x: string; y: string };45 this.cose = cbor(new Map<number, Cbor>([[1, 2], [3, -7], [-1, 1], [-2, Buffer.from(jwk.x, 'base64url')], [-3, Buffer.from(jwk.y, 'base64url')]]));46 }47 get id(): string {48 return b64u(this.credentialId);49 }5051 private authData(o: SoftOptions, attested: boolean): Buffer {52 const flags = 0x01 | (o.userVerified === false ? 0 : 0x04) | (attested ? 0x40 : 0);53 const count = Buffer.alloc(4);54 count.writeUInt32BE(this.signCount);55 const base = Buffer.concat([sha256(o.rpId), Buffer.from([flags]), count]);56 if (!attested) return base;57 const len = Buffer.alloc(2);58 len.writeUInt16BE(this.credentialId.length);59 return Buffer.concat([base, Buffer.alloc(16), len, this.credentialId, this.cose]);60 }6162 /** Response to `navigator.credentials.create()`. */63 register(options: { challenge: string; user: { id: string } }, o: SoftOptions): Record<string, unknown> {64 this.userHandle = options.user.id;65 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.create', challenge: options.challenge, origin: o.origin, crossOrigin: false }));66 const attestationObject = cbor(new Map<string, Cbor>([['fmt', 'none'], ['attStmt', new Map()], ['authData', this.authData(o, true)]]));67 return {68 id: this.id, rawId: this.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},69 response: { clientDataJSON: b64u(clientDataJSON), attestationObject: b64u(attestationObject), transports: ['internal'] },70 };71 }7273 /** Response to `navigator.credentials.get()`. Increments the signature counter unless told not to. */74 authenticate(options: { challenge: string }, o: SoftOptions & { keepCounter?: boolean }): Record<string, unknown> {75 if (!o.keepCounter) this.signCount++;76 const clientDataJSON = Buffer.from(JSON.stringify({ type: 'webauthn.get', challenge: options.challenge, origin: o.origin, crossOrigin: false }));77 const authenticatorData = this.authData(o, false);78 const signature = createSign('SHA256').update(Buffer.concat([authenticatorData, sha256(clientDataJSON)])).sign(this.privateKey);79 return {80 id: this.id, rawId: this.id, type: 'public-key', authenticatorAttachment: 'platform', clientExtensionResults: {},81 response: { clientDataJSON: b64u(clientDataJSON), authenticatorData: b64u(authenticatorData), signature: b64u(signature), userHandle: this.userHandle },82 };83 }84}85