@blamejs/pki 0.4.15 → 0.5.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/CHANGELOG.md +37 -1
- package/MIGRATING.md +2 -2
- package/README.md +142 -137
- package/index.js +4 -0
- package/lib/acme.js +73 -1
- package/lib/asn1-der.js +2 -0
- package/lib/attrcert-sign.js +4 -0
- package/lib/cbor-det.js +32 -16
- package/lib/cmc-build.js +880 -0
- package/lib/cmc-verify.js +657 -0
- package/lib/cmp-build.js +8 -7
- package/lib/cmp-verify.js +11 -1
- package/lib/cms-sign.js +170 -8
- package/lib/cms-verify.js +80 -14
- package/lib/crl-sign.js +22 -0
- package/lib/crmf-sign.js +5 -2
- package/lib/csr-sign.js +3 -0
- package/lib/ct.js +72 -0
- package/lib/est.js +828 -32
- package/lib/framework-error.js +13 -0
- package/lib/guard-bytes.js +37 -1
- package/lib/guard-range.js +23 -1
- package/lib/http-transport.js +9 -3
- package/lib/inspect.js +28 -5
- package/lib/jose.js +15 -0
- package/lib/lint.js +4 -0
- package/lib/merkle.js +5 -5
- package/lib/ocsp.js +139 -11
- package/lib/oid.js +69 -1
- package/lib/path-validate.js +27 -4
- package/lib/pkcs12-build.js +12 -0
- package/lib/schema-all.js +19 -1
- package/lib/schema-attrcert.js +27 -0
- package/lib/schema-c509.js +6 -0
- package/lib/schema-cmc.js +791 -0
- package/lib/schema-cmp.js +25 -0
- package/lib/schema-cms.js +17 -1
- package/lib/schema-crl.js +23 -1
- package/lib/schema-crmf.js +13 -0
- package/lib/schema-csr.js +11 -0
- package/lib/schema-csrattrs.js +6 -0
- package/lib/schema-engine.js +6 -2
- package/lib/schema-ocsp.js +41 -0
- package/lib/schema-pkcs12.js +16 -0
- package/lib/schema-pkcs8.js +8 -0
- package/lib/schema-smime.js +4 -4
- package/lib/schema-tsp.js +32 -1
- package/lib/schema-x509.js +14 -1
- package/lib/shbs.js +12 -4
- package/lib/sigstore.js +4 -0
- package/lib/smime.js +28 -7
- package/lib/tls-cert-compress.js +15 -3
- package/lib/trust.js +27 -4
- package/lib/tsp-sign.js +41 -6
- package/lib/vendor/README.md +19 -19
- package/lib/webauthn.js +895 -26
- package/lib/x509-sign.js +3 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
CHANGED
|
@@ -304,7 +304,7 @@ function _findExt(cert, oidName) {
|
|
|
304
304
|
* @primitive pki.webauthn.parseAttestationObject
|
|
305
305
|
* @signature pki.webauthn.parseAttestationObject(bytes) -> { fmt, attStmt, authData, authDataBytes }
|
|
306
306
|
* @since 0.2.5
|
|
307
|
-
* @status
|
|
307
|
+
* @status stable
|
|
308
308
|
* @spec W3C WebAuthn Level 3 sec. 6.5.4 / 6.1
|
|
309
309
|
* @related pki.webauthn.verify
|
|
310
310
|
*
|
|
@@ -316,6 +316,8 @@ function _findExt(cert, oidName) {
|
|
|
316
316
|
* A malformed object throws `webauthn/bad-attestation-object`.
|
|
317
317
|
*
|
|
318
318
|
* @example
|
|
319
|
+
* // requires: `attestationObject` -- the CBOR bytes a browser returns from
|
|
320
|
+
* // navigator.credentials.create(), i.e. credential.response.attestationObject
|
|
319
321
|
* var att = pki.webauthn.parseAttestationObject(attestationObject);
|
|
320
322
|
* att.fmt; // "packed"
|
|
321
323
|
* att.authData.credentialPublicKey.kty; // 2 (EC2)
|
|
@@ -404,8 +406,279 @@ function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
|
|
|
404
406
|
// resolve to an inherited Object member and read as recognised.
|
|
405
407
|
var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
406
408
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
409
|
+
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
410
|
+
rootCertificates: 1,
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Anchor an attestation's trust path to roots the CALLER pins. The metadata route
|
|
414
|
+
// resolves an authenticator's roots from the catalogue that registered it, which is
|
|
415
|
+
// the stronger source -- but it only reaches models the catalogue lists, and some
|
|
416
|
+
// are not there: Apple does not publish its authenticators to the FIDO Metadata
|
|
417
|
+
// Service, and the Google hardware-attestation roots are distributed by Google. For
|
|
418
|
+
// those formats the catalogue route resolves nothing, so without this there is no
|
|
419
|
+
// way to anchor them at all and a trust path comes back unchecked. (android-safetynet
|
|
420
|
+
// already had its own `safetyNetRoots` for the same reason; this is that carve-out
|
|
421
|
+
// made general.)
|
|
422
|
+
//
|
|
423
|
+
// PRECEDENCE, stated rather than implied: `metadata` governs when it is supplied,
|
|
424
|
+
// because a model's own registered roots are a stronger claim than a static pin, and
|
|
425
|
+
// its status reports can disqualify a model these roots would still accept.
|
|
426
|
+
// `rootCertificates` is the fallback for the models the catalogue does not cover.
|
|
427
|
+
// Supplying both is not an error -- it is the ordinary configuration for a relying
|
|
428
|
+
// party that accepts MDS-listed authenticators AND Apple.
|
|
429
|
+
// The caller's pinned roots are copied SYNCHRONOUSLY, at the entry point, because
|
|
430
|
+
// they are not read until the attestation verifier has resolved -- a later promise
|
|
431
|
+
// turn. Both the ARRAY and each DER buffer in it stay caller-owned across that gap,
|
|
432
|
+
// so a caller that recycles the array or overwrites a certificate's bytes in between
|
|
433
|
+
// would have the attestation anchored against the replacement roots while the verdict
|
|
434
|
+
// still reports `anchoredTo: "rootCertificates"` (CWE-367 reaching a wrong trust
|
|
435
|
+
// decision). Same defence, and the same reason, as the assertion input's snapshot.
|
|
436
|
+
// Shape faults are NOT raised here: the array is validated where it is consumed, so
|
|
437
|
+
// supplying a malformed pin alongside a metadata catalogue that answers keeps failing
|
|
438
|
+
// exactly where it did before.
|
|
439
|
+
// A parsed certificate is a tree of plain objects, arrays and Buffers, and the anchor
|
|
440
|
+
// comparison downstream reads its `subject` and `subjectPublicKeyInfo` -- both nested
|
|
441
|
+
// Buffers the caller still owns. Copying only the top-level array would leave those
|
|
442
|
+
// aliased, so the parsed form is deep-copied: every Buffer is duplicated and every
|
|
443
|
+
// container rebuilt, while the value types a parsed certificate carries (Date for a
|
|
444
|
+
// validity bound, BigInt for a serial) are preserved rather than flattened.
|
|
445
|
+
//
|
|
446
|
+
// The depth bound is a RECURSION BACKSTOP set far above any real certificate, never the
|
|
447
|
+
// precision mechanism -- a bound tuned to today's shapes silently rejects a legitimate root
|
|
448
|
+
// the moment a deeper one appears. A measured certificate reaches depth 5, and the deepest
|
|
449
|
+
// structure the profile allows -- a certificate policy whose qualifier carries a UserNotice
|
|
450
|
+
// with a noticeRef and its noticeNumbers -- lands near 12, so a cap of 12 would sit exactly
|
|
451
|
+
// on a conforming root. This is set well clear of that; it exists only so a cyclic or hostile
|
|
452
|
+
// object cannot recurse without end.
|
|
453
|
+
function _cloneParsed(v, depth) {
|
|
454
|
+
if (depth > 64) throw _err("webauthn/bad-input", "opts.rootCertificates[] is nested too deeply to be a parsed certificate");
|
|
455
|
+
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.from(v);
|
|
456
|
+
if (Array.isArray(v)) return v.map(function (x) { return _cloneParsed(x, depth + 1); });
|
|
457
|
+
if (v instanceof Date) return new Date(v.getTime());
|
|
458
|
+
if (v && typeof v === "object") {
|
|
459
|
+
var out = {};
|
|
460
|
+
Object.keys(v).forEach(function (k) { out[k] = _cloneParsed(v[k], depth + 1); });
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
return v; // string, number, boolean, bigint, null, undefined -- all immutable
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// A caller-owned byte argument, copied so nothing downstream reads bytes the caller can still
|
|
467
|
+
// rewrite. Every byte form the parsers accept is covered -- an ArrayBuffer or a DataView arrives
|
|
468
|
+
// by the same door as a Buffer, and leaving those aliased would reopen the window for exactly the
|
|
469
|
+
// inputs that came in by the wider one. Anything else passes through untouched so this cannot
|
|
470
|
+
// change which inputs are accepted; the parser downstream still names a wrong type.
|
|
471
|
+
function _snapshotBytes(v, label) {
|
|
472
|
+
if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) {
|
|
473
|
+
return guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", label);
|
|
474
|
+
}
|
|
475
|
+
return v;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function _snapshotRoots(supplied) {
|
|
479
|
+
if (!Array.isArray(supplied)) return supplied;
|
|
480
|
+
return supplied.map(function (root) {
|
|
481
|
+
if (Buffer.isBuffer(root) || root instanceof Uint8Array) {
|
|
482
|
+
return guard.bytes.snapshot(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
|
|
483
|
+
}
|
|
484
|
+
if (root && typeof root === "object") return _cloneParsed(root, 0);
|
|
485
|
+
return root; // a PEM string is immutable
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// `supplied` is the SNAPSHOT taken at the entry point, never `opts.rootCertificates`
|
|
490
|
+
// read afresh here -- that re-read is the window this guard closes. `opts` is still
|
|
491
|
+
// threaded for the non-aliasing options (the validation time).
|
|
492
|
+
// `vopts` is `verify`'s NORMALIZED options object, never the caller's -- the parameter is
|
|
493
|
+
// named for that so a future caller cannot hand it the live one without noticing. Everything
|
|
494
|
+
// it reads was captured synchronously at the entry.
|
|
495
|
+
function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
496
|
+
if (!Array.isArray(supplied) || !supplied.length) {
|
|
497
|
+
throw _err("webauthn/bad-input", "opts.rootCertificates must be a non-empty array of root certificates (DER, PEM, or parsed)");
|
|
498
|
+
}
|
|
499
|
+
// DER, PEM or an already-parsed certificate, decoded HERE -- the anchor comparison
|
|
500
|
+
// downstream reads a certificate's subject and key, so a raw buffer reaching it
|
|
501
|
+
// would fault on a field it does not have rather than naming the caller's mistake.
|
|
502
|
+
// The same three forms opts.safetyNetRoots takes, since it is the same question.
|
|
503
|
+
var roots = supplied.map(function (root, i) {
|
|
504
|
+
var cert;
|
|
505
|
+
try { cert = (Buffer.isBuffer(root) || typeof root === "string") ? x509.parse(root) : root; }
|
|
506
|
+
catch (e) { throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a decodable certificate", e); }
|
|
507
|
+
if (!cert || !cert.subject || !cert.subjectPublicKeyInfo) {
|
|
508
|
+
throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a certificate");
|
|
509
|
+
}
|
|
510
|
+
return cert;
|
|
511
|
+
});
|
|
512
|
+
// Same rule the metadata route applies: a compound element carrying no
|
|
513
|
+
// certificates makes no claim there is anything to anchor, so it is not a reason to
|
|
514
|
+
// refuse the statement -- but `anchoredTo` alone would then say "anchored" over a
|
|
515
|
+
// statement it did not wholly cover. The coverage is reported instead.
|
|
516
|
+
// `onlyPaths`, when given, is the set of paths the CATALOGUE did not cover -- the
|
|
517
|
+
// fallback is for exactly those. Applying it to the whole statement instead would
|
|
518
|
+
// re-judge an element the catalogue already governed against roots that have nothing
|
|
519
|
+
// to do with it: a compound holding one listed element (chaining to the root its own
|
|
520
|
+
// metadata entry registers) beside one unlisted element (chaining to a caller pin) would
|
|
521
|
+
// be refused, because the listed element does not chain to the pin. That is the
|
|
522
|
+
// documented "MDS-listed authenticators AND Apple" configuration, and each element is
|
|
523
|
+
// still anchored to something the caller trusts -- just not all to the same thing.
|
|
524
|
+
var paths = onlyPaths || ((res.fmt === "compound" && Array.isArray(res.compound))
|
|
525
|
+
? res.compound.filter(function (el) { return el.trustPath && el.trustPath.length; })
|
|
526
|
+
.map(function (el) { return { tp: el.trustPath, at: el.chainValidatedAt }; })
|
|
527
|
+
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, at: res.chainValidatedAt }] : []));
|
|
528
|
+
// Coverage is ACCUMULATED across routes, not overwritten by whichever ran last. When
|
|
529
|
+
// `onlyPaths` is given the metadata route already anchored the rest and recorded that here,
|
|
530
|
+
// so reporting the fallback's own count would say 1 of 2 for a statement whose two elements
|
|
531
|
+
// were both anchored -- one by the catalogue, one by the pin. A caller enforcing
|
|
532
|
+
// `anchored === total` would then reject the mixed configuration this fallback exists to
|
|
533
|
+
// support, and the verdict would understate the trust evaluation it actually performed.
|
|
534
|
+
if (!onlyPaths) {
|
|
535
|
+
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
536
|
+
anchored: paths.length };
|
|
537
|
+
}
|
|
538
|
+
// `none` and a self-attestation carry no certificates, so there is nothing to
|
|
539
|
+
// anchor -- and a caller who asked for anchoring is told it could not be applied
|
|
540
|
+
// rather than handed a pass that looks like it was. Same rule the metadata route
|
|
541
|
+
// states, for the same reason.
|
|
542
|
+
if (!paths.length) {
|
|
543
|
+
throw _err("webauthn/anchor-not-applicable",
|
|
544
|
+
"opts.rootCertificates was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
545
|
+
}
|
|
546
|
+
var at = vopts.time !== undefined ? vopts.time : new Date();
|
|
547
|
+
// EVERY path, not merely one: a compound's elements are independent claims, and
|
|
548
|
+
// accepting the whole because one element anchored would let an unanchored element
|
|
549
|
+
// ride along on its neighbour's trust.
|
|
550
|
+
return paths.reduce(function (p, info) {
|
|
551
|
+
return p.then(function () {
|
|
552
|
+
var pathAt = vopts.time !== undefined ? vopts.time : (info.at || at);
|
|
553
|
+
return mds.chainToAnchor(info.tp.slice().reverse(), roots, pathAt,
|
|
554
|
+
"attestation trust path (against the roots supplied as opts.rootCertificates)");
|
|
555
|
+
});
|
|
556
|
+
}, Promise.resolve()).then(function () {
|
|
557
|
+
res.anchoredTo = _anchoredRoutes(res, "rootCertificates");
|
|
558
|
+
return res;
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// android-safetynet anchors through a route of its own. The verifier REQUIRES
|
|
563
|
+
// `opts.safetyNetRoots` (8.5) and refuses the statement unless the x5c chain validates to
|
|
564
|
+
// one of them, so by the time a verdict is composed that element's path HAS been anchored --
|
|
565
|
+
// without either `metadata` or `rootCertificates` being involved. Counting only those two
|
|
566
|
+
// routes reports `anchoredTo: null` over it, whose documented meaning is that nobody checked
|
|
567
|
+
// the path, so a caller enforcing `anchoredTo !== null` would refuse an attestation this
|
|
568
|
+
// library did in fact anchor. Every route that contributed is named.
|
|
569
|
+
function _safetyNetAnchored(res) {
|
|
570
|
+
if (res.fmt === "android-safetynet") return 1;
|
|
571
|
+
if (res.fmt === "compound" && Array.isArray(res.compound)) {
|
|
572
|
+
return res.compound.filter(function (el) { return el && el.fmt === "android-safetynet"; }).length;
|
|
573
|
+
}
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// `base` is whichever of the two caller-selected routes ran, or null when neither did.
|
|
578
|
+
// Routes are named in a stable order so a caller may compare the string.
|
|
579
|
+
function _anchoredRoutes(res, base) {
|
|
580
|
+
var routes = base ? base.split("+") : [];
|
|
581
|
+
if (_safetyNetAnchored(res) && routes.indexOf("safetyNetRoots") < 0) routes.push("safetyNetRoots");
|
|
582
|
+
return routes.length ? routes.join("+") : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---- ceremony binding (WebAuthn sec. 7.1 / 7.2) ------------------------------
|
|
586
|
+
//
|
|
587
|
+
// The attestation and assertion procedures verify a SIGNATURE. What makes a
|
|
588
|
+
// response acceptable is a separate set of checks the relying party owns, because
|
|
589
|
+
// only it knows what it asked for: the challenge it issued, the origin the browser
|
|
590
|
+
// reported, the RP ID it operates under, and the user-presence / user-verification
|
|
591
|
+
// policy it requires. A verifier that answers only the first question, under a name
|
|
592
|
+
// that sounds like the second, is the shape of every phishing-resistant login that
|
|
593
|
+
// turns out not to be -- an attestation naming ANOTHER RP, with user presence
|
|
594
|
+
// clear, is a perfectly sound statement about a credential the caller must not
|
|
595
|
+
// accept.
|
|
596
|
+
//
|
|
597
|
+
// So the checks this layer CAN make are offered here, opt-in, and every verdict
|
|
598
|
+
// reports which of them actually ran. The challenge and the origin stay with the
|
|
599
|
+
// caller: they live in clientDataJSON, which the caller already holds and compares
|
|
600
|
+
// against state only it has.
|
|
601
|
+
var _BINDING_OPTS = Object.assign(Object.create(null), {
|
|
602
|
+
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
407
603
|
});
|
|
408
604
|
|
|
605
|
+
function _assertBool(v, name) {
|
|
606
|
+
if (typeof v !== "boolean") throw _err("webauthn/bad-input", "opts." + name + " must be a boolean");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Runs the bindings the caller asked for and reports EACH one's status: `true` it
|
|
610
|
+
// was checked and holds, `false` it was not asked for. A caller reading the verdict
|
|
611
|
+
// can therefore tell a check that passed from one that never ran -- the distinction
|
|
612
|
+
// the single `verified` boolean could not express.
|
|
613
|
+
function _applyBindings(authData, coseKey, opts) {
|
|
614
|
+
var checked = { rpId: false, userPresence: false, userVerification: false, algorithm: false };
|
|
615
|
+
if (opts.expectedRpId !== undefined) {
|
|
616
|
+
if (typeof opts.expectedRpId !== "string" || !opts.expectedRpId.length) {
|
|
617
|
+
throw _err("webauthn/bad-input", "opts.expectedRpId must be a non-empty RP ID string");
|
|
618
|
+
}
|
|
619
|
+
// Compared in constant time and by full value, like every other identity
|
|
620
|
+
// comparison in this toolkit -- a prefix must not read as a match.
|
|
621
|
+
if (!guard.crypto.constantTimeEqual(_sha("sha256", Buffer.from(opts.expectedRpId, "utf8")),
|
|
622
|
+
Buffer.from(authData.rpIdHash))) {
|
|
623
|
+
throw _err("webauthn/rp-id-mismatch",
|
|
624
|
+
"the authenticatorData rpIdHash is not SHA-256 of opts.expectedRpId, so this response was " +
|
|
625
|
+
"produced for a different relying party (WebAuthn sec. 7.1 step 13 / sec. 7.2 step 15)");
|
|
626
|
+
}
|
|
627
|
+
checked.rpId = true;
|
|
628
|
+
}
|
|
629
|
+
if (opts.requireUserPresence !== undefined) {
|
|
630
|
+
_assertBool(opts.requireUserPresence, "requireUserPresence");
|
|
631
|
+
if (opts.requireUserPresence && !authData.flags.up) {
|
|
632
|
+
throw _err("webauthn/user-presence-required",
|
|
633
|
+
"opts.requireUserPresence is set and the authenticatorData User Present (UP) flag is clear " +
|
|
634
|
+
"(WebAuthn sec. 7.1 step 14 / sec. 7.2 step 16)");
|
|
635
|
+
}
|
|
636
|
+
checked.userPresence = opts.requireUserPresence;
|
|
637
|
+
}
|
|
638
|
+
if (opts.requireUserVerification !== undefined) {
|
|
639
|
+
_assertBool(opts.requireUserVerification, "requireUserVerification");
|
|
640
|
+
if (opts.requireUserVerification && !authData.flags.uv) {
|
|
641
|
+
throw _err("webauthn/user-verification-required",
|
|
642
|
+
"opts.requireUserVerification is set and the authenticatorData User Verified (UV) flag is clear " +
|
|
643
|
+
"(WebAuthn sec. 7.1 step 15 / sec. 7.2 step 17)");
|
|
644
|
+
}
|
|
645
|
+
checked.userVerification = opts.requireUserVerification;
|
|
646
|
+
}
|
|
647
|
+
// COSE alg -65535 is RSASSA-PKCS1-v1_5 with SHA-1, and a credential key may name
|
|
648
|
+
// it -- which would make SHA-1 the signature algorithm of every login that
|
|
649
|
+
// credential ever performs. Refused unless the caller lists it, so supporting a
|
|
650
|
+
// legacy authenticator is a decision someone wrote down rather than the default.
|
|
651
|
+
// (This is the CREDENTIAL key's algorithm. A legacy TPM attestation STATEMENT
|
|
652
|
+
// signed with RS1 is a different field and is still verified, since refusing it
|
|
653
|
+
// would reject the authenticator's own evidence about itself.)
|
|
654
|
+
var alg65535Allowed = Array.isArray(opts.allowedAlgorithms) && opts.allowedAlgorithms.indexOf(-65535) !== -1;
|
|
655
|
+
if (coseKey && coseKey.alg === -65535 && !alg65535Allowed) {
|
|
656
|
+
throw _err("webauthn/algorithm-not-allowed",
|
|
657
|
+
"the credential public key declares COSE algorithm -65535 (RSASSA-PKCS1-v1_5 with SHA-1); every " +
|
|
658
|
+
"signature made by this credential would use SHA-1, so it is refused unless opts.allowedAlgorithms " +
|
|
659
|
+
"names -65535 explicitly");
|
|
660
|
+
}
|
|
661
|
+
// The rest of algorithm policy is the RP's, declared to the browser as
|
|
662
|
+
// pubKeyCredParams, and nothing in the response proves what was offered -- so it
|
|
663
|
+
// is checked only when the caller states the list.
|
|
664
|
+
if (opts.allowedAlgorithms !== undefined) {
|
|
665
|
+
if (!Array.isArray(opts.allowedAlgorithms) || !opts.allowedAlgorithms.length ||
|
|
666
|
+
// isSafeInteger, not isInteger: a value above 2^53 is not held exactly as a
|
|
667
|
+
// Number, so one written that way is not the identifier the caller meant --
|
|
668
|
+
// and no COSE algorithm identifier lives out there anyway.
|
|
669
|
+
!opts.allowedAlgorithms.every(function (a) { return typeof a === "number" && Number.isSafeInteger(a); })) {
|
|
670
|
+
throw _err("webauthn/bad-input", "opts.allowedAlgorithms must be a non-empty array of COSE algorithm integers");
|
|
671
|
+
}
|
|
672
|
+
var alg = coseKey && coseKey.alg;
|
|
673
|
+
if (opts.allowedAlgorithms.indexOf(alg) === -1) {
|
|
674
|
+
throw _err("webauthn/algorithm-not-allowed",
|
|
675
|
+
"the credential public key declares COSE algorithm " + alg + ", which is not in opts.allowedAlgorithms");
|
|
676
|
+
}
|
|
677
|
+
checked.algorithm = true;
|
|
678
|
+
}
|
|
679
|
+
return checked;
|
|
680
|
+
}
|
|
681
|
+
|
|
409
682
|
// One bound for every attestation certificate chain, wherever it arrives from -- an attStmt x5c
|
|
410
683
|
// array or a JWS x5c header. Capping the bytes of a single entry does not bound the COUNT, and the
|
|
411
684
|
// cost of an entry is a DER parse plus, downstream, a signature check or a path validation. Kept in
|
|
@@ -849,8 +1122,26 @@ function _safetyNetChainTrusted(chain, roots, time) {
|
|
|
849
1122
|
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
850
1123
|
// order (anchor-adjacent first, target/leaf last) so the caller passes it straight
|
|
851
1124
|
// to the path validator without re-ordering. The input array is not mutated.
|
|
1125
|
+
// `attestationVerified`, not `verified`: this says the attestation STATEMENT is
|
|
1126
|
+
// sound, which is not the same claim as "this registration is acceptable". The
|
|
1127
|
+
// ceremony bindings the caller may also have asked for are reported separately, in
|
|
1128
|
+
// `bindingChecked`, so a field that passed can be told from one that never ran.
|
|
1129
|
+
// It also carries everything a relying party must STORE to finish registration and
|
|
1130
|
+
// run a later login: the credentialId it will look the credential up by, the
|
|
1131
|
+
// credential public key that will verify assertions, and the signCount that is the
|
|
1132
|
+
// starting point for the sec. 7.2 step 21 counter rule. Leaving those out sent the
|
|
1133
|
+
// caller back to parse the attestation object a second time for values this call
|
|
1134
|
+
// had already decoded -- and a login cannot be verified without them.
|
|
852
1135
|
function _result(fmt, attestationType, chain, att) {
|
|
853
|
-
return {
|
|
1136
|
+
return {
|
|
1137
|
+
attestationVerified: true, fmt: fmt, attestationType: attestationType,
|
|
1138
|
+
trustPath: (chain || []).slice().reverse(),
|
|
1139
|
+
aaguid: att.authData.aaguid,
|
|
1140
|
+
credentialId: att.authData.credentialId,
|
|
1141
|
+
credentialPublicKey: att.authData.credentialPublicKey,
|
|
1142
|
+
signCount: att.authData.signCount,
|
|
1143
|
+
flags: att.authData.flags,
|
|
1144
|
+
};
|
|
854
1145
|
}
|
|
855
1146
|
|
|
856
1147
|
// Decode the Apple extension AppleAnonymousAttestation ::= SEQUENCE { nonce [1]
|
|
@@ -858,9 +1149,19 @@ function _result(fmt, attestationType, chain, att) {
|
|
|
858
1149
|
function _appleNonce(extValue) {
|
|
859
1150
|
var seq;
|
|
860
1151
|
try { seq = asn1.decode(extValue); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation extension is not decodable", e); }
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1152
|
+
// ARITY IS PART OF THE DECLARED SHAPE. Reading the first child and ignoring the rest
|
|
1153
|
+
// accepts a certificate carrying a second, unchecked value beside the nonce -- and this
|
|
1154
|
+
// extension exists precisely to carry the value the attestation binds to, so an
|
|
1155
|
+
// ambiguous encoding of it is not a shape this verifier gets to pick a reading from.
|
|
1156
|
+
// The type is one SEQUENCE of exactly one field, and the EXPLICIT wrapper holds exactly
|
|
1157
|
+
// one value; anything else is refused rather than partially read.
|
|
1158
|
+
var isSeq = seq.tagClass === "universal" && seq.tagNumber === asn1.TAGS.SEQUENCE;
|
|
1159
|
+
if (!isSeq || !seq.children || seq.children.length !== 1) {
|
|
1160
|
+
throw _err("webauthn/bad-att-cert", "the apple attestation extension is not SEQUENCE { [1] OCTET STRING } (expected exactly one field)");
|
|
1161
|
+
}
|
|
1162
|
+
var tagged = seq.children[0];
|
|
1163
|
+
if (!tagged || tagged.tagClass !== "context" || tagged.tagNumber !== 1 || !tagged.children || tagged.children.length !== 1) {
|
|
1164
|
+
throw _err("webauthn/bad-att-cert", "the apple attestation extension is not SEQUENCE { [1] OCTET STRING } (expected one EXPLICIT [1] value)");
|
|
864
1165
|
}
|
|
865
1166
|
try { return asn1.read.octetString(tagged.children[0]); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation nonce is not an OCTET STRING", e); }
|
|
866
1167
|
}
|
|
@@ -875,11 +1176,11 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
875
1176
|
|
|
876
1177
|
/**
|
|
877
1178
|
* @primitive pki.webauthn.verify
|
|
878
|
-
* @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{
|
|
1179
|
+
* @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, anchoredElements, aaguid, credentialId, credentialPublicKey, signCount, flags, bindingChecked }>
|
|
879
1180
|
* @since 0.2.5
|
|
880
|
-
* @status
|
|
881
|
-
* @spec W3C WebAuthn Level 3 sec. 8
|
|
882
|
-
* @related pki.webauthn.parseAttestationObject
|
|
1181
|
+
* @status stable
|
|
1182
|
+
* @spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1
|
|
1183
|
+
* @related pki.webauthn.parseAttestationObject, pki.webauthn.parseClientData, pki.webauthn.verifyAssertion
|
|
883
1184
|
*
|
|
884
1185
|
* Verify a WebAuthn attestation statement: the attestation signature over
|
|
885
1186
|
* `authenticatorData || clientDataHash` and (for the x5c formats) the format's
|
|
@@ -888,22 +1189,61 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
888
1189
|
* throws a typed `webauthn/*` error; a signature that does not verify is a
|
|
889
1190
|
* `webauthn/verify-failed` verdict, never a silent pass.
|
|
890
1191
|
*
|
|
1192
|
+
* The verdict field is `attestationVerified`, and the name is the point: a sound
|
|
1193
|
+
* attestation statement is not the same claim as an acceptable registration. The
|
|
1194
|
+
* statement says nothing about WHICH relying party asked for it, or whether a user
|
|
1195
|
+
* was present -- so an attestation naming another origin's RP ID, with user presence
|
|
1196
|
+
* clear, is perfectly sound and must not be registered. Supply `expectedRpId`,
|
|
1197
|
+
* `requireUserPresence`, `requireUserVerification` and `allowedAlgorithms` and those
|
|
1198
|
+
* are checked here; `bindingChecked` reports which ran, so a check that passed can be
|
|
1199
|
+
* told from one that never happened. The CHALLENGE and the ORIGIN live in
|
|
1200
|
+
* clientDataJSON -- read them with `pki.webauthn.parseClientData`, which compares
|
|
1201
|
+
* them against the state only the relying party has.
|
|
1202
|
+
*
|
|
1203
|
+
* The verdict also carries what a relying party must STORE to run a later login:
|
|
1204
|
+
* `credentialId`, `credentialPublicKey` and the initial `signCount`.
|
|
1205
|
+
*
|
|
891
1206
|
* @intro This verifies the attestation STATEMENT -- the signature and the format's
|
|
892
1207
|
* structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
|
|
893
1208
|
* certInfo Name/extraData, the android KeyDescription, the fido-u2f verificationData).
|
|
894
1209
|
* Chaining the returned `trustPath` (the x5c certificates in `pki.path.validate`
|
|
895
|
-
* order -- anchor-adjacent first, leaf last) to a trusted root is
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
900
|
-
*
|
|
1210
|
+
* order -- anchor-adjacent first, leaf last) to a trusted root is a separate step,
|
|
1211
|
+
* and there are three ways to take it. Pass `opts.metadata` -- a
|
|
1212
|
+
* `pki.webauthn.verifyMetadataBlob` result -- and the authenticator's registered
|
|
1213
|
+
* attestation roots are resolved from its own identifier and the trust path is
|
|
1214
|
+
* required to reach one, so an unlisted or revoked model is refused rather than
|
|
1215
|
+
* reported as verified. Pass `opts.rootCertificates` and the path is required to
|
|
1216
|
+
* validate to a root you pin: this is what anchors the formats the catalogue does
|
|
1217
|
+
* not cover, since Apple does not publish its authenticators to the FIDO Metadata
|
|
1218
|
+
* Service and the Google hardware-attestation roots come from Google. Or anchor
|
|
1219
|
+
* `trustPath` yourself with `pki.path.validate`. Supplying both options is the
|
|
1220
|
+
* ordinary configuration for a relying party that accepts MDS-listed authenticators
|
|
1221
|
+
* AND Apple; `metadata` governs when present, because a model's own registered
|
|
1222
|
+
* roots are a stronger claim than a static pin and its status reports can
|
|
1223
|
+
* disqualify a model those roots would still accept. `anchoredTo` reports which
|
|
1224
|
+
* route ran, naming every route that contributed and joining them with `+` when more
|
|
1225
|
+
* than one did -- `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the
|
|
1226
|
+
* android-safetynet chain, which anchors through the roots that format requires whether
|
|
1227
|
+
* or not either other route was asked for. It is `null` only when nothing anchored the
|
|
1228
|
+
* path. On the mixed metadata/pinned-root route `metadata` still carries the
|
|
1229
|
+
* entries that governed, so the stronger half of the decision stays auditable rather
|
|
1230
|
+
* than being flattened into the pinned-root half. `anchoredElements` reports how much of the
|
|
1231
|
+
* statement it covered. That matters for a compound attestation (sec. 8.9), whose
|
|
1232
|
+
* elements are independent claims: an element carrying no certificates makes no
|
|
1233
|
+
* claim there is anything to anchor, so it is not a reason to refuse the statement,
|
|
1234
|
+
* but it does mean "anchored" covered fewer elements than the statement holds.
|
|
901
1235
|
*
|
|
902
1236
|
* @example
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
* res.
|
|
906
|
-
*
|
|
1237
|
+
* // requires: `attestationObject` from navigator.credentials.create(), and
|
|
1238
|
+
* // `clientDataHash` = SHA-256 of the matching credential.response.clientDataJSON
|
|
1239
|
+
* var res = await pki.webauthn.verify(attestationObject, clientDataHash, {
|
|
1240
|
+
* expectedRpId: "example.com", requireUserPresence: true,
|
|
1241
|
+
* });
|
|
1242
|
+
* res.attestationVerified; // true (statement signature + bindings hold)
|
|
1243
|
+
* res.bindingChecked.rpId; // true -- this response names example.com
|
|
1244
|
+
* res.attestationType; // "Basic"
|
|
1245
|
+
* // store res.credentialId / res.credentialPublicKey / res.signCount for logins,
|
|
1246
|
+
* // and anchor res.trustPath to your pinned roots with pki.path.validate
|
|
907
1247
|
*/
|
|
908
1248
|
function verify(attestationObject, clientDataHash, opts) {
|
|
909
1249
|
opts = opts || {};
|
|
@@ -921,6 +1261,22 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
921
1261
|
return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
|
|
922
1262
|
}
|
|
923
1263
|
var att;
|
|
1264
|
+
// Both byte inputs are snapshotted BEFORE anything reads them, for the same reason the trust
|
|
1265
|
+
// anchors are: the attestation statement is not evaluated until a later promise turn, and both
|
|
1266
|
+
// stay caller-owned across that gap. The attestation object is copied before it is parsed, not
|
|
1267
|
+
// after, because the parse surfaces its byte fields as VIEWS -- the authenticator data the
|
|
1268
|
+
// signature covers, the statement, the credential key -- so copying afterwards would leave every
|
|
1269
|
+
// one of them pointing at bytes the caller can still rewrite. The clientDataHash is what binds
|
|
1270
|
+
// the statement to this ceremony; a caller who overwrote it in that gap would have the signature
|
|
1271
|
+
// checked against a challenge and origin nobody agreed to, and the verdict would still report a
|
|
1272
|
+
// sound attestation. A DataView or ArrayBuffer comes in by the same door and is copied too.
|
|
1273
|
+
var attBytes, cdh;
|
|
1274
|
+
try {
|
|
1275
|
+
attBytes = _snapshotBytes(attestationObject, "attestationObject");
|
|
1276
|
+
cdh = _snapshotBytes(clientDataHash, "clientDataHash");
|
|
1277
|
+
} catch (e) { return Promise.reject(e); }
|
|
1278
|
+
attestationObject = attBytes;
|
|
1279
|
+
clientDataHash = cdh;
|
|
924
1280
|
try { att = parseAttestationObject(attestationObject); } catch (e) { return Promise.reject(e); }
|
|
925
1281
|
// A registration attestation MUST carry attestedCredentialData (the AT flag): the
|
|
926
1282
|
// whole point is to bind the attestation to a credential public key. Reject an
|
|
@@ -940,8 +1296,91 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
940
1296
|
if (opts.tpmPolicy !== undefined && !_formatCanSatisfyTpmPolicy(att)) {
|
|
941
1297
|
return Promise.reject(_err("webauthn/tpm-policy", "opts.tpmPolicy requires a TPM attestation, but this attestation is format '" + att.fmt + "', which carries no TPM public area"));
|
|
942
1298
|
}
|
|
943
|
-
|
|
944
|
-
|
|
1299
|
+
// The ceremony bindings run BEFORE the statement is evaluated: a response
|
|
1300
|
+
// produced for another relying party, or without the user presence the caller
|
|
1301
|
+
// requires, is not a message this call should spend a signature verification on
|
|
1302
|
+
// -- and refusing it early keeps the "acceptable" question ahead of the "sound"
|
|
1303
|
+
// one, which is the order sec. 7.1 puts them in.
|
|
1304
|
+
// ONE normalized options object, built synchronously here. Nothing past this point reads
|
|
1305
|
+
// the caller's `opts` again -- not the values, and not the presence tests that pick which
|
|
1306
|
+
// trust policy runs.
|
|
1307
|
+
//
|
|
1308
|
+
// Fixing this per-field does not converge. The verifier resolves in a later turn, so every
|
|
1309
|
+
// field the deferred path touches stays caller-owned across that gap, and each repair that
|
|
1310
|
+
// snapshotted one more field left the next one aliased: the roots, then the safetynet
|
|
1311
|
+
// roots, then the routing test beside them, then the metadata routing test, then the
|
|
1312
|
+
// validation instant. They are not separate bugs, they are one window, and the way to
|
|
1313
|
+
// close a window is to stop reading through it rather than to copy one more thing across.
|
|
1314
|
+
//
|
|
1315
|
+
// `Object.assign` captures every own field by value at this instant, which fixes both the
|
|
1316
|
+
// values and the `!== undefined` presence tests derived from them. The two root arrays get
|
|
1317
|
+
// a deeper copy because their CONTENTS are caller-owned too. `metadata` is captured by
|
|
1318
|
+
// reference: a verified catalogue can hold thousands of entries and copying it per
|
|
1319
|
+
// registration is a real cost, so reassignment can no longer swap it, while mutation of
|
|
1320
|
+
// the object it points at remains open and is tracked separately.
|
|
1321
|
+
var bindingChecked, vopts;
|
|
1322
|
+
try {
|
|
1323
|
+
bindingChecked = _applyBindings(att.authData, att.authData.credentialPublicKey, opts);
|
|
1324
|
+
vopts = Object.assign({}, opts, {
|
|
1325
|
+
rootCertificates: _snapshotRoots(opts.rootCertificates),
|
|
1326
|
+
safetyNetRoots: _snapshotRoots(opts.safetyNetRoots),
|
|
1327
|
+
});
|
|
1328
|
+
} catch (e) { return Promise.reject(e); }
|
|
1329
|
+
return Promise.resolve().then(function () { return verifier(att, clientDataHash, vopts); })
|
|
1330
|
+
.then(function (res) {
|
|
1331
|
+
// Metadata governs when supplied; caller roots are the fallback for the models
|
|
1332
|
+
// the catalogue does not cover. Whichever ran, the verdict SAYS which -- so a
|
|
1333
|
+
// caller can tell an anchored trust path from one nobody checked, rather than
|
|
1334
|
+
// inferring it from which option they happened to pass.
|
|
1335
|
+
if (vopts.metadata === undefined) {
|
|
1336
|
+
if (vopts.rootCertificates !== undefined) return _applyCallerRoots(res, vopts.rootCertificates, vopts);
|
|
1337
|
+
res.anchoredTo = _anchoredRoutes(res, null);
|
|
1338
|
+
// Coverage is reported whenever anything WAS anchored, for the same reason the other
|
|
1339
|
+
// routes report it: `anchoredTo` alone cannot say how much of a compound it covered.
|
|
1340
|
+
var sn = _safetyNetAnchored(res);
|
|
1341
|
+
if (sn) {
|
|
1342
|
+
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
1343
|
+
anchored: sn };
|
|
1344
|
+
}
|
|
1345
|
+
return res;
|
|
1346
|
+
}
|
|
1347
|
+
return Promise.resolve().then(function () { return _applyMetadata(res, att, vopts); })
|
|
1348
|
+
.then(function (out) { out.anchoredTo = _anchoredRoutes(out, "metadata"); return out; }, function (e) {
|
|
1349
|
+
// A catalogue MISS is the case pinned roots exist for: the combined
|
|
1350
|
+
// configuration is exactly "MDS-listed authenticators AND Apple", and Apple
|
|
1351
|
+
// is in no catalogue, so failing here would make the documented pairing
|
|
1352
|
+
// unusable for the very models it was added to reach.
|
|
1353
|
+
//
|
|
1354
|
+
// A catalogue DENIAL is not a miss and never falls through. If the model IS
|
|
1355
|
+
// listed and its entry disqualifies it -- a revoked status report, a trust
|
|
1356
|
+
// path that does not reach the roots that model registered -- then the
|
|
1357
|
+
// catalogue has spoken about this authenticator, and letting a static pin
|
|
1358
|
+
// overrule it would turn the stronger source into the weaker one.
|
|
1359
|
+
if (!e || e.code !== "webauthn/metadata-not-found" || vopts.rootCertificates === undefined) throw e;
|
|
1360
|
+
// Only the elements the catalogue did not cover fall back. The listed ones were
|
|
1361
|
+
// already governed AND chain-validated to the roots their own entries register, so
|
|
1362
|
+
// re-judging them against an unrelated pin would refuse the combined configuration
|
|
1363
|
+
// this fallback exists to support.
|
|
1364
|
+
return Promise.resolve(_applyCallerRoots(res, vopts.rootCertificates, vopts, e.missedPaths))
|
|
1365
|
+
.then(function (out) {
|
|
1366
|
+
// Report BOTH routes. The pinned roots covered only the elements the catalogue
|
|
1367
|
+
// missed; the rest were governed by their metadata entries and chain-validated
|
|
1368
|
+
// against the roots those entries register. Saying "rootCertificates" alone
|
|
1369
|
+
// would attribute the whole evaluation to the weaker half and lose the entries
|
|
1370
|
+
// an auditor needs to see. `anchoredTo` names both, and the governed entries
|
|
1371
|
+
// are surfaced exactly as the pure-metadata route surfaces them.
|
|
1372
|
+
var applied = e.appliedEntries || [];
|
|
1373
|
+
if (!applied.length) return out;
|
|
1374
|
+
var primary = applied[0];
|
|
1375
|
+
out.metadata = { aaguid: primary.entry.aaguid, keyIdentifiers: primary.entry.keyIdentifiers || [],
|
|
1376
|
+
entry: primary.entry, entries: applied.map(function (a) { return a.entry; }),
|
|
1377
|
+
anchors: primary.anchors.length };
|
|
1378
|
+
out.anchoredTo = _anchoredRoutes(out, "metadata+rootCertificates");
|
|
1379
|
+
return out;
|
|
1380
|
+
});
|
|
1381
|
+
});
|
|
1382
|
+
})
|
|
1383
|
+
.then(function (res) { res.bindingChecked = bindingChecked; return res; });
|
|
945
1384
|
}
|
|
946
1385
|
|
|
947
1386
|
// WebAuthn sec. 7.1 step 22-23: having verified the attestation, look the authenticator model up in
|
|
@@ -968,10 +1407,19 @@ function _applyMetadata(res, att, opts) {
|
|
|
968
1407
|
// single choice for the whole statement is wrong in both directions. It would push the packed
|
|
969
1408
|
// element down the certificate-identifier path (where a conforming entry indexed only by its
|
|
970
1409
|
// AAGUID is not found), or trust the u2f element's unsigned AAGUID.
|
|
1410
|
+
// A compound's elements are independent claims (sec. 8.9), and one carrying no
|
|
1411
|
+
// certificates -- a `none` or self element -- makes no attestation claim there is
|
|
1412
|
+
// anything to anchor. Refusing the whole statement over it would reject a
|
|
1413
|
+
// conforming compound whose certificate-bearing elements do chain; enforcing over
|
|
1414
|
+
// the rest and saying nothing would report catalogue enforcement over a statement
|
|
1415
|
+
// it did not wholly reach. So the elements that CAN be governed are, and the
|
|
1416
|
+
// coverage is reported (`anchoredElements`) rather than left to be assumed.
|
|
971
1417
|
var paths = (res.fmt === "compound" && Array.isArray(res.compound))
|
|
972
1418
|
? res.compound.filter(function (el) { return el.trustPath && el.trustPath.length; })
|
|
973
1419
|
.map(function (el) { return { tp: el.trustPath, fmt: el.fmt, at: el.chainValidatedAt }; })
|
|
974
1420
|
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, fmt: res.fmt, at: res.chainValidatedAt }] : []);
|
|
1421
|
+
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
1422
|
+
anchored: paths.length };
|
|
975
1423
|
// An attestation with no trust path at all (`none`, or a self-attestation) has nothing to anchor,
|
|
976
1424
|
// so a caller who asked for metadata enforcement must be told it could not be applied rather than
|
|
977
1425
|
// receiving a pass that looks like it was.
|
|
@@ -1046,9 +1494,45 @@ function _applyMetadata(res, att, opts) {
|
|
|
1046
1494
|
// EVERY path must pass, not merely one of them: for a compound attestation each element is an
|
|
1047
1495
|
// independent claim, and accepting the whole because one element anchored would let an
|
|
1048
1496
|
// unanchored -- or revoked -- element ride along on its neighbour's trust.
|
|
1049
|
-
|
|
1497
|
+
// Govern EVERY path BEFORE any is chain-validated, and let the SEVEREST outcome decide
|
|
1498
|
+
// rather than the first one reached. A compound's elements are independent claims and
|
|
1499
|
+
// its element order is NOT signed, so aborting on the first failure lets that order pick
|
|
1500
|
+
// which element the catalogue gets to speak about. The asymmetry that makes it exploitable
|
|
1501
|
+
// is that the errors are not equal: `metadata-not-found` is the one outcome the caller may
|
|
1502
|
+
// fall back to pinned roots on, and that fallback covers the WHOLE statement. So an element
|
|
1503
|
+
// the catalogue does not list, placed first, would raise the fallback error and a listed
|
|
1504
|
+
// sibling carrying a disqualifying status report would never be consulted -- the unlisted
|
|
1505
|
+
// element laundering the revoked one. A denial therefore outranks a miss wherever each sits:
|
|
1506
|
+
// a miss is only reported once every other path has been governed and none of them objected.
|
|
1507
|
+
var governed = [];
|
|
1508
|
+
var missed = null;
|
|
1509
|
+
var missedPaths = [];
|
|
1510
|
+
for (var gi = 0; gi < paths.length; gi++) {
|
|
1511
|
+
try {
|
|
1512
|
+
governed.push({ info: paths[gi], g: govern(paths[gi]) });
|
|
1513
|
+
} catch (ge) {
|
|
1514
|
+
// WHICH paths missed is carried with the error, not just THAT one did. The caller's
|
|
1515
|
+
// pinned-root fallback is for the elements the catalogue did not cover, and it cannot
|
|
1516
|
+
// tell which those were from the verdict alone -- every element looks the same there.
|
|
1517
|
+
if (ge && ge.code === "webauthn/metadata-not-found") { missed = missed || ge; missedPaths.push(paths[gi]); continue; }
|
|
1518
|
+
throw ge; // a status denial, a missing anchor, any other governance failure: terminal
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
// The miss carries BOTH halves: which paths the catalogue did not cover, and which entries
|
|
1522
|
+
// it did govern. Without the second the fallback can only describe its own half, so a
|
|
1523
|
+
// mixed-route verdict would attribute the whole evaluation to the pinned roots and drop the
|
|
1524
|
+
// metadata-backed part entirely -- the stronger half, and the one an auditor most needs.
|
|
1525
|
+
if (missed) { missed.missedPaths = missedPaths; missed.appliedEntries = applied; }
|
|
1526
|
+
// The miss is NOT reported yet. Resolving an entry is only half of governance: the other
|
|
1527
|
+
// half is that the path must actually VALIDATE to the roots that entry registers, and that
|
|
1528
|
+
// runs below. Reporting the miss here would skip it for every listed element -- so a listed
|
|
1529
|
+
// element whose path reaches the caller's pinned roots but NOT its own registered roots
|
|
1530
|
+
// would ride out on the unlisted sibling's fallback, which is that same bypass moved one
|
|
1531
|
+
// phase down. Every listed element is chain-validated first, and only then may a miss be
|
|
1532
|
+
// raised.
|
|
1533
|
+
return governed.reduce(function (p, item) {
|
|
1050
1534
|
return p.then(function () {
|
|
1051
|
-
var g =
|
|
1535
|
+
var g = item.g, info = item.info;
|
|
1052
1536
|
// A path is re-validated at the instant its own format already judged it at, when the format
|
|
1053
1537
|
// established one from signed data. An android-safetynet response carries its signing time and
|
|
1054
1538
|
// its service chain has usually expired since; resetting to the current clock here would
|
|
@@ -1061,6 +1545,12 @@ function _applyMetadata(res, att, opts) {
|
|
|
1061
1545
|
});
|
|
1062
1546
|
}, Promise.resolve())
|
|
1063
1547
|
.then(function () {
|
|
1548
|
+
// Every LISTED element has now been fully governed -- entry resolved, status checked, and its
|
|
1549
|
+
// path validated to that entry's own registered roots. A miss recorded above may finally be
|
|
1550
|
+
// reported, and the caller's pinned-roots fallback is reachable only from here: a statement
|
|
1551
|
+
// whose listed elements all satisfied their catalogue entries, with a sibling the catalogue
|
|
1552
|
+
// simply does not cover.
|
|
1553
|
+
if (missed) throw missed;
|
|
1064
1554
|
// The ENTRY's aaguid, not the authenticator's raw field: for a U2F authenticator that field is
|
|
1065
1555
|
// all zeroes, which means "no model identity" -- reporting it back as though it were one would
|
|
1066
1556
|
// hand the caller a value that matches nothing and reads like an identifier. `entries` lists
|
|
@@ -1092,7 +1582,7 @@ void constants;
|
|
|
1092
1582
|
* @primitive pki.webauthn.verifyMetadataBlob
|
|
1093
1583
|
* @signature pki.webauthn.verifyMetadataBlob(blob, opts) -> Promise<{ no, nextUpdate, entries, byAaguid }>
|
|
1094
1584
|
* @since 0.4.11
|
|
1095
|
-
* @status
|
|
1585
|
+
* @status stable
|
|
1096
1586
|
* @spec FIDO Metadata Service v3.0 sec. 3.1, RFC 7515
|
|
1097
1587
|
* @related pki.webauthn.verify, pki.webauthn.metadataFor
|
|
1098
1588
|
*
|
|
@@ -1130,6 +1620,8 @@ void constants;
|
|
|
1130
1620
|
* ignored rather than failed on.
|
|
1131
1621
|
*
|
|
1132
1622
|
* @example
|
|
1623
|
+
* // requires: `mdsBlobBytes` -- the signed BLOB from https://mds3.fidoalliance.org/
|
|
1624
|
+
* // -- and `fidoRootDer`, the FIDO Alliance root certificate it chains to
|
|
1133
1625
|
* var md = await pki.webauthn.verifyMetadataBlob(mdsBlobBytes, {
|
|
1134
1626
|
* rootCertificates: [fidoRootDer],
|
|
1135
1627
|
* previousNo: 41, // refuse a replay of a BLOB you have already superseded
|
|
@@ -1144,7 +1636,7 @@ void constants;
|
|
|
1144
1636
|
* @primitive pki.webauthn.metadataFor
|
|
1145
1637
|
* @signature pki.webauthn.metadataFor(metadata, identifier) -> entry | null
|
|
1146
1638
|
* @since 0.4.11
|
|
1147
|
-
* @status
|
|
1639
|
+
* @status stable
|
|
1148
1640
|
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1149
1641
|
* @related pki.webauthn.verifyMetadataBlob, pki.webauthn.metadataAnchors
|
|
1150
1642
|
*
|
|
@@ -1160,6 +1652,8 @@ void constants;
|
|
|
1160
1652
|
* declares no model identity" and matches nothing.
|
|
1161
1653
|
*
|
|
1162
1654
|
* @example
|
|
1655
|
+
* // requires: `mdsMetadata` -- a verifyMetadataBlob RESULT (never raw bytes, so a
|
|
1656
|
+
* // lookup cannot be answered out of an unverified BLOB) -- and the model's aaguid
|
|
1163
1657
|
* var entry = pki.webauthn.metadataFor(mdsMetadata, mdsAaguid);
|
|
1164
1658
|
* entry.statusReports[0].status; // "FIDO_CERTIFIED_L1"
|
|
1165
1659
|
* pki.webauthn.metadataFor(mdsMetadata, "00000000-0000-0000-0000-000000000000"); // null
|
|
@@ -1169,7 +1663,7 @@ void constants;
|
|
|
1169
1663
|
* @primitive pki.webauthn.metadataAnchors
|
|
1170
1664
|
* @signature pki.webauthn.metadataAnchors(entry) -> [certificate]
|
|
1171
1665
|
* @since 0.4.11
|
|
1172
|
-
* @status
|
|
1666
|
+
* @status stable
|
|
1173
1667
|
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1174
1668
|
* @related pki.webauthn.metadataFor, pki.path.validate
|
|
1175
1669
|
*
|
|
@@ -1180,6 +1674,7 @@ void constants;
|
|
|
1180
1674
|
* malformed root refuse the entire catalogue for every other authenticator in it.
|
|
1181
1675
|
*
|
|
1182
1676
|
* @example
|
|
1677
|
+
* // requires: `mdsEntry` -- one entry from a verified BLOB, as metadataFor returns
|
|
1183
1678
|
* var anchors = pki.webauthn.metadataAnchors(mdsEntry);
|
|
1184
1679
|
* anchors.length; // the attestation roots this model registered
|
|
1185
1680
|
* anchors[0].subject; // the decoded root DN
|
|
@@ -1187,9 +1682,383 @@ void constants;
|
|
|
1187
1682
|
* // await pki.path.validate(res.trustPath, { trustAnchors: anchors, time: mdsTime });
|
|
1188
1683
|
*/
|
|
1189
1684
|
|
|
1685
|
+
// ---- public: parseClientData -------------------------------------------------
|
|
1686
|
+
|
|
1687
|
+
// The two ceremony types, fixed by the spec -- `webauthn.create` for registration
|
|
1688
|
+
// and `webauthn.get` for authentication. This is NOT relying-party policy: a
|
|
1689
|
+
// registration response replayed into a login (or the reverse) is a real attack,
|
|
1690
|
+
// and which one a caller is finishing is known at the call site, never negotiated.
|
|
1691
|
+
var CLIENT_DATA_TYPE = Object.assign(Object.create(null), { "webauthn.create": 1, "webauthn.get": 1 });
|
|
1692
|
+
|
|
1693
|
+
/**
|
|
1694
|
+
* @primitive pki.webauthn.parseClientData
|
|
1695
|
+
* @signature pki.webauthn.parseClientData(bytes, opts?) -> { type, challenge, origin, crossOrigin, topOrigin, checked }
|
|
1696
|
+
* @since 0.5.0
|
|
1697
|
+
* @status experimental
|
|
1698
|
+
* @spec W3C WebAuthn Level 3 sec. 5.8.1 / 7.1 / 7.2
|
|
1699
|
+
* @defends webauthn-ceremony-confusion (CWE-345)
|
|
1700
|
+
* @related pki.webauthn.verify, pki.webauthn.verifyAssertion
|
|
1701
|
+
*
|
|
1702
|
+
* Decode the `clientDataJSON` a ceremony returns -- the half of a WebAuthn response
|
|
1703
|
+
* the signature covers by digest but that no signature check ever looks inside.
|
|
1704
|
+
* Parsed through the shared fail-closed JSON guard (bounded bytes and depth,
|
|
1705
|
+
* fatal UTF-8, duplicate members refused, no prototype pollution), because these
|
|
1706
|
+
* are bytes an attacker chose. `challenge` is returned DECODED from base64url as a
|
|
1707
|
+
* Buffer, so a caller compares raw bytes and never two spellings of the same
|
|
1708
|
+
* value; `type`, `origin`, `crossOrigin` and `topOrigin` come back as they were.
|
|
1709
|
+
*
|
|
1710
|
+
* Supply `expectedType`, `expectedChallenge` and `expectedOrigin` and each is
|
|
1711
|
+
* checked here -- the challenge in constant time and by full value. `checked`
|
|
1712
|
+
* reports which ran, so a check that passed is distinguishable from one that never
|
|
1713
|
+
* happened. `expectedType` is worth setting on every call: the ceremony a response
|
|
1714
|
+
* belongs to is fixed, and accepting a `webauthn.create` where a `webauthn.get` was
|
|
1715
|
+
* expected is a credential-registration response replayed as a login.
|
|
1716
|
+
*
|
|
1717
|
+
* @opts
|
|
1718
|
+
* expectedType -- "webauthn.create" or "webauthn.get"
|
|
1719
|
+
* expectedChallenge -- the challenge bytes this ceremony issued (Buffer)
|
|
1720
|
+
* expectedOrigin -- the origin string, or an array of acceptable origins
|
|
1721
|
+
*
|
|
1722
|
+
* @example
|
|
1723
|
+
* // requires: `clientDataJSON` -- credential.response.clientDataJSON;
|
|
1724
|
+
* // `issuedChallenge` -- the random bytes this server sent
|
|
1725
|
+
* var cd = pki.webauthn.parseClientData(clientDataJSON, {
|
|
1726
|
+
* expectedType: "webauthn.get",
|
|
1727
|
+
* expectedChallenge: issuedChallenge,
|
|
1728
|
+
* expectedOrigin: "https://example.com",
|
|
1729
|
+
* });
|
|
1730
|
+
* cd.checked.challenge; // true -- the issued challenge came back
|
|
1731
|
+
* cd.crossOrigin; // false
|
|
1732
|
+
*/
|
|
1733
|
+
function parseClientData(bytes, opts) {
|
|
1734
|
+
opts = opts || {};
|
|
1735
|
+
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1736
|
+
guard.identifier.assertKnownKeys(opts, _CLIENT_DATA_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1737
|
+
if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
|
|
1738
|
+
throw _err("webauthn/bad-input", "clientDataJSON must be the RAW Buffer or Uint8Array, not a parsed object");
|
|
1739
|
+
}
|
|
1740
|
+
var doc = guard.json.parse(Buffer.from(bytes), _err, {
|
|
1741
|
+
maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
|
|
1742
|
+
badJson: "webauthn/bad-client-data", tooDeep: "webauthn/bad-client-data",
|
|
1743
|
+
duplicateMember: "webauthn/bad-client-data", tooLarge: "webauthn/bad-client-data",
|
|
1744
|
+
badInput: "webauthn/bad-input", label: "clientDataJSON",
|
|
1745
|
+
});
|
|
1746
|
+
if (!_isPlainObject(doc)) throw _err("webauthn/bad-client-data", "clientDataJSON is not a JSON object (WebAuthn sec. 5.8.1)");
|
|
1747
|
+
if (typeof doc.type !== "string" || CLIENT_DATA_TYPE[doc.type] !== 1) {
|
|
1748
|
+
throw _err("webauthn/bad-client-data",
|
|
1749
|
+
"clientDataJSON type must be \"webauthn.create\" or \"webauthn.get\", got " + JSON.stringify(doc.type) +
|
|
1750
|
+
" (WebAuthn sec. 5.8.1)");
|
|
1751
|
+
}
|
|
1752
|
+
if (typeof doc.origin !== "string" || !doc.origin.length) {
|
|
1753
|
+
throw _err("webauthn/bad-client-data", "clientDataJSON carries no origin (WebAuthn sec. 5.8.1)");
|
|
1754
|
+
}
|
|
1755
|
+
if (typeof doc.challenge !== "string" || !doc.challenge.length) {
|
|
1756
|
+
throw _err("webauthn/bad-client-data", "clientDataJSON carries no challenge (WebAuthn sec. 5.8.1)");
|
|
1757
|
+
}
|
|
1758
|
+
// base64url, and STRICTLY: the challenge is a comparison key, so an encoding the
|
|
1759
|
+
// decoder had to guess at would let two spellings of one value both "match".
|
|
1760
|
+
var challenge;
|
|
1761
|
+
try {
|
|
1762
|
+
challenge = guard.encoding.base64url(doc.challenge, constants.LIMITS.JSON_MAX_BYTES, _err,
|
|
1763
|
+
"webauthn/bad-client-data", "the clientDataJSON challenge");
|
|
1764
|
+
} catch (e) {
|
|
1765
|
+
if (e && e.code === "webauthn/bad-client-data") throw e;
|
|
1766
|
+
throw _err("webauthn/bad-client-data", "the clientDataJSON challenge is not base64url (WebAuthn sec. 5.8.1)", e);
|
|
1767
|
+
}
|
|
1768
|
+
if (doc.crossOrigin !== undefined && typeof doc.crossOrigin !== "boolean") {
|
|
1769
|
+
throw _err("webauthn/bad-client-data", "clientDataJSON crossOrigin must be a boolean when present (WebAuthn sec. 5.8.1)");
|
|
1770
|
+
}
|
|
1771
|
+
// A present-but-malformed topOrigin is refused rather than reported as absent. It
|
|
1772
|
+
// is the top-level origin of a cross-origin ceremony -- something a caller makes a
|
|
1773
|
+
// policy decision on -- so "the sender wrote something that is not an origin" and
|
|
1774
|
+
// "the sender said nothing" must not arrive as the same `null`.
|
|
1775
|
+
if (doc.topOrigin !== undefined && (typeof doc.topOrigin !== "string" || !doc.topOrigin.length)) {
|
|
1776
|
+
throw _err("webauthn/bad-client-data", "clientDataJSON topOrigin must be a non-empty string when present (WebAuthn sec. 5.8.1)");
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
var checked = { type: false, challenge: false, origin: false };
|
|
1780
|
+
if (opts.expectedType !== undefined) {
|
|
1781
|
+
if (CLIENT_DATA_TYPE[opts.expectedType] !== 1) {
|
|
1782
|
+
throw _err("webauthn/bad-input", "opts.expectedType must be \"webauthn.create\" or \"webauthn.get\"");
|
|
1783
|
+
}
|
|
1784
|
+
if (doc.type !== opts.expectedType) {
|
|
1785
|
+
throw _err("webauthn/client-data-mismatch",
|
|
1786
|
+
"this is a " + doc.type + " response and a " + opts.expectedType + " one was expected -- a response " +
|
|
1787
|
+
"from the other ceremony (WebAuthn sec. 7.1 step 8 / sec. 7.2 step 11)");
|
|
1788
|
+
}
|
|
1789
|
+
checked.type = true;
|
|
1790
|
+
}
|
|
1791
|
+
if (opts.expectedChallenge !== undefined) {
|
|
1792
|
+
if (!Buffer.isBuffer(opts.expectedChallenge) && !(opts.expectedChallenge instanceof Uint8Array)) {
|
|
1793
|
+
throw _err("webauthn/bad-input", "opts.expectedChallenge must be the raw challenge bytes");
|
|
1794
|
+
}
|
|
1795
|
+
if (!guard.crypto.constantTimeEqual(Buffer.from(opts.expectedChallenge), challenge)) {
|
|
1796
|
+
throw _err("webauthn/client-data-mismatch",
|
|
1797
|
+
"the clientDataJSON challenge is not the one this ceremony issued (WebAuthn sec. 7.1 step 9 / sec. 7.2 step 12)");
|
|
1798
|
+
}
|
|
1799
|
+
checked.challenge = true;
|
|
1800
|
+
}
|
|
1801
|
+
if (opts.expectedOrigin !== undefined) {
|
|
1802
|
+
var allowed = Array.isArray(opts.expectedOrigin) ? opts.expectedOrigin : [opts.expectedOrigin];
|
|
1803
|
+
if (!allowed.length || !allowed.every(function (o) { return typeof o === "string" && o.length; })) {
|
|
1804
|
+
throw _err("webauthn/bad-input", "opts.expectedOrigin must be a non-empty origin string, or an array of them");
|
|
1805
|
+
}
|
|
1806
|
+
// Compared WHOLE and case-sensitively as the serialization it is. A prefix or
|
|
1807
|
+
// suffix test is how "https://example.com.attacker.tld" passes for
|
|
1808
|
+
// "https://example.com".
|
|
1809
|
+
if (allowed.indexOf(doc.origin) === -1) {
|
|
1810
|
+
throw _err("webauthn/client-data-mismatch",
|
|
1811
|
+
"the clientDataJSON origin " + JSON.stringify(doc.origin) + " is not one this relying party accepts " +
|
|
1812
|
+
"(WebAuthn sec. 7.1 step 10 / sec. 7.2 step 13)");
|
|
1813
|
+
}
|
|
1814
|
+
checked.origin = true;
|
|
1815
|
+
}
|
|
1816
|
+
return {
|
|
1817
|
+
type: doc.type, challenge: challenge, origin: doc.origin,
|
|
1818
|
+
crossOrigin: doc.crossOrigin === undefined ? false : doc.crossOrigin,
|
|
1819
|
+
topOrigin: doc.topOrigin === undefined ? null : doc.topOrigin,
|
|
1820
|
+
checked: checked,
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
1824
|
+
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1,
|
|
1825
|
+
});
|
|
1826
|
+
|
|
1827
|
+
// ---- public: parseAuthenticatorData / verifyAssertion ------------------------
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* @primitive pki.webauthn.parseAuthenticatorData
|
|
1831
|
+
* @signature pki.webauthn.parseAuthenticatorData(bytes) -> { rpIdHash, flags, signCount, aaguid, credentialId, credentialPublicKey, extensions }
|
|
1832
|
+
* @since 0.5.0
|
|
1833
|
+
* @status experimental
|
|
1834
|
+
* @spec W3C WebAuthn Level 3 sec. 6.1
|
|
1835
|
+
* @related pki.webauthn.verifyAssertion, pki.webauthn.parseAttestationObject
|
|
1836
|
+
*
|
|
1837
|
+
* Decode a BARE authenticatorData, fail-closed -- the form an authentication
|
|
1838
|
+
* assertion returns, with no attestation-object wrapper around it. Same parser the
|
|
1839
|
+
* registration path uses: the 37-byte minimum, the reserved (RFU) flag bits, the
|
|
1840
|
+
* Backup State / Backup Eligibility rule, the 1..1023 credentialId bound, a
|
|
1841
|
+
* credential public key that must be one well-formed COSE_Key, and extensions that
|
|
1842
|
+
* must be exactly one CBOR map when the ED flag is set and absent when it is clear.
|
|
1843
|
+
* `flags` is decoded to `{ up, uv, be, bs, at, ed }`. An assertion normally has the
|
|
1844
|
+
* AT flag clear, so `aaguid` / `credentialId` / `credentialPublicKey` are null.
|
|
1845
|
+
* Malformed input throws `webauthn/bad-auth-data`.
|
|
1846
|
+
*
|
|
1847
|
+
* @example
|
|
1848
|
+
* // requires: `authenticatorData` -- credential.response.authenticatorData from
|
|
1849
|
+
* // navigator.credentials.get()
|
|
1850
|
+
* var ad = pki.webauthn.parseAuthenticatorData(authenticatorData);
|
|
1851
|
+
* ad.flags.up; // true when the user was present
|
|
1852
|
+
* ad.signCount; // the authenticator's counter for this credential
|
|
1853
|
+
*/
|
|
1854
|
+
function parseAuthenticatorData(bytes) {
|
|
1855
|
+
if (!Buffer.isBuffer(bytes) && !(bytes instanceof Uint8Array)) {
|
|
1856
|
+
throw _err("webauthn/bad-input", "authenticatorData must be a Buffer or Uint8Array");
|
|
1857
|
+
}
|
|
1858
|
+
return _parseAuthData(guard.bytes.snapshotSource(bytes, WebauthnError, "webauthn/bad-input", "authenticatorData"), _err);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
// The options pki.webauthn.verifyAssertion recognises, null-prototype for the same
|
|
1862
|
+
// reason _VERIFY_OPTS is.
|
|
1863
|
+
var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
1864
|
+
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
|
1865
|
+
credentialPublicKey: 1, previousSignCount: 1,
|
|
1866
|
+
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
1867
|
+
expectedChallenge: 1, expectedOrigin: 1,
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
/**
|
|
1871
|
+
* @primitive pki.webauthn.verifyAssertion
|
|
1872
|
+
* @signature pki.webauthn.verifyAssertion(input) -> Promise<{ signatureVerified, signCount, signCountChecked, flags, rpIdHash, bindingChecked }>
|
|
1873
|
+
* @since 0.5.0
|
|
1874
|
+
* @status experimental
|
|
1875
|
+
* @spec W3C WebAuthn Level 3 sec. 7.2
|
|
1876
|
+
* @defends webauthn-assertion-forgery (CWE-347)
|
|
1877
|
+
* @related pki.webauthn.parseAuthenticatorData, pki.webauthn.verify
|
|
1878
|
+
*
|
|
1879
|
+
* Verify an authentication assertion's signature: the authenticator signs
|
|
1880
|
+
* `authenticatorData || SHA-256(clientDataJSON)` as RAW bytes with the credential
|
|
1881
|
+
* key registered earlier -- no COSE_Sign1 wrapper, so a COSE message verifier is
|
|
1882
|
+
* the wrong tool and fails on structure before it ever reaches the signature. An
|
|
1883
|
+
* ES256 assertion signature is an ASN.1 DER `SEQUENCE { r, s }`, converted here
|
|
1884
|
+
* with the same order-aware reader the attestation path uses, so an r or s outside
|
|
1885
|
+
* `[1, n-1]` is refused rather than normalized.
|
|
1886
|
+
*
|
|
1887
|
+
* `signatureVerified`, not `verified`: this establishes that the holder of the
|
|
1888
|
+
* registered credential key produced this response. What makes the response
|
|
1889
|
+
* ACCEPTABLE is the sec. 7.2 binding, and the caller owns most of it. Supply
|
|
1890
|
+
* `expectedRpId`, `requireUserPresence`, `requireUserVerification` and
|
|
1891
|
+
* `allowedAlgorithms` and they are checked here -- `bindingChecked` reports which
|
|
1892
|
+
* ones ran, so a check that passed is distinguishable from one that never
|
|
1893
|
+
* happened. The CHALLENGE and the ORIGIN stay with the caller: both live in
|
|
1894
|
+
* clientDataJSON and are compared against state only the relying party has.
|
|
1895
|
+
*
|
|
1896
|
+
* Pass `previousSignCount` (the value stored at registration or the last login) and
|
|
1897
|
+
* the sec. 7.2 step 21 counter rule is applied: a counter that fails to advance is
|
|
1898
|
+
* a cloned authenticator and is refused, except for the `0`/`0` case an
|
|
1899
|
+
* authenticator that does not implement a counter reports. Without it the counter
|
|
1900
|
+
* is surfaced and not judged, and `signCountChecked` says so.
|
|
1901
|
+
*
|
|
1902
|
+
* @opts
|
|
1903
|
+
* authenticatorData -- the raw bytes from the assertion (Buffer)
|
|
1904
|
+
* clientDataJSON -- the raw clientDataJSON bytes; its SHA-256 is what the signature covers
|
|
1905
|
+
* clientDataHash -- the 32-byte digest instead, when the caller already has it
|
|
1906
|
+
* signature -- the assertion signature bytes
|
|
1907
|
+
* credentialPublicKey -- the stored COSE key (as parseAttestationObject surfaced it)
|
|
1908
|
+
* previousSignCount -- the stored counter, enabling the sec. 7.2 step 21 rule
|
|
1909
|
+
* expectedRpId, requireUserPresence, requireUserVerification, allowedAlgorithms -- the bindings above
|
|
1910
|
+
*
|
|
1911
|
+
* @example
|
|
1912
|
+
* // requires: `assertion` -- credential.response from navigator.credentials.get();
|
|
1913
|
+
* // `storedKey` -- the COSE credentialPublicKey kept at registration
|
|
1914
|
+
* var res = await pki.webauthn.verifyAssertion({
|
|
1915
|
+
* authenticatorData: assertion.authenticatorData,
|
|
1916
|
+
* clientDataJSON: assertion.clientDataJSON,
|
|
1917
|
+
* signature: assertion.signature,
|
|
1918
|
+
* credentialPublicKey: storedKey,
|
|
1919
|
+
* expectedRpId: "example.com", requireUserPresence: true,
|
|
1920
|
+
* });
|
|
1921
|
+
* res.signatureVerified; // true
|
|
1922
|
+
* res.bindingChecked.rpId; // true -- the rpIdHash matched example.com
|
|
1923
|
+
* // the challenge and origin in clientDataJSON are still yours to compare
|
|
1924
|
+
*/
|
|
1925
|
+
// A private copy of the assertion inputs. Only what the caller can still write to
|
|
1926
|
+
// is copied: the byte fields, the descriptor itself, and the option arrays. The
|
|
1927
|
+
// COSE key is a decoded object the caller supplies from its own storage; it is
|
|
1928
|
+
// shallow-copied so a swapped field cannot reach the verification, which is the
|
|
1929
|
+
// mutation this can defend against without deep-copying an arbitrary object.
|
|
1930
|
+
function _snapshotAssertion(input) {
|
|
1931
|
+
if (!_isPlainObject(input)) throw _err("webauthn/bad-input", "pki.webauthn.verifyAssertion takes an options object");
|
|
1932
|
+
guard.identifier.assertKnownKeys(input, _ASSERT_OPTS, _err, "webauthn/bad-input", "verifyAssertion input has an unknown key ");
|
|
1933
|
+
var out = {}, k;
|
|
1934
|
+
for (k in input) { if (Object.prototype.hasOwnProperty.call(input, k)) out[k] = input[k]; }
|
|
1935
|
+
["authenticatorData", "clientDataJSON", "clientDataHash", "signature", "expectedChallenge"].forEach(function (f) {
|
|
1936
|
+
if (Buffer.isBuffer(out[f]) || out[f] instanceof Uint8Array || out[f] instanceof ArrayBuffer) {
|
|
1937
|
+
out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1938
|
+
}
|
|
1939
|
+
});
|
|
1940
|
+
if (_isPlainObject(out.credentialPublicKey)) {
|
|
1941
|
+
var key = {}, kk;
|
|
1942
|
+
for (kk in out.credentialPublicKey) {
|
|
1943
|
+
if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
|
|
1944
|
+
var v = out.credentialPublicKey[kk];
|
|
1945
|
+
key[kk] = (Buffer.isBuffer(v) || v instanceof Uint8Array) ? Buffer.from(v) : v;
|
|
1946
|
+
}
|
|
1947
|
+
out.credentialPublicKey = key;
|
|
1948
|
+
}
|
|
1949
|
+
if (Array.isArray(out.allowedAlgorithms)) out.allowedAlgorithms = out.allowedAlgorithms.slice();
|
|
1950
|
+
if (Array.isArray(out.expectedOrigin)) out.expectedOrigin = out.expectedOrigin.slice();
|
|
1951
|
+
return out;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
function verifyAssertion(input) {
|
|
1955
|
+
// Every byte this verdict depends on is COPIED here, synchronously, before
|
|
1956
|
+
// anything defers. The descriptor is the caller's object and the buffers in it
|
|
1957
|
+
// are the caller's memory: a caller that reuses or zeroizes them on the line
|
|
1958
|
+
// after this call has already changed them before the deferred body would run,
|
|
1959
|
+
// and an assertion that did not verify could be replaced by one that does. The
|
|
1960
|
+
// same rule pki.cmc.verify applies to a response, for the same reason.
|
|
1961
|
+
var frozen;
|
|
1962
|
+
try { frozen = _snapshotAssertion(input); } catch (e) { return Promise.reject(e); }
|
|
1963
|
+
return Promise.resolve().then(function () {
|
|
1964
|
+
input = frozen;
|
|
1965
|
+
var authData = parseAuthenticatorData(input.authenticatorData);
|
|
1966
|
+
if (!Buffer.isBuffer(input.signature) && !(input.signature instanceof Uint8Array)) {
|
|
1967
|
+
throw _err("webauthn/bad-input", "signature must be a Buffer or Uint8Array");
|
|
1968
|
+
}
|
|
1969
|
+
// The two forms of the same input, and NEITHER is inferred from the other's
|
|
1970
|
+
// absence: supplying both invites them to disagree, and picking one would make
|
|
1971
|
+
// the signature cover something the caller did not mean.
|
|
1972
|
+
var haveJson = input.clientDataJSON !== undefined, haveHash = input.clientDataHash !== undefined;
|
|
1973
|
+
if (haveJson === haveHash) {
|
|
1974
|
+
throw _err("webauthn/bad-input", "verifyAssertion takes exactly one of clientDataJSON or clientDataHash");
|
|
1975
|
+
}
|
|
1976
|
+
var clientDataHash, clientData = null;
|
|
1977
|
+
if (haveJson) {
|
|
1978
|
+
if (!Buffer.isBuffer(input.clientDataJSON) && !(input.clientDataJSON instanceof Uint8Array)) {
|
|
1979
|
+
throw _err("webauthn/bad-input", "clientDataJSON must be a Buffer or Uint8Array (the RAW bytes, not a parsed object)");
|
|
1980
|
+
}
|
|
1981
|
+
// Given the JSON, this verb reads it -- the ceremony TYPE is checked
|
|
1982
|
+
// unconditionally, because which ceremony a response belongs to is fixed by
|
|
1983
|
+
// the spec rather than chosen by the caller, and a registration response
|
|
1984
|
+
// replayed as a login is exactly what that check stops. The challenge and
|
|
1985
|
+
// origin are checked when the caller supplies what it issued.
|
|
1986
|
+
clientData = parseClientData(input.clientDataJSON, {
|
|
1987
|
+
expectedType: "webauthn.get",
|
|
1988
|
+
expectedChallenge: input.expectedChallenge,
|
|
1989
|
+
expectedOrigin: input.expectedOrigin,
|
|
1990
|
+
});
|
|
1991
|
+
clientDataHash = _sha("sha256", Buffer.from(input.clientDataJSON));
|
|
1992
|
+
} else {
|
|
1993
|
+
if (input.expectedChallenge !== undefined || input.expectedOrigin !== undefined) {
|
|
1994
|
+
throw _err("webauthn/bad-input",
|
|
1995
|
+
"expectedChallenge / expectedOrigin are checked against clientDataJSON, which this call did not " +
|
|
1996
|
+
"supply -- pass clientDataJSON instead of clientDataHash, or check them yourself");
|
|
1997
|
+
}
|
|
1998
|
+
if (!Buffer.isBuffer(input.clientDataHash) || input.clientDataHash.length !== 32) {
|
|
1999
|
+
throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
2000
|
+
}
|
|
2001
|
+
clientDataHash = Buffer.from(input.clientDataHash);
|
|
2002
|
+
}
|
|
2003
|
+
var coseKey = input.credentialPublicKey;
|
|
2004
|
+
if (!_isPlainObject(coseKey)) {
|
|
2005
|
+
throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key object");
|
|
2006
|
+
}
|
|
2007
|
+
var bindingChecked = _applyBindings(authData, coseKey, input);
|
|
2008
|
+
// The counter's SHAPE is a config-time question and is answered here; whether it
|
|
2009
|
+
// advanced is a question about the message, and that is asked only after the
|
|
2010
|
+
// signature holds -- see below.
|
|
2011
|
+
var prev = input.previousSignCount;
|
|
2012
|
+
if (prev !== undefined && (typeof prev !== "number" || !Number.isSafeInteger(prev) || prev < 0 || prev > 0xFFFFFFFF)) {
|
|
2013
|
+
throw _err("webauthn/bad-input", "previousSignCount must be an integer in 0..4294967295");
|
|
2014
|
+
}
|
|
2015
|
+
// The signed message is the CONCATENATION, in that order, of the
|
|
2016
|
+
// authenticatorData exactly as it arrived and the clientDataJSON digest.
|
|
2017
|
+
var message = Buffer.concat([Buffer.from(input.authenticatorData), clientDataHash]);
|
|
2018
|
+
var spki = _coseKeyToSpki(coseKey);
|
|
2019
|
+
return _verifySig(coseKey.alg, Buffer.from(input.signature), spki, message, _err).then(function (ok) {
|
|
2020
|
+
if (!ok) {
|
|
2021
|
+
throw _err("webauthn/bad-signature",
|
|
2022
|
+
"the assertion signature does not verify under the stored credential public key (WebAuthn sec. 7.2 step 20)");
|
|
2023
|
+
}
|
|
2024
|
+
// sec. 7.2 step 21, and only NOW. A counter that fails to advance means two
|
|
2025
|
+
// authenticators hold one credential -- an alarm a relying party acts on, by
|
|
2026
|
+
// locking the account or revoking the credential. Judging it before the
|
|
2027
|
+
// signature would let anyone raise that alarm with arbitrary bytes, so the
|
|
2028
|
+
// counter is read only out of an assertion that proved to be authentic. The
|
|
2029
|
+
// 0/0 case is an authenticator that implements no counter, which is permitted.
|
|
2030
|
+
var signCountChecked = false;
|
|
2031
|
+
if (prev !== undefined) {
|
|
2032
|
+
if (!(prev === 0 && authData.signCount === 0) && authData.signCount <= prev) {
|
|
2033
|
+
throw _err("webauthn/sign-count-not-advanced",
|
|
2034
|
+
"the assertion signCount " + authData.signCount + " does not advance past the stored " + prev +
|
|
2035
|
+
", which is the signal of a cloned authenticator (WebAuthn sec. 7.2 step 21)");
|
|
2036
|
+
}
|
|
2037
|
+
signCountChecked = true;
|
|
2038
|
+
}
|
|
2039
|
+
return {
|
|
2040
|
+
signatureVerified: true,
|
|
2041
|
+
signCount: authData.signCount,
|
|
2042
|
+
signCountChecked: signCountChecked,
|
|
2043
|
+
flags: authData.flags,
|
|
2044
|
+
rpIdHash: authData.rpIdHash,
|
|
2045
|
+
extensions: authData.extensions,
|
|
2046
|
+
// The decoded clientData when this call was given the JSON, so a caller that
|
|
2047
|
+
// still owes itself a comparison has the values without re-parsing; null
|
|
2048
|
+
// when only the digest was supplied and there was nothing to read.
|
|
2049
|
+
clientData: clientData,
|
|
2050
|
+
bindingChecked: bindingChecked,
|
|
2051
|
+
};
|
|
2052
|
+
});
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1190
2056
|
module.exports = {
|
|
1191
2057
|
parseAttestationObject: parseAttestationObject,
|
|
2058
|
+
parseAuthenticatorData: parseAuthenticatorData,
|
|
2059
|
+
parseClientData: parseClientData,
|
|
1192
2060
|
verify: verify,
|
|
2061
|
+
verifyAssertion: verifyAssertion,
|
|
1193
2062
|
verifyMetadataBlob: mds.verifyMetadataBlob,
|
|
1194
2063
|
metadataFor: mds.metadataFor,
|
|
1195
2064
|
metadataAnchors: mds.metadataAnchors,
|