@motebit/crypto-webauthn 1.0.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/dist/verify.js ADDED
@@ -0,0 +1,693 @@
1
+ /**
2
+ * WebAuthn platform-authenticator attestation verifier — the core
3
+ * judgment function this package exports.
4
+ *
5
+ * Flow (matches the W3C WebAuthn `packed` attestation verification
6
+ * recipe, plus the motebit-specific identity-key binding step):
7
+ *
8
+ * 1. Split the receipt into (attestationObjectBase64, clientDataJSONBase64).
9
+ * 2. CBOR-decode the attestation object to {fmt, attStmt:{alg, sig, x5c?},
10
+ * authData}. Assert fmt === "packed" in v1. Other fmts are named-not-
11
+ * supported errors (tpm / android-key / android-safetynet / fido-u2f /
12
+ * apple / none) — each is a separate additive arm future passes can add.
13
+ * 3a. Full attestation (x5c present): parse leaf, walk the chain against
14
+ * the pinned FIDO roots. Every non-leaf must carry basicConstraints.cA
15
+ * === true; every signature must verify; every cert must be within
16
+ * its validity window; the terminal cert's DER must equal one of the
17
+ * pinned roots byte-for-byte. Then verify `attStmt.sig` over
18
+ * `authData || clientDataHash` using the leaf's public key and alg.
19
+ * 3b. Self attestation (no x5c): extract the credential public key from
20
+ * `authData.attestedCredentialData`. Verify `attStmt.sig` over
21
+ * `authData || clientDataHash` using that key. Self-attested
22
+ * credentials carry no vendor chain and score as hardware-exported-
23
+ * equivalent (0.5) in the semiring — still better than software
24
+ * because the binding is still hardware-held, just not chain-proven.
25
+ * 4. Parse `clientDataJSON` (UTF-8 JSON): assert its `challenge` field
26
+ * (base64url-decoded) byte-equals the reconstructed
27
+ * SHA256(canonical body) the caller threads in via
28
+ * (motebit_id, device_id, identity_public_key, attested_at). The
29
+ * identity-binding step — byte-identical contract to App Attest.
30
+ * 5. Parse `authData` minimally: `rpIdHash` is the first 32 bytes; the
31
+ * caller's RP ID (e.g. "motebit.com") hashed with SHA-256 must match
32
+ * byte-for-byte. Bundle/RP binding.
33
+ *
34
+ * FIDO Metadata Service (MDS) dynamic fetch is explicitly out of scope —
35
+ * the pinned-roots model keeps the verifier sovereign and offline.
36
+ * Rotations land as additive constants in `fido-roots.ts`.
37
+ */
38
+ import * as x509 from "@peculiar/x509";
39
+ import { decode as cborDecode } from "cbor2";
40
+ import { parseWebAuthnAttestationObjectCbor } from "./cbor.js";
41
+ import { DEFAULT_FIDO_ROOTS, WEBAUTHN_FMT_PACKED } from "./fido-roots.js";
42
+ /** COSE algorithm identifiers we support in v1. */
43
+ const COSE_ALG_ES256 = -7; // ECDSA w/ SHA-256 on P-256
44
+ /** OID for X.509 basic-constraints extension. */
45
+ const BASIC_CONSTRAINTS_OID = "2.5.29.19";
46
+ /**
47
+ * WebAuthn platform-authenticator attestation verifier.
48
+ *
49
+ * Pure. No network. No filesystem. Deterministic given `now()`.
50
+ *
51
+ * `claim` is the `HardwareAttestationClaim` as carried inside the motebit
52
+ * AgentTrustCredential. For WebAuthn, the `attestation_receipt` field is
53
+ * two base64url segments separated by `.`:
54
+ *
55
+ * `{attestationObjectB64}.{clientDataJSONB64}`
56
+ *
57
+ * The web mint path constructs this shape; see
58
+ * `apps/web/src/mint-hardware-credential.ts`.
59
+ */
60
+ export async function verifyWebAuthnAttestation(claim, opts) {
61
+ const errors = [];
62
+ let cert_chain_valid = false;
63
+ let signature_valid = false;
64
+ let rp_bound = false;
65
+ let identity_bound = false;
66
+ let attestation_kind = null;
67
+ if (!claim.attestation_receipt) {
68
+ errors.push({ message: "webauthn claim missing `attestation_receipt`" });
69
+ return fail(errors, {
70
+ cert_chain_valid,
71
+ signature_valid,
72
+ rp_bound,
73
+ identity_bound,
74
+ attestation_kind,
75
+ });
76
+ }
77
+ const parts = claim.attestation_receipt.split(".");
78
+ if (parts.length !== 2) {
79
+ errors.push({
80
+ message: `attestation_receipt must be 2 base64url parts (attObj.clientDataJSON); got ${parts.length}`,
81
+ });
82
+ return fail(errors, {
83
+ cert_chain_valid,
84
+ signature_valid,
85
+ rp_bound,
86
+ identity_bound,
87
+ attestation_kind,
88
+ });
89
+ }
90
+ const [attObjB64, clientDataJsonB64] = parts;
91
+ let attestationObjectBytes;
92
+ let clientDataJsonBytes;
93
+ try {
94
+ attestationObjectBytes = fromBase64Url(attObjB64);
95
+ clientDataJsonBytes = fromBase64Url(clientDataJsonB64);
96
+ }
97
+ catch (err) {
98
+ errors.push({ message: `base64url decode failed: ${messageOf(err)}` });
99
+ return fail(errors, {
100
+ cert_chain_valid,
101
+ signature_valid,
102
+ rp_bound,
103
+ identity_bound,
104
+ attestation_kind,
105
+ });
106
+ }
107
+ let cbor;
108
+ try {
109
+ cbor = parseWebAuthnAttestationObjectCbor(attestationObjectBytes);
110
+ }
111
+ catch (err) {
112
+ errors.push({ message: `CBOR decode: ${messageOf(err)}` });
113
+ return fail(errors, {
114
+ cert_chain_valid,
115
+ signature_valid,
116
+ rp_bound,
117
+ identity_bound,
118
+ attestation_kind,
119
+ });
120
+ }
121
+ const expectedFmt = opts.expectedFmt ?? WEBAUTHN_FMT_PACKED;
122
+ if (cbor.fmt !== expectedFmt) {
123
+ errors.push({
124
+ message: `attestation fmt is \`${cbor.fmt}\`; only \`${expectedFmt}\` is supported in v1 (tpm / android-key / android-safetynet / fido-u2f / apple / none are additive future arms)`,
125
+ });
126
+ return fail(errors, {
127
+ cert_chain_valid,
128
+ signature_valid,
129
+ rp_bound,
130
+ identity_bound,
131
+ attestation_kind,
132
+ });
133
+ }
134
+ if (cbor.sig === null) {
135
+ errors.push({ message: "attStmt.sig missing — packed attestation requires a signature" });
136
+ return fail(errors, {
137
+ cert_chain_valid,
138
+ signature_valid,
139
+ rp_bound,
140
+ identity_bound,
141
+ attestation_kind,
142
+ });
143
+ }
144
+ if (cbor.alg === null) {
145
+ errors.push({ message: "attStmt.alg missing — packed attestation requires an algorithm" });
146
+ return fail(errors, {
147
+ cert_chain_valid,
148
+ signature_valid,
149
+ rp_bound,
150
+ identity_bound,
151
+ attestation_kind,
152
+ });
153
+ }
154
+ if (cbor.alg !== COSE_ALG_ES256) {
155
+ errors.push({
156
+ message: `attStmt.alg ${cbor.alg} not supported in v1 (only ES256 / -7; RS256 and EdDSA are additive future arms)`,
157
+ });
158
+ return fail(errors, {
159
+ cert_chain_valid,
160
+ signature_valid,
161
+ rp_bound,
162
+ identity_bound,
163
+ attestation_kind,
164
+ });
165
+ }
166
+ const nowDate = new Date(opts.now ? opts.now() : Date.now());
167
+ // Compute the signed bytes common to both attestation kinds: the
168
+ // WebAuthn spec prescribes verifying the packed signature over
169
+ // `authData || clientDataHash` where clientDataHash = SHA256(clientDataJSON).
170
+ const clientDataHash = await sha256Bytes(clientDataJsonBytes);
171
+ const signedBytes = concatBytes(cbor.authData, clientDataHash);
172
+ attestation_kind = cbor.x5c.length > 0 ? "full" : "self";
173
+ // ── Signature + chain verification ────────────────────────────────
174
+ if (attestation_kind === "full") {
175
+ // Full attestation — verify chain against pinned FIDO roots, then
176
+ // verify signature under the leaf's public key.
177
+ try {
178
+ const leaf = new x509.X509Certificate(toArrayBuffer(cbor.x5c[0]));
179
+ const chainCerts = [leaf];
180
+ for (let i = 1; i < cbor.x5c.length; i++) {
181
+ chainCerts.push(new x509.X509Certificate(toArrayBuffer(cbor.x5c[i])));
182
+ }
183
+ const rootPems = opts.rootPems ?? DEFAULT_FIDO_ROOTS;
184
+ if (rootPems.length === 0) {
185
+ errors.push({ message: "no pinned FIDO roots configured" });
186
+ return fail(errors, {
187
+ cert_chain_valid,
188
+ signature_valid,
189
+ rp_bound,
190
+ identity_bound,
191
+ attestation_kind,
192
+ });
193
+ }
194
+ let parsedRoots;
195
+ try {
196
+ parsedRoots = rootPems.map((pem) => new x509.X509Certificate(pem));
197
+ }
198
+ catch (err) {
199
+ errors.push({ message: `x509 root parse: ${messageOf(err)}` });
200
+ return fail(errors, {
201
+ cert_chain_valid,
202
+ signature_valid,
203
+ rp_bound,
204
+ identity_bound,
205
+ attestation_kind,
206
+ });
207
+ }
208
+ const chainResult = await verifyCertChain({
209
+ supplied: chainCerts,
210
+ roots: parsedRoots,
211
+ nowDate,
212
+ });
213
+ cert_chain_valid = chainResult.valid;
214
+ if (!cert_chain_valid) {
215
+ errors.push({ message: chainResult.reason });
216
+ }
217
+ else {
218
+ // Chain verified — now verify the packed signature under leaf.
219
+ try {
220
+ const ok = await verifyP256Signature(leaf.publicKey, cbor.sig, signedBytes);
221
+ signature_valid = ok;
222
+ if (!ok) {
223
+ errors.push({
224
+ message: "packed signature did not verify under leaf public key",
225
+ });
226
+ }
227
+ }
228
+ catch (err) {
229
+ errors.push({ message: `packed signature verify crashed: ${messageOf(err)}` });
230
+ }
231
+ }
232
+ }
233
+ catch (err) {
234
+ errors.push({ message: `x509 leaf parse: ${messageOf(err)}` });
235
+ }
236
+ }
237
+ else {
238
+ // Self attestation — no chain; verify signature against the credential
239
+ // public key embedded in authData.attestedCredentialData. The credential
240
+ // key is the `one` key the authenticator is asserting ownership of —
241
+ // proving it signed the challenge is the only claim self-attestation
242
+ // makes.
243
+ try {
244
+ const credPubKey = extractCredentialPublicKeyFromAuthData(cbor.authData);
245
+ const ok = await verifyP256SignatureFromCoseKey(credPubKey, cbor.sig, signedBytes);
246
+ // Chain-validity is trivially true for self attestation — there is
247
+ // no chain, so we report `cert_chain_valid: true` to mean "no chain
248
+ // was required for this kind". The kind field is the discriminator
249
+ // the scorer reads.
250
+ cert_chain_valid = true;
251
+ signature_valid = ok;
252
+ if (!ok) {
253
+ errors.push({
254
+ message: "self-attestation signature did not verify under credential public key",
255
+ });
256
+ }
257
+ }
258
+ catch (err) {
259
+ errors.push({ message: `self-attestation verify crashed: ${messageOf(err)}` });
260
+ }
261
+ }
262
+ // ── RP binding ────────────────────────────────────────────────────
263
+ try {
264
+ if (cbor.authData.length < 32) {
265
+ errors.push({ message: `authData shorter than 32 bytes (got ${cbor.authData.length})` });
266
+ }
267
+ else {
268
+ const rpIdHash = cbor.authData.subarray(0, 32);
269
+ const expected = await sha256Bytes(new TextEncoder().encode(opts.expectedRpId));
270
+ if (bytesEq(rpIdHash, expected)) {
271
+ rp_bound = true;
272
+ }
273
+ else {
274
+ errors.push({
275
+ message: `authData.rpIdHash does not equal SHA256("${opts.expectedRpId}")`,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ catch (err) {
281
+ errors.push({ message: `rp binding crashed: ${messageOf(err)}` });
282
+ }
283
+ // ── Identity binding ──────────────────────────────────────────────
284
+ // Parse clientDataJSON, decode its `challenge` (base64url), and
285
+ // byte-compare against the SHA256 of the reconstructed canonical body
286
+ // naming the caller's identity. This is the cross-stack binding —
287
+ // without it, every other step would prove only that SOME WebAuthn
288
+ // platform authenticator did something, not that the Ed25519 key the
289
+ // credential subject claims is actually on this device.
290
+ try {
291
+ if (typeof opts.expectedIdentityPublicKeyHex !== "string" ||
292
+ opts.expectedIdentityPublicKeyHex.length === 0) {
293
+ errors.push({
294
+ message: "identity_bound: expectedIdentityPublicKeyHex not supplied",
295
+ });
296
+ }
297
+ else if (typeof opts.expectedMotebitId !== "string" || opts.expectedMotebitId.length === 0) {
298
+ errors.push({
299
+ message: "identity_bound: expectedMotebitId not supplied (required for body re-derivation)",
300
+ });
301
+ }
302
+ else if (typeof opts.expectedDeviceId !== "string" || opts.expectedDeviceId.length === 0) {
303
+ errors.push({
304
+ message: "identity_bound: expectedDeviceId not supplied (required for body re-derivation)",
305
+ });
306
+ }
307
+ else if (typeof opts.expectedAttestedAt !== "number" ||
308
+ !Number.isFinite(opts.expectedAttestedAt)) {
309
+ errors.push({
310
+ message: "identity_bound: expectedAttestedAt not supplied (required for body re-derivation)",
311
+ });
312
+ }
313
+ else {
314
+ const clientData = JSON.parse(new TextDecoder().decode(clientDataJsonBytes));
315
+ if (clientData === null || typeof clientData !== "object") {
316
+ errors.push({ message: "identity_bound: clientDataJSON is not a JSON object" });
317
+ }
318
+ else {
319
+ const challengeB64 = clientData.challenge;
320
+ if (typeof challengeB64 !== "string") {
321
+ errors.push({
322
+ message: "identity_bound: clientDataJSON.challenge missing or not a string",
323
+ });
324
+ }
325
+ else {
326
+ let challengeBytes;
327
+ try {
328
+ challengeBytes = fromBase64Url(challengeB64);
329
+ }
330
+ catch (err) {
331
+ errors.push({
332
+ message: `identity_bound: clientDataJSON.challenge base64url decode failed: ${messageOf(err)}`,
333
+ });
334
+ return buildResult();
335
+ }
336
+ const canonicalBody = buildCanonicalAttestationBody({
337
+ attested_at: opts.expectedAttestedAt,
338
+ device_id: opts.expectedDeviceId,
339
+ identity_public_key: opts.expectedIdentityPublicKeyHex.toLowerCase(),
340
+ motebit_id: opts.expectedMotebitId,
341
+ });
342
+ const derived = await sha256Bytes(new TextEncoder().encode(canonicalBody));
343
+ if (bytesEq(derived, challengeBytes)) {
344
+ identity_bound = true;
345
+ }
346
+ else {
347
+ errors.push({
348
+ message: "identity_bound: reconstructed SHA256(canonical body) does not equal clientDataJSON.challenge — body naming the caller's identity was not the body the browser signed over",
349
+ });
350
+ }
351
+ }
352
+ }
353
+ }
354
+ }
355
+ catch (err) {
356
+ errors.push({ message: `identity binding crashed: ${messageOf(err)}` });
357
+ }
358
+ return buildResult();
359
+ function buildResult() {
360
+ return {
361
+ valid: cert_chain_valid && signature_valid && rp_bound && identity_bound,
362
+ cert_chain_valid,
363
+ signature_valid,
364
+ rp_bound,
365
+ identity_bound,
366
+ attestation_kind,
367
+ errors,
368
+ };
369
+ }
370
+ }
371
+ // ── Helpers ──────────────────────────────────────────────────────────
372
+ function fail(errors, partial) {
373
+ return {
374
+ valid: false,
375
+ cert_chain_valid: partial.cert_chain_valid,
376
+ signature_valid: partial.signature_valid,
377
+ rp_bound: partial.rp_bound,
378
+ identity_bound: partial.identity_bound,
379
+ attestation_kind: partial.attestation_kind,
380
+ errors,
381
+ };
382
+ }
383
+ function messageOf(err) {
384
+ return err instanceof Error ? err.message : String(err);
385
+ }
386
+ async function sha256Bytes(data) {
387
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", data);
388
+ return new Uint8Array(buf);
389
+ }
390
+ function concatBytes(a, b) {
391
+ const out = new Uint8Array(a.length + b.length);
392
+ out.set(a, 0);
393
+ out.set(b, a.length);
394
+ return out;
395
+ }
396
+ function bytesEq(a, b) {
397
+ if (a.length !== b.length)
398
+ return false;
399
+ for (let i = 0; i < a.length; i++)
400
+ if (a[i] !== b[i])
401
+ return false;
402
+ return true;
403
+ }
404
+ function toArrayBuffer(bytes) {
405
+ const copy = new Uint8Array(bytes.length);
406
+ copy.set(bytes);
407
+ return copy.buffer;
408
+ }
409
+ function fromBase64Url(str) {
410
+ let b64 = str.replace(/-/g, "+").replace(/_/g, "/");
411
+ const pad = b64.length % 4;
412
+ if (pad === 2)
413
+ b64 += "==";
414
+ else if (pad === 3)
415
+ b64 += "=";
416
+ else if (pad === 1)
417
+ throw new Error("invalid base64url length");
418
+ const binary = atob(b64);
419
+ const out = new Uint8Array(binary.length);
420
+ for (let i = 0; i < binary.length; i++)
421
+ out[i] = binary.charCodeAt(i);
422
+ return out;
423
+ }
424
+ /**
425
+ * Reconstruct the byte-identical canonical body the web mint path
426
+ * composes at WebAuthn creation time. Must stay byte-equal to
427
+ * `buildCanonicalAttestationBody` in
428
+ * `apps/web/src/mint-hardware-credential.ts`.
429
+ *
430
+ * Ordering: alphabetical (JCS), which is what the browser-side mint emits:
431
+ * attested_at, device_id, identity_public_key, motebit_id, platform,
432
+ * version.
433
+ *
434
+ * `platform` is always `"webauthn"` and `version` is always `"1"` — both
435
+ * constants live in the web mint path and must match exactly.
436
+ */
437
+ function buildCanonicalAttestationBody(input) {
438
+ return (`{"attested_at":${input.attested_at}` +
439
+ `,"device_id":${jsonEscapeString(input.device_id)}` +
440
+ `,"identity_public_key":${jsonEscapeString(input.identity_public_key)}` +
441
+ `,"motebit_id":${jsonEscapeString(input.motebit_id)}` +
442
+ `,"platform":"webauthn"` +
443
+ `,"version":"1"}`);
444
+ }
445
+ function jsonEscapeString(s) {
446
+ let out = '"';
447
+ for (const ch of s) {
448
+ const code = ch.codePointAt(0);
449
+ if (ch === '"')
450
+ out += '\\"';
451
+ else if (ch === "\\")
452
+ out += "\\\\";
453
+ else if (ch === "\n")
454
+ out += "\\n";
455
+ else if (ch === "\r")
456
+ out += "\\r";
457
+ else if (ch === "\t")
458
+ out += "\\t";
459
+ else if (code < 0x20)
460
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
461
+ else
462
+ out += ch;
463
+ }
464
+ out += '"';
465
+ return out;
466
+ }
467
+ /**
468
+ * Build and verify a FIDO attestation chain. The supplied certs begin
469
+ * with the leaf and walk toward the issuer; the caller supplies the
470
+ * pinned-root accept-set. We match one of the roots by subject DN and
471
+ * byte-equal DER after chain traversal. Mirrors the App Attest chain
472
+ * verifier's invariants:
473
+ *
474
+ * 1. `X509ChainBuilder.build(leaf)` returns a chain terminating at a
475
+ * self-signed cert reachable from the pool of (supplied, roots).
476
+ * 2. The terminal cert's DER byte-equals one of the pinned roots.
477
+ * 3. Every non-leaf cert carries `basicConstraints.cA === true`.
478
+ * 4. Every signature verifies under its issuer's public key.
479
+ * 5. Every cert is within its validity window at `nowDate`.
480
+ */
481
+ async function verifyCertChain(input) {
482
+ const { supplied, roots, nowDate } = input;
483
+ const leaf = supplied[0];
484
+ const builder = new x509.X509ChainBuilder({
485
+ certificates: [...supplied, ...roots],
486
+ });
487
+ const chain = await builder.build(leaf);
488
+ const terminal = chain[chain.length - 1];
489
+ const terminalSelfSigned = await terminal.isSelfSigned();
490
+ if (!terminalSelfSigned) {
491
+ return { valid: false, reason: "chain does not terminate at a self-signed root" };
492
+ }
493
+ // Byte-equal against the pinned accept-set — any of the supplied roots
494
+ // is acceptable, but the terminal MUST match one.
495
+ const terminalDer = new Uint8Array(terminal.rawData);
496
+ const matchesPinned = roots.some((root) => bytesEq(terminalDer, new Uint8Array(root.rawData)));
497
+ if (!matchesPinned) {
498
+ return {
499
+ valid: false,
500
+ reason: "chain terminal cert DER does not match any pinned FIDO root",
501
+ };
502
+ }
503
+ for (let i = 0; i < chain.length; i++) {
504
+ const cert = chain[i];
505
+ if (nowDate < cert.notBefore || nowDate > cert.notAfter) {
506
+ return {
507
+ valid: false,
508
+ reason: `cert at chain position ${i} is outside its validity window at ${nowDate.toISOString()}`,
509
+ };
510
+ }
511
+ const isLeaf = i === 0;
512
+ if (!isLeaf && !certHasCaTrue(cert)) {
513
+ return {
514
+ valid: false,
515
+ reason: `cert at chain position ${i} lacks basicConstraints.cA=true (CA constraint not enforced)`,
516
+ };
517
+ }
518
+ const issuer = i === chain.length - 1 ? cert : chain[i + 1];
519
+ const sigOk = await cert.verify({ publicKey: issuer.publicKey, date: nowDate });
520
+ if (!sigOk) {
521
+ return {
522
+ valid: false,
523
+ reason: `cert at chain position ${i} signature did not verify under its issuer's public key`,
524
+ };
525
+ }
526
+ }
527
+ return { valid: true, reason: "ok" };
528
+ }
529
+ function certHasCaTrue(cert) {
530
+ const ext = cert.getExtension(BASIC_CONSTRAINTS_OID);
531
+ if (!ext)
532
+ return false;
533
+ return ext.ca === true;
534
+ }
535
+ /**
536
+ * Verify an ECDSA-P256 signature using a `@peculiar/x509` public-key
537
+ * handle. The leaf's `publicKey` is already a `PublicKey` wrapper; we
538
+ * import into WebCrypto with SHA-256 and verify the DER-encoded
539
+ * signature (packed fmt ships the signature in ASN.1 DER).
540
+ */
541
+ async function verifyP256Signature(leafPublicKey, signatureDer, signedBytes) {
542
+ // Import the leaf's public key into the same WebCrypto provider
543
+ // `globalThis.crypto` exposes — `leafPublicKey.rawData` is the
544
+ // SubjectPublicKeyInfo DER bytes the cert carries; WebCrypto accepts
545
+ // it via the `spki` import format. This keeps the CryptoKey handle
546
+ // bound to the caller-installed `crypto` provider so the subsequent
547
+ // `subtle.verify` call doesn't hit the "2nd argument is not of type
548
+ // CryptoKey" cross-provider mismatch.
549
+ const cryptoKey = await globalThis.crypto.subtle.importKey("spki", leafPublicKey.rawData, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
550
+ const rawSig = derToRawP256(signatureDer);
551
+ const ok = await globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, cryptoKey, rawSig, signedBytes);
552
+ return ok;
553
+ }
554
+ /**
555
+ * Verify an ECDSA-P256 signature using a COSE_Key-encoded public key
556
+ * extracted from `authData.attestedCredentialData` (self-attestation
557
+ * path).
558
+ */
559
+ async function verifyP256SignatureFromCoseKey(coseKeyBytes, signatureDer, signedBytes) {
560
+ const { x, y } = parseCoseEc2P256(coseKeyBytes);
561
+ // Assemble an uncompressed SEC1 public key: 0x04 || x(32) || y(32)
562
+ const raw = new Uint8Array(65);
563
+ raw[0] = 0x04;
564
+ raw.set(x, 1);
565
+ raw.set(y, 33);
566
+ const key = await globalThis.crypto.subtle.importKey("raw", raw, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
567
+ const rawSig = derToRawP256(signatureDer);
568
+ return globalThis.crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, rawSig, signedBytes);
569
+ }
570
+ /**
571
+ * WebAuthn `authData` layout (bytes):
572
+ * rpIdHash(32) || flags(1) || counter(4) || [attestedCredentialData] || [extensions]
573
+ *
574
+ * `attestedCredentialData` (when flags.AT set):
575
+ * aaguid(16) || credentialIdLen(2) || credentialId(credentialIdLen) || credentialPublicKey (COSE_Key CBOR)
576
+ *
577
+ * Returns the raw COSE_Key CBOR bytes. The COSE_Key parser does the
578
+ * field-level extraction in `parseCoseEc2P256`.
579
+ */
580
+ function extractCredentialPublicKeyFromAuthData(authData) {
581
+ if (authData.length < 37) {
582
+ throw new Error(`authData too short for attestedCredentialData (${authData.length} < 37)`);
583
+ }
584
+ const flags = authData[32];
585
+ const atFlag = (flags & 0x40) !== 0;
586
+ if (!atFlag) {
587
+ throw new Error("authData.flags.AT is not set — attestedCredentialData missing");
588
+ }
589
+ // Skip rpIdHash(32) + flags(1) + counter(4) = 37, then aaguid(16) = 53
590
+ if (authData.length < 55) {
591
+ throw new Error("authData too short to carry aaguid + credentialIdLen");
592
+ }
593
+ const credIdLen = (authData[53] << 8) | authData[54];
594
+ const credIdEnd = 55 + credIdLen;
595
+ if (authData.length < credIdEnd) {
596
+ throw new Error("authData too short to carry credentialId");
597
+ }
598
+ // The remainder of authData (minus any trailing extensions) is the
599
+ // COSE_Key CBOR. For v1 we do not support extensions, so we consume
600
+ // to the end.
601
+ return authData.subarray(credIdEnd);
602
+ }
603
+ /**
604
+ * Parse the subset of COSE_Key fields needed to reconstruct an ES256
605
+ * public key: kty=2 (EC2), alg=-7 (ES256), crv=1 (P-256), x, y.
606
+ *
607
+ * COSE_Key is a CBOR map with integer keys. The shape is well-defined
608
+ * and fixed for ES256 credentials — we delegate the CBOR parse to
609
+ * `cbor2` and validate the int-keyed field presence here.
610
+ */
611
+ function parseCoseEc2P256(coseBytes) {
612
+ const decoded = cborDecode(coseBytes);
613
+ if (!(decoded instanceof Map)) {
614
+ throw new Error("COSE_Key is not a CBOR map");
615
+ }
616
+ // cbor2 types its Map as `Map<any, any>`; narrow via `unknown` reads
617
+ // so the COSE_Key field-presence checks below are type-safe.
618
+ const map = decoded;
619
+ const kty = map.get(1);
620
+ const alg = map.get(3);
621
+ const crv = map.get(-1);
622
+ const x = map.get(-2);
623
+ const y = map.get(-3);
624
+ if (kty !== 2)
625
+ throw new Error(`COSE_Key.kty ${String(kty)} is not EC2 (2)`);
626
+ if (alg !== -7)
627
+ throw new Error(`COSE_Key.alg ${String(alg)} is not ES256 (-7)`);
628
+ if (crv !== 1)
629
+ throw new Error(`COSE_Key.crv ${String(crv)} is not P-256 (1)`);
630
+ if (!(x instanceof Uint8Array) || x.length !== 32) {
631
+ throw new Error(`COSE_Key.x missing or wrong length`);
632
+ }
633
+ if (!(y instanceof Uint8Array) || y.length !== 32) {
634
+ throw new Error(`COSE_Key.y missing or wrong length`);
635
+ }
636
+ return {
637
+ x: new Uint8Array(x.buffer, x.byteOffset, x.byteLength).slice(),
638
+ y: new Uint8Array(y.buffer, y.byteOffset, y.byteLength).slice(),
639
+ };
640
+ }
641
+ /**
642
+ * Convert an ASN.1 DER-encoded ECDSA signature to the raw r||s form
643
+ * WebCrypto expects (64 bytes for P-256).
644
+ *
645
+ * SEQUENCE { INTEGER r, INTEGER s }
646
+ *
647
+ * DER integers are signed big-endian with a leading 0x00 if the MSB of
648
+ * the unsigned value is set; we strip leading zeros and left-pad to 32.
649
+ */
650
+ function derToRawP256(derSig) {
651
+ let i = 0;
652
+ if (derSig[i++] !== 0x30)
653
+ throw new Error("ECDSA signature not a DER SEQUENCE");
654
+ let seqLen = derSig[i++];
655
+ if (seqLen & 0x80) {
656
+ const nBytes = seqLen & 0x7f;
657
+ seqLen = 0;
658
+ for (let j = 0; j < nBytes; j++)
659
+ seqLen = (seqLen << 8) | derSig[i++];
660
+ }
661
+ void seqLen;
662
+ if (derSig[i++] !== 0x02)
663
+ throw new Error("ECDSA signature.r not a DER INTEGER");
664
+ const rLen = derSig[i++];
665
+ const r = derSig.subarray(i, i + rLen);
666
+ i += rLen;
667
+ if (derSig[i++] !== 0x02)
668
+ throw new Error("ECDSA signature.s not a DER INTEGER");
669
+ const sLen = derSig[i++];
670
+ const s = derSig.subarray(i, i + sLen);
671
+ const rPadded = leftPadTo32(stripLeadingZero(r));
672
+ const sPadded = leftPadTo32(stripLeadingZero(s));
673
+ const out = new Uint8Array(64);
674
+ out.set(rPadded, 0);
675
+ out.set(sPadded, 32);
676
+ return out;
677
+ }
678
+ function stripLeadingZero(bytes) {
679
+ let i = 0;
680
+ while (i < bytes.length - 1 && bytes[i] === 0x00)
681
+ i++;
682
+ return bytes.subarray(i);
683
+ }
684
+ function leftPadTo32(bytes) {
685
+ if (bytes.length === 32)
686
+ return bytes;
687
+ if (bytes.length > 32)
688
+ throw new Error(`ECDSA integer longer than 32 bytes (got ${bytes.length})`);
689
+ const out = new Uint8Array(32);
690
+ out.set(bytes, 32 - bytes.length);
691
+ return out;
692
+ }
693
+ //# sourceMappingURL=verify.js.map