@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/verify.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
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
|
+
|
|
22
|
+
import { decodeFirst, CborError, type CborValue } from "./cbor";
|
|
23
|
+
import {
|
|
24
|
+
coseToKey,
|
|
25
|
+
cryptoParamsForCoseAlg,
|
|
26
|
+
derToRawEcdsaSignature,
|
|
27
|
+
CoseError,
|
|
28
|
+
} from "./cose";
|
|
29
|
+
import {
|
|
30
|
+
bytesEqual,
|
|
31
|
+
bytesToBase64url,
|
|
32
|
+
bytesToUtf8,
|
|
33
|
+
normalizeBase64url,
|
|
34
|
+
sha256,
|
|
35
|
+
} from "./encoding";
|
|
36
|
+
|
|
37
|
+
/** Stable, locale-independent verification failure codes. */
|
|
38
|
+
export type VerifyFailureReason =
|
|
39
|
+
| "client_data_malformed"
|
|
40
|
+
| "client_data_type"
|
|
41
|
+
| "challenge_mismatch"
|
|
42
|
+
| "origin_mismatch"
|
|
43
|
+
| "attestation_malformed"
|
|
44
|
+
| "attestation_format_unsupported"
|
|
45
|
+
| "auth_data_malformed"
|
|
46
|
+
| "rp_id_mismatch"
|
|
47
|
+
| "user_not_present"
|
|
48
|
+
| "user_not_verified"
|
|
49
|
+
| "no_credential_data"
|
|
50
|
+
| "credential_key_unsupported"
|
|
51
|
+
| "signature_invalid"
|
|
52
|
+
| "counter_regression";
|
|
53
|
+
|
|
54
|
+
/** The decoded WebAuthn `clientDataJSON`. */
|
|
55
|
+
export interface ClientData {
|
|
56
|
+
readonly type: string;
|
|
57
|
+
readonly challenge: string;
|
|
58
|
+
readonly origin: string;
|
|
59
|
+
readonly crossOrigin?: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Authenticator-data flag bits (WebAuthn §6.1). */
|
|
63
|
+
export interface AuthDataFlags {
|
|
64
|
+
/** User Present. */
|
|
65
|
+
readonly up: boolean;
|
|
66
|
+
/** User Verified. */
|
|
67
|
+
readonly uv: boolean;
|
|
68
|
+
/** Attested credential data included. */
|
|
69
|
+
readonly at: boolean;
|
|
70
|
+
/** Extension data included. */
|
|
71
|
+
readonly ed: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Parsed attested credential data (present on registration). */
|
|
75
|
+
export interface AttestedCredentialData {
|
|
76
|
+
readonly aaguid: Uint8Array;
|
|
77
|
+
readonly credentialId: Uint8Array;
|
|
78
|
+
/** The credential public key, still a COSE_Key map. */
|
|
79
|
+
readonly credentialPublicKey: Map<unknown, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parsed authenticator data (WebAuthn §6.1). */
|
|
83
|
+
export interface AuthenticatorData {
|
|
84
|
+
readonly rpIdHash: Uint8Array;
|
|
85
|
+
readonly flags: AuthDataFlags;
|
|
86
|
+
readonly signCount: number;
|
|
87
|
+
readonly attestedCredentialData?: AttestedCredentialData;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Parse `clientDataJSON` bytes, or `null` if it is not a valid client-data object. */
|
|
91
|
+
export function parseClientData(bytes: Uint8Array): ClientData | null {
|
|
92
|
+
let parsed: unknown;
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(bytesToUtf8(bytes));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
99
|
+
const obj = parsed as Record<string, unknown>;
|
|
100
|
+
if (
|
|
101
|
+
typeof obj.type !== "string" ||
|
|
102
|
+
typeof obj.challenge !== "string" ||
|
|
103
|
+
typeof obj.origin !== "string"
|
|
104
|
+
) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
type: obj.type,
|
|
109
|
+
challenge: obj.challenge,
|
|
110
|
+
origin: obj.origin,
|
|
111
|
+
...(typeof obj.crossOrigin === "boolean"
|
|
112
|
+
? { crossOrigin: obj.crossOrigin }
|
|
113
|
+
: {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse authenticator data. The fixed header is 37 bytes (32 rpIdHash + 1 flags
|
|
119
|
+
* + 4 signCount); when the AT flag is set, attested credential data follows
|
|
120
|
+
* (16 aaguid + 2 credentialIdLength + L credentialId + the COSE public key).
|
|
121
|
+
* Returns `null` on any structural problem.
|
|
122
|
+
*/
|
|
123
|
+
export function parseAuthenticatorData(
|
|
124
|
+
bytes: Uint8Array,
|
|
125
|
+
): AuthenticatorData | null {
|
|
126
|
+
if (bytes.length < 37) return null;
|
|
127
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
128
|
+
const rpIdHash = bytes.subarray(0, 32);
|
|
129
|
+
const flagsByte = bytes[32]!;
|
|
130
|
+
const flags: AuthDataFlags = {
|
|
131
|
+
up: (flagsByte & 0x01) !== 0,
|
|
132
|
+
uv: (flagsByte & 0x04) !== 0,
|
|
133
|
+
at: (flagsByte & 0x40) !== 0,
|
|
134
|
+
ed: (flagsByte & 0x80) !== 0,
|
|
135
|
+
};
|
|
136
|
+
const signCount = view.getUint32(33);
|
|
137
|
+
|
|
138
|
+
if (!flags.at) {
|
|
139
|
+
return { rpIdHash, flags, signCount };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Attested credential data follows the fixed header.
|
|
143
|
+
if (bytes.length < 55) return null;
|
|
144
|
+
const aaguid = bytes.subarray(37, 53);
|
|
145
|
+
const credentialIdLength = view.getUint16(53);
|
|
146
|
+
const idStart = 55;
|
|
147
|
+
const idEnd = idStart + credentialIdLength;
|
|
148
|
+
if (idEnd > bytes.length) return null;
|
|
149
|
+
const credentialId = bytes.subarray(idStart, idEnd);
|
|
150
|
+
|
|
151
|
+
let decoded;
|
|
152
|
+
try {
|
|
153
|
+
decoded = decodeFirst(bytes, idEnd);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (error instanceof CborError) return null;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
if (!(decoded.value instanceof Map)) return null;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
rpIdHash,
|
|
162
|
+
flags,
|
|
163
|
+
signCount,
|
|
164
|
+
attestedCredentialData: {
|
|
165
|
+
aaguid,
|
|
166
|
+
credentialId,
|
|
167
|
+
credentialPublicKey: decoded.value as Map<unknown, unknown>,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Inputs to {@link verifyRegistration}. */
|
|
173
|
+
export interface RegistrationVerifyInput {
|
|
174
|
+
/** The CBOR attestation object bytes. */
|
|
175
|
+
readonly attestationObject: Uint8Array;
|
|
176
|
+
/** The raw `clientDataJSON` bytes. */
|
|
177
|
+
readonly clientDataJSON: Uint8Array;
|
|
178
|
+
/** The challenge the relying party issued (base64url). */
|
|
179
|
+
readonly expectedChallenge: string;
|
|
180
|
+
/** Acceptable client origins. */
|
|
181
|
+
readonly expectedOrigins: readonly string[];
|
|
182
|
+
/** The relying party id (effective domain). */
|
|
183
|
+
readonly expectedRpId: string;
|
|
184
|
+
/** Whether the User Verified flag must be set. */
|
|
185
|
+
readonly requireUserVerification: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A verified credential ready to persist. */
|
|
189
|
+
export interface VerifiedCredential {
|
|
190
|
+
/** Credential id (base64url). */
|
|
191
|
+
readonly id: string;
|
|
192
|
+
/** Public key as a JWK. */
|
|
193
|
+
readonly publicKeyJwk: JsonWebKey;
|
|
194
|
+
/** COSE algorithm the key signs with. */
|
|
195
|
+
readonly alg: number;
|
|
196
|
+
/** Initial signature counter. */
|
|
197
|
+
readonly counter: number;
|
|
198
|
+
/** Authenticator AAGUID (base64url). */
|
|
199
|
+
readonly aaguid: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Result of {@link verifyRegistration}. */
|
|
203
|
+
export type RegistrationVerifyResult =
|
|
204
|
+
| { readonly verified: true; readonly credential: VerifiedCredential }
|
|
205
|
+
| { readonly verified: false; readonly reason: VerifyFailureReason };
|
|
206
|
+
|
|
207
|
+
function reject(reason: VerifyFailureReason): {
|
|
208
|
+
verified: false;
|
|
209
|
+
reason: VerifyFailureReason;
|
|
210
|
+
} {
|
|
211
|
+
return { verified: false, reason };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Shared `clientDataJSON` checks for both ceremonies. */
|
|
215
|
+
function checkClientData(
|
|
216
|
+
clientDataJSON: Uint8Array,
|
|
217
|
+
expectedType: string,
|
|
218
|
+
expectedChallenge: string,
|
|
219
|
+
expectedOrigins: readonly string[],
|
|
220
|
+
): ClientData | VerifyFailureReason {
|
|
221
|
+
const clientData = parseClientData(clientDataJSON);
|
|
222
|
+
if (clientData === null) return "client_data_malformed";
|
|
223
|
+
if (clientData.type !== expectedType) return "client_data_type";
|
|
224
|
+
if (
|
|
225
|
+
normalizeBase64url(clientData.challenge) !==
|
|
226
|
+
normalizeBase64url(expectedChallenge)
|
|
227
|
+
) {
|
|
228
|
+
return "challenge_mismatch";
|
|
229
|
+
}
|
|
230
|
+
if (!expectedOrigins.includes(clientData.origin)) return "origin_mismatch";
|
|
231
|
+
return clientData;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Verify a registration (attestation) ceremony (WebAuthn §7.1). */
|
|
235
|
+
export async function verifyRegistration(
|
|
236
|
+
input: RegistrationVerifyInput,
|
|
237
|
+
): Promise<RegistrationVerifyResult> {
|
|
238
|
+
const clientData = checkClientData(
|
|
239
|
+
input.clientDataJSON,
|
|
240
|
+
"webauthn.create",
|
|
241
|
+
input.expectedChallenge,
|
|
242
|
+
input.expectedOrigins,
|
|
243
|
+
);
|
|
244
|
+
if (typeof clientData === "string") return reject(clientData);
|
|
245
|
+
|
|
246
|
+
// Decode the attestation object: { fmt, attStmt, authData }.
|
|
247
|
+
let attestation: CborValue;
|
|
248
|
+
try {
|
|
249
|
+
attestation = decodeFirst(input.attestationObject).value;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof CborError) return reject("attestation_malformed");
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
if (!(attestation instanceof Map)) return reject("attestation_malformed");
|
|
255
|
+
const fmt = attestation.get("fmt");
|
|
256
|
+
const authDataBytes = attestation.get("authData");
|
|
257
|
+
const attStmt = attestation.get("attStmt");
|
|
258
|
+
if (typeof fmt !== "string" || !(authDataBytes instanceof Uint8Array)) {
|
|
259
|
+
return reject("attestation_malformed");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const authData = parseAuthenticatorData(authDataBytes);
|
|
263
|
+
if (authData === null) return reject("auth_data_malformed");
|
|
264
|
+
|
|
265
|
+
const rpCheck = await checkRpAndFlags(authData, input);
|
|
266
|
+
if (rpCheck !== null) return reject(rpCheck);
|
|
267
|
+
|
|
268
|
+
if (!authData.attestedCredentialData) return reject("no_credential_data");
|
|
269
|
+
|
|
270
|
+
// Resolve the credential public key from its COSE_Key.
|
|
271
|
+
let coseKey;
|
|
272
|
+
try {
|
|
273
|
+
coseKey = coseToKey(authData.attestedCredentialData.credentialPublicKey);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof CoseError) return reject("credential_key_unsupported");
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
if (cryptoParamsForCoseAlg(coseKey.alg) === null) {
|
|
279
|
+
return reject("credential_key_unsupported");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Verify the attestation statement (none / packed self-attestation only).
|
|
283
|
+
const attResult = await verifyAttestationStatement(
|
|
284
|
+
fmt,
|
|
285
|
+
attStmt,
|
|
286
|
+
authDataBytes,
|
|
287
|
+
input.clientDataJSON,
|
|
288
|
+
coseKey.alg,
|
|
289
|
+
coseKey.jwk,
|
|
290
|
+
);
|
|
291
|
+
if (attResult !== null) return reject(attResult);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
verified: true,
|
|
295
|
+
credential: {
|
|
296
|
+
id: bytesToBase64url(authData.attestedCredentialData.credentialId),
|
|
297
|
+
publicKeyJwk: coseKey.jwk,
|
|
298
|
+
alg: coseKey.alg,
|
|
299
|
+
counter: authData.signCount,
|
|
300
|
+
aaguid: bytesToBase64url(authData.attestedCredentialData.aaguid),
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Verify the supported attestation statement formats. Returns `null` on success
|
|
307
|
+
* or a failure reason. `none` carries no statement. `packed` *self*-attestation
|
|
308
|
+
* signs `authData ‖ SHA-256(clientDataJSON)` with the credential's own private
|
|
309
|
+
* key, so it verifies against the just-extracted credential public key; a
|
|
310
|
+
* `packed` statement carrying `x5c` (full chain) is unsupported.
|
|
311
|
+
*/
|
|
312
|
+
async function verifyAttestationStatement(
|
|
313
|
+
fmt: string,
|
|
314
|
+
attStmt: CborValue | undefined,
|
|
315
|
+
authDataBytes: Uint8Array,
|
|
316
|
+
clientDataJSON: Uint8Array,
|
|
317
|
+
credentialAlg: number,
|
|
318
|
+
credentialJwk: JsonWebKey,
|
|
319
|
+
): Promise<VerifyFailureReason | null> {
|
|
320
|
+
if (fmt === "none") return null;
|
|
321
|
+
if (fmt !== "packed") return "attestation_format_unsupported";
|
|
322
|
+
if (!(attStmt instanceof Map)) return "attestation_malformed";
|
|
323
|
+
// Full attestation-certificate chains are out of scope.
|
|
324
|
+
if (attStmt.has("x5c")) return "attestation_format_unsupported";
|
|
325
|
+
|
|
326
|
+
const alg = attStmt.get("alg");
|
|
327
|
+
const sig = attStmt.get("sig");
|
|
328
|
+
if (typeof alg !== "number" || !(sig instanceof Uint8Array)) {
|
|
329
|
+
return "attestation_malformed";
|
|
330
|
+
}
|
|
331
|
+
// Self attestation MUST use the credential key's algorithm.
|
|
332
|
+
if (alg !== credentialAlg) return "attestation_format_unsupported";
|
|
333
|
+
|
|
334
|
+
const clientDataHash = await sha256(clientDataJSON);
|
|
335
|
+
const signedData = concat(authDataBytes, clientDataHash);
|
|
336
|
+
const ok = await verifySignature(credentialJwk, alg, sig, signedData);
|
|
337
|
+
return ok ? null : "signature_invalid";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Inputs to {@link verifyAuthentication}. */
|
|
341
|
+
export interface AuthenticationVerifyInput {
|
|
342
|
+
/** The raw authenticator data bytes. */
|
|
343
|
+
readonly authenticatorData: Uint8Array;
|
|
344
|
+
/** The raw `clientDataJSON` bytes. */
|
|
345
|
+
readonly clientDataJSON: Uint8Array;
|
|
346
|
+
/** The assertion signature bytes. */
|
|
347
|
+
readonly signature: Uint8Array;
|
|
348
|
+
/** The challenge the relying party issued (base64url). */
|
|
349
|
+
readonly expectedChallenge: string;
|
|
350
|
+
/** Acceptable client origins. */
|
|
351
|
+
readonly expectedOrigins: readonly string[];
|
|
352
|
+
/** The relying party id (effective domain). */
|
|
353
|
+
readonly expectedRpId: string;
|
|
354
|
+
/** Whether the User Verified flag must be set. */
|
|
355
|
+
readonly requireUserVerification: boolean;
|
|
356
|
+
/** The stored credential public key (JWK). */
|
|
357
|
+
readonly credentialPublicKeyJwk: JsonWebKey;
|
|
358
|
+
/** The stored credential's COSE algorithm. */
|
|
359
|
+
readonly credentialAlg: number;
|
|
360
|
+
/** The last signature counter recorded for this credential. */
|
|
361
|
+
readonly storedCounter: number;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Result of {@link verifyAuthentication}. */
|
|
365
|
+
export type AuthenticationVerifyResult =
|
|
366
|
+
| { readonly verified: true; readonly newCounter: number }
|
|
367
|
+
| { readonly verified: false; readonly reason: VerifyFailureReason };
|
|
368
|
+
|
|
369
|
+
/** Verify an authentication (assertion) ceremony (WebAuthn §7.2). */
|
|
370
|
+
export async function verifyAuthentication(
|
|
371
|
+
input: AuthenticationVerifyInput,
|
|
372
|
+
): Promise<AuthenticationVerifyResult> {
|
|
373
|
+
const clientData = checkClientData(
|
|
374
|
+
input.clientDataJSON,
|
|
375
|
+
"webauthn.get",
|
|
376
|
+
input.expectedChallenge,
|
|
377
|
+
input.expectedOrigins,
|
|
378
|
+
);
|
|
379
|
+
if (typeof clientData === "string") return reject(clientData);
|
|
380
|
+
|
|
381
|
+
const authData = parseAuthenticatorData(input.authenticatorData);
|
|
382
|
+
if (authData === null) return reject("auth_data_malformed");
|
|
383
|
+
|
|
384
|
+
const rpCheck = await checkRpAndFlags(authData, input);
|
|
385
|
+
if (rpCheck !== null) return reject(rpCheck);
|
|
386
|
+
|
|
387
|
+
// Signature is over authenticatorData ‖ SHA-256(clientDataJSON).
|
|
388
|
+
const clientDataHash = await sha256(input.clientDataJSON);
|
|
389
|
+
const signedData = concat(input.authenticatorData, clientDataHash);
|
|
390
|
+
const ok = await verifySignature(
|
|
391
|
+
input.credentialPublicKeyJwk,
|
|
392
|
+
input.credentialAlg,
|
|
393
|
+
input.signature,
|
|
394
|
+
signedData,
|
|
395
|
+
);
|
|
396
|
+
if (!ok) return reject("signature_invalid");
|
|
397
|
+
|
|
398
|
+
// Cloned-authenticator detection (WebAuthn §7.2 step 21): when either counter
|
|
399
|
+
// is non-zero the new value MUST be strictly greater than the stored one.
|
|
400
|
+
if (
|
|
401
|
+
(authData.signCount !== 0 || input.storedCounter !== 0) &&
|
|
402
|
+
authData.signCount <= input.storedCounter
|
|
403
|
+
) {
|
|
404
|
+
return reject("counter_regression");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { verified: true, newCounter: authData.signCount };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Verify the rpIdHash and the User Present / User Verified flags. */
|
|
411
|
+
async function checkRpAndFlags(
|
|
412
|
+
authData: AuthenticatorData,
|
|
413
|
+
input: {
|
|
414
|
+
readonly expectedRpId: string;
|
|
415
|
+
readonly requireUserVerification: boolean;
|
|
416
|
+
},
|
|
417
|
+
): Promise<VerifyFailureReason | null> {
|
|
418
|
+
const expectedHash = await sha256(
|
|
419
|
+
new TextEncoder().encode(input.expectedRpId),
|
|
420
|
+
);
|
|
421
|
+
if (!bytesEqual(authData.rpIdHash, expectedHash)) return "rp_id_mismatch";
|
|
422
|
+
if (!authData.flags.up) return "user_not_present";
|
|
423
|
+
if (input.requireUserVerification && !authData.flags.uv) {
|
|
424
|
+
return "user_not_verified";
|
|
425
|
+
}
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Import a JWK for the COSE alg and verify a signature, normalizing EC DER. */
|
|
430
|
+
async function verifySignature(
|
|
431
|
+
jwk: JsonWebKey,
|
|
432
|
+
alg: number,
|
|
433
|
+
signature: Uint8Array,
|
|
434
|
+
signedData: Uint8Array,
|
|
435
|
+
): Promise<boolean> {
|
|
436
|
+
const params = cryptoParamsForCoseAlg(alg);
|
|
437
|
+
if (params === null) return false;
|
|
438
|
+
let rawSignature = signature;
|
|
439
|
+
if (params.ec) {
|
|
440
|
+
try {
|
|
441
|
+
rawSignature = derToRawEcdsaSignature(signature, params.ecSignatureBytes);
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
const key = await crypto.subtle.importKey(
|
|
448
|
+
"jwk",
|
|
449
|
+
jwk,
|
|
450
|
+
params.importParams,
|
|
451
|
+
false,
|
|
452
|
+
["verify"],
|
|
453
|
+
);
|
|
454
|
+
return await crypto.subtle.verify(
|
|
455
|
+
params.verifyParams,
|
|
456
|
+
key,
|
|
457
|
+
rawSignature as BufferSource,
|
|
458
|
+
signedData as BufferSource,
|
|
459
|
+
);
|
|
460
|
+
} catch {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Concatenate two byte arrays into one. */
|
|
466
|
+
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
467
|
+
const out = new Uint8Array(a.length + b.length);
|
|
468
|
+
out.set(a, 0);
|
|
469
|
+
out.set(b, a.length);
|
|
470
|
+
return out;
|
|
471
|
+
}
|