@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.
- package/LICENSE +15 -0
- package/README.md +111 -0
- package/dist/cbor.d.ts +34 -0
- package/dist/cbor.d.ts.map +1 -0
- package/dist/cbor.js +144 -0
- package/dist/cbor.js.map +1 -0
- package/dist/config.d.ts +108 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +70 -0
- package/dist/config.js.map +1 -0
- package/dist/cose.d.ts +73 -0
- package/dist/cose.d.ts.map +1 -0
- package/dist/cose.js +191 -0
- package/dist/cose.js.map +1 -0
- package/dist/encoding.d.ts +28 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +63 -0
- package/dist/encoding.js.map +1 -0
- package/dist/handler.d.ts +20 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +101 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +25 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +26 -0
- package/dist/log.js.map +1 -0
- package/dist/rp.d.ts +21 -0
- package/dist/rp.d.ts.map +1 -0
- package/dist/rp.js +336 -0
- package/dist/rp.js.map +1 -0
- package/dist/verify.d.ts +135 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +277 -0
- package/dist/verify.js.map +1 -0
- package/package.json +50 -0
- package/src/cbor.ts +168 -0
- package/src/config.ts +179 -0
- package/src/cose.ts +238 -0
- package/src/encoding.ts +68 -0
- package/src/handler.ts +135 -0
- package/src/index.ts +54 -0
- package/src/log.ts +25 -0
- package/src/rp.ts +492 -0
- package/src/verify.ts +471 -0
package/src/cose.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COSE_Key (RFC 9052) → JWK conversion and the Web Crypto parameters for the
|
|
3
|
+
* COSE signature algorithms a WebAuthn relying party accepts.
|
|
4
|
+
*
|
|
5
|
+
* Authenticators hand the relying party a credential public key as a COSE_Key
|
|
6
|
+
* (an integer-keyed CBOR map) and sign assertions with a COSE algorithm
|
|
7
|
+
* identifier. This module narrows that to the algorithms we support — the same
|
|
8
|
+
* asymmetric set as `@dwk/dpop` (ES256/ES384, RS256/PS256) — and produces a JWK
|
|
9
|
+
* plus the `importKey`/`verify` parameter objects, so verification stays on Web
|
|
10
|
+
* Crypto with no external dependency.
|
|
11
|
+
*
|
|
12
|
+
* Web Crypto's ECDSA `verify` expects a raw `r‖s` signature, but WebAuthn EC
|
|
13
|
+
* assertions are ASN.1 DER `SEQUENCE { r, s }`; {@link derToRawEcdsaSignature}
|
|
14
|
+
* bridges that gap.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { bytesToBase64url } from "./encoding";
|
|
18
|
+
|
|
19
|
+
/** COSE_Key map labels (RFC 9052 §7, RFC 9053). */
|
|
20
|
+
const COSE_KTY = 1;
|
|
21
|
+
const COSE_ALG = 3;
|
|
22
|
+
const COSE_EC_CRV = -1;
|
|
23
|
+
const COSE_EC_X = -2;
|
|
24
|
+
const COSE_EC_Y = -3;
|
|
25
|
+
const COSE_RSA_N = -1;
|
|
26
|
+
const COSE_RSA_E = -2;
|
|
27
|
+
|
|
28
|
+
/** COSE key types we accept. */
|
|
29
|
+
const KTY_EC2 = 2;
|
|
30
|
+
const KTY_RSA = 3;
|
|
31
|
+
|
|
32
|
+
/** COSE elliptic curves (RFC 9053 §7.1). */
|
|
33
|
+
const CRV_P256 = 1;
|
|
34
|
+
const CRV_P384 = 2;
|
|
35
|
+
const CRV_P521 = 3;
|
|
36
|
+
|
|
37
|
+
/** COSE algorithm identifiers (the asymmetric subset we accept). */
|
|
38
|
+
export const COSE_ALG_ES256 = -7;
|
|
39
|
+
export const COSE_ALG_ES384 = -35;
|
|
40
|
+
export const COSE_ALG_ES512 = -36;
|
|
41
|
+
export const COSE_ALG_RS256 = -257;
|
|
42
|
+
export const COSE_ALG_PS256 = -37;
|
|
43
|
+
|
|
44
|
+
/** Default `pubKeyCredParams`, most-preferred first: ES256 then RS256. */
|
|
45
|
+
export const DEFAULT_COSE_ALGORITHMS: readonly number[] = [
|
|
46
|
+
COSE_ALG_ES256,
|
|
47
|
+
COSE_ALG_RS256,
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** A credential public key extracted from a COSE_Key. */
|
|
51
|
+
export interface CoseKey {
|
|
52
|
+
/** The public key as a JWK ready for `crypto.subtle.importKey`. */
|
|
53
|
+
readonly jwk: JsonWebKey;
|
|
54
|
+
/** The COSE signature algorithm identifier the key declared. */
|
|
55
|
+
readonly alg: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Thrown when a COSE_Key is malformed or uses an unsupported algorithm. */
|
|
59
|
+
export class CoseError extends Error {}
|
|
60
|
+
|
|
61
|
+
function intValue(map: Map<unknown, unknown>, label: number): number | null {
|
|
62
|
+
const v = map.get(label);
|
|
63
|
+
return typeof v === "number" ? v : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bytesValue(
|
|
67
|
+
map: Map<unknown, unknown>,
|
|
68
|
+
label: number,
|
|
69
|
+
): Uint8Array | null {
|
|
70
|
+
const v = map.get(label);
|
|
71
|
+
return v instanceof Uint8Array ? v : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ecCurveName(crv: number): string | null {
|
|
75
|
+
switch (crv) {
|
|
76
|
+
case CRV_P256:
|
|
77
|
+
return "P-256";
|
|
78
|
+
case CRV_P384:
|
|
79
|
+
return "P-384";
|
|
80
|
+
case CRV_P521:
|
|
81
|
+
return "P-521";
|
|
82
|
+
default:
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Convert a decoded COSE_Key map to a JWK plus its declared algorithm. */
|
|
88
|
+
export function coseToKey(map: Map<unknown, unknown>): CoseKey {
|
|
89
|
+
const kty = intValue(map, COSE_KTY);
|
|
90
|
+
const alg = intValue(map, COSE_ALG);
|
|
91
|
+
if (kty === null || alg === null) {
|
|
92
|
+
throw new CoseError("COSE_Key missing kty/alg");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (kty === KTY_EC2) {
|
|
96
|
+
const crv = intValue(map, COSE_EC_CRV);
|
|
97
|
+
const x = bytesValue(map, COSE_EC_X);
|
|
98
|
+
const y = bytesValue(map, COSE_EC_Y);
|
|
99
|
+
if (crv === null || x === null || y === null) {
|
|
100
|
+
throw new CoseError("COSE EC2 key missing crv/x/y");
|
|
101
|
+
}
|
|
102
|
+
const curve = ecCurveName(crv);
|
|
103
|
+
if (curve === null) throw new CoseError(`unsupported COSE curve ${crv}`);
|
|
104
|
+
return {
|
|
105
|
+
jwk: {
|
|
106
|
+
kty: "EC",
|
|
107
|
+
crv: curve,
|
|
108
|
+
x: bytesToBase64url(x),
|
|
109
|
+
y: bytesToBase64url(y),
|
|
110
|
+
},
|
|
111
|
+
alg,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (kty === KTY_RSA) {
|
|
116
|
+
const n = bytesValue(map, COSE_RSA_N);
|
|
117
|
+
const e = bytesValue(map, COSE_RSA_E);
|
|
118
|
+
if (n === null || e === null) {
|
|
119
|
+
throw new CoseError("COSE RSA key missing n/e");
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
jwk: { kty: "RSA", n: bytesToBase64url(n), e: bytesToBase64url(e) },
|
|
123
|
+
alg,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new CoseError(`unsupported COSE kty ${kty}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface ImportAlg {
|
|
131
|
+
name: string;
|
|
132
|
+
namedCurve?: string;
|
|
133
|
+
hash?: string;
|
|
134
|
+
}
|
|
135
|
+
interface VerifyAlg {
|
|
136
|
+
name: string;
|
|
137
|
+
hash?: string;
|
|
138
|
+
saltLength?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Web Crypto parameters for one COSE algorithm. */
|
|
142
|
+
export interface CryptoParams {
|
|
143
|
+
readonly importParams: ImportAlg;
|
|
144
|
+
readonly verifyParams: VerifyAlg;
|
|
145
|
+
/** EC algorithms carry a DER-encoded signature that needs unwrapping. */
|
|
146
|
+
readonly ec: boolean;
|
|
147
|
+
/** Raw `r‖s` length (one coordinate × 2) for EC; unused for RSA. */
|
|
148
|
+
readonly ecSignatureBytes: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Resolve the `importKey`/`verify` parameters for a COSE algorithm, or `null`
|
|
153
|
+
* when it is outside the accepted set. The set mirrors `@dwk/dpop`: ES256/ES384
|
|
154
|
+
* (ECDSA), RS256 (RSASSA-PKCS1-v1_5), PS256 (RSA-PSS).
|
|
155
|
+
*/
|
|
156
|
+
export function cryptoParamsForCoseAlg(alg: number): CryptoParams | null {
|
|
157
|
+
switch (alg) {
|
|
158
|
+
case COSE_ALG_ES256:
|
|
159
|
+
return {
|
|
160
|
+
importParams: { name: "ECDSA", namedCurve: "P-256" },
|
|
161
|
+
verifyParams: { name: "ECDSA", hash: "SHA-256" },
|
|
162
|
+
ec: true,
|
|
163
|
+
ecSignatureBytes: 64,
|
|
164
|
+
};
|
|
165
|
+
case COSE_ALG_ES384:
|
|
166
|
+
return {
|
|
167
|
+
importParams: { name: "ECDSA", namedCurve: "P-384" },
|
|
168
|
+
verifyParams: { name: "ECDSA", hash: "SHA-384" },
|
|
169
|
+
ec: true,
|
|
170
|
+
ecSignatureBytes: 96,
|
|
171
|
+
};
|
|
172
|
+
case COSE_ALG_RS256:
|
|
173
|
+
return {
|
|
174
|
+
importParams: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
175
|
+
verifyParams: { name: "RSASSA-PKCS1-v1_5" },
|
|
176
|
+
ec: false,
|
|
177
|
+
ecSignatureBytes: 0,
|
|
178
|
+
};
|
|
179
|
+
case COSE_ALG_PS256:
|
|
180
|
+
return {
|
|
181
|
+
importParams: { name: "RSA-PSS", hash: "SHA-256" },
|
|
182
|
+
verifyParams: { name: "RSA-PSS", saltLength: 32 },
|
|
183
|
+
ec: false,
|
|
184
|
+
ecSignatureBytes: 0,
|
|
185
|
+
};
|
|
186
|
+
default:
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Convert an ASN.1 DER `SEQUENCE { INTEGER r, INTEGER s }` ECDSA signature to
|
|
193
|
+
* the fixed-length raw `r‖s` form Web Crypto's ECDSA `verify` expects. DER
|
|
194
|
+
* integers are big-endian, minimally encoded, and may carry a leading `0x00`
|
|
195
|
+
* sign byte; each is left-padded (or its sign byte trimmed) to `size/2` bytes.
|
|
196
|
+
*
|
|
197
|
+
* @param der - the DER-encoded signature
|
|
198
|
+
* @param size - the raw signature length (64 for P-256, 96 for P-384)
|
|
199
|
+
* @throws CoseError if the DER structure is malformed
|
|
200
|
+
*/
|
|
201
|
+
export function derToRawEcdsaSignature(
|
|
202
|
+
der: Uint8Array,
|
|
203
|
+
size: number,
|
|
204
|
+
): Uint8Array {
|
|
205
|
+
let offset = 0;
|
|
206
|
+
const next = (): number => {
|
|
207
|
+
if (offset >= der.length) throw new CoseError("malformed DER signature");
|
|
208
|
+
return der[offset++]!;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (next() !== 0x30) throw new CoseError("DER signature not a SEQUENCE");
|
|
212
|
+
// SEQUENCE length (short form is sufficient for ECDSA signatures).
|
|
213
|
+
next();
|
|
214
|
+
|
|
215
|
+
const readInteger = (): Uint8Array => {
|
|
216
|
+
if (next() !== 0x02) throw new CoseError("DER signature missing INTEGER");
|
|
217
|
+
const len = next();
|
|
218
|
+
const end = offset + len;
|
|
219
|
+
if (end > der.length) throw new CoseError("malformed DER signature");
|
|
220
|
+
let value = der.subarray(offset, end);
|
|
221
|
+
offset = end;
|
|
222
|
+
// Drop a leading sign byte and any over-long left padding.
|
|
223
|
+
while (value.length > 1 && value[0] === 0x00) value = value.subarray(1);
|
|
224
|
+
return value;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const r = readInteger();
|
|
228
|
+
const s = readInteger();
|
|
229
|
+
const half = size / 2;
|
|
230
|
+
if (r.length > half || s.length > half) {
|
|
231
|
+
throw new CoseError("DER signature integer too large");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const raw = new Uint8Array(size);
|
|
235
|
+
raw.set(r, half - r.length);
|
|
236
|
+
raw.set(s, size - s.length);
|
|
237
|
+
return raw;
|
|
238
|
+
}
|
package/src/encoding.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte / base64url encoding helpers shared across the WebAuthn ceremonies.
|
|
3
|
+
*
|
|
4
|
+
* WebAuthn transports binary fields (challenges, credential ids, signatures,
|
|
5
|
+
* attestation objects) as base64url over JSON, so every ceremony boundary needs
|
|
6
|
+
* the same encode/decode pair. These are pure and runtime-agnostic — Web APIs
|
|
7
|
+
* (`atob`/`btoa`/`TextEncoder`) only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Decode a base64url (or padded base64) string to raw bytes. */
|
|
11
|
+
export function base64urlToBytes(input: string): Uint8Array {
|
|
12
|
+
const b64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
13
|
+
const padded =
|
|
14
|
+
b64.length % 4 === 0 ? b64 : b64 + "=".repeat(4 - (b64.length % 4));
|
|
15
|
+
const binary = atob(padded);
|
|
16
|
+
const bytes = new Uint8Array(binary.length);
|
|
17
|
+
for (let i = 0; i < binary.length; i++) {
|
|
18
|
+
bytes[i] = binary.charCodeAt(i);
|
|
19
|
+
}
|
|
20
|
+
return bytes;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Encode raw bytes as an unpadded base64url string. */
|
|
24
|
+
export function bytesToBase64url(bytes: Uint8Array): string {
|
|
25
|
+
let binary = "";
|
|
26
|
+
for (const byte of bytes) {
|
|
27
|
+
binary += String.fromCharCode(byte);
|
|
28
|
+
}
|
|
29
|
+
return btoa(binary)
|
|
30
|
+
.replace(/\+/g, "-")
|
|
31
|
+
.replace(/\//g, "_")
|
|
32
|
+
.replace(/=+$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Strip any base64 padding and normalize base64 to base64url so two encodings
|
|
37
|
+
* of the same bytes compare equal. WebAuthn clients emit the challenge in
|
|
38
|
+
* `clientDataJSON` as unpadded base64url; this lets a comparison tolerate a
|
|
39
|
+
* padded or `+`/`/`-alphabet copy without a full decode/re-encode round trip.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeBase64url(input: string): string {
|
|
42
|
+
return input.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether two byte arrays are equal (length + contents). Not constant-time. */
|
|
46
|
+
export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
47
|
+
if (a.length !== b.length) return false;
|
|
48
|
+
for (let i = 0; i < a.length; i++) {
|
|
49
|
+
if (a[i] !== b[i]) return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** SHA-256 digest of the input bytes as a fresh `Uint8Array`. */
|
|
55
|
+
export async function sha256(input: Uint8Array): Promise<Uint8Array> {
|
|
56
|
+
const digest = await crypto.subtle.digest("SHA-256", input as BufferSource);
|
|
57
|
+
return new Uint8Array(digest);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** UTF-8 encode a string to bytes. */
|
|
61
|
+
export function utf8ToBytes(input: string): Uint8Array {
|
|
62
|
+
return new TextEncoder().encode(input);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** UTF-8 decode bytes to a string. */
|
|
66
|
+
export function bytesToUtf8(bytes: Uint8Array): string {
|
|
67
|
+
return new TextDecoder().decode(bytes);
|
|
68
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stateless WebAuthn relying-party front door.
|
|
3
|
+
*
|
|
4
|
+
* It routes the four ceremony steps — `/register/options`, `/register/verify`,
|
|
5
|
+
* `/authenticate/options`, `/authenticate/verify` — to the per-relying-party
|
|
6
|
+
* Durable Object, which owns all challenge and credential state. The handler is
|
|
7
|
+
* mountable under any path prefix because it identifies the step by the *tail*
|
|
8
|
+
* of the request path, and it injects the trusted clock and forwarded config the
|
|
9
|
+
* DO needs (the DO cannot hold the injected logger/metrics/clock itself). It
|
|
10
|
+
* emits each ceremony outcome on the wired logger and metrics seams.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { type LogFields } from "@dwk/log";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
INTERNAL_HEADERS,
|
|
17
|
+
forwardedConfig,
|
|
18
|
+
resolveConfig,
|
|
19
|
+
type ResolvedConfig,
|
|
20
|
+
type WebAuthnConfig,
|
|
21
|
+
type WebAuthnEnv,
|
|
22
|
+
type WebAuthnOperation,
|
|
23
|
+
} from "./config";
|
|
24
|
+
|
|
25
|
+
/** A `fetch`-compatible Worker handler. */
|
|
26
|
+
export type WebAuthnHandler = (
|
|
27
|
+
request: Request,
|
|
28
|
+
env: WebAuthnEnv,
|
|
29
|
+
ctx: ExecutionContext,
|
|
30
|
+
) => Promise<Response>;
|
|
31
|
+
|
|
32
|
+
/** Map a request path tail to a ceremony operation, or `null` if unmatched. */
|
|
33
|
+
function operationForPath(pathname: string): WebAuthnOperation | null {
|
|
34
|
+
if (pathname.endsWith("/register/options")) return "register/options";
|
|
35
|
+
if (pathname.endsWith("/register/verify")) return "register/verify";
|
|
36
|
+
if (pathname.endsWith("/authenticate/options")) return "authenticate/options";
|
|
37
|
+
if (pathname.endsWith("/authenticate/verify")) return "authenticate/verify";
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Fail loudly if a required Cloudflare binding is missing (no silent degradation). */
|
|
42
|
+
function assertBindings(env: WebAuthnEnv): void {
|
|
43
|
+
if (!env.WEBAUTHN) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
"@dwk/webauthn: missing required Durable Object binding `WEBAUTHN`",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Emit a structured event on both the logger and the metrics seam. */
|
|
51
|
+
function emit(
|
|
52
|
+
config: ResolvedConfig,
|
|
53
|
+
level: "info" | "warn",
|
|
54
|
+
event: string,
|
|
55
|
+
fields?: LogFields,
|
|
56
|
+
): void {
|
|
57
|
+
config.logger[level](event, fields);
|
|
58
|
+
config.metrics.count(event, fields);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Translate the Durable Object's outcome headers into a structured event on the
|
|
63
|
+
* injected seams, then strip those internal headers before the response reaches
|
|
64
|
+
* the client. A rejection (any non-2xx) logs at `warn` with its stable reason;
|
|
65
|
+
* a success logs at `info`.
|
|
66
|
+
*/
|
|
67
|
+
function logOutcome(config: ResolvedConfig, response: Response): Response {
|
|
68
|
+
const event = response.headers.get(INTERNAL_HEADERS.event);
|
|
69
|
+
if (event) {
|
|
70
|
+
const reason = response.headers.get(INTERNAL_HEADERS.reason);
|
|
71
|
+
if (response.ok) {
|
|
72
|
+
emit(config, "info", event);
|
|
73
|
+
} else {
|
|
74
|
+
emit(config, "warn", event, reason ? { reason } : undefined);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const headers = new Headers(response.headers);
|
|
79
|
+
headers.delete(INTERNAL_HEADERS.event);
|
|
80
|
+
headers.delete(INTERNAL_HEADERS.reason);
|
|
81
|
+
return new Response(response.body, {
|
|
82
|
+
status: response.status,
|
|
83
|
+
statusText: response.statusText,
|
|
84
|
+
headers,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the internal DO request: forwarded config, trusted clock, op, body. */
|
|
89
|
+
function internalRequest(
|
|
90
|
+
request: Request,
|
|
91
|
+
config: ResolvedConfig,
|
|
92
|
+
op: WebAuthnOperation,
|
|
93
|
+
): Request {
|
|
94
|
+
const headers = new Headers({
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
[INTERNAL_HEADERS.op]: op,
|
|
97
|
+
[INTERNAL_HEADERS.config]: JSON.stringify(forwardedConfig(config)),
|
|
98
|
+
[INTERNAL_HEADERS.now]: String(config.now()),
|
|
99
|
+
});
|
|
100
|
+
return new Request(request.url, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers,
|
|
103
|
+
body: request.body,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create the stateless WebAuthn relying-party handler. All four ceremony steps
|
|
109
|
+
* are `POST`; anything else is `405`. Unmatched paths are `404`.
|
|
110
|
+
*/
|
|
111
|
+
export function createWebAuthn(config: WebAuthnConfig): WebAuthnHandler {
|
|
112
|
+
const resolved = resolveConfig(config);
|
|
113
|
+
|
|
114
|
+
return async (request, env, _ctx) => {
|
|
115
|
+
assertBindings(env);
|
|
116
|
+
|
|
117
|
+
const op = operationForPath(new URL(request.url).pathname);
|
|
118
|
+
if (op === null) {
|
|
119
|
+
return new Response("Not Found", { status: 404 });
|
|
120
|
+
}
|
|
121
|
+
if (request.method.toUpperCase() !== "POST") {
|
|
122
|
+
return new Response("Method Not Allowed", {
|
|
123
|
+
status: 405,
|
|
124
|
+
headers: { allow: "POST" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// One Durable Object per relying party, keyed by the rpId (no sharding).
|
|
129
|
+
const id = env.WEBAUTHN.idFromName(resolved.rpId);
|
|
130
|
+
const response = await env.WEBAUTHN.get(id).fetch(
|
|
131
|
+
internalRequest(request, resolved, op),
|
|
132
|
+
);
|
|
133
|
+
return logOutcome(resolved, response);
|
|
134
|
+
};
|
|
135
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dwk/webauthn` — a WebAuthn / passkeys relying party.
|
|
3
|
+
*
|
|
4
|
+
* A stateless Worker front door over a per-relying-party Durable Object that is
|
|
5
|
+
* the consistency authority for short-TTL challenge state and credential
|
|
6
|
+
* records. It runs the four ceremony steps — registration (attestation) and
|
|
7
|
+
* authentication (assertion) options + verification — entirely on Web Crypto,
|
|
8
|
+
* with no external dependency beyond `@dwk/log`.
|
|
9
|
+
*
|
|
10
|
+
* The package is **not** protocol-agnostic: it is the WebAuthn endpoint. It is
|
|
11
|
+
* filed for completeness as an **exploratory, lowest-priority** package — a clean
|
|
12
|
+
* Workers fit, but a step toward generic authentication rather than the
|
|
13
|
+
* web-presence standards this cohort centers on.
|
|
14
|
+
*
|
|
15
|
+
* Attestation is verified for the `none` and `packed` self-attestation formats
|
|
16
|
+
* (the relying party requests `attestation: "none"`); full attestation-chain
|
|
17
|
+
* verification is intentionally out of scope.
|
|
18
|
+
*
|
|
19
|
+
* @see spec/packages/webauthn.md
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { createWebAuthn, type WebAuthnHandler } from "./handler";
|
|
24
|
+
export { WebAuthnObject } from "./rp";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
type WebAuthnConfig,
|
|
28
|
+
type WebAuthnEnv,
|
|
29
|
+
type UserVerificationRequirement,
|
|
30
|
+
} from "./config";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
DEFAULT_COSE_ALGORITHMS,
|
|
34
|
+
COSE_ALG_ES256,
|
|
35
|
+
COSE_ALG_ES384,
|
|
36
|
+
COSE_ALG_RS256,
|
|
37
|
+
COSE_ALG_PS256,
|
|
38
|
+
} from "./cose";
|
|
39
|
+
|
|
40
|
+
export {
|
|
41
|
+
verifyRegistration,
|
|
42
|
+
verifyAuthentication,
|
|
43
|
+
parseClientData,
|
|
44
|
+
parseAuthenticatorData,
|
|
45
|
+
type RegistrationVerifyInput,
|
|
46
|
+
type RegistrationVerifyResult,
|
|
47
|
+
type AuthenticationVerifyInput,
|
|
48
|
+
type AuthenticationVerifyResult,
|
|
49
|
+
type VerifiedCredential,
|
|
50
|
+
type VerifyFailureReason,
|
|
51
|
+
} from "./verify";
|
|
52
|
+
|
|
53
|
+
export { WebAuthnLogEvent } from "./log";
|
|
54
|
+
export type { Logger, Metrics } from "@dwk/log";
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured-logging vocabulary for `@dwk/webauthn`.
|
|
3
|
+
*
|
|
4
|
+
* The Durable Object cannot hold the injected logger/metrics (they do not cross
|
|
5
|
+
* the isolate boundary), so it names its ceremony outcome in a response header
|
|
6
|
+
* ({@link INTERNAL_HEADERS.event}) and the front door emits it on the wired
|
|
7
|
+
* seams. Event names are stable and dotted; callers attach only reason codes and
|
|
8
|
+
* non-sensitive facts — never challenges, signatures, or public keys.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Stable, dotted event names emitted on the logger and metrics seams. */
|
|
12
|
+
export enum WebAuthnLogEvent {
|
|
13
|
+
/** Registration options issued (a fresh challenge was minted). */
|
|
14
|
+
RegisterOptions = "webauthn.register.options",
|
|
15
|
+
/** A registration ceremony verified and a credential was stored. */
|
|
16
|
+
RegisterVerified = "webauthn.register.verified",
|
|
17
|
+
/** A registration ceremony was rejected. */
|
|
18
|
+
RegisterRejected = "webauthn.register.rejected",
|
|
19
|
+
/** Authentication options issued. */
|
|
20
|
+
AuthenticateOptions = "webauthn.authenticate.options",
|
|
21
|
+
/** An authentication ceremony verified. */
|
|
22
|
+
AuthenticateVerified = "webauthn.authenticate.verified",
|
|
23
|
+
/** An authentication ceremony was rejected. */
|
|
24
|
+
AuthenticateRejected = "webauthn.authenticate.rejected",
|
|
25
|
+
}
|