Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions clients/passkeys-browser/src/base64url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArrayBuffer> {
const pad = "=".repeat((4 - (input.length % 4)) % 4);
const normalized = input.replaceAll("-", "+").replaceAll("_", "/") + pad;
const binary = atob(normalized);
Expand All @@ -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);
}
106 changes: 99 additions & 7 deletions clients/passkeys-browser/test/helpers/registration.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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<string, unknown>;
const attStmt = attObj.get("attStmt") as Map<string, unknown>;
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");
});
});
});
150 changes: 142 additions & 8 deletions clients/passkeys-browser/test/helpers/registration.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<string, PendingEntry>;
private readonly rpId : string;
private readonly allowedUsernames : string[];
Expand All @@ -34,7 +38,7 @@ export class RegistrationService {
this.allowedUsernames = allowedUsernames ?? [];
}

start(req : StartRegistrationRequest) : StartRegistrationResponse {
async start(req : StartRegistrationRequest) : Promise<StartRegistrationResponse> {
if (!this.allowedUsernames.includes(req.username)) {
throw new HttpError(403, { error: `username '${req.username}' not allowed` });
}
Expand All @@ -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<FinishRegistrationResponse> {
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<boolean> {
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<ArrayBuffer>) : Promise<CryptoKey> {
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<ArrayBuffer>,
authData: Uint8Array<ArrayBufferLike>,
publicKeyBytes: Uint8Array<ArrayBuffer>,
}

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<string, unknown>;
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<string, unknown>;
const sig = attStmt.get("sig") as Uint8Array<ArrayBuffer>;

// 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<number, unknown>;
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 {
Expand Down
Loading