From 02274350cd5c0728e13c96dbb7d2c8886f1a15b0 Mon Sep 17 00:00:00 2001 From: Andrew Bernat Date: Sun, 21 Jun 2026 21:28:36 -0700 Subject: [PATCH] Implement "get" method for authenticator. Use "get" method in a test to validate it works. The "get" metho will be used more when implementing authenticate flow. Also removed one test whose scenario was already covered [i.e. duplicate test] --- .../passkeys-browser/test/ceremonies.test.ts | 31 ++++--- .../test/helpers/fakeAuthenticator.test.ts | 88 +++++++++++++++++++ .../test/helpers/fakeAuthenticator.ts | 84 ++++++++++++++++-- 3 files changed, 180 insertions(+), 23 deletions(-) diff --git a/clients/passkeys-browser/test/ceremonies.test.ts b/clients/passkeys-browser/test/ceremonies.test.ts index de1b8a2..6909bad 100644 --- a/clients/passkeys-browser/test/ceremonies.test.ts +++ b/clients/passkeys-browser/test/ceremonies.test.ts @@ -165,7 +165,7 @@ describe("PkAuthCeremonyClient http test", () => { const registration = new RegistrationService("localhost", "alice", "bob") using server= await startHttpServer({ [startPath]: newStartHandler(registration), - [finishPath]: errorHttpHandler + [finishPath]: newFinishHandler(registration) }); const credentialsContainer = noOpCredentialsContainer(); const client = new PkAuthCeremonyClient( @@ -173,8 +173,7 @@ describe("PkAuthCeremonyClient http test", () => { { credentials: credentialsContainer }, ); - await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow(); - + await expect(client.register({ username: "alice", label: "key" })).rejects.toThrow(/creation was cancelled/); expect(credentialsContainer.create).toHaveBeenCalled(); // this proves the client did attempt to create a credential }) @@ -202,16 +201,28 @@ describe("PkAuthCeremonyClient http test", () => { [startPath]: newStartHandler(registration), [finishPath]: newFinishHandler(registration) }); + const authenticator = new FakeAuthenticator(); const client = new PkAuthCeremonyClient( { apiBase: server.url }, - { credentials: new FakeAuthenticator() }, + { credentials: authenticator }, ); const response = await client.register({ username: "alice", label: "key" }); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + const credentialRequestOptions = { + publicKey: { + challenge: challenge, + rpId: "localhost" + } + } + const retrievedCredential = await authenticator.get(credentialRequestOptions); + expect(response.credential.label).to.equal("key"); expect(response.credential.credentialId).not.toBe(""); expect(response.credential.userHandle).not.toBe(""); expect(response.credential.authenticatorData).not.toBe(""); + expect(retrievedCredential?.id).toEqual(response.credential.credentialId); + expect(retrievedCredential?.type).toEqual("public-key"); }) const startPath = "/auth/passkeys/registration/start"; @@ -270,18 +281,6 @@ describe("PkAuthCeremonyClient.register (start-body contract)", () => { expect(bodies["/registration/start"]).toMatchObject({ displayName: "Bobby", label: "yubikey" }); expect(bodies["/registration/finish"]).toMatchObject({ label: "yubikey" }); }); - - it("rejects when the authenticator returns no credential (create cancelled)", async () => { - const { fetchImpl } = ceremonyFetch({ - "/registration/start": { challengeId: "ch-1", publicKey: CREATE_OPTIONS_JSON }, - }); - const fakeCreds = { create: vi.fn(async () => null), get: vi.fn() } as unknown as CredentialsContainer; - const client = new PkAuthCeremonyClient( - { apiBase: "https://x", fetch: fetchImpl as unknown as typeof fetch }, - { credentials: fakeCreds }, - ); - await expect(client.register({ username: "alice" })).rejects.toThrow(/creation was cancelled/); - }); }); describe("PkAuthCeremonyClient.authenticate (end-to-end with stubbed credentials)", () => { diff --git a/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts b/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts index 750fc9c..4383c33 100644 --- a/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts +++ b/clients/passkeys-browser/test/helpers/fakeAuthenticator.test.ts @@ -121,15 +121,103 @@ function makeOptions(rpId: string, challenge: Uint8Array): CredentialCreationOpt } +describe("FakeAuthenticator.get", () => { + it("returns a credential whose clientDataJSON embeds the challenge and has type 'webauthn.get'", async () => { + const auth = new FakeAuthenticator(); + const assertionChallenge = crypto.getRandomValues(new Uint8Array(32)); + + await auth.create(makeOptions("example.com", crypto.getRandomValues(new Uint8Array(32)))); + const credential = await auth.get(makeGetOptions("example.com", assertionChallenge)); + + const clientData = decodeAssertionClientData(credential!); + expect(clientData.type).toBe("webauthn.get"); + expect(b64u.decode(clientData.challenge)).toEqual(assertionChallenge); + expect(clientData.origin).toBe("https://example.com"); + }); + + it("returns an assertion whose signature verifies against the registered public key", async () => { + const auth = new FakeAuthenticator(); + const assertionChallenge = crypto.getRandomValues(new Uint8Array(32)); + + const registered = await auth.create(makeOptions("example.com", crypto.getRandomValues(new Uint8Array(32)))); + const assertion = await auth.get(makeGetOptions("example.com", assertionChallenge)); + + const publicKey = await extractPublicKey(registered); + const response = assertion!.response as AuthenticatorAssertionResponse; + const sigData = await toBeSigned(new Uint8Array(response.authenticatorData), response.clientDataJSON); + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + response.signature, + sigData, + ); + expect(valid).toBe(true); + }); + + it("uses the same credential id as the registered credential", async () => { + const auth = new FakeAuthenticator(); + + const registered = await auth.create(makeOptions("example.com", new Uint8Array(32))); + const assertion = await auth.get(makeGetOptions("example.com", new Uint8Array(32))); + + expect(assertion!.id).toBe(registered.id); + expect(new Uint8Array(assertion!.rawId)).toEqual(new Uint8Array(registered.rawId)); + }); +}); + interface ClientData { type: string; challenge: string; origin: string } +function makeGetOptions(rpId: string, challenge: Uint8Array): CredentialRequestOptions { + return { + publicKey: { + challenge: challenge.buffer.slice( + challenge.byteOffset, + challenge.byteOffset + challenge.byteLength, + ) as ArrayBuffer, + rpId, + userVerification: "preferred", + allowCredentials: [], + }, + }; +} + function decodeClientData(credential: PublicKeyCredential): ClientData { const response = credential.response as AuthenticatorAttestationResponse; return JSON.parse( new TextDecoder().decode(response.clientDataJSON), ) } + +function decodeAssertionClientData(credential: PublicKeyCredential): ClientData { + const response = credential.response as AuthenticatorAssertionResponse; + return JSON.parse(new TextDecoder().decode(response.clientDataJSON)); +} + +async function extractPublicKey(credential: PublicKeyCredential): Promise { + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + const response = credential.response as AuthenticatorAttestationResponse; + const attObj = cbor.decode(new Uint8Array(response.attestationObject)) as Map; + const authData = attObj.get("authData") as Uint8Array; + 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); + + return crypto.subtle.importKey( + "raw", + rawPoint, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); +} diff --git a/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts index 88311d4..0b17b83 100644 --- a/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts +++ b/clients/passkeys-browser/test/helpers/fakeAuthenticator.ts @@ -3,7 +3,19 @@ import {Encoder} from "cbor-x"; import * as b64u from "../../src/base64url"; import {BufferUint8} from "./buffer"; +type StoredCredential = { keyPair: CryptoKeyPair; rawId: Uint8Array }; + export class FakeAuthenticator implements CredentialsContainer { + // map id to keypair. The id is the base64 encoding of the randomly generated id [rawId]. + private readonly stored : Map; + // lastId is used when get method is invoked w/o specifying a specific id. + private lastId: string | null; + + constructor() { + this.stored = new Map(); + this.lastId = null; + } + async create(options: CredentialCreationOptions): Promise { const pk = options.publicKey; if (!pk) throw new Error("FakeAuthenticator.create: publicKey options are required"); @@ -11,16 +23,20 @@ export class FakeAuthenticator implements CredentialsContainer { 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 clientDataBytes = encodeClientData(challenge, rpId, "webauthn.create"); 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); + const id = b64u.encode(rawId); + this.stored.set(id, { keyPair, rawId }); + this.lastId = id; + return { rawId: rawId.buffer, - id: b64u.encode(rawId), + id, type: "public-key", authenticatorAttachment: null, getClientExtensionResults: () => ({}), @@ -33,8 +49,49 @@ export class FakeAuthenticator implements CredentialsContainer { }; } - async get(_options: CredentialRequestOptions): Promise { - throw new Error("FakeAuthenticator.get() not yet implemented"); + async get(options: CredentialRequestOptions): Promise { + const pk = options.publicKey; + if (!pk) throw new Error("FakeAuthenticator.get: publicKey options are required"); + const rpId = pk.rpId; + if (!rpId) throw new Error("FakeAuthenticator.get: rpId is required"); + + const credential = this.findCredential(pk.allowCredentials); + if (!credential) throw new Error("FakeAuthenticator.get: no matching credential found"); + + const challenge = new Uint8Array(pk.challenge as ArrayBuffer); + const clientDataBytes = encodeClientData(challenge, rpId, "webauthn.get"); + const authData = await assertionAuthData(rpId); + const signature = await sign(authData, clientDataBytes, credential.keyPair); + const { rawId } = credential; + + return { + rawId: newArrayBuffer(rawId), + id: b64u.encode(rawId), + type: "public-key", + authenticatorAttachment: null, + getClientExtensionResults: () => ({}), + response: { + clientDataJSON: newArrayBuffer(clientDataBytes), + authenticatorData: newArrayBuffer(authData), + signature: newArrayBuffer(signature), + userHandle: null, + } as AuthenticatorAssertionResponse, + toJSON: () => ({} as AuthenticationResponseJSON), + }; + } + + private findCredential( + allowCredentials: PublicKeyCredentialDescriptor[] | undefined, + ): StoredCredential | undefined { + if (allowCredentials && allowCredentials.length > 0) { + for (const desc of allowCredentials) { + const id = b64u.encode(new Uint8Array(desc.id as ArrayBuffer)); + const entry = this.stored.get(id); + if (entry) return entry; + } + return undefined; + } + return this.lastId ? this.stored.get(this.lastId) : undefined; } async preventSilentAccess() { /*no impl*/ } @@ -83,6 +140,17 @@ async function newAuthData(keyPair: CryptoKeyPair, rpId: string, id: Uint8Array) bytes(); } +async function assertionAuthData(rpId: string): Promise { + const rpIdHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(rpId)), + ); + return new BufferUint8() + .uint8array(rpIdHash) + .uint8(0x05) // UP | UV flags (no AT — assertion carries no attested credential data) + .skip(4) // counter = 0 + .bytes(); +} + type Curve = "P-256" | "P-384" | "P-512" function ecdsa(curve: Curve) : Promise { return crypto.subtle.generateKey( @@ -141,10 +209,12 @@ function curveValue(publicKey : CryptoKey) : number { throw new Error(`Unsupported algorithm: ${publicKey.algorithm.name}`) } -function encodeClientData(challenge: Uint8Array, rpId: string): Uint8Array { +type WebAuthnType = "webauthn.create" | "webauthn.get"; + +function encodeClientData(challenge: Uint8Array, rpId: string, webAuthnType: WebAuthnType): Uint8Array { return new TextEncoder().encode( JSON.stringify({ - type: "webauthn.create", + type: webAuthnType, challenge: b64u.encode(challenge), origin: `https://${rpId}`, crossOrigin: false, @@ -158,5 +228,5 @@ function encodeAttestationObject(authData: Uint8Array, signatur fmt: "packed", attStmt: { alg: -7, sig: signature }, authData, - }) as Uint8Array; + }); }