diff --git a/clients/passkeys-browser/src/base64url.ts b/clients/passkeys-browser/src/base64url.ts index 8a24352..b063518 100644 --- a/clients/passkeys-browser/src/base64url.ts +++ b/clients/passkeys-browser/src/base64url.ts @@ -22,7 +22,7 @@ export function encode(input: ArrayBuffer | Uint8Array | ArrayBufferView): Base6 return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } -export function decode(input: Base64Url): Uint8Array { +export function decode(input: Base64Url): Uint8Array { const pad = "=".repeat((4 - (input.length % 4)) % 4); const normalized = input.replaceAll("-", "+").replaceAll("_", "/") + pad; const binary = atob(normalized); @@ -43,6 +43,11 @@ export function decodeToArrayBuffer(input: Base64Url): ArrayBuffer { function toUint8Array(input: ArrayBuffer | Uint8Array | ArrayBufferView): Uint8Array { if (input instanceof Uint8Array) return input; - if (input instanceof ArrayBuffer) return new Uint8Array(input); + // Use toString-based check to handle cross-realm ArrayBuffers (e.g. from TextEncoder in jsdom) + // jsdom's TextEncoder creates an ArrayBuffer that is different from NodeJs ArrayBuffer so "instanceof" + // will not work. The string tag for the type will work. + if (input instanceof ArrayBuffer || Object.prototype.toString.call(input) === "[object ArrayBuffer]") { + return new Uint8Array(input as ArrayBuffer); + } return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); } diff --git a/clients/passkeys-browser/test/helpers/registration.test.ts b/clients/passkeys-browser/test/helpers/registration.test.ts index 7c50064..ec86bf8 100644 --- a/clients/passkeys-browser/test/helpers/registration.test.ts +++ b/clients/passkeys-browser/test/helpers/registration.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from "vitest"; -import {RegistrationService} from "./registration"; +import { Encoder } from "cbor-x"; +import * as b64u from "../../src/base64url"; +import { decodeCreationOptions, encodeRegistrationResponse } from "../../src/ceremonies"; +import { FakeAuthenticator } from "./fakeAuthenticator"; +import { RegistrationService } from "./registration"; describe("RegistrationService", () => { - describe("start", () => { + describe("start a registration", () => { it("returns 403 when a username is not in the allowlist", async () => { - const service = new RegistrationService("localhost", "alice") - expect( - () => service.start({ username: "eve", displayName: null, label: null, challenge: null }), - ).toThrow("HTTP 403"); + const service = new RegistrationService("localhost", "alice"); + await expect( + service.start({ username: "eve", displayName: null, label: null, challenge: null }), + ).rejects.toThrow("HTTP 403"); }); it("returns a StartRegistrationResponse when the request is valid", async () => { const service = new RegistrationService("localhost", "alice") - const response = service.start({ username: "alice", displayName: null, label: null, challenge: null }) + const response = await service.start({ username: "alice", displayName: null, label: null, challenge: null }) expect(response.challengeId).toBeTypeOf("string"); expect(response.publicKey.user.id).toBeTypeOf("string"); @@ -26,4 +30,92 @@ describe("RegistrationService", () => { expect(response.publicKey.attestation).toEqual("direct"); }); }); + + describe("finish a registration", () => { + it("returns 400 for an unknown challengeId", async () => { + const service = new RegistrationService("localhost", "alice"); + await expect( + service.finish({ + challengeId: "does-not-exist", + username: "alice", + label: "key", + response: { + id: "x", + rawId: "x", + type: "public-key", + response: { clientDataJSON: "x", attestationObject: "x", transports: [] }, + }, + }), + ).rejects.toThrow("HTTP 400"); + }); + + it("returns 400 for a username that does not match the started session", async () => { + const service = new RegistrationService("localhost", "alice", "bob"); + const { challengeId, publicKey } = await service.start({ username: "alice", displayName: null, label: null, challenge: null }); + + const auth = new FakeAuthenticator(); + const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) }); + const encoded = encodeRegistrationResponse(credential); + + await expect( + service.finish({ challengeId, username: "bob", label: null, response: encoded }), + ).rejects.toThrow("HTTP 400"); + }); + + it("verifies the attestation and returns credential metadata", async () => { + const service = new RegistrationService("localhost", "alice"); + const { challengeId, publicKey } = await service.start({ username: "alice", displayName: "Alice", label: null, challenge: null }); + + const auth = new FakeAuthenticator(); + const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) }); + const encoded = encodeRegistrationResponse(credential); + + const { credential: cred } = await service.finish({ challengeId, username: "alice", label: "my-key", response: encoded }); + + expect(cred.credentialId).toBeTypeOf("string"); + expect(cred.userHandle).toBeTypeOf("string"); + expect(cred.label).toBe("my-key"); + expect(cred.transports).toEqual(["internal"]); + expect(cred.backupEligible).toBe(false); + expect(cred.backupState).toBe(false); + }); + + it("returns 400 when the attestation signature is tampered", async () => { + const service = new RegistrationService("localhost", "alice"); + const { challengeId, publicKey } = await service.start({ username: "alice", displayName: null, label: null, challenge: null }); + + const auth = new FakeAuthenticator(); + const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) }); + const encoded = encodeRegistrationResponse(credential); + + // decode the attestation statement; modify it by flipping bits + // then re-encode and submit to finish + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + const attObjBytes = b64u.decode(encoded.response.attestationObject); + const attObj = cbor.decode(attObjBytes) as Map; + const attStmt = attObj.get("attStmt") as Map; + const sig = attStmt.get("sig") as Uint8Array; + sig[0]! ^= 0xff; + const tampered = { ...encoded, response: { ...encoded.response, attestationObject: b64u.encode(cbor.encode(attObj) as Uint8Array) } }; + + await expect( + service.finish({ challengeId, username: "alice", label: null, response: tampered }), + ).rejects.toThrow("HTTP 400"); + }); + + it("returns 400 when the challenge in clientDataJSON belongs to a different session", async () => { + const service = new RegistrationService("localhost", "alice"); + const s1 = await service.start({ username: "alice", displayName: null, label: null, challenge: null }); + const s2 = await service.start({ username: "alice", displayName: null, label: null, challenge: null }); + + const auth = new FakeAuthenticator(); + const credential = await auth.create({ publicKey: decodeCreationOptions(s2.publicKey) }); + const encoded = encodeRegistrationResponse(credential); + + // finish using s1 challengeId but with s2's credential -- this should fail + await expect( + service.finish({ challengeId: s1.challengeId, username: "alice", label: null, response: encoded }), + ).rejects.toThrow("HTTP 400"); + }); + }); }); diff --git a/clients/passkeys-browser/test/helpers/registration.ts b/clients/passkeys-browser/test/helpers/registration.ts index fb1c77a..afa71b9 100644 --- a/clients/passkeys-browser/test/helpers/registration.ts +++ b/clients/passkeys-browser/test/helpers/registration.ts @@ -1,7 +1,13 @@ import * as b64u from "../../src/base64url"; -import {StartRegistrationRequest, StartRegistrationResponse} from "../../src"; +import { + FinishRegistrationRequest, + FinishRegistrationResponse, + StartRegistrationRequest, + StartRegistrationResponse +} from "../../src"; import {HttpError} from "./httpServer"; +import {Encoder} from "cbor-x"; interface PendingEntry { displayName: string; @@ -13,17 +19,15 @@ interface PendingEntry { } /* -RegistrationService is a representation of a server side service that can start and finish +RegistrationService is a server side service that can start and finish client registrations. Instantiate the service with a relying party id [rpId] and a list of allowed users. A registration is started with a call to start which will produce a challenge that -the client must respond to when calling finish. +the client must respond to with a call to finish. */ export class RegistrationService { // pending is the map of registrations which have been started; but not yet finished. // Maps challenge id to a PendingEntry structure. - // A registration.ts cannot be finished unless it has been successfully started. - // A successfully started registration.ts must appear on the pending map. private readonly pending : Map; private readonly rpId : string; private readonly allowedUsernames : string[]; @@ -34,7 +38,7 @@ export class RegistrationService { this.allowedUsernames = allowedUsernames ?? []; } - start(req : StartRegistrationRequest) : StartRegistrationResponse { + async start(req : StartRegistrationRequest) : Promise { if (!this.allowedUsernames.includes(req.username)) { throw new HttpError(403, { error: `username '${req.username}' not allowed` }); } @@ -48,11 +52,141 @@ export class RegistrationService { userId, challenge, challengeId, - rpId: this.rpId }; + rpId: this.rpId + }; - this.pending.set(challengeId, pendingEntry); // save the requests which will be validated in finish + // save the requests which will be validated in finish + this.pending.set(challengeId, pendingEntry); return newStartRegistrationResponse(pendingEntry) } + + async finish(req: FinishRegistrationRequest): Promise { + const entry = this.pending.get(req.challengeId); + if (!entry) { + throw new HttpError(400, { error: `unknown challengeId: ${req.challengeId}` }); + } + if (entry.username !== req.username) { + throw new HttpError(400, { error: `username mismatch: ${req.username}` }); + } + const clientData = parseClientData(req.response.response.clientDataJSON); + if (clientData.type !== "webauthn.create") { + throw new HttpError(400, { error: `wrong clientDataJSON type: ${clientData.type}` }); + } + if (clientData.challenge !== entry.challenge) { + throw new HttpError(400, { error: "challenge mismatch" }); + } + + const attestation = decodeAttestation(req.response.response.attestationObject) + const publicKey = await parsePublicKey(attestation.publicKeyBytes) + const isValid = await validateChallenge(publicKey, attestation, req.response.response.clientDataJSON) + if (!isValid) { + throw new HttpError(400, { error: "invalid signature" }); + } + + const credId = attestation.authData.slice(55, 55 + attestation.credentialIdLength); + const flags = attestation.authData[32]!; + this.pending.delete(req.challengeId); + return { + credential: { + credentialId: b64u.encode(credId), + userHandle: entry.userId, + label: req.label ?? "key", + transports: req.response.response.transports ?? [], + counter: attestation.dv.getUint32(33), + backupEligible: (flags & 0x08) !== 0, + backupState: (flags & 0x10) !== 0, + authenticatorData: b64u.encode(attestation.authData), + }, + }; + } +} + +async function validateChallenge(publicKey: CryptoKey, attestation: Attestation, clientDataJson: string) : Promise { + const data = b64u.decode(clientDataJson); + const clientDataHash = new Uint8Array( + await crypto.subtle.digest("SHA-256", data), + ); + const verificationData = new Uint8Array(attestation.authData.length + clientDataHash.length); + verificationData.set(attestation.authData); + verificationData.set(clientDataHash, attestation.authData.length); + + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + attestation.signature, + verificationData, + ); +} + +function parsePublicKey(publicKeyBytes : Uint8Array) : Promise { + try { + return crypto.subtle.importKey( + "raw", + publicKeyBytes, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + } catch(e) { + const eMessage = e as {message: string} + const message = eMessage && eMessage.message ? eMessage.message : "unknown error"; + throw new HttpError(400, { error: `invalid public key in authData: ${message}` }); + } +} + +type Attestation = { + dv: DataView, + credentialIdLength: number, + signature: Uint8Array, + authData: Uint8Array, + publicKeyBytes: Uint8Array, +} + +function decodeAttestation(attestation: string) : Attestation { + const cbor = new Encoder({ useRecords: false, mapsAsObjects: false }); + const attestationObjectBytes = b64u.decode(attestation); + const attObj = cbor.decode(attestationObjectBytes) as Map; + if (attObj.get("fmt") !== "packed") { + throw new HttpError(400, { error: `unsupported attestation format: ${attObj.get("fmt")}` }); + } + const authData = attObj.get("authData") as Uint8Array; + const attStmt = attObj.get("attStmt") as Map; + const sig = attStmt.get("sig") as Uint8Array; + + // Extract COSE public key from authData + const dv = new DataView(authData.buffer, authData.byteOffset); + const credIdLen = dv.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 { + dv: dv, + credentialIdLength: credIdLen, + signature: sig, + authData: authData, + publicKeyBytes: rawPoint + } +} + +function parseClientData(clientDataJson: string) : { type:string, challenge: string } { + // Decode and validate clientDataJSON + const data = b64u.decode(clientDataJson); + try { + return JSON.parse(new TextDecoder().decode(data)) as { + type: string; + challenge: string; + }; + } catch(e){ + const eMessage = e as {message: string} + const message = eMessage && eMessage.message ? eMessage.message : "unknown error"; + throw new HttpError(400, { error: "invalid client data json: " + message }); + } } function newStartRegistrationResponse(pendingEntry: PendingEntry) : StartRegistrationResponse {