- Create @reviq/virtual-authenticator package with cryptographically valid WebAuthn credential generation for testing - Add e2e tests for WebAuthn registration, authentication, passkey management - Add unit tests for passkey-helpers and VirtualAuthenticator - Add security tests for counter replay and tampered responses - Configure test database environment in devenv.nix - Add turbo.json test tasks and workspace configuration Test results: 98 tests passing (54 virtual-authenticator, 25 e2e, 19 unit) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
/**
|
|
* 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<string, StoredCredential>();
|
|
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,
|
|
]);
|
|
}
|