/** * Virtual WebAuthn Authenticator for testing * * Generates real cryptographically-signed WebAuthn credentials that pass * @simplewebauthn/server verification without needing a browser or physical device. */ import type { KeyObject } from "node:crypto"; import type { AuthenticationResponseJSON, AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from "@simplewebauthn/types"; import { createHash, createSign, generateKeyPairSync } from "node:crypto"; /** Default AAGUID for virtual authenticator (unknown authenticator) */ export const DEFAULT_AAGUID = "00000000-0000-0000-0000-000000000000"; /** Stored credential in the virtual authenticator */ interface StoredCredential { credentialId: Uint8Array; privateKey: KeyObject; publicKey: KeyObject; rpId: string; userHandle: Uint8Array; signCount: number; } /** Options for creating a virtual authenticator */ export interface VirtualAuthenticatorOptions { /** AAGUID to use (defaults to all zeros) */ aaguid?: string; /** Origin for clientDataJSON */ origin?: string; } /** * Virtual WebAuthn authenticator for testing. * * Usage: * ```ts * const authenticator = new VirtualAuthenticator(); * const regResponse = await authenticator.createCredential(regOptions); * // ... verify registration ... * const authResponse = await authenticator.getAssertion(authOptions); * // ... verify authentication ... * ``` */ export class VirtualAuthenticator { private _credentials = new Map(); private _aaguid: Uint8Array; private _origin: string; constructor(options: VirtualAuthenticatorOptions = {}) { this._aaguid = parseAaguid(options.aaguid ?? DEFAULT_AAGUID); this._origin = options.origin ?? "http://localhost:3000"; } /** * Creates a new credential (registration). * Returns a RegistrationResponseJSON that can be verified by @simplewebauthn/server. */ createCredential( options: PublicKeyCredentialCreationOptionsJSON, ): RegistrationResponseJSON { // Generate ECDSA P-256 key pair const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "P-256", }); // Generate random credential ID (32 bytes) const credentialId = new Uint8Array(32); crypto.getRandomValues(credentialId); const credentialIdBase64 = uint8ArrayToBase64url(credentialId); // Parse user handle from options const userHandle = base64urlToUint8Array(options.user.id); // Store the credential const rpId = options.rp.id ?? new URL(this._origin).hostname; this._credentials.set(credentialIdBase64, { credentialId, privateKey, publicKey, rpId, userHandle, signCount: 0, }); // Build authenticator data with attested credential data const authData = this._buildAuthenticatorData( rpId, true, // attestedCredentialData included 0, // initial sign count { credentialId, publicKey, }, ); // Build clientDataJSON const clientDataJSON = JSON.stringify({ type: "webauthn.create", challenge: options.challenge, origin: this._origin, crossOrigin: false, }); const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON); // Build attestation object (using "none" attestation) // Manually encode to avoid cbor-x adding tags that break parsing const attestationObject = encodeAttestationObject(authData); // Get public key in SPKI format for the response const publicKeySpki = publicKey.export({ format: "der", type: "spki" }); return { id: credentialIdBase64, rawId: credentialIdBase64, type: "public-key", response: { clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes), attestationObject: uint8ArrayToBase64url(attestationObject), transports: ["internal", "hybrid"] as AuthenticatorTransportFuture[], publicKeyAlgorithm: -7, // ES256 publicKey: uint8ArrayToBase64url(new Uint8Array(publicKeySpki)), authenticatorData: uint8ArrayToBase64url(authData), }, clientExtensionResults: {}, authenticatorAttachment: "platform", }; } /** * Creates an assertion (authentication). * Returns an AuthenticationResponseJSON that can be verified by @simplewebauthn/server. */ getAssertion( options: PublicKeyCredentialRequestOptionsJSON, ): AuthenticationResponseJSON { // Find a matching credential const allowedIds = options.allowCredentials?.map((c) => c.id) ?? []; let credential: StoredCredential | undefined; let credentialIdBase64: string | undefined; for (const id of allowedIds) { if (this._credentials.has(id)) { credential = this._credentials.get(id); credentialIdBase64 = id; break; } } if (!(credential && credentialIdBase64)) { throw new Error("No matching credential found in virtual authenticator"); } // Increment sign count credential.signCount++; // Build authenticator data (no attested credential data for assertions) const rpId = options.rpId ?? new URL(this._origin).hostname; const authData = this._buildAuthenticatorData( rpId, false, // no attestedCredentialData credential.signCount, ); // Build clientDataJSON const clientDataJSON = JSON.stringify({ type: "webauthn.get", challenge: options.challenge, origin: this._origin, crossOrigin: false, }); const clientDataJSONBytes = new TextEncoder().encode(clientDataJSON); // Create signature over authData || sha256(clientDataJSON) const clientDataHash = createHash("sha256") .update(clientDataJSONBytes) .digest(); const signedData = Buffer.concat([authData, clientDataHash]); const signature = createSign("sha256") .update(signedData) .sign(credential.privateKey); return { id: credentialIdBase64, rawId: credentialIdBase64, type: "public-key", response: { clientDataJSON: uint8ArrayToBase64url(clientDataJSONBytes), authenticatorData: uint8ArrayToBase64url(authData), signature: uint8ArrayToBase64url(new Uint8Array(signature)), userHandle: uint8ArrayToBase64url(credential.userHandle), }, clientExtensionResults: {}, authenticatorAttachment: "platform", }; } /** * Gets all stored credentials (for test assertions). */ getCredentials(): { credentialId: string; rpId: string; signCount: number; }[] { return Array.from(this._credentials.entries()).map(([id, cred]) => ({ credentialId: id, rpId: cred.rpId, signCount: cred.signCount, })); } /** * Clears all stored credentials. */ clear(): void { this._credentials.clear(); } /** * Sets the sign count for a credential (for testing counter replay attacks). * @param credentialId - The base64url-encoded credential ID * @param signCount - The sign count to set */ setSignCount(credentialId: string, signCount: number): void { const credential = this._credentials.get(credentialId); if (!credential) { throw new Error(`Credential not found: ${credentialId}`); } credential.signCount = signCount; } /** * Builds WebAuthn authenticator data. */ private _buildAuthenticatorData( rpId: string, includeAttestedCredentialData: boolean, signCount: number, attestedCredentialData?: { credentialId: Uint8Array; publicKey: KeyObject; }, ): Uint8Array { // RP ID hash (32 bytes) const rpIdHash = createHash("sha256").update(rpId).digest(); // Flags (1 byte) // Bit 0: User Present (UP) = 1 // Bit 2: User Verified (UV) = 1 // Bit 6: Attested credential data included (AT) = 1 if registration let flags = 0x01 | 0x04; // UP + UV if (includeAttestedCredentialData) { flags |= 0x40; // AT } // Sign count (4 bytes, big-endian) const signCountBytes = new Uint8Array(4); new DataView(signCountBytes.buffer).setUint32(0, signCount, false); if (!(includeAttestedCredentialData && attestedCredentialData)) { // Simple authenticator data (for assertions) return new Uint8Array([...rpIdHash, flags, ...signCountBytes]); } // Build attested credential data for registration const { credentialId, publicKey } = attestedCredentialData; // AAGUID (16 bytes) const aaguid = this._aaguid; // Credential ID length (2 bytes, big-endian) const credIdLenBytes = new Uint8Array(2); new DataView(credIdLenBytes.buffer).setUint16( 0, credentialId.length, false, ); // COSE public key const coseKey = this._buildCosePublicKey(publicKey); return new Uint8Array([ ...rpIdHash, flags, ...signCountBytes, ...aaguid, ...credIdLenBytes, ...credentialId, ...coseKey, ]); } /** * Builds a COSE-encoded public key for ES256 (P-256). * * COSE key format for ES256: * { * 1: 2, // kty: EC2 * 3: -7, // alg: ES256 * -1: 1, // crv: P-256 * -2: x, // x-coordinate (32 bytes) * -3: y // y-coordinate (32 bytes) * } */ private _buildCosePublicKey(publicKey: KeyObject): Uint8Array { // Export public key in JWK format to get x and y coordinates const jwk = publicKey.export({ format: "jwk" }); if (!(jwk.x && jwk.y)) { throw new Error("Failed to export public key coordinates"); } const x = base64urlToUint8Array(jwk.x); const y = base64urlToUint8Array(jwk.y); // Build COSE key manually to avoid cbor-x adding tags to Map // CBOR map with 5 items: A5 // Key 1 (kty): 01 -> Value 2: 02 // Key 3 (alg): 03 -> Value -7: 26 (negative int -7 = 0x20 + 6) // Key -1 (crv): 20 (negative int -1 = 0x20 + 0) -> Value 1: 01 // Key -2 (x): 21 (negative int -2 = 0x20 + 1) -> Value: bytes(32) // Key -3 (y): 22 (negative int -3 = 0x20 + 2) -> Value: bytes(32) const coseKey = new Uint8Array([ 0xa5, // map(5) 0x01, 0x02, // 1: 2 (kty: EC2) 0x03, 0x26, // 3: -7 (alg: ES256) 0x20, 0x01, // -1: 1 (crv: P-256) 0x21, 0x58, 0x20, ...x, // -2: bytes(32) x-coordinate 0x22, 0x58, 0x20, ...y, // -3: bytes(32) y-coordinate ]); return coseKey; } } /** * Parses an AAGUID string (e.g., "fbfc3007-154e-4ecc-8c0b-6e020557d7bd") to bytes. */ export function parseAaguid(aaguid: string): Uint8Array { const hex = aaguid.replace(/-/g, ""); const bytes = new Uint8Array(16); for (let i = 0; i < 16; i++) { bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16); } return bytes; } /** * Converts a Uint8Array to a base64url-encoded string. */ export function uint8ArrayToBase64url(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64url"); } /** * Converts a base64url-encoded string to a Uint8Array. */ export function base64urlToUint8Array(base64url: string): Uint8Array { return new Uint8Array(Buffer.from(base64url, "base64url")); } /** * Manually encode the attestation object in CBOR format. * This avoids cbor-x adding tags that break @simplewebauthn/server parsing. * * CBOR structure: * A3 - map(3) * 63 "fmt" -> 64 "none" * 67 "attStmt" -> A0 (empty map) * 68 "authData" -> bytes(authData) */ function encodeAttestationObject(authData: Uint8Array): Uint8Array { // Encode "fmt" key (text string, 3 bytes) const fmtKey = new Uint8Array([0x63, 0x66, 0x6d, 0x74]); // text(3) "fmt" // Encode "none" value (text string, 4 bytes) const noneValue = new Uint8Array([0x64, 0x6e, 0x6f, 0x6e, 0x65]); // text(4) "none" // Encode "attStmt" key (text string, 7 bytes) const attStmtKey = new Uint8Array([ 0x67, 0x61, 0x74, 0x74, 0x53, 0x74, 0x6d, 0x74, ]); // text(7) "attStmt" // Encode empty map value const emptyMap = new Uint8Array([0xa0]); // map(0) // Encode "authData" key (text string, 8 bytes) const authDataKey = new Uint8Array([ 0x68, 0x61, 0x75, 0x74, 0x68, 0x44, 0x61, 0x74, 0x61, ]); // text(8) "authData" // Encode authData value as bytes // For lengths 24-255, use 0x58 + 1-byte length // For lengths 256-65535, use 0x59 + 2-byte length let authDataEncoded: Uint8Array; if (authData.length < 24) { authDataEncoded = new Uint8Array([0x40 + authData.length, ...authData]); } else if (authData.length < 256) { authDataEncoded = new Uint8Array([0x58, authData.length, ...authData]); } else { authDataEncoded = new Uint8Array([ 0x59, (authData.length >> 8) & 0xff, authData.length & 0xff, ...authData, ]); } // Combine into final CBOR map(3) return new Uint8Array([ 0xa3, // map(3) ...fmtKey, ...noneValue, ...attStmtKey, ...emptyMap, ...authDataKey, ...authDataEncoded, ]); }