@dwk/webauthn 0.1.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +111 -0
  3. package/dist/cbor.d.ts +34 -0
  4. package/dist/cbor.d.ts.map +1 -0
  5. package/dist/cbor.js +144 -0
  6. package/dist/cbor.js.map +1 -0
  7. package/dist/config.d.ts +108 -0
  8. package/dist/config.d.ts.map +1 -0
  9. package/dist/config.js +70 -0
  10. package/dist/config.js.map +1 -0
  11. package/dist/cose.d.ts +73 -0
  12. package/dist/cose.d.ts.map +1 -0
  13. package/dist/cose.js +191 -0
  14. package/dist/cose.js.map +1 -0
  15. package/dist/encoding.d.ts +28 -0
  16. package/dist/encoding.d.ts.map +1 -0
  17. package/dist/encoding.js +63 -0
  18. package/dist/encoding.js.map +1 -0
  19. package/dist/handler.d.ts +20 -0
  20. package/dist/handler.d.ts.map +1 -0
  21. package/dist/handler.js +101 -0
  22. package/dist/handler.js.map +1 -0
  23. package/dist/index.d.ts +29 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +28 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/log.d.ts +25 -0
  28. package/dist/log.d.ts.map +1 -0
  29. package/dist/log.js +26 -0
  30. package/dist/log.js.map +1 -0
  31. package/dist/rp.d.ts +21 -0
  32. package/dist/rp.d.ts.map +1 -0
  33. package/dist/rp.js +336 -0
  34. package/dist/rp.js.map +1 -0
  35. package/dist/verify.d.ts +135 -0
  36. package/dist/verify.d.ts.map +1 -0
  37. package/dist/verify.js +277 -0
  38. package/dist/verify.js.map +1 -0
  39. package/package.json +50 -0
  40. package/src/cbor.ts +168 -0
  41. package/src/config.ts +179 -0
  42. package/src/cose.ts +238 -0
  43. package/src/encoding.ts +68 -0
  44. package/src/handler.ts +135 -0
  45. package/src/index.ts +54 -0
  46. package/src/log.ts +25 -0
  47. package/src/rp.ts +492 -0
  48. package/src/verify.ts +471 -0
package/src/rp.ts ADDED
@@ -0,0 +1,492 @@
1
+ /**
2
+ * The per-relying-party Durable Object: the single-threaded consistency
3
+ * authority for WebAuthn challenge state and credential records.
4
+ *
5
+ * The stateless front door (`handler.ts`) routes a ceremony step to this object
6
+ * via the {@link INTERNAL_HEADERS.op} header and the request body; everything
7
+ * that must be strongly consistent — minting and single-use consumption of
8
+ * short-TTL challenges, and reading/writing credential records (public key,
9
+ * signature counter, transports) — happens here, where Cloudflare guarantees a
10
+ * single thread per relying party. Challenge and credential state are kept in DO
11
+ * SQLite (never KV: a stale challenge or counter is a security bug). Consumers
12
+ * bind this class as a Durable Object namespace.
13
+ */
14
+
15
+ import { DurableObject } from "cloudflare:workers";
16
+
17
+ import {
18
+ INTERNAL_HEADERS,
19
+ type ForwardedConfig,
20
+ type WebAuthnEnv,
21
+ } from "./config";
22
+ import { base64urlToBytes, bytesToBase64url } from "./encoding";
23
+ import { WebAuthnLogEvent } from "./log";
24
+ import {
25
+ verifyAuthentication,
26
+ verifyRegistration,
27
+ type VerifyFailureReason,
28
+ } from "./verify";
29
+
30
+ /** A stored challenge row. */
31
+ type ChallengeRow = {
32
+ challenge: string;
33
+ type: string;
34
+ user_id: string | null;
35
+ expires_at: number;
36
+ };
37
+
38
+ /** A stored credential row. */
39
+ type CredentialRow = {
40
+ credential_id: string;
41
+ user_id: string;
42
+ public_key: string;
43
+ alg: number;
44
+ counter: number;
45
+ transports: string | null;
46
+ };
47
+
48
+ /** Number of random bytes in a challenge (WebAuthn recommends ≥16). */
49
+ const CHALLENGE_BYTES = 32;
50
+ /** Random bytes minted for a generated user handle. */
51
+ const USER_HANDLE_BYTES = 16;
52
+
53
+ export class WebAuthnObject extends DurableObject<WebAuthnEnv> {
54
+ readonly #sql: SqlStorage;
55
+
56
+ constructor(state: DurableObjectState, env: WebAuthnEnv) {
57
+ super(state, env);
58
+ this.#sql = state.storage.sql;
59
+ this.#sql.exec(
60
+ `CREATE TABLE IF NOT EXISTS challenges (
61
+ challenge TEXT PRIMARY KEY,
62
+ type TEXT NOT NULL,
63
+ user_id TEXT,
64
+ expires_at INTEGER NOT NULL
65
+ )`,
66
+ );
67
+ this.#sql.exec(
68
+ `CREATE TABLE IF NOT EXISTS credentials (
69
+ credential_id TEXT PRIMARY KEY,
70
+ user_id TEXT NOT NULL,
71
+ public_key TEXT NOT NULL,
72
+ alg INTEGER NOT NULL,
73
+ counter INTEGER NOT NULL,
74
+ transports TEXT
75
+ )`,
76
+ );
77
+ this.#sql.exec(
78
+ `CREATE INDEX IF NOT EXISTS credentials_user_id ON credentials (user_id)`,
79
+ );
80
+ }
81
+
82
+ override async fetch(request: Request): Promise<Response> {
83
+ const op = request.headers.get(INTERNAL_HEADERS.op);
84
+ const config = this.#readConfig(request);
85
+ const now = Number(request.headers.get(INTERNAL_HEADERS.now)) || Date.now();
86
+ const body = await readJsonObject(request);
87
+ if (config === null) return badRequest("missing config");
88
+
89
+ switch (op) {
90
+ case "register/options":
91
+ return this.#registerOptions(config, now, body);
92
+ case "register/verify":
93
+ return this.#registerVerify(config, now, body);
94
+ case "authenticate/options":
95
+ return this.#authenticateOptions(config, now, body);
96
+ case "authenticate/verify":
97
+ return this.#authenticateVerify(config, now, body);
98
+ default:
99
+ return new Response("Not Found", { status: 404 });
100
+ }
101
+ }
102
+
103
+ #readConfig(request: Request): ForwardedConfig | null {
104
+ const raw = request.headers.get(INTERNAL_HEADERS.config);
105
+ if (!raw) return null;
106
+ try {
107
+ return JSON.parse(raw) as ForwardedConfig;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ // -- registration ----------------------------------------------------------
114
+
115
+ #registerOptions(
116
+ config: ForwardedConfig,
117
+ now: number,
118
+ body: Record<string, unknown>,
119
+ ): Response {
120
+ const user = isObject(body.user) ? body.user : {};
121
+ const userId =
122
+ typeof user.id === "string" && user.id.length > 0
123
+ ? user.id
124
+ : bytesToBase64url(randomBytes(USER_HANDLE_BYTES));
125
+ const name = typeof user.name === "string" ? user.name : "user";
126
+ const displayName =
127
+ typeof user.displayName === "string" ? user.displayName : name;
128
+
129
+ const challenge = this.#mintChallenge("registration", userId, config, now);
130
+
131
+ const options = {
132
+ challenge,
133
+ rp: { id: config.rpId, name: config.rpName },
134
+ user: { id: userId, name, displayName },
135
+ pubKeyCredParams: config.algorithms.map((alg) => ({
136
+ type: "public-key",
137
+ alg,
138
+ })),
139
+ timeout: config.timeoutMs,
140
+ attestation: "none",
141
+ authenticatorSelection: {
142
+ userVerification: config.userVerification,
143
+ residentKey: "preferred",
144
+ },
145
+ excludeCredentials: this.#credentialDescriptors(userId),
146
+ };
147
+ return ok(options, WebAuthnLogEvent.RegisterOptions);
148
+ }
149
+
150
+ async #registerVerify(
151
+ config: ForwardedConfig,
152
+ now: number,
153
+ body: Record<string, unknown>,
154
+ ): Promise<Response> {
155
+ const response = isObject(body.response) ? body.response : null;
156
+ const clientDataB64 = response?.clientDataJSON;
157
+ const attestationB64 = response?.attestationObject;
158
+ if (
159
+ typeof clientDataB64 !== "string" ||
160
+ typeof attestationB64 !== "string"
161
+ ) {
162
+ return rejected(WebAuthnLogEvent.RegisterRejected, "bad_request");
163
+ }
164
+
165
+ const clientDataJSON = decode(clientDataB64);
166
+ const attestationObject = decode(attestationB64);
167
+ if (clientDataJSON === null || attestationObject === null) {
168
+ return rejected(WebAuthnLogEvent.RegisterRejected, "bad_request");
169
+ }
170
+
171
+ const row = this.#consumeChallenge(clientDataJSON, "registration", now);
172
+ if (row === null) {
173
+ return rejected(WebAuthnLogEvent.RegisterRejected, "challenge_mismatch");
174
+ }
175
+
176
+ const result = await verifyRegistration({
177
+ attestationObject,
178
+ clientDataJSON,
179
+ expectedChallenge: row.challenge,
180
+ expectedOrigins: config.origins,
181
+ expectedRpId: config.rpId,
182
+ requireUserVerification: config.userVerification === "required",
183
+ });
184
+ if (!result.verified) {
185
+ return rejected(WebAuthnLogEvent.RegisterRejected, result.reason);
186
+ }
187
+
188
+ const userId = row.user_id ?? "";
189
+ const transports = normalizeTransports(
190
+ response?.transports ?? body.transports,
191
+ );
192
+ try {
193
+ this.#sql.exec(
194
+ `INSERT INTO credentials
195
+ (credential_id, user_id, public_key, alg, counter, transports)
196
+ VALUES (?, ?, ?, ?, ?, ?)`,
197
+ result.credential.id,
198
+ userId,
199
+ JSON.stringify(result.credential.publicKeyJwk),
200
+ result.credential.alg,
201
+ result.credential.counter,
202
+ transports === null ? null : JSON.stringify(transports),
203
+ );
204
+ } catch {
205
+ // PRIMARY KEY conflict: this credential is already registered.
206
+ return rejected(WebAuthnLogEvent.RegisterRejected, "credential_exists");
207
+ }
208
+
209
+ return ok(
210
+ {
211
+ verified: true,
212
+ credentialId: result.credential.id,
213
+ userId,
214
+ aaguid: result.credential.aaguid,
215
+ },
216
+ WebAuthnLogEvent.RegisterVerified,
217
+ );
218
+ }
219
+
220
+ // -- authentication --------------------------------------------------------
221
+
222
+ #authenticateOptions(
223
+ config: ForwardedConfig,
224
+ now: number,
225
+ body: Record<string, unknown>,
226
+ ): Response {
227
+ const userId = typeof body.userId === "string" ? body.userId : null;
228
+ const challenge = this.#mintChallenge(
229
+ "authentication",
230
+ userId,
231
+ config,
232
+ now,
233
+ );
234
+
235
+ const options: Record<string, unknown> = {
236
+ challenge,
237
+ timeout: config.timeoutMs,
238
+ rpId: config.rpId,
239
+ userVerification: config.userVerification,
240
+ };
241
+ // With a known user we scope the assertion to their credentials; without
242
+ // one we omit allowCredentials so a discoverable credential can be used.
243
+ if (userId !== null) {
244
+ options.allowCredentials = this.#credentialDescriptors(userId);
245
+ }
246
+ return ok(options, WebAuthnLogEvent.AuthenticateOptions);
247
+ }
248
+
249
+ async #authenticateVerify(
250
+ config: ForwardedConfig,
251
+ now: number,
252
+ body: Record<string, unknown>,
253
+ ): Promise<Response> {
254
+ const response = isObject(body.response) ? body.response : null;
255
+ const clientDataB64 = response?.clientDataJSON;
256
+ const authDataB64 = response?.authenticatorData;
257
+ const signatureB64 = response?.signature;
258
+ const credentialId = typeof body.id === "string" ? body.id : body.rawId;
259
+ if (
260
+ typeof clientDataB64 !== "string" ||
261
+ typeof authDataB64 !== "string" ||
262
+ typeof signatureB64 !== "string" ||
263
+ typeof credentialId !== "string"
264
+ ) {
265
+ return rejected(WebAuthnLogEvent.AuthenticateRejected, "bad_request");
266
+ }
267
+
268
+ const clientDataJSON = decode(clientDataB64);
269
+ const authenticatorData = decode(authDataB64);
270
+ const signature = decode(signatureB64);
271
+ if (
272
+ clientDataJSON === null ||
273
+ authenticatorData === null ||
274
+ signature === null
275
+ ) {
276
+ return rejected(WebAuthnLogEvent.AuthenticateRejected, "bad_request");
277
+ }
278
+
279
+ const row = this.#consumeChallenge(clientDataJSON, "authentication", now);
280
+ if (row === null) {
281
+ return rejected(
282
+ WebAuthnLogEvent.AuthenticateRejected,
283
+ "challenge_mismatch",
284
+ );
285
+ }
286
+
287
+ const credential = this.#sql
288
+ .exec<CredentialRow>(
289
+ "SELECT * FROM credentials WHERE credential_id = ?",
290
+ credentialId,
291
+ )
292
+ .toArray()[0];
293
+ if (!credential) {
294
+ return rejected(WebAuthnLogEvent.AuthenticateRejected, "no_credential");
295
+ }
296
+ // When the ceremony was scoped to a user, the asserted credential MUST be
297
+ // one of theirs.
298
+ if (row.user_id !== null && credential.user_id !== row.user_id) {
299
+ return rejected(WebAuthnLogEvent.AuthenticateRejected, "no_credential");
300
+ }
301
+
302
+ const result = await verifyAuthentication({
303
+ authenticatorData,
304
+ clientDataJSON,
305
+ signature,
306
+ expectedChallenge: row.challenge,
307
+ expectedOrigins: config.origins,
308
+ expectedRpId: config.rpId,
309
+ requireUserVerification: config.userVerification === "required",
310
+ credentialPublicKeyJwk: JSON.parse(credential.public_key) as JsonWebKey,
311
+ credentialAlg: credential.alg,
312
+ storedCounter: credential.counter,
313
+ });
314
+ if (!result.verified) {
315
+ return rejected(WebAuthnLogEvent.AuthenticateRejected, result.reason);
316
+ }
317
+
318
+ this.#sql.exec(
319
+ "UPDATE credentials SET counter = ? WHERE credential_id = ?",
320
+ result.newCounter,
321
+ credentialId,
322
+ );
323
+
324
+ return ok(
325
+ {
326
+ verified: true,
327
+ credentialId,
328
+ userId: credential.user_id,
329
+ },
330
+ WebAuthnLogEvent.AuthenticateVerified,
331
+ );
332
+ }
333
+
334
+ // -- challenge state -------------------------------------------------------
335
+
336
+ /** Mint, store, and return a fresh challenge; prune expired rows first. */
337
+ #mintChallenge(
338
+ type: "registration" | "authentication",
339
+ userId: string | null,
340
+ config: ForwardedConfig,
341
+ now: number,
342
+ ): string {
343
+ this.#sql.exec("DELETE FROM challenges WHERE expires_at < ?", now);
344
+ const challenge = bytesToBase64url(randomBytes(CHALLENGE_BYTES));
345
+ this.#sql.exec(
346
+ `INSERT INTO challenges (challenge, type, user_id, expires_at)
347
+ VALUES (?, ?, ?, ?)`,
348
+ challenge,
349
+ type,
350
+ userId,
351
+ now + config.challengeTtlSeconds * 1000,
352
+ );
353
+ return challenge;
354
+ }
355
+
356
+ /**
357
+ * Find the stored challenge named in `clientDataJSON`, of the expected type
358
+ * and unexpired, and delete it (single use). Returns the row or `null` when no
359
+ * live matching challenge exists. The challenge is consumed even on a later
360
+ * verification failure, so a captured ceremony cannot be replayed.
361
+ */
362
+ #consumeChallenge(
363
+ clientDataJSON: Uint8Array,
364
+ type: "registration" | "authentication",
365
+ now: number,
366
+ ): ChallengeRow | null {
367
+ let challenge: string;
368
+ try {
369
+ const parsed = JSON.parse(new TextDecoder().decode(clientDataJSON)) as {
370
+ challenge?: unknown;
371
+ };
372
+ if (typeof parsed.challenge !== "string") return null;
373
+ challenge = parsed.challenge;
374
+ } catch {
375
+ return null;
376
+ }
377
+
378
+ const row = this.#sql
379
+ .exec<ChallengeRow>(
380
+ "SELECT * FROM challenges WHERE challenge = ? AND type = ?",
381
+ challenge,
382
+ type,
383
+ )
384
+ .toArray()[0];
385
+ if (!row) return null;
386
+ this.#sql.exec("DELETE FROM challenges WHERE challenge = ?", challenge);
387
+ if (row.expires_at < now) return null;
388
+ return row;
389
+ }
390
+
391
+ /** Build the credential-descriptor list for a user (exclude / allow lists). */
392
+ #credentialDescriptors(
393
+ userId: string,
394
+ ): { type: "public-key"; id: string; transports?: string[] }[] {
395
+ return this.#sql
396
+ .exec<CredentialRow>(
397
+ "SELECT * FROM credentials WHERE user_id = ?",
398
+ userId,
399
+ )
400
+ .toArray()
401
+ .map((row) => {
402
+ const transports = parseTransports(row.transports);
403
+ return {
404
+ type: "public-key" as const,
405
+ id: row.credential_id,
406
+ ...(transports ? { transports } : {}),
407
+ };
408
+ });
409
+ }
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Module-level helpers
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function randomBytes(length: number): Uint8Array {
417
+ return crypto.getRandomValues(new Uint8Array(length));
418
+ }
419
+
420
+ function isObject(value: unknown): value is Record<string, unknown> {
421
+ return typeof value === "object" && value !== null && !Array.isArray(value);
422
+ }
423
+
424
+ /** Base64url-decode a field, returning `null` if it is not decodable. */
425
+ function decode(input: string): Uint8Array | null {
426
+ try {
427
+ return base64urlToBytes(input);
428
+ } catch {
429
+ return null;
430
+ }
431
+ }
432
+
433
+ async function readJsonObject(
434
+ request: Request,
435
+ ): Promise<Record<string, unknown>> {
436
+ try {
437
+ const parsed = (await request.json()) as unknown;
438
+ return isObject(parsed) ? parsed : {};
439
+ } catch {
440
+ return {};
441
+ }
442
+ }
443
+
444
+ /** Coerce a client-supplied transports value to a string array, or `null`. */
445
+ function normalizeTransports(value: unknown): string[] | null {
446
+ if (!Array.isArray(value)) return null;
447
+ const transports = value.filter((t): t is string => typeof t === "string");
448
+ return transports.length > 0 ? transports : null;
449
+ }
450
+
451
+ /** Parse the stored transports JSON back to an array, or `null`. */
452
+ function parseTransports(stored: string | null): string[] | null {
453
+ if (stored === null) return null;
454
+ try {
455
+ const parsed = JSON.parse(stored) as unknown;
456
+ return normalizeTransports(parsed);
457
+ } catch {
458
+ return null;
459
+ }
460
+ }
461
+
462
+ /** A `200` JSON response carrying the ceremony outcome event for the front door. */
463
+ function ok(body: unknown, event: WebAuthnLogEvent): Response {
464
+ return json(200, body, { [INTERNAL_HEADERS.event]: event });
465
+ }
466
+
467
+ /** A `400` JSON rejection carrying the outcome event and a stable reason. */
468
+ function rejected(
469
+ event: WebAuthnLogEvent,
470
+ reason: VerifyFailureReason | string,
471
+ ): Response {
472
+ return json(
473
+ 400,
474
+ { verified: false, error: reason },
475
+ { [INTERNAL_HEADERS.event]: event, [INTERNAL_HEADERS.reason]: reason },
476
+ );
477
+ }
478
+
479
+ function badRequest(message: string): Response {
480
+ return json(400, { error: message });
481
+ }
482
+
483
+ function json(
484
+ status: number,
485
+ body: unknown,
486
+ headers: Record<string, string> = {},
487
+ ): Response {
488
+ return new Response(JSON.stringify(body), {
489
+ status,
490
+ headers: { "content-type": "application/json; charset=utf-8", ...headers },
491
+ });
492
+ }