Skip to content

Commit 0090da9

Browse files
Andrew Bernatwolpert
authored andcommitted
Create RegistrationService
This is the server side component for registration. This component has operations start and finish. In a follow up PR, it will be used as part of an http service for which the client can start and finish registrations.
1 parent b513429 commit 0090da9

3 files changed

Lines changed: 248 additions & 17 deletions

File tree

clients/passkeys-browser/src/base64url.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function encode(input: ArrayBuffer | Uint8Array | ArrayBufferView): Base6
2222
return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
2323
}
2424

25-
export function decode(input: Base64Url): Uint8Array {
25+
export function decode(input: Base64Url): Uint8Array<ArrayBuffer> {
2626
const pad = "=".repeat((4 - (input.length % 4)) % 4);
2727
const normalized = input.replaceAll("-", "+").replaceAll("_", "/") + pad;
2828
const binary = atob(normalized);
@@ -43,6 +43,11 @@ export function decodeToArrayBuffer(input: Base64Url): ArrayBuffer {
4343

4444
function toUint8Array(input: ArrayBuffer | Uint8Array | ArrayBufferView): Uint8Array {
4545
if (input instanceof Uint8Array) return input;
46-
if (input instanceof ArrayBuffer) return new Uint8Array(input);
46+
// Use toString-based check to handle cross-realm ArrayBuffers (e.g. from TextEncoder in jsdom)
47+
// jsdom's TextEncoder creates an ArrayBuffer that is different from NodeJs ArrayBuffer so "instanceof"
48+
// will not work. The string tag for the type will work.
49+
if (input instanceof ArrayBuffer || Object.prototype.toString.call(input) === "[object ArrayBuffer]") {
50+
return new Uint8Array(input as ArrayBuffer);
51+
}
4752
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
4853
}
Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { describe, expect, it } from "vitest";
2-
import {RegistrationService} from "./registration";
2+
import { Encoder } from "cbor-x";
3+
import * as b64u from "../../src/base64url";
4+
import { decodeCreationOptions, encodeRegistrationResponse } from "../../src/ceremonies";
5+
import { FakeAuthenticator } from "./fakeAuthenticator";
6+
import { RegistrationService } from "./registration";
37

48
describe("RegistrationService", () => {
5-
describe("start", () => {
9+
describe("start a registration", () => {
610
it("returns 403 when a username is not in the allowlist", async () => {
7-
const service = new RegistrationService("localhost", "alice")
8-
expect(
9-
() => service.start({ username: "eve", displayName: null, label: null, challenge: null }),
10-
).toThrow("HTTP 403");
11+
const service = new RegistrationService("localhost", "alice");
12+
await expect(
13+
service.start({ username: "eve", displayName: null, label: null, challenge: null }),
14+
).rejects.toThrow("HTTP 403");
1115
});
1216

1317
it("returns a StartRegistrationResponse when the request is valid", async () => {
1418
const service = new RegistrationService("localhost", "alice")
1519

16-
const response = service.start({ username: "alice", displayName: null, label: null, challenge: null })
20+
const response = await service.start({ username: "alice", displayName: null, label: null, challenge: null })
1721

1822
expect(response.challengeId).toBeTypeOf("string");
1923
expect(response.publicKey.user.id).toBeTypeOf("string");
@@ -26,4 +30,92 @@ describe("RegistrationService", () => {
2630
expect(response.publicKey.attestation).toEqual("direct");
2731
});
2832
});
33+
34+
describe("finish a registration", () => {
35+
it("returns 400 for an unknown challengeId", async () => {
36+
const service = new RegistrationService("localhost", "alice");
37+
await expect(
38+
service.finish({
39+
challengeId: "does-not-exist",
40+
username: "alice",
41+
label: "key",
42+
response: {
43+
id: "x",
44+
rawId: "x",
45+
type: "public-key",
46+
response: { clientDataJSON: "x", attestationObject: "x", transports: [] },
47+
},
48+
}),
49+
).rejects.toThrow("HTTP 400");
50+
});
51+
52+
it("returns 400 for a username that does not match the started session", async () => {
53+
const service = new RegistrationService("localhost", "alice", "bob");
54+
const { challengeId, publicKey } = await service.start({ username: "alice", displayName: null, label: null, challenge: null });
55+
56+
const auth = new FakeAuthenticator();
57+
const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) });
58+
const encoded = encodeRegistrationResponse(credential);
59+
60+
await expect(
61+
service.finish({ challengeId, username: "bob", label: null, response: encoded }),
62+
).rejects.toThrow("HTTP 400");
63+
});
64+
65+
it("verifies the attestation and returns credential metadata", async () => {
66+
const service = new RegistrationService("localhost", "alice");
67+
const { challengeId, publicKey } = await service.start({ username: "alice", displayName: "Alice", label: null, challenge: null });
68+
69+
const auth = new FakeAuthenticator();
70+
const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) });
71+
const encoded = encodeRegistrationResponse(credential);
72+
73+
const { credential: cred } = await service.finish({ challengeId, username: "alice", label: "my-key", response: encoded });
74+
75+
expect(cred.credentialId).toBeTypeOf("string");
76+
expect(cred.userHandle).toBeTypeOf("string");
77+
expect(cred.label).toBe("my-key");
78+
expect(cred.transports).toEqual(["internal"]);
79+
expect(cred.backupEligible).toBe(false);
80+
expect(cred.backupState).toBe(false);
81+
});
82+
83+
it("returns 400 when the attestation signature is tampered", async () => {
84+
const service = new RegistrationService("localhost", "alice");
85+
const { challengeId, publicKey } = await service.start({ username: "alice", displayName: null, label: null, challenge: null });
86+
87+
const auth = new FakeAuthenticator();
88+
const credential = await auth.create({ publicKey: decodeCreationOptions(publicKey) });
89+
const encoded = encodeRegistrationResponse(credential);
90+
91+
// decode the attestation statement; modify it by flipping bits
92+
// then re-encode and submit to finish
93+
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
94+
const attObjBytes = b64u.decode(encoded.response.attestationObject);
95+
const attObj = cbor.decode(attObjBytes) as Map<string, unknown>;
96+
const attStmt = attObj.get("attStmt") as Map<string, unknown>;
97+
const sig = attStmt.get("sig") as Uint8Array;
98+
sig[0]! ^= 0xff;
99+
const tampered = { ...encoded, response: { ...encoded.response, attestationObject: b64u.encode(cbor.encode(attObj) as Uint8Array) } };
100+
101+
await expect(
102+
service.finish({ challengeId, username: "alice", label: null, response: tampered }),
103+
).rejects.toThrow("HTTP 400");
104+
});
105+
106+
it("returns 400 when the challenge in clientDataJSON belongs to a different session", async () => {
107+
const service = new RegistrationService("localhost", "alice");
108+
const s1 = await service.start({ username: "alice", displayName: null, label: null, challenge: null });
109+
const s2 = await service.start({ username: "alice", displayName: null, label: null, challenge: null });
110+
111+
const auth = new FakeAuthenticator();
112+
const credential = await auth.create({ publicKey: decodeCreationOptions(s2.publicKey) });
113+
const encoded = encodeRegistrationResponse(credential);
114+
115+
// finish using s1 challengeId but with s2's credential -- this should fail
116+
await expect(
117+
service.finish({ challengeId: s1.challengeId, username: "alice", label: null, response: encoded }),
118+
).rejects.toThrow("HTTP 400");
119+
});
120+
});
29121
});

clients/passkeys-browser/test/helpers/registration.ts

Lines changed: 142 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11

22
import * as b64u from "../../src/base64url";
3-
import {StartRegistrationRequest, StartRegistrationResponse} from "../../src";
3+
import {
4+
FinishRegistrationRequest,
5+
FinishRegistrationResponse,
6+
StartRegistrationRequest,
7+
StartRegistrationResponse
8+
} from "../../src";
49
import {HttpError} from "./httpServer";
10+
import {Encoder} from "cbor-x";
511

612
interface PendingEntry {
713
displayName: string;
@@ -13,17 +19,15 @@ interface PendingEntry {
1319
}
1420

1521
/*
16-
RegistrationService is a representation of a server side service that can start and finish
22+
RegistrationService is a server side service that can start and finish
1723
client registrations.
1824
Instantiate the service with a relying party id [rpId] and a list of allowed users.
1925
A registration is started with a call to start which will produce a challenge that
20-
the client must respond to when calling finish.
26+
the client must respond to with a call to finish.
2127
*/
2228
export class RegistrationService {
2329
// pending is the map of registrations which have been started; but not yet finished.
2430
// Maps challenge id to a PendingEntry structure.
25-
// A registration.ts cannot be finished unless it has been successfully started.
26-
// A successfully started registration.ts must appear on the pending map.
2731
private readonly pending : Map<string, PendingEntry>;
2832
private readonly rpId : string;
2933
private readonly allowedUsernames : string[];
@@ -34,7 +38,7 @@ export class RegistrationService {
3438
this.allowedUsernames = allowedUsernames ?? [];
3539
}
3640

37-
start(req : StartRegistrationRequest) : StartRegistrationResponse {
41+
async start(req : StartRegistrationRequest) : Promise<StartRegistrationResponse> {
3842
if (!this.allowedUsernames.includes(req.username)) {
3943
throw new HttpError(403, { error: `username '${req.username}' not allowed` });
4044
}
@@ -48,11 +52,141 @@ export class RegistrationService {
4852
userId,
4953
challenge,
5054
challengeId,
51-
rpId: this.rpId };
55+
rpId: this.rpId
56+
};
5257

53-
this.pending.set(challengeId, pendingEntry); // save the requests which will be validated in finish
58+
// save the requests which will be validated in finish
59+
this.pending.set(challengeId, pendingEntry);
5460
return newStartRegistrationResponse(pendingEntry)
5561
}
62+
63+
async finish(req: FinishRegistrationRequest): Promise<FinishRegistrationResponse> {
64+
const entry = this.pending.get(req.challengeId);
65+
if (!entry) {
66+
throw new HttpError(400, { error: `unknown challengeId: ${req.challengeId}` });
67+
}
68+
if (entry.username !== req.username) {
69+
throw new HttpError(400, { error: `username mismatch: ${req.username}` });
70+
}
71+
const clientData = parseClientData(req.response.response.clientDataJSON);
72+
if (clientData.type !== "webauthn.create") {
73+
throw new HttpError(400, { error: `wrong clientDataJSON type: ${clientData.type}` });
74+
}
75+
if (clientData.challenge !== entry.challenge) {
76+
throw new HttpError(400, { error: "challenge mismatch" });
77+
}
78+
79+
const attestation = decodeAttestation(req.response.response.attestationObject)
80+
const publicKey = await parsePublicKey(attestation.publicKeyBytes)
81+
const isValid = await validateChallenge(publicKey, attestation, req.response.response.clientDataJSON)
82+
if (!isValid) {
83+
throw new HttpError(400, { error: "invalid signature" });
84+
}
85+
86+
const credId = attestation.authData.slice(55, 55 + attestation.credentialIdLength);
87+
const flags = attestation.authData[32]!;
88+
this.pending.delete(req.challengeId);
89+
return {
90+
credential: {
91+
credentialId: b64u.encode(credId),
92+
userHandle: entry.userId,
93+
label: req.label ?? "key",
94+
transports: req.response.response.transports ?? [],
95+
counter: attestation.dv.getUint32(33),
96+
backupEligible: (flags & 0x08) !== 0,
97+
backupState: (flags & 0x10) !== 0,
98+
authenticatorData: b64u.encode(attestation.authData),
99+
},
100+
};
101+
}
102+
}
103+
104+
async function validateChallenge(publicKey: CryptoKey, attestation: Attestation, clientDataJson: string) : Promise<boolean> {
105+
const data = b64u.decode(clientDataJson);
106+
const clientDataHash = new Uint8Array(
107+
await crypto.subtle.digest("SHA-256", data),
108+
);
109+
const verificationData = new Uint8Array(attestation.authData.length + clientDataHash.length);
110+
verificationData.set(attestation.authData);
111+
verificationData.set(clientDataHash, attestation.authData.length);
112+
113+
return crypto.subtle.verify(
114+
{ name: "ECDSA", hash: "SHA-256" },
115+
publicKey,
116+
attestation.signature,
117+
verificationData,
118+
);
119+
}
120+
121+
function parsePublicKey(publicKeyBytes : Uint8Array<ArrayBuffer>) : Promise<CryptoKey> {
122+
try {
123+
return crypto.subtle.importKey(
124+
"raw",
125+
publicKeyBytes,
126+
{ name: "ECDSA", namedCurve: "P-256" },
127+
false,
128+
["verify"],
129+
);
130+
} catch(e) {
131+
const eMessage = e as {message: string}
132+
const message = eMessage && eMessage.message ? eMessage.message : "unknown error";
133+
throw new HttpError(400, { error: `invalid public key in authData: ${message}` });
134+
}
135+
}
136+
137+
type Attestation = {
138+
dv: DataView,
139+
credentialIdLength: number,
140+
signature: Uint8Array<ArrayBuffer>,
141+
authData: Uint8Array<ArrayBufferLike>,
142+
publicKeyBytes: Uint8Array<ArrayBuffer>,
143+
}
144+
145+
function decodeAttestation(attestation: string) : Attestation {
146+
const cbor = new Encoder({ useRecords: false, mapsAsObjects: false });
147+
const attestationObjectBytes = b64u.decode(attestation);
148+
const attObj = cbor.decode(attestationObjectBytes) as Map<string, unknown>;
149+
if (attObj.get("fmt") !== "packed") {
150+
throw new HttpError(400, { error: `unsupported attestation format: ${attObj.get("fmt")}` });
151+
}
152+
const authData = attObj.get("authData") as Uint8Array;
153+
const attStmt = attObj.get("attStmt") as Map<string, unknown>;
154+
const sig = attStmt.get("sig") as Uint8Array<ArrayBuffer>;
155+
156+
// Extract COSE public key from authData
157+
const dv = new DataView(authData.buffer, authData.byteOffset);
158+
const credIdLen = dv.getUint16(53);
159+
const coseKey = cbor.decode(authData.slice(55 + credIdLen)) as Map<number, unknown>;
160+
const xBytes = coseKey.get(-2) as Uint8Array;
161+
const yBytes = coseKey.get(-3) as Uint8Array;
162+
163+
const rawPoint = new Uint8Array(65);
164+
rawPoint[0] = 0x04;
165+
rawPoint.set(xBytes, 1);
166+
rawPoint.set(yBytes, 33);
167+
168+
return {
169+
dv: dv,
170+
credentialIdLength: credIdLen,
171+
signature: sig,
172+
authData: authData,
173+
publicKeyBytes: rawPoint
174+
}
175+
}
176+
177+
function parseClientData(clientDataJson: string) : { type:string, challenge: string } {
178+
// Decode and validate clientDataJSON
179+
const data = b64u.decode(clientDataJson);
180+
try {
181+
return JSON.parse(new TextDecoder().decode(data)) as {
182+
type: string;
183+
challenge: string;
184+
};
185+
} catch(e){
186+
const eMessage = e as {message: string}
187+
const message = eMessage && eMessage.message ? eMessage.message : "unknown error";
188+
throw new HttpError(400, { error: "invalid client data json: " + message });
189+
}
56190
}
57191

58192
function newStartRegistrationResponse(pendingEntry: PendingEntry) : StartRegistrationResponse {

0 commit comments

Comments
 (0)