From f0513d07c366c02ba53f3d71a357c98243e49a36 Mon Sep 17 00:00:00 2001 From: Andrew Bernat Date: Fri, 19 Jun 2026 21:53:39 -0700 Subject: [PATCH] Create a fake authenticator. This simulates a real CredentialsContainer interface that is provided by the browser. Currently "create" is the only method that is implemented. --- .../test/helpers/fakeAuthenticator.test.ts | 135 +++++++++++++++ .../test/helpers/fakeAuthenticator.ts | 162 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts create mode 100644 clients/passkeys-browser/test/helpers/fakeAuthenticator.ts diff --git a/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts b/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts new file mode 100644 index 0000000..750fc9c --- /dev/null +++ b/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from "vitest"; +import * as b64u from "../../src/base64url"; +import { FakeAuthenticator } from "./fakeAuthenticator"; +import { Encoder } from "cbor-x"; + +describe("FakeAuthenticator", () => { + it("returns a credential whose clientDataJSON embeds the challenge and correct type", async () => { + const auth = new FakeAuthenticator(); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const credentialCreateOptions = makeOptions("example.com", challenge); + const credential = await auth.create(credentialCreateOptions); + + const clientData = decodeClientData(credential); + expect(clientData.type).toBe("webauthn.create"); + expect(b64u.decode(clientData.challenge)).toEqual(challenge); + expect(clientData.origin).toBe("https://example.com"); + }); + + it("returns a packed attestation with a signature that verifies against the embedded public key", async () => { + const auth = new FakeAuthenticator(); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + const credential = await auth.create(makeOptions("example.com", challenge)); + + const response = credential.response as AuthenticatorAttestationResponse; + const attestation = await decodeAttestationData(response); + expect(attestation.fmt).toBe("packed"); + const authData = attestation.authData; + const verificationData = await toBeSigned(authData, response.clientDataJSON); + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + attestation.publicKey, + attestation.signature, + verificationData, + ); + expect(valid).toBe(true); + }); + + it("encodes rawId as base64url in the credential id field", async () => { + const auth = new FakeAuthenticator(); + const credential = await auth.create( + makeOptions("example.com", new Uint8Array(32)), + ); + expect(credential.id).toBe(b64u.encode(credential.rawId)); + }); +}); + +async function toBeSigned(authData: Uint8Array, clientDataJSON: ArrayBuffer): Promise> { + const clientDataJSONBytes = new Uint8Array(clientDataJSON); + const clientDataHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", clientDataJSONBytes), + ); + const verificationData = new Uint8Array(authData.length + clientDataHash.length); + verificationData.set(authData); + verificationData.set(clientDataHash, authData.length); + return verificationData; +} + +interface AttestationData { + fmt: string; + statement: Map; + signature: Uint8Array; + publicKey: CryptoKey; + authData: Uint8Array; +} + +async function decodeAttestationData(attestation: AuthenticatorAttestationResponse): Promise { + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + const attObj = cbor.decode(new Uint8Array(attestation.attestationObject)) as Map; + const authData = attObj.get("authData") as Uint8Array; + const attStmt = attObj.get("attStmt") as Map; + const sig = attStmt.get("sig") as Uint8Array; + + // Extract COSE key from authData at offset 55 + credIdLen + const view = new DataView(authData.buffer, authData.byteOffset); + const credIdLen = view.getUint16(53); + const coseKey = cbor.decode(authData.slice(55 + credIdLen)) as Map; + + const xBytes = coseKey.get(-2) as Uint8Array; + const yBytes = coseKey.get(-3) as Uint8Array; + const rawPoint = new Uint8Array(65); + rawPoint[0] = 0x04; + rawPoint.set(xBytes, 1); + rawPoint.set(yBytes, 33); + + const publicKey = await crypto.subtle.importKey( + "raw", + rawPoint, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + + return { + fmt: attObj.get("fmt") as string, + statement: attStmt, + signature: sig, + publicKey: publicKey, + authData: authData + } +} + +function makeOptions(rpId: string, challenge: Uint8Array): CredentialCreationOptions { + return { + publicKey: { + rp: { id: rpId, name: "Test" }, + user: { + id: new Uint8Array([1, 2, 3, 4]).buffer.slice(0, 4) as ArrayBuffer, + name: "alice", + displayName: "Alice", + }, + challenge: challenge.buffer.slice( + challenge.byteOffset, + challenge.byteOffset + challenge.byteLength, + ) as ArrayBuffer, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + }, + }; +} + + +interface ClientData { + type: string; + challenge: string; + origin: string +} + +function decodeClientData(credential: PublicKeyCredential): ClientData { + const response = credential.response as AuthenticatorAttestationResponse; + return JSON.parse( + new TextDecoder().decode(response.clientDataJSON), + ) +} diff --git a/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts new file mode 100644 index 0000000..88311d4 --- /dev/null +++ b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +import {Encoder} from "cbor-x"; +import * as b64u from "../../src/base64url"; +import {BufferUint8} from "./buffer"; + +export class FakeAuthenticator implements CredentialsContainer { + async create(options: CredentialCreationOptions): Promise { + const pk = options.publicKey; + if (!pk) throw new Error("FakeAuthenticator.create: publicKey options are required"); + const rpId = pk.rp.id; + if (!rpId) throw new Error("FakeAuthenticator.create: rp.id is required"); + + const challenge = new Uint8Array(pk.challenge as ArrayBuffer); + const clientDataBytes = encodeClientData(challenge, rpId); + const keyPair = await ecdsa("P-256") + const rawId = crypto.getRandomValues(new Uint8Array(16)); + const authData = await newAuthData(keyPair, rpId, rawId); + const signature = await sign(authData, clientDataBytes, keyPair); + const atoBytes = encodeAttestationObject(authData, signature); + + return { + rawId: rawId.buffer, + id: b64u.encode(rawId), + type: "public-key", + authenticatorAttachment: null, + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: newArrayBuffer(clientDataBytes), + attestationObject: newArrayBuffer(atoBytes), + getTransports: () => ["internal"], + } as AuthenticatorAttestationResponse, + toJSON: () => ({} as RegistrationResponseJSON), + }; + } + + async get(_options: CredentialRequestOptions): Promise { + throw new Error("FakeAuthenticator.get() not yet implemented"); + } + + async preventSilentAccess() { /*no impl*/ } + + async store() { /*no impl*/ } +} + +function newArrayBuffer(buf: Uint8Array | Uint8Array): ArrayBuffer { + return buf.buffer.slice( + buf.byteOffset, + buf.byteOffset + buf.byteLength + ) as ArrayBuffer +} + +// sign authData || SHA-256(clientDataJSON) +async function sign(authData: Uint8Array, clientData: Uint8Array, keyPair: CryptoKeyPair): Promise { + const clientDataHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", clientData), + ); + const toSign = new BufferUint8(). + uint8array(authData). + uint8array(clientDataHash). + bytes(); + return new Uint8Array( + await crypto.subtle.sign( + {name: "ECDSA", hash: "SHA-256"}, + keyPair.privateKey, + new Uint8Array(toSign), + ), + ); +} + +async function newAuthData(keyPair: CryptoKeyPair, rpId: string, id: Uint8Array): Promise { + const coseKeyEncoded = await cose(keyPair.publicKey) + const rpIdHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)), + ); + return new BufferUint8(). + uint8array(rpIdHash). + uint8(0x45). // UP | UV | AT + skip(4). // counter = 0 + skip(16). // AAGUID = all zeros + uint16(id.length). + uint8array(id). + uint8array(coseKeyEncoded). + bytes(); +} + +type Curve = "P-256" | "P-384" | "P-512" +function ecdsa(curve: Curve) : Promise { + return crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: curve }, + true, + ["sign", "verify"], + ); +} + +async function cose(publicKey : CryptoKey): Promise { + const kty = 1 + const algorithm = 3; + const curve = -1; + const xCoord = -2; + const yCoord = -3; + + const jwk = await crypto.subtle.exportKey("jwk", publicKey); + const xBytes = b64u.decode(jwk.x!); + const yBytes = b64u.decode(jwk.y!); + const coseKey = new Map([ + [kty, ktyValue(publicKey)], + [algorithm, algorithmValue(publicKey)], + [curve, curveValue(publicKey)], + [xCoord, xBytes], + [yCoord, yBytes], + ]); + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + return cbor.encode(coseKey); +} + +function ktyValue(publicKey : CryptoKey) : number { + const ktyECDSA = 2; + if (publicKey.algorithm.name === "ECDSA") { + return ktyECDSA; + } + throw new Error(`Unsupported key type: ${publicKey.algorithm.name}`) +} + +function algorithmValue(publicKey : CryptoKey) : number { + const algorithmES256 = -7; + if (publicKey.algorithm.name === "ECDSA") { + return algorithmES256; + } + throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`) +} + +function curveValue(publicKey : CryptoKey) : number { + const curveP256 = -7; + if (publicKey.algorithm.name === "ECDSA") { + const { namedCurve } = publicKey.algorithm as EcKeyAlgorithm + if (namedCurve === "P-256") { + return curveP256; + } + throw new Error(`Unsupported curve: ${namedCurve}`) + } + throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`) +} + +function encodeClientData(challenge: Uint8Array, rpId: string): Uint8Array { + return new TextEncoder().encode( + JSON.stringify({ + type: "webauthn.create", + challenge: b64u.encode(challenge), + origin: `https://${rpId}`, + crossOrigin: false, + }), + ); +} + +function encodeAttestationObject(authData: Uint8Array, signature: Uint8Array): Uint8Array { + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + return cbor.encode({ + fmt: "packed", + attStmt: { alg: -7, sig: signature }, + authData, + }) as Uint8Array; +}