@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/dist/verify.js ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * The WebAuthn ceremony verification core: parsing `clientDataJSON` and
3
+ * authenticator data, then verifying a registration (attestation) and an
4
+ * authentication (assertion) per the
5
+ * [W3C WebAuthn Level 3](https://www.w3.org/TR/webauthn-3/) verification
6
+ * procedures (§7.1 and §7.2).
7
+ *
8
+ * These functions are **pure** and runtime-agnostic — plain-data in, result
9
+ * out, Web Crypto the only I/O — so they unit-test in isolation and never read
10
+ * Cloudflare bindings. The relying party state (single-use challenges, stored
11
+ * credential records, the signature counter) lives in the Durable Object, which
12
+ * supplies the expected values to these functions and persists what they return.
13
+ *
14
+ * Attestation scope: the relying party requests `attestation: "none"`, so the
15
+ * accepted attestation statement formats are `none` and `packed` *self*
16
+ * attestation (no `x5c`). Verifying a full attestation-certificate chain (basic
17
+ * / AttCA attestation) is intentionally out of scope — it proves authenticator
18
+ * provenance, which a personal-site relying party does not need. A format
19
+ * carrying `x5c` is rejected rather than silently trusted.
20
+ */
21
+ import { decodeFirst, CborError } from "./cbor";
22
+ import { coseToKey, cryptoParamsForCoseAlg, derToRawEcdsaSignature, CoseError, } from "./cose";
23
+ import { bytesEqual, bytesToBase64url, bytesToUtf8, normalizeBase64url, sha256, } from "./encoding";
24
+ /** Parse `clientDataJSON` bytes, or `null` if it is not a valid client-data object. */
25
+ export function parseClientData(bytes) {
26
+ let parsed;
27
+ try {
28
+ parsed = JSON.parse(bytesToUtf8(bytes));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ if (typeof parsed !== "object" || parsed === null)
34
+ return null;
35
+ const obj = parsed;
36
+ if (typeof obj.type !== "string" ||
37
+ typeof obj.challenge !== "string" ||
38
+ typeof obj.origin !== "string") {
39
+ return null;
40
+ }
41
+ return {
42
+ type: obj.type,
43
+ challenge: obj.challenge,
44
+ origin: obj.origin,
45
+ ...(typeof obj.crossOrigin === "boolean"
46
+ ? { crossOrigin: obj.crossOrigin }
47
+ : {}),
48
+ };
49
+ }
50
+ /**
51
+ * Parse authenticator data. The fixed header is 37 bytes (32 rpIdHash + 1 flags
52
+ * + 4 signCount); when the AT flag is set, attested credential data follows
53
+ * (16 aaguid + 2 credentialIdLength + L credentialId + the COSE public key).
54
+ * Returns `null` on any structural problem.
55
+ */
56
+ export function parseAuthenticatorData(bytes) {
57
+ if (bytes.length < 37)
58
+ return null;
59
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
60
+ const rpIdHash = bytes.subarray(0, 32);
61
+ const flagsByte = bytes[32];
62
+ const flags = {
63
+ up: (flagsByte & 0x01) !== 0,
64
+ uv: (flagsByte & 0x04) !== 0,
65
+ at: (flagsByte & 0x40) !== 0,
66
+ ed: (flagsByte & 0x80) !== 0,
67
+ };
68
+ const signCount = view.getUint32(33);
69
+ if (!flags.at) {
70
+ return { rpIdHash, flags, signCount };
71
+ }
72
+ // Attested credential data follows the fixed header.
73
+ if (bytes.length < 55)
74
+ return null;
75
+ const aaguid = bytes.subarray(37, 53);
76
+ const credentialIdLength = view.getUint16(53);
77
+ const idStart = 55;
78
+ const idEnd = idStart + credentialIdLength;
79
+ if (idEnd > bytes.length)
80
+ return null;
81
+ const credentialId = bytes.subarray(idStart, idEnd);
82
+ let decoded;
83
+ try {
84
+ decoded = decodeFirst(bytes, idEnd);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof CborError)
88
+ return null;
89
+ throw error;
90
+ }
91
+ if (!(decoded.value instanceof Map))
92
+ return null;
93
+ return {
94
+ rpIdHash,
95
+ flags,
96
+ signCount,
97
+ attestedCredentialData: {
98
+ aaguid,
99
+ credentialId,
100
+ credentialPublicKey: decoded.value,
101
+ },
102
+ };
103
+ }
104
+ function reject(reason) {
105
+ return { verified: false, reason };
106
+ }
107
+ /** Shared `clientDataJSON` checks for both ceremonies. */
108
+ function checkClientData(clientDataJSON, expectedType, expectedChallenge, expectedOrigins) {
109
+ const clientData = parseClientData(clientDataJSON);
110
+ if (clientData === null)
111
+ return "client_data_malformed";
112
+ if (clientData.type !== expectedType)
113
+ return "client_data_type";
114
+ if (normalizeBase64url(clientData.challenge) !==
115
+ normalizeBase64url(expectedChallenge)) {
116
+ return "challenge_mismatch";
117
+ }
118
+ if (!expectedOrigins.includes(clientData.origin))
119
+ return "origin_mismatch";
120
+ return clientData;
121
+ }
122
+ /** Verify a registration (attestation) ceremony (WebAuthn §7.1). */
123
+ export async function verifyRegistration(input) {
124
+ const clientData = checkClientData(input.clientDataJSON, "webauthn.create", input.expectedChallenge, input.expectedOrigins);
125
+ if (typeof clientData === "string")
126
+ return reject(clientData);
127
+ // Decode the attestation object: { fmt, attStmt, authData }.
128
+ let attestation;
129
+ try {
130
+ attestation = decodeFirst(input.attestationObject).value;
131
+ }
132
+ catch (error) {
133
+ if (error instanceof CborError)
134
+ return reject("attestation_malformed");
135
+ throw error;
136
+ }
137
+ if (!(attestation instanceof Map))
138
+ return reject("attestation_malformed");
139
+ const fmt = attestation.get("fmt");
140
+ const authDataBytes = attestation.get("authData");
141
+ const attStmt = attestation.get("attStmt");
142
+ if (typeof fmt !== "string" || !(authDataBytes instanceof Uint8Array)) {
143
+ return reject("attestation_malformed");
144
+ }
145
+ const authData = parseAuthenticatorData(authDataBytes);
146
+ if (authData === null)
147
+ return reject("auth_data_malformed");
148
+ const rpCheck = await checkRpAndFlags(authData, input);
149
+ if (rpCheck !== null)
150
+ return reject(rpCheck);
151
+ if (!authData.attestedCredentialData)
152
+ return reject("no_credential_data");
153
+ // Resolve the credential public key from its COSE_Key.
154
+ let coseKey;
155
+ try {
156
+ coseKey = coseToKey(authData.attestedCredentialData.credentialPublicKey);
157
+ }
158
+ catch (error) {
159
+ if (error instanceof CoseError)
160
+ return reject("credential_key_unsupported");
161
+ throw error;
162
+ }
163
+ if (cryptoParamsForCoseAlg(coseKey.alg) === null) {
164
+ return reject("credential_key_unsupported");
165
+ }
166
+ // Verify the attestation statement (none / packed self-attestation only).
167
+ const attResult = await verifyAttestationStatement(fmt, attStmt, authDataBytes, input.clientDataJSON, coseKey.alg, coseKey.jwk);
168
+ if (attResult !== null)
169
+ return reject(attResult);
170
+ return {
171
+ verified: true,
172
+ credential: {
173
+ id: bytesToBase64url(authData.attestedCredentialData.credentialId),
174
+ publicKeyJwk: coseKey.jwk,
175
+ alg: coseKey.alg,
176
+ counter: authData.signCount,
177
+ aaguid: bytesToBase64url(authData.attestedCredentialData.aaguid),
178
+ },
179
+ };
180
+ }
181
+ /**
182
+ * Verify the supported attestation statement formats. Returns `null` on success
183
+ * or a failure reason. `none` carries no statement. `packed` *self*-attestation
184
+ * signs `authData ‖ SHA-256(clientDataJSON)` with the credential's own private
185
+ * key, so it verifies against the just-extracted credential public key; a
186
+ * `packed` statement carrying `x5c` (full chain) is unsupported.
187
+ */
188
+ async function verifyAttestationStatement(fmt, attStmt, authDataBytes, clientDataJSON, credentialAlg, credentialJwk) {
189
+ if (fmt === "none")
190
+ return null;
191
+ if (fmt !== "packed")
192
+ return "attestation_format_unsupported";
193
+ if (!(attStmt instanceof Map))
194
+ return "attestation_malformed";
195
+ // Full attestation-certificate chains are out of scope.
196
+ if (attStmt.has("x5c"))
197
+ return "attestation_format_unsupported";
198
+ const alg = attStmt.get("alg");
199
+ const sig = attStmt.get("sig");
200
+ if (typeof alg !== "number" || !(sig instanceof Uint8Array)) {
201
+ return "attestation_malformed";
202
+ }
203
+ // Self attestation MUST use the credential key's algorithm.
204
+ if (alg !== credentialAlg)
205
+ return "attestation_format_unsupported";
206
+ const clientDataHash = await sha256(clientDataJSON);
207
+ const signedData = concat(authDataBytes, clientDataHash);
208
+ const ok = await verifySignature(credentialJwk, alg, sig, signedData);
209
+ return ok ? null : "signature_invalid";
210
+ }
211
+ /** Verify an authentication (assertion) ceremony (WebAuthn §7.2). */
212
+ export async function verifyAuthentication(input) {
213
+ const clientData = checkClientData(input.clientDataJSON, "webauthn.get", input.expectedChallenge, input.expectedOrigins);
214
+ if (typeof clientData === "string")
215
+ return reject(clientData);
216
+ const authData = parseAuthenticatorData(input.authenticatorData);
217
+ if (authData === null)
218
+ return reject("auth_data_malformed");
219
+ const rpCheck = await checkRpAndFlags(authData, input);
220
+ if (rpCheck !== null)
221
+ return reject(rpCheck);
222
+ // Signature is over authenticatorData ‖ SHA-256(clientDataJSON).
223
+ const clientDataHash = await sha256(input.clientDataJSON);
224
+ const signedData = concat(input.authenticatorData, clientDataHash);
225
+ const ok = await verifySignature(input.credentialPublicKeyJwk, input.credentialAlg, input.signature, signedData);
226
+ if (!ok)
227
+ return reject("signature_invalid");
228
+ // Cloned-authenticator detection (WebAuthn §7.2 step 21): when either counter
229
+ // is non-zero the new value MUST be strictly greater than the stored one.
230
+ if ((authData.signCount !== 0 || input.storedCounter !== 0) &&
231
+ authData.signCount <= input.storedCounter) {
232
+ return reject("counter_regression");
233
+ }
234
+ return { verified: true, newCounter: authData.signCount };
235
+ }
236
+ /** Verify the rpIdHash and the User Present / User Verified flags. */
237
+ async function checkRpAndFlags(authData, input) {
238
+ const expectedHash = await sha256(new TextEncoder().encode(input.expectedRpId));
239
+ if (!bytesEqual(authData.rpIdHash, expectedHash))
240
+ return "rp_id_mismatch";
241
+ if (!authData.flags.up)
242
+ return "user_not_present";
243
+ if (input.requireUserVerification && !authData.flags.uv) {
244
+ return "user_not_verified";
245
+ }
246
+ return null;
247
+ }
248
+ /** Import a JWK for the COSE alg and verify a signature, normalizing EC DER. */
249
+ async function verifySignature(jwk, alg, signature, signedData) {
250
+ const params = cryptoParamsForCoseAlg(alg);
251
+ if (params === null)
252
+ return false;
253
+ let rawSignature = signature;
254
+ if (params.ec) {
255
+ try {
256
+ rawSignature = derToRawEcdsaSignature(signature, params.ecSignatureBytes);
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ try {
263
+ const key = await crypto.subtle.importKey("jwk", jwk, params.importParams, false, ["verify"]);
264
+ return await crypto.subtle.verify(params.verifyParams, key, rawSignature, signedData);
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ }
270
+ /** Concatenate two byte arrays into one. */
271
+ function concat(a, b) {
272
+ const out = new Uint8Array(a.length + b.length);
273
+ out.set(a, 0);
274
+ out.set(b, a.length);
275
+ return out;
276
+ }
277
+ //# sourceMappingURL=verify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify.js","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@dwk/webauthn",
3
+ "version": "0.1.0-beta.0",
4
+ "description": "WebAuthn / passkeys relying party: registration + authentication ceremonies over a per-RP Durable Object for challenge state and credential records.",
5
+ "keywords": [
6
+ "webauthn",
7
+ "passkeys",
8
+ "fido2",
9
+ "relying-party",
10
+ "attestation",
11
+ "assertion",
12
+ "cloudflare-workers",
13
+ "durable-objects"
14
+ ],
15
+ "type": "module",
16
+ "license": "ISC",
17
+ "author": "David W. Keith <me@dwk.io>",
18
+ "homepage": "https://github.com/davidwkeith/workers/tree/main/packages/webauthn#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/davidwkeith/workers.git",
22
+ "directory": "packages/webauthn"
23
+ },
24
+ "sideEffects": false,
25
+ "main": "./dist/index.js",
26
+ "types": "./dist/index.d.ts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "src",
36
+ "!src/**/*.test.ts",
37
+ "!src/test-harness.ts"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@dwk/log": "0.1.0-beta.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "typecheck": "tsc -p tsconfig.json",
48
+ "clean": "rm -rf dist"
49
+ }
50
+ }
package/src/cbor.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * A minimal CBOR (RFC 8949) decoder — only the subset WebAuthn needs.
3
+ *
4
+ * Two WebAuthn structures are CBOR: the attestation object (a string-keyed map
5
+ * wrapping `fmt` / `attStmt` / `authData`) and the credential public key (a
6
+ * COSE_Key, an integer-keyed map). Decoding those needs unsigned and negative
7
+ * integers, byte and text strings, arrays, and maps — nothing else. We
8
+ * deliberately do **not** pull a general CBOR library: the script-size budget is
9
+ * tight (see `spec/non-functional-requirements.md`) and a focused decoder is
10
+ * easy to audit.
11
+ *
12
+ * Maps decode to `Map` so integer COSE labels survive as keys (a plain object
13
+ * would coerce them to strings and collide negative/positive labels by string
14
+ * form). The decoder reports how many bytes it consumed so a caller can locate
15
+ * the bytes that follow a variable-length structure (the COSE key embedded in
16
+ * authenticator data is followed by optional extension bytes).
17
+ */
18
+
19
+ /** A decoded CBOR value (the subset this decoder produces). */
20
+ export type CborValue =
21
+ | number
22
+ | bigint
23
+ | string
24
+ | Uint8Array
25
+ | CborValue[]
26
+ | Map<CborValue, CborValue>;
27
+
28
+ /** A decoded value together with the number of input bytes it consumed. */
29
+ export interface CborDecoded {
30
+ readonly value: CborValue;
31
+ readonly length: number;
32
+ }
33
+
34
+ /** Thrown when the input is not valid CBOR (or uses an unsupported feature). */
35
+ export class CborError extends Error {}
36
+
37
+ /**
38
+ * Decode the first CBOR data item starting at `start`. Returns the value and
39
+ * the number of bytes consumed; trailing bytes are ignored (the caller decides
40
+ * whether they are allowed).
41
+ */
42
+ export function decodeFirst(bytes: Uint8Array, start = 0): CborDecoded {
43
+ const reader = new Reader(bytes, start);
44
+ const value = reader.readItem();
45
+ return { value, length: reader.offset - start };
46
+ }
47
+
48
+ class Reader {
49
+ offset: number;
50
+ readonly #bytes: Uint8Array;
51
+ readonly #view: DataView;
52
+
53
+ constructor(bytes: Uint8Array, start: number) {
54
+ this.#bytes = bytes;
55
+ this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
56
+ this.offset = start;
57
+ }
58
+
59
+ readItem(): CborValue {
60
+ const initial = this.#byte();
61
+ const major = initial >> 5;
62
+ const info = initial & 0x1f;
63
+
64
+ switch (major) {
65
+ case 0: // unsigned integer
66
+ return this.#argument(info);
67
+ case 1: {
68
+ // negative integer: encodes -(n + 1)
69
+ const n = this.#argument(info);
70
+ return typeof n === "bigint" ? -(n + 1n) : -(n + 1);
71
+ }
72
+ case 2: // byte string
73
+ return this.#bytesOfLength(this.#lengthArgument(info));
74
+ case 3: // text string
75
+ return new TextDecoder().decode(
76
+ this.#bytesOfLength(this.#lengthArgument(info)),
77
+ );
78
+ case 4: {
79
+ // array
80
+ const len = this.#lengthArgument(info);
81
+ const items: CborValue[] = [];
82
+ for (let i = 0; i < len; i++) items.push(this.readItem());
83
+ return items;
84
+ }
85
+ case 5: {
86
+ // map
87
+ const len = this.#lengthArgument(info);
88
+ const map = new Map<CborValue, CborValue>();
89
+ for (let i = 0; i < len; i++) {
90
+ const key = this.readItem();
91
+ map.set(key, this.readItem());
92
+ }
93
+ return map;
94
+ }
95
+ default:
96
+ // Tags (6) and simple/float (7) are not used by the WebAuthn subset.
97
+ throw new CborError(`unsupported CBOR major type ${major}`);
98
+ }
99
+ }
100
+
101
+ /** Read the integer argument that follows the initial byte. */
102
+ #argument(info: number): number | bigint {
103
+ if (info < 24) return info;
104
+ switch (info) {
105
+ case 24:
106
+ return this.#byte();
107
+ case 25: {
108
+ this.#need(2);
109
+ const v = this.#view.getUint16(this.offset);
110
+ this.offset += 2;
111
+ return v;
112
+ }
113
+ case 26: {
114
+ this.#need(4);
115
+ const v = this.#view.getUint32(this.offset);
116
+ this.offset += 4;
117
+ return v;
118
+ }
119
+ case 27: {
120
+ this.#need(8);
121
+ const v = this.#view.getBigUint64(this.offset);
122
+ this.offset += 8;
123
+ // Collapse to a number when it fits, so callers compare with `===`.
124
+ return v <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v) : v;
125
+ }
126
+ default:
127
+ // 28-30 are reserved; 31 (indefinite length) is unsupported here.
128
+ throw new CborError(`unsupported CBOR additional info ${info}`);
129
+ }
130
+ }
131
+
132
+ /** Like {@link #argument} but as a JS number bounded for use as a length. */
133
+ #lengthArgument(info: number): number {
134
+ const value = this.#argument(info);
135
+ if (typeof value === "bigint" || value > this.#bytes.length) {
136
+ throw new CborError("CBOR length out of range");
137
+ }
138
+ return value;
139
+ }
140
+
141
+ #byte(): number {
142
+ if (this.offset >= this.#bytes.length) {
143
+ throw new CborError("unexpected end of CBOR input");
144
+ }
145
+ return this.#bytes[this.offset++]!;
146
+ }
147
+
148
+ /**
149
+ * Assert `n` more bytes are available before a multi-byte read. Without this,
150
+ * a truncated argument would let `DataView` throw a native `RangeError` that
151
+ * escapes the `CborError` contract callers rely on.
152
+ */
153
+ #need(n: number): void {
154
+ if (this.offset + n > this.#bytes.length) {
155
+ throw new CborError("unexpected end of CBOR input");
156
+ }
157
+ }
158
+
159
+ #bytesOfLength(length: number): Uint8Array {
160
+ const end = this.offset + length;
161
+ if (end > this.#bytes.length) {
162
+ throw new CborError("unexpected end of CBOR input");
163
+ }
164
+ const slice = this.#bytes.subarray(this.offset, end);
165
+ this.offset = end;
166
+ return slice;
167
+ }
168
+ }
package/src/config.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Configuration, the declared Cloudflare `Env` fragment, and config resolution
3
+ * for `@dwk/webauthn`.
4
+ *
5
+ * Per the composition contract the package never reads the global environment
6
+ * directly: the relying party id, expected origins, accepted algorithms, and
7
+ * TTLs are all passed into {@link createWebAuthn}, so the handler can be
8
+ * instantiated multiple times and unit-tested in isolation. The only runtime
9
+ * coupling is the per-relying-party Durable Object namespace, and a missing
10
+ * binding fails loudly at startup.
11
+ */
12
+
13
+ import { noopLogger, noopMetrics, type Logger, type Metrics } from "@dwk/log";
14
+
15
+ import { DEFAULT_COSE_ALGORITHMS } from "./cose";
16
+ import type { WebAuthnObject } from "./rp";
17
+
18
+ /** Cloudflare bindings required by the WebAuthn handler and Durable Object. */
19
+ export interface WebAuthnEnv {
20
+ /**
21
+ * Durable Object namespace for the per-relying-party class
22
+ * ({@link WebAuthnObject}). It owns short-TTL challenge state and the
23
+ * credential records — both strongly consistent, never KV (staleness in
24
+ * either is a security bug).
25
+ */
26
+ readonly WEBAUTHN: DurableObjectNamespace<WebAuthnObject>;
27
+ }
28
+
29
+ /** Configuration passed to {@link createWebAuthn}. */
30
+ export interface WebAuthnConfig {
31
+ /**
32
+ * The relying party id: the effective domain credentials are scoped to, e.g.
33
+ * `example.com`. MUST be a registrable suffix of every accepted origin's
34
+ * host. The authenticator hashes this into `rpIdHash`.
35
+ */
36
+ readonly rpId: string;
37
+
38
+ /** Human-readable relying party name shown in the authenticator UI. */
39
+ readonly rpName: string;
40
+
41
+ /**
42
+ * Acceptable client origin(s), e.g. `https://example.com`. The browser puts
43
+ * the ceremony origin in `clientDataJSON`; it must match one of these exactly.
44
+ */
45
+ readonly origin: string | readonly string[];
46
+
47
+ /**
48
+ * Accepted COSE signature algorithm identifiers, most-preferred first, offered
49
+ * as `pubKeyCredParams`. Defaults to `[-7, -257]` (ES256, RS256).
50
+ */
51
+ readonly algorithms?: readonly number[];
52
+
53
+ /** Challenge lifetime in seconds. Defaults to 300 (5 minutes). */
54
+ readonly challengeTtlSeconds?: number;
55
+
56
+ /** Ceremony timeout in milliseconds advertised to the client. Defaults to 60000. */
57
+ readonly timeoutMs?: number;
58
+
59
+ /**
60
+ * User-verification requirement for both ceremonies. `"required"` additionally
61
+ * makes the verifier reject an assertion whose UV flag is unset. Defaults to
62
+ * `"preferred"`.
63
+ */
64
+ readonly userVerification?: UserVerificationRequirement;
65
+
66
+ /** Injectable clock (epoch ms) for deterministic tests. Defaults to `Date.now`. */
67
+ readonly now?: () => number;
68
+
69
+ /** Logger for ceremony outcomes; defaults to a no-op. */
70
+ readonly logger?: Logger;
71
+
72
+ /** Metrics sink for ceremony outcomes; defaults to a no-op. */
73
+ readonly metrics?: Metrics;
74
+ }
75
+
76
+ /** WebAuthn user-verification requirement values. */
77
+ export type UserVerificationRequirement =
78
+ | "required"
79
+ | "preferred"
80
+ | "discouraged";
81
+
82
+ /** Fully-resolved configuration with defaults applied. */
83
+ export interface ResolvedConfig {
84
+ readonly rpId: string;
85
+ readonly rpName: string;
86
+ readonly origins: readonly string[];
87
+ readonly algorithms: readonly number[];
88
+ readonly challengeTtlSeconds: number;
89
+ readonly timeoutMs: number;
90
+ readonly userVerification: UserVerificationRequirement;
91
+ readonly now: () => number;
92
+ readonly logger: Logger;
93
+ readonly metrics: Metrics;
94
+ }
95
+
96
+ /**
97
+ * The serializable subset of resolved config the front door forwards to the
98
+ * Durable Object (which cannot receive the injected logger/metrics/clock).
99
+ */
100
+ export interface ForwardedConfig {
101
+ readonly rpId: string;
102
+ readonly rpName: string;
103
+ readonly origins: readonly string[];
104
+ readonly algorithms: readonly number[];
105
+ readonly challengeTtlSeconds: number;
106
+ readonly timeoutMs: number;
107
+ readonly userVerification: UserVerificationRequirement;
108
+ }
109
+
110
+ /** Internal headers the trusted front door uses to drive the Durable Object. */
111
+ export const INTERNAL_HEADERS = {
112
+ /** Which ceremony step to run (see {@link WebAuthnOperation}). */
113
+ op: "x-webauthn-op",
114
+ /** JSON-encoded {@link ForwardedConfig}. */
115
+ config: "x-webauthn-config",
116
+ /** Current time (epoch ms) from the injected clock, for TTL decisions. */
117
+ now: "x-webauthn-now",
118
+ /** DO→front-door: the ceremony outcome event name, logged then stripped. */
119
+ event: "x-webauthn-event",
120
+ /** DO→front-door: a stable failure reason when the ceremony was rejected. */
121
+ reason: "x-webauthn-reason",
122
+ } as const;
123
+
124
+ /** The four ceremony steps the handler routes. */
125
+ export type WebAuthnOperation =
126
+ | "register/options"
127
+ | "register/verify"
128
+ | "authenticate/options"
129
+ | "authenticate/verify";
130
+
131
+ const DEFAULT_CHALLENGE_TTL_SECONDS = 300;
132
+ const DEFAULT_TIMEOUT_MS = 60_000;
133
+
134
+ /** Apply defaults and derive values from raw {@link WebAuthnConfig}. */
135
+ export function resolveConfig(config: WebAuthnConfig): ResolvedConfig {
136
+ if (!config.rpId) throw new Error("@dwk/webauthn: `rpId` is required");
137
+ if (!config.rpName) throw new Error("@dwk/webauthn: `rpName` is required");
138
+
139
+ const origins =
140
+ typeof config.origin === "string" ? [config.origin] : [...config.origin];
141
+ if (origins.length === 0) {
142
+ throw new Error("@dwk/webauthn: at least one `origin` is required");
143
+ }
144
+
145
+ const algorithms =
146
+ config.algorithms === undefined
147
+ ? [...DEFAULT_COSE_ALGORITHMS]
148
+ : [...config.algorithms];
149
+ if (algorithms.length === 0) {
150
+ throw new Error("@dwk/webauthn: at least one algorithm is required");
151
+ }
152
+
153
+ return {
154
+ rpId: config.rpId,
155
+ rpName: config.rpName,
156
+ origins,
157
+ algorithms,
158
+ challengeTtlSeconds:
159
+ config.challengeTtlSeconds ?? DEFAULT_CHALLENGE_TTL_SECONDS,
160
+ timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
161
+ userVerification: config.userVerification ?? "preferred",
162
+ now: config.now ?? (() => Date.now()),
163
+ logger: config.logger ?? noopLogger,
164
+ metrics: config.metrics ?? noopMetrics,
165
+ };
166
+ }
167
+
168
+ /** Project the resolved config to the serializable subset forwarded to the DO. */
169
+ export function forwardedConfig(config: ResolvedConfig): ForwardedConfig {
170
+ return {
171
+ rpId: config.rpId,
172
+ rpName: config.rpName,
173
+ origins: config.origins,
174
+ algorithms: config.algorithms,
175
+ challengeTtlSeconds: config.challengeTtlSeconds,
176
+ timeoutMs: config.timeoutMs,
177
+ userVerification: config.userVerification,
178
+ };
179
+ }