Merge branch 'wt3': WebAuthn enhancements and virtual authenticator
- Enhanced createRegistrationOptions to look up existing users - Added virtual-authenticator testing package - Added WebAuthn e2e and unit tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
packages/testing/virtual-authenticator/eslint.config.js
Normal file
12
packages/testing/virtual-authenticator/eslint.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
26
packages/testing/virtual-authenticator/package.json
Normal file
26
packages/testing/virtual-authenticator/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@reviq/virtual-authenticator",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache",
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/types": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^25.0.3",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import type {
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
} from "@simplewebauthn/types";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
base64urlToUint8Array,
|
||||
DEFAULT_AAGUID,
|
||||
parseAaguid,
|
||||
uint8ArrayToBase64url,
|
||||
VirtualAuthenticator,
|
||||
} from "../virtual-authenticator.js";
|
||||
|
||||
describe("base64url encoding helpers", () => {
|
||||
test("uint8ArrayToBase64url encodes bytes correctly", () => {
|
||||
const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
|
||||
const encoded = uint8ArrayToBase64url(bytes);
|
||||
expect(encoded).toBe("SGVsbG8");
|
||||
});
|
||||
|
||||
test("base64urlToUint8Array decodes bytes correctly", () => {
|
||||
const decoded = base64urlToUint8Array("SGVsbG8");
|
||||
expect(Array.from(decoded)).toEqual([72, 101, 108, 108, 111]);
|
||||
});
|
||||
|
||||
test("round-trip encoding preserves data", () => {
|
||||
const original = new Uint8Array([0, 1, 2, 255, 254, 253]);
|
||||
const encoded = uint8ArrayToBase64url(original);
|
||||
const decoded = base64urlToUint8Array(encoded);
|
||||
expect(Array.from(decoded)).toEqual(Array.from(original));
|
||||
});
|
||||
|
||||
test("handles empty arrays", () => {
|
||||
const empty = new Uint8Array([]);
|
||||
const encoded = uint8ArrayToBase64url(empty);
|
||||
expect(encoded).toBe("");
|
||||
const decoded = base64urlToUint8Array("");
|
||||
expect(decoded.length).toBe(0);
|
||||
});
|
||||
|
||||
test("handles URL-unsafe characters correctly", () => {
|
||||
// Base64url should use - instead of + and _ instead of /
|
||||
const bytes = new Uint8Array([251, 255]); // Would be +/8= in standard base64
|
||||
const encoded = uint8ArrayToBase64url(bytes);
|
||||
expect(encoded).not.toContain("+");
|
||||
expect(encoded).not.toContain("/");
|
||||
expect(encoded).not.toContain("=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAaguid", () => {
|
||||
test("parses default AAGUID (all zeros)", () => {
|
||||
const bytes = parseAaguid(DEFAULT_AAGUID);
|
||||
expect(bytes.length).toBe(16);
|
||||
expect(Array.from(bytes)).toEqual(new Array<number>(16).fill(0));
|
||||
});
|
||||
|
||||
test("parses AAGUID with dashes", () => {
|
||||
const bytes = parseAaguid("fbfc3007-154e-4ecc-8c0b-6e020557d7bd");
|
||||
expect(bytes.length).toBe(16);
|
||||
expect(bytes[0]).toBe(0xfb);
|
||||
expect(bytes[1]).toBe(0xfc);
|
||||
expect(bytes[2]).toBe(0x30);
|
||||
expect(bytes[3]).toBe(0x07);
|
||||
});
|
||||
|
||||
test("parses specific byte values correctly", () => {
|
||||
const bytes = parseAaguid("01020304-0506-0708-090a-0b0c0d0e0f10");
|
||||
expect(Array.from(bytes)).toEqual([
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
|
||||
0x0d, 0x0e, 0x0f, 0x10,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VirtualAuthenticator", () => {
|
||||
const createRegistrationOptions = (
|
||||
challenge: string,
|
||||
userId: string,
|
||||
): PublicKeyCredentialCreationOptionsJSON => ({
|
||||
challenge,
|
||||
rp: {
|
||||
name: "Test App",
|
||||
id: "localhost",
|
||||
},
|
||||
user: {
|
||||
id: userId,
|
||||
name: "test@example.com",
|
||||
displayName: "Test User",
|
||||
},
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
timeout: 60000,
|
||||
attestation: "none",
|
||||
authenticatorSelection: {
|
||||
residentKey: "preferred",
|
||||
userVerification: "preferred",
|
||||
},
|
||||
});
|
||||
|
||||
const createAuthenticationOptions = (
|
||||
challenge: string,
|
||||
credentialId: string,
|
||||
): PublicKeyCredentialRequestOptionsJSON => ({
|
||||
challenge,
|
||||
rpId: "localhost",
|
||||
timeout: 60000,
|
||||
userVerification: "preferred",
|
||||
allowCredentials: [
|
||||
{
|
||||
type: "public-key",
|
||||
id: credentialId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
test("uses default AAGUID when not specified", () => {
|
||||
const auth = new VirtualAuthenticator();
|
||||
expect(auth.getCredentials()).toEqual([]);
|
||||
});
|
||||
|
||||
test("uses default origin when not specified", () => {
|
||||
const auth = new VirtualAuthenticator();
|
||||
// We can verify this by checking clientDataJSON origin in created credentials
|
||||
expect(auth.getCredentials()).toEqual([]);
|
||||
});
|
||||
|
||||
test("accepts custom origin", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "https://example.com",
|
||||
});
|
||||
expect(auth.getCredentials()).toEqual([]);
|
||||
});
|
||||
|
||||
test("accepts custom AAGUID", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
aaguid: "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
|
||||
});
|
||||
expect(auth.getCredentials()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCredential", () => {
|
||||
test("creates a valid registration response", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const options = createRegistrationOptions("test-challenge", "dXNlci0x");
|
||||
|
||||
const response = auth.createCredential(options);
|
||||
|
||||
expect(response.type).toBe("public-key");
|
||||
expect(response.id).toBeDefined();
|
||||
expect(response.rawId).toBe(response.id);
|
||||
expect(response.authenticatorAttachment).toBe("platform");
|
||||
expect(response.response.clientDataJSON).toBeDefined();
|
||||
expect(response.response.attestationObject).toBeDefined();
|
||||
expect(response.response.transports).toContain("internal");
|
||||
expect(response.response.publicKeyAlgorithm).toBe(-7); // ES256
|
||||
});
|
||||
|
||||
test("stores credential after creation", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const options = createRegistrationOptions("test-challenge", "dXNlci0x");
|
||||
|
||||
expect(auth.getCredentials()).toHaveLength(0);
|
||||
|
||||
const response = auth.createCredential(options);
|
||||
|
||||
const credentials = auth.getCredentials();
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0]?.credentialId).toBe(response.id);
|
||||
expect(credentials[0]?.rpId).toBe("localhost");
|
||||
expect(credentials[0]?.signCount).toBe(0);
|
||||
});
|
||||
|
||||
test("creates unique credential IDs for each registration", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
|
||||
const response1 = auth.createCredential(
|
||||
createRegistrationOptions("challenge-1", "dXNlci0x"),
|
||||
);
|
||||
const response2 = auth.createCredential(
|
||||
createRegistrationOptions("challenge-2", "dXNlci0y"),
|
||||
);
|
||||
|
||||
expect(response1.id).not.toBe(response2.id);
|
||||
expect(auth.getCredentials()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("includes challenge in clientDataJSON", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const challenge = "unique-test-challenge";
|
||||
const options = createRegistrationOptions(challenge, "dXNlci0x");
|
||||
|
||||
const response = auth.createCredential(options);
|
||||
|
||||
const clientDataJSON = JSON.parse(
|
||||
Buffer.from(response.response.clientDataJSON, "base64url").toString(),
|
||||
) as { type: string; challenge: string; origin: string };
|
||||
expect(clientDataJSON.type).toBe("webauthn.create");
|
||||
expect(clientDataJSON.challenge).toBe(challenge);
|
||||
expect(clientDataJSON.origin).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssertion", () => {
|
||||
test("creates a valid authentication response", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x");
|
||||
const regResponse = auth.createCredential(regOptions);
|
||||
|
||||
const authOptions = createAuthenticationOptions(
|
||||
"auth-challenge",
|
||||
regResponse.id,
|
||||
);
|
||||
const authResponse = auth.getAssertion(authOptions);
|
||||
|
||||
expect(authResponse.type).toBe("public-key");
|
||||
expect(authResponse.id).toBe(regResponse.id);
|
||||
expect(authResponse.response.authenticatorData).toBeDefined();
|
||||
expect(authResponse.response.signature).toBeDefined();
|
||||
expect(authResponse.response.userHandle).toBeDefined();
|
||||
expect(authResponse.response.clientDataJSON).toBeDefined();
|
||||
});
|
||||
|
||||
test("increments sign count on each assertion", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const regOptions = createRegistrationOptions("reg-challenge", "dXNlci0x");
|
||||
const regResponse = auth.createCredential(regOptions);
|
||||
|
||||
const credentials = auth.getCredentials();
|
||||
expect(credentials[0]?.signCount).toBe(0);
|
||||
|
||||
auth.getAssertion(
|
||||
createAuthenticationOptions("challenge-1", regResponse.id),
|
||||
);
|
||||
expect(auth.getCredentials()[0]?.signCount).toBe(1);
|
||||
|
||||
auth.getAssertion(
|
||||
createAuthenticationOptions("challenge-2", regResponse.id),
|
||||
);
|
||||
expect(auth.getCredentials()[0]?.signCount).toBe(2);
|
||||
});
|
||||
|
||||
test("throws error when no matching credential found", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const authOptions = createAuthenticationOptions(
|
||||
"auth-challenge",
|
||||
"non-existent-credential-id",
|
||||
);
|
||||
|
||||
expect(() => auth.getAssertion(authOptions)).toThrow(
|
||||
"No matching credential found",
|
||||
);
|
||||
});
|
||||
|
||||
test("includes challenge in clientDataJSON", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const regResponse = auth.createCredential(
|
||||
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||
);
|
||||
|
||||
const challenge = "unique-auth-challenge";
|
||||
const authResponse = auth.getAssertion(
|
||||
createAuthenticationOptions(challenge, regResponse.id),
|
||||
);
|
||||
|
||||
const clientDataJSON = JSON.parse(
|
||||
Buffer.from(
|
||||
authResponse.response.clientDataJSON,
|
||||
"base64url",
|
||||
).toString(),
|
||||
) as { type: string; challenge: string; origin: string };
|
||||
expect(clientDataJSON.type).toBe("webauthn.get");
|
||||
expect(clientDataJSON.challenge).toBe(challenge);
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential management", () => {
|
||||
test("clear removes all credentials", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
auth.createCredential(
|
||||
createRegistrationOptions("challenge-1", "dXNlci0x"),
|
||||
);
|
||||
auth.createCredential(
|
||||
createRegistrationOptions("challenge-2", "dXNlci0y"),
|
||||
);
|
||||
|
||||
expect(auth.getCredentials()).toHaveLength(2);
|
||||
|
||||
auth.clear();
|
||||
|
||||
expect(auth.getCredentials()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("setSignCount updates credential sign count", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const response = auth.createCredential(
|
||||
createRegistrationOptions("challenge", "dXNlci0x"),
|
||||
);
|
||||
|
||||
expect(auth.getCredentials()[0]?.signCount).toBe(0);
|
||||
|
||||
auth.setSignCount(response.id, 100);
|
||||
|
||||
expect(auth.getCredentials()[0]?.signCount).toBe(100);
|
||||
});
|
||||
|
||||
test("setSignCount throws for non-existent credential", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
auth.setSignCount("non-existent", 100);
|
||||
}).toThrow("Credential not found");
|
||||
});
|
||||
|
||||
test("getCredentials returns credential info without private keys", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
auth.createCredential(createRegistrationOptions("challenge", "dXNlci0x"));
|
||||
|
||||
const credentials = auth.getCredentials();
|
||||
expect(credentials).toHaveLength(1);
|
||||
|
||||
const cred = credentials[0];
|
||||
expect(cred).toHaveProperty("credentialId");
|
||||
expect(cred).toHaveProperty("rpId");
|
||||
expect(cred).toHaveProperty("signCount");
|
||||
// Should NOT expose private key
|
||||
expect(cred).not.toHaveProperty("privateKey");
|
||||
expect(cred).not.toHaveProperty("publicKey");
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticator data structure", () => {
|
||||
test("registration authenticator data has correct flags", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const response = auth.createCredential(
|
||||
createRegistrationOptions("challenge", "dXNlci0x"),
|
||||
);
|
||||
|
||||
if (!response.response.authenticatorData) {
|
||||
throw new Error("authenticatorData missing from registration response");
|
||||
}
|
||||
const authData = base64urlToUint8Array(
|
||||
response.response.authenticatorData,
|
||||
);
|
||||
|
||||
// Authenticator data structure:
|
||||
// - 32 bytes: RP ID hash
|
||||
// - 1 byte: flags
|
||||
// - 4 bytes: sign count
|
||||
// - variable: attested credential data (for registration)
|
||||
expect(authData.length).toBeGreaterThan(37);
|
||||
|
||||
// Flags byte (index 32): UP (0x01) + UV (0x04) + AT (0x40) = 0x45
|
||||
const flags = authData[32];
|
||||
expect(flags).toBe(0x45);
|
||||
});
|
||||
|
||||
test("assertion authenticator data has correct flags", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const regResponse = auth.createCredential(
|
||||
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||
);
|
||||
|
||||
const authResponse = auth.getAssertion(
|
||||
createAuthenticationOptions("auth-challenge", regResponse.id),
|
||||
);
|
||||
|
||||
const authData = base64urlToUint8Array(
|
||||
authResponse.response.authenticatorData,
|
||||
);
|
||||
|
||||
// Assertion authenticator data: 32 + 1 + 4 = 37 bytes (no attested credential data)
|
||||
expect(authData.length).toBe(37);
|
||||
|
||||
// Flags byte: UP (0x01) + UV (0x04) = 0x05 (no AT flag for assertions)
|
||||
const flags = authData[32];
|
||||
expect(flags).toBe(0x05);
|
||||
});
|
||||
|
||||
test("sign count is encoded in big-endian", () => {
|
||||
const auth = new VirtualAuthenticator({
|
||||
origin: "http://localhost:3000",
|
||||
});
|
||||
const regResponse = auth.createCredential(
|
||||
createRegistrationOptions("reg-challenge", "dXNlci0x"),
|
||||
);
|
||||
|
||||
// Set a specific sign count
|
||||
auth.setSignCount(regResponse.id, 0x01020304);
|
||||
|
||||
const authResponse = auth.getAssertion(
|
||||
createAuthenticationOptions("auth-challenge", regResponse.id),
|
||||
);
|
||||
|
||||
const authData = base64urlToUint8Array(
|
||||
authResponse.response.authenticatorData,
|
||||
);
|
||||
|
||||
// Sign count is at bytes 33-36 (after RP ID hash and flags)
|
||||
// After getAssertion, sign count will be incremented by 1
|
||||
const signCountBytes = authData.slice(33, 37);
|
||||
expect(signCountBytes[0]).toBe(0x01);
|
||||
expect(signCountBytes[1]).toBe(0x02);
|
||||
expect(signCountBytes[2]).toBe(0x03);
|
||||
expect(signCountBytes[3]).toBe(0x05); // 0x01020304 + 1 = 0x01020305
|
||||
});
|
||||
});
|
||||
});
|
||||
8
packages/testing/virtual-authenticator/src/index.ts
Normal file
8
packages/testing/virtual-authenticator/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type { VirtualAuthenticatorOptions } from "./virtual-authenticator.js";
|
||||
export {
|
||||
base64urlToUint8Array,
|
||||
DEFAULT_AAGUID,
|
||||
parseAaguid,
|
||||
uint8ArrayToBase64url,
|
||||
VirtualAuthenticator,
|
||||
} from "./virtual-authenticator.js";
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 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,
|
||||
]);
|
||||
}
|
||||
15
packages/testing/virtual-authenticator/tsconfig.json
Normal file
15
packages/testing/virtual-authenticator/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"composite": true,
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user