@blamejs/pki 0.4.9 → 0.4.11
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 +44 -1
- package/README.md +1 -1
- package/lib/attrcert-sign.js +7 -5
- package/lib/cmp-build.js +11 -6
- package/lib/cmp-session.js +1 -1
- package/lib/cmp-verify.js +1 -1
- package/lib/constants.js +23 -0
- package/lib/crl-sign.js +6 -2
- package/lib/crmf-sign.js +10 -4
- package/lib/csr-sign.js +3 -3
- package/lib/ct.js +1 -1
- package/lib/guard-identifier.js +31 -1
- package/lib/path-validate.js +3 -1
- package/lib/pki-build.js +4 -5
- package/lib/rfc3339.js +28 -1
- package/lib/validator-tpm.js +173 -3
- package/lib/webauthn-mds.js +753 -0
- package/lib/webauthn.js +320 -11
- package/lib/x509-sign.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
CHANGED
|
@@ -35,6 +35,7 @@ var edwardsPoint = require("./edwards-point");
|
|
|
35
35
|
var guard = require("./guard-all");
|
|
36
36
|
var jose = require("./jose");
|
|
37
37
|
var pathValidate = require("./path-validate");
|
|
38
|
+
var mds = require("./webauthn-mds");
|
|
38
39
|
var nodeCrypto = require("crypto");
|
|
39
40
|
|
|
40
41
|
var WebauthnError = frameworkError.WebauthnError;
|
|
@@ -388,6 +389,23 @@ function _requireAttShape(attStmt, allowed, required) {
|
|
|
388
389
|
// future format declares its shape here instead of loosening the shared envelope check.
|
|
389
390
|
var ATT_STMT_MAJOR = { compound: 4 };
|
|
390
391
|
|
|
392
|
+
// Which attestation formats put the AAGUID under their signature. A registry row rather than a
|
|
393
|
+
// branch, so adding a format forces the question to be answered for it rather than inheriting the
|
|
394
|
+
// permissive default. Every format signs over authenticatorData (which contains the AAGUID) except
|
|
395
|
+
// fido-u2f, whose sec. 8.6 verificationData is assembled from named fields and omits it.
|
|
396
|
+
// The question is asked PER FORMAT, and answered for the element being judged -- never once for a
|
|
397
|
+
// whole statement. A compound mixes formats, and a single answer would be wrong in both directions:
|
|
398
|
+
// it would either trust a u2f element's unsigned AAGUID, or refuse to look a packed element up by
|
|
399
|
+
// the very AAGUID its own signature covers.
|
|
400
|
+
var _AAGUID_SIGNED_BY_FMT = Object.assign(Object.create(null), { "fido-u2f": false });
|
|
401
|
+
function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
|
|
402
|
+
|
|
403
|
+
// The options pki.webauthn.verify recognises. Null-prototype, so a caller-supplied key cannot
|
|
404
|
+
// resolve to an inherited Object member and read as recognised.
|
|
405
|
+
var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
406
|
+
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
407
|
+
});
|
|
408
|
+
|
|
391
409
|
// One bound for every attestation certificate chain, wherever it arrives from -- an attStmt x5c
|
|
392
410
|
// array or a JWS x5c header. Capping the bytes of a single entry does not bound the COUNT, and the
|
|
393
411
|
// cost of an entry is a DER parse plus, downstream, a signature check or a path validation. Kept in
|
|
@@ -500,7 +518,10 @@ var VERIFIERS = {
|
|
|
500
518
|
|
|
501
519
|
// tpm (WebAuthn 8.3): decode certInfo/pubArea, enforce magic/type/extraData/Name,
|
|
502
520
|
// bind pubArea to the credential key, and verify sig over certInfo with the AIK.
|
|
503
|
-
tpm: function (att, clientDataHash) {
|
|
521
|
+
tpm: function (att, clientDataHash, opts) {
|
|
522
|
+
// Config-time: a malformed or mistyped tpmPolicy is a caller error, caught before any parsing so
|
|
523
|
+
// a typo cannot silently disable the check the caller believes they enabled.
|
|
524
|
+
var tpmPolicy = validator.tpm.normalizeObjectAttributePolicy((opts || {}).tpmPolicy, WebauthnError, "webauthn/bad-input");
|
|
504
525
|
_requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
|
|
505
526
|
var verN = cbor.read.mapGet(att.attStmt, "ver");
|
|
506
527
|
if (!verN || verN.majorType !== 3 || cbor.read.textString(verN) !== "2.0") throw _err("webauthn/bad-att-stmt", "tpm attestation 'ver' MUST be \"2.0\" (WebAuthn 8.3)");
|
|
@@ -537,7 +558,15 @@ var VERIFIERS = {
|
|
|
537
558
|
if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
|
|
538
559
|
_checkAikCert(aik); // 8.3.1
|
|
539
560
|
_checkAaguidExt(aik, att.authData.aaguid); // 8.3.1: aaguid ext, if present, MUST match
|
|
540
|
-
|
|
561
|
+
// The TPMT_PUBLIC object attributes and authPolicy are properties of the credential key that
|
|
562
|
+
// sec. 8.3 does not constrain -- it bounds only pubArea's `parameters` and `unique`. They are
|
|
563
|
+
// surfaced for relying-party policy, and gated only when the caller supplies opts.tpmPolicy.
|
|
564
|
+
// Applied AFTER the signature and Name checks, so the bytes being judged are ones the AIK
|
|
565
|
+
// signature already covers rather than attacker-chosen input.
|
|
566
|
+
validator.tpm.assertObjectAttributePolicy(pub, tpmPolicy, WebauthnError, "webauthn/tpm-policy", "webauthn/bad-tpm");
|
|
567
|
+
var tpmRes = _result("tpm", "AttCA", chain, att);
|
|
568
|
+
tpmRes.tpm = { objectAttributes: pub.objectAttributes, attributes: pub.attributes, authPolicy: pub.authPolicy };
|
|
569
|
+
return tpmRes;
|
|
541
570
|
});
|
|
542
571
|
},
|
|
543
572
|
|
|
@@ -562,7 +591,10 @@ var VERIFIERS = {
|
|
|
562
591
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
563
592
|
throw _err("webauthn/safetynet-no-root", "verifying an android-safetynet attestation requires opts.safetyNetRoots -- the Google root(s) to anchor the x5c chain to; this library bundles none (WebAuthn 8.5)");
|
|
564
593
|
}
|
|
565
|
-
|
|
594
|
+
// The guard rejects through a (code, message) FACTORY. Handing it the error CLASS makes the
|
|
595
|
+
// reject path raise "class constructor cannot be invoked without new" -- a raw, untyped throw
|
|
596
|
+
// escaping a public verb, on the branch a valid-input test never takes.
|
|
597
|
+
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
566
598
|
|
|
567
599
|
// 8.5 attStmt syntax: safetynetStmtFormat = { ver: text, response: bytes }. `ver` is READ but
|
|
568
600
|
// never gated on -- 8.5 states it is reserved for future use.
|
|
@@ -624,6 +656,10 @@ var VERIFIERS = {
|
|
|
624
656
|
// specification does not state. They are relying-party policy, so they are surfaced on the result
|
|
625
657
|
// for a caller to act on, and enforced here only when the caller explicitly asks. A caller that
|
|
626
658
|
// asks and finds them missing or false gets a refusal, never a silent pass.
|
|
659
|
+
// The instant the service chain is judged at, resolved below once the signature has authenticated
|
|
660
|
+
// the timestamp it comes from, and carried out on the result so a later check of the same path
|
|
661
|
+
// uses the same one rather than resetting to the current clock.
|
|
662
|
+
var chainAt;
|
|
627
663
|
var signals = {
|
|
628
664
|
ctsProfileMatch: payload.ctsProfileMatch, basicIntegrity: payload.basicIntegrity,
|
|
629
665
|
timestampMs: payload.timestampMs, apkPackageName: payload.apkPackageName,
|
|
@@ -646,14 +682,18 @@ var VERIFIERS = {
|
|
|
646
682
|
// signature just verified under the leaf, so it is authenticated rather than caller-asserted.
|
|
647
683
|
// Precedence: an explicit opts.time (the caller knows when the registration happened) beats
|
|
648
684
|
// the signed timestamp, which beats now (a response that carries no usable timestamp).
|
|
649
|
-
|
|
685
|
+
chainAt = opts.time !== undefined ? opts.time
|
|
650
686
|
: (typeof payload.timestampMs === "number" && isFinite(payload.timestampMs) && payload.timestampMs > 0
|
|
651
687
|
? new Date(payload.timestampMs) : undefined);
|
|
652
|
-
return _safetyNetChainTrusted(chain, roots,
|
|
688
|
+
return _safetyNetChainTrusted(chain, roots, chainAt);
|
|
653
689
|
}).then(function () {
|
|
654
690
|
// 8.5 bullet 5: attestation type Basic, trust path x5c.
|
|
655
691
|
var res = _result("android-safetynet", "Basic", chain, att);
|
|
656
692
|
res.safetyNet = signals;
|
|
693
|
+
// The instant this chain was judged at, surfaced so any later check of the SAME path uses the
|
|
694
|
+
// same one. A stored response's service chain has usually expired by now, and re-validating
|
|
695
|
+
// it against the current clock would refuse a registration that was valid when it was made.
|
|
696
|
+
if (chainAt !== undefined) res.chainValidatedAt = chainAt;
|
|
657
697
|
return res;
|
|
658
698
|
});
|
|
659
699
|
},
|
|
@@ -790,7 +830,12 @@ function _safetyNetChainTrusted(chain, roots, time) {
|
|
|
790
830
|
}
|
|
791
831
|
return pathValidate.validate(path, {
|
|
792
832
|
time: when,
|
|
793
|
-
|
|
833
|
+
// The anchor's own KEY algorithm and its parameters, not the algorithm its issuer signed it
|
|
834
|
+
// with: the validator carries these forward as the working public key, and a certificate
|
|
835
|
+
// below the anchor may inherit its key parameters from them.
|
|
836
|
+
trustAnchor: { name: anchorCert.subject, publicKey: anchorCert.subjectPublicKeyInfo.bytes,
|
|
837
|
+
algorithm: anchorCert.subjectPublicKeyInfo.algorithm.oid,
|
|
838
|
+
parameters: anchorCert.subjectPublicKeyInfo.algorithm.parameters },
|
|
794
839
|
}).then(function (r) { return !!(r && r.valid); }, function () { return false; });
|
|
795
840
|
};
|
|
796
841
|
});
|
|
@@ -848,10 +893,11 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
848
893
|
* certInfo Name/extraData, the android KeyDescription, the fido-u2f verificationData).
|
|
849
894
|
* Chaining the returned `trustPath` (the x5c certificates in `pki.path.validate`
|
|
850
895
|
* order -- anchor-adjacent first, leaf last) to a trusted root is the caller's
|
|
851
|
-
* step: anchor
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
896
|
+
* step. Two ways to take it: anchor the path with `pki.path.validate` against roots
|
|
897
|
+
* you pin, or pass `opts.metadata` -- a `pki.webauthn.verifyMetadataBlob` result --
|
|
898
|
+
* and the authenticator's registered attestation roots are resolved from its aaguid
|
|
899
|
+
* and the trust path is required to reach one, so an unlisted or revoked model is
|
|
900
|
+
* refused rather than reported as verified.
|
|
855
901
|
*
|
|
856
902
|
* @example
|
|
857
903
|
* var res = await pki.webauthn.verify(attestationObject, clientDataHash, {});
|
|
@@ -861,6 +907,16 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
861
907
|
*/
|
|
862
908
|
function verify(attestationObject, clientDataHash, opts) {
|
|
863
909
|
opts = opts || {};
|
|
910
|
+
// Every option here either GATES the verdict or supplies the trust material a gate needs, so a
|
|
911
|
+
// misspelled key is not a harmless no-op: `metdata` leaves the metadata gate switched off and the
|
|
912
|
+
// call returns a pass the caller believes was checked against the catalogue. Reject an
|
|
913
|
+
// unrecognised key at the boundary, and validate the one shared option every arm reads, so a bad
|
|
914
|
+
// instant is a config fault here rather than a trust failure reported from deep inside a chain.
|
|
915
|
+
try {
|
|
916
|
+
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
917
|
+
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
918
|
+
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
919
|
+
} catch (e) { return Promise.reject(e); }
|
|
864
920
|
if (!Buffer.isBuffer(clientDataHash) || clientDataHash.length !== 32) {
|
|
865
921
|
return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
|
|
866
922
|
}
|
|
@@ -876,12 +932,265 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
876
932
|
}
|
|
877
933
|
var verifier = VERIFIERS[att.fmt];
|
|
878
934
|
if (!verifier) return Promise.reject(_err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
|
|
879
|
-
|
|
935
|
+
// A policy about TPM key properties is checked INSIDE the tpm arm, so an attestation in any other
|
|
936
|
+
// format would never reach it and the policy would silently apply to nothing -- a caller who
|
|
937
|
+
// demanded a TPM-bound key would accept a `none` attestation instead. The requirement therefore
|
|
938
|
+
// belongs at the dispatch, where it can refuse a format that cannot satisfy it, not in the arm
|
|
939
|
+
// that only runs once that format was already chosen.
|
|
940
|
+
if (opts.tpmPolicy !== undefined && !_formatCanSatisfyTpmPolicy(att)) {
|
|
941
|
+
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
|
+
}
|
|
943
|
+
return Promise.resolve().then(function () { return verifier(att, clientDataHash, opts); })
|
|
944
|
+
.then(function (res) { return opts.metadata === undefined ? res : _applyMetadata(res, att, opts); });
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// WebAuthn sec. 7.1 step 22-23: having verified the attestation, look the authenticator model up in
|
|
948
|
+
// the metadata and require its trust path to chain to one of the roots that model is registered
|
|
949
|
+
// with. The metadata must be a verifyMetadataBlob RESULT, never raw bytes -- accepting bytes here
|
|
950
|
+
// would let an unverified BLOB decide which roots are acceptable, which is the whole question.
|
|
951
|
+
function _applyMetadata(res, att, opts) {
|
|
952
|
+
var md = opts.metadata;
|
|
953
|
+
// Provenance, not shape: only a catalogue this toolkit actually verified may decide which roots
|
|
954
|
+
// an authenticator is allowed to chain to. Recognising it by its property names would accept a
|
|
955
|
+
// hand-built object, or one restored from a cache an attacker can write -- neither of which has
|
|
956
|
+
// been through the signature and chain checks that give the catalogue its authority.
|
|
957
|
+
if (!mds.isVerifiedResult(md)) {
|
|
958
|
+
throw _err("webauthn/bad-input", "opts.metadata must be a pki.webauthn.verifyMetadataBlob result, not a raw BLOB");
|
|
959
|
+
}
|
|
960
|
+
// A compound attestation (sec. 8.9) deliberately carries an EMPTY top-level trust path, because
|
|
961
|
+
// its elements yield independent chains with no single ordered path -- each element's own path is
|
|
962
|
+
// on its entry in `compound`. Treating that empty path as "nothing to anchor" would refuse every
|
|
963
|
+
// compound attestation outright, including ones whose certificate-bearing elements all chain to
|
|
964
|
+
// the model's registered roots. So the paths to check are the elements'.
|
|
965
|
+
// Each path travels WITH the format that produced it, because the format is what decides which
|
|
966
|
+
// identifier may name its entry -- and a compound mixes formats. A compound's elements are
|
|
967
|
+
// independent claims: a packed element's AAGUID is signed while a fido-u2f element's is not, so a
|
|
968
|
+
// single choice for the whole statement is wrong in both directions. It would push the packed
|
|
969
|
+
// element down the certificate-identifier path (where a conforming entry indexed only by its
|
|
970
|
+
// AAGUID is not found), or trust the u2f element's unsigned AAGUID.
|
|
971
|
+
var paths = (res.fmt === "compound" && Array.isArray(res.compound))
|
|
972
|
+
? res.compound.filter(function (el) { return el.trustPath && el.trustPath.length; })
|
|
973
|
+
.map(function (el) { return { tp: el.trustPath, fmt: el.fmt, at: el.chainValidatedAt }; })
|
|
974
|
+
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, fmt: res.fmt, at: res.chainValidatedAt }] : []);
|
|
975
|
+
// An attestation with no trust path at all (`none`, or a self-attestation) has nothing to anchor,
|
|
976
|
+
// so a caller who asked for metadata enforcement must be told it could not be applied rather than
|
|
977
|
+
// receiving a pass that looks like it was.
|
|
978
|
+
if (!paths.length) {
|
|
979
|
+
throw _err("webauthn/metadata-not-applicable", "opts.metadata was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
980
|
+
}
|
|
981
|
+
// The catalogue's two key spaces are DISJOINT, and which one applies is decided by what the
|
|
982
|
+
// authenticator declares -- never by trying one and falling back to the other. An authenticator
|
|
983
|
+
// that declares a signed model identity is looked up by it and by nothing else, so a model the
|
|
984
|
+
// catalogue does not list is refused instead of being resolved out of the U2F key space under
|
|
985
|
+
// some other authenticator's entry.
|
|
986
|
+
var aaguid = mds.aaguidToString(att.authData.aaguid);
|
|
987
|
+
// The aaguid may only select the entry when the attestation SIGNATURE covers it. For fido-u2f it
|
|
988
|
+
// does not -- sec. 8.6 signs 0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F,
|
|
989
|
+
// which excludes the field entirely -- so those 16 bytes are attacker-editable. Trusting them
|
|
990
|
+
// there does not merely misreport a model: setting them to a LISTED model that shares the
|
|
991
|
+
// vendor's registered root makes the forged statement resolve to that model's entry, skipping the
|
|
992
|
+
// real U2F entry's own key-identifier lookup and, with it, its status reports. A revoked
|
|
993
|
+
// authenticator would present itself as its healthy sibling. So for that format the certificate
|
|
994
|
+
// key identifier decides, whatever the field says.
|
|
995
|
+
// Every path is resolved and enforced against ITS OWN entry, by the identifier ITS OWN format
|
|
996
|
+
// allows. A compound carries independent claims and its element order is not signed, so choosing
|
|
997
|
+
// one entry for the whole statement would let that order decide whose status report is consulted:
|
|
998
|
+
// a healthy element listed first would suppress a revoked sibling's report while both still
|
|
999
|
+
// anchor successfully. Each path answers for itself, and all of them must pass.
|
|
1000
|
+
var applied = [];
|
|
1001
|
+
function govern(info) {
|
|
1002
|
+
var tp = info.tp;
|
|
1003
|
+
var declared = _aaguidIsSigned(info.fmt) && aaguid && aaguid !== mds.ZERO_AAGUID;
|
|
1004
|
+
var entry, identifier;
|
|
1005
|
+
if (declared) {
|
|
1006
|
+
entry = mds.metadataFor(md, aaguid);
|
|
1007
|
+
identifier = "aaguid " + aaguid;
|
|
1008
|
+
} else {
|
|
1009
|
+
// No signed model identity: keyed by the attestation certificate instead. A path is
|
|
1010
|
+
// anchor-adjacent first, so that certificate is its LAST element -- the one whose key the
|
|
1011
|
+
// catalogue identifies, not the root it chains to.
|
|
1012
|
+
var keyId = mds.certKeyIdentifier(tp[tp.length - 1]);
|
|
1013
|
+
entry = mds.metadataForKeyIdentifier(md, keyId);
|
|
1014
|
+
identifier = "attestation certificate key identifier " + keyId;
|
|
1015
|
+
}
|
|
1016
|
+
if (!entry) throw _err("webauthn/metadata-not-found", "no metadata entry matches this authenticator (" + identifier + ")");
|
|
1017
|
+
// The attestation certificate is passed in so a report that names a single certificate is
|
|
1018
|
+
// judged against the one actually presented, rather than denying every device the entry covers.
|
|
1019
|
+
if (mds.statusDenied(entry, md, tp[tp.length - 1], at)) {
|
|
1020
|
+
throw _err("webauthn/metadata-status", "the metadata entry for " + identifier + " carries a disqualifying status report");
|
|
1021
|
+
}
|
|
1022
|
+
var anchors = mds.metadataAnchors(entry);
|
|
1023
|
+
if (!anchors.length) throw _err("webauthn/metadata-no-anchor", "the metadata entry for " + identifier + " supplies no attestation root certificate");
|
|
1024
|
+
applied.push({ entry: entry, anchors: anchors, identifier: identifier });
|
|
1025
|
+
return { anchors: anchors, identifier: identifier };
|
|
1026
|
+
}
|
|
1027
|
+
// A path must VALIDATE to one of the roots its own model registered -- signature chaining,
|
|
1028
|
+
// validity, constraints -- not merely resemble one. A name comparison against the top of the path
|
|
1029
|
+
// would accept any certificate asserting the registered issuer's name, which is the assertion an
|
|
1030
|
+
// attacker controls; and certificate equality is not the question either, since a registered root
|
|
1031
|
+
// normally ISSUES the attestation certificate rather than being it. So this composes the same
|
|
1032
|
+
// path validation the BLOB's own chain goes through. chainToAnchor takes leaf-first and a trust
|
|
1033
|
+
// path is anchor-adjacent first, so the order is reversed on the way in.
|
|
1034
|
+
//
|
|
1035
|
+
// opts.time was validated at the entry, so this instant is usable: an unusable one reaching the
|
|
1036
|
+
// path validator would be absorbed by the chain walk and reported as an authenticator trust
|
|
1037
|
+
// failure, which is the wrong verdict for a caller's configuration mistake.
|
|
1038
|
+
var at = opts.time !== undefined ? opts.time : new Date();
|
|
1039
|
+
// The catalogue's own freshness is re-established HERE, at the instant it is being used to decide
|
|
1040
|
+
// trust -- not only at the instant it was parsed. A verified result is a plain object a caller may
|
|
1041
|
+
// hold and reuse indefinitely, so a catalogue fetched before its nextUpdate and then reused a
|
|
1042
|
+
// month afterwards would otherwise keep authorizing an authenticator whose status reports have
|
|
1043
|
+
// since revoked it, which is exactly what nextUpdate exists to prevent. The caller's original
|
|
1044
|
+
// allowStale decision rides on the result, so opting out stays opted out.
|
|
1045
|
+
mds.assertFresh(md, at, "the metadata supplied as opts.metadata");
|
|
1046
|
+
// EVERY path must pass, not merely one of them: for a compound attestation each element is an
|
|
1047
|
+
// independent claim, and accepting the whole because one element anchored would let an
|
|
1048
|
+
// unanchored -- or revoked -- element ride along on its neighbour's trust.
|
|
1049
|
+
return paths.reduce(function (p, info) {
|
|
1050
|
+
return p.then(function () {
|
|
1051
|
+
var g = govern(info);
|
|
1052
|
+
// A path is re-validated at the instant its own format already judged it at, when the format
|
|
1053
|
+
// established one from signed data. An android-safetynet response carries its signing time and
|
|
1054
|
+
// its service chain has usually expired since; resetting to the current clock here would
|
|
1055
|
+
// refuse the very registration the format verifier just accepted. An explicit opts.time still
|
|
1056
|
+
// wins, since the caller knows when the registration happened. The catalogue's own expiry is a
|
|
1057
|
+
// separate question and is judged above, against the caller's instant rather than this one.
|
|
1058
|
+
var pathAt = opts.time !== undefined ? opts.time : (info.at || at);
|
|
1059
|
+
return mds.chainToAnchor(info.tp.slice().reverse(), g.anchors, pathAt,
|
|
1060
|
+
"attestation trust path for " + g.identifier + " (against the roots its metadata entry registers)");
|
|
1061
|
+
});
|
|
1062
|
+
}, Promise.resolve())
|
|
1063
|
+
.then(function () {
|
|
1064
|
+
// The ENTRY's aaguid, not the authenticator's raw field: for a U2F authenticator that field is
|
|
1065
|
+
// all zeroes, which means "no model identity" -- reporting it back as though it were one would
|
|
1066
|
+
// hand the caller a value that matches nothing and reads like an identifier. `entries` lists
|
|
1067
|
+
// every entry that governed a path, which for a compound is one per certificate-bearing
|
|
1068
|
+
// element; `entry` is the first, and equals the only one for every other format.
|
|
1069
|
+
var primary = applied[0];
|
|
1070
|
+
res.metadata = { aaguid: primary.entry.aaguid, keyIdentifiers: primary.entry.keyIdentifiers || [],
|
|
1071
|
+
entry: primary.entry, entries: applied.map(function (a) { return a.entry; }),
|
|
1072
|
+
anchors: primary.anchors.length };
|
|
1073
|
+
return res;
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// Only an attestation that actually carries a TPM public area can satisfy a TPM policy: the tpm
|
|
1078
|
+
// format directly, or a compound holding at least one tpm element (whose own arm applies it).
|
|
1079
|
+
function _formatCanSatisfyTpmPolicy(att) {
|
|
1080
|
+
if (att.fmt === "tpm") return true;
|
|
1081
|
+
if (att.fmt !== "compound") return false;
|
|
1082
|
+
return (att.attStmt.children || []).some(function (el) {
|
|
1083
|
+
if (!el || el.majorType !== 5) return false;
|
|
1084
|
+
var fN = cbor.read.mapGet(el, "fmt");
|
|
1085
|
+
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) === "tpm";
|
|
1086
|
+
});
|
|
880
1087
|
}
|
|
881
1088
|
|
|
882
1089
|
void constants;
|
|
883
1090
|
|
|
1091
|
+
/**
|
|
1092
|
+
* @primitive pki.webauthn.verifyMetadataBlob
|
|
1093
|
+
* @signature pki.webauthn.verifyMetadataBlob(blob, opts) -> Promise<{ no, nextUpdate, entries, byAaguid }>
|
|
1094
|
+
* @since 0.4.11
|
|
1095
|
+
* @status experimental
|
|
1096
|
+
* @spec FIDO Metadata Service v3.0 sec. 3.1, RFC 7515
|
|
1097
|
+
* @related pki.webauthn.verify, pki.webauthn.metadataFor
|
|
1098
|
+
*
|
|
1099
|
+
* Verify a FIDO Metadata Service BLOB -- the signed catalogue of every registered
|
|
1100
|
+
* authenticator model, its attestation roots, and its certification status -- and
|
|
1101
|
+
* return its entries indexed by aaguid for lookup. `blob` is caller-supplied bytes or
|
|
1102
|
+
* a string; retrieval is out of scope, so this never touches the network.
|
|
1103
|
+
*
|
|
1104
|
+
* The BLOB is a JWS. Its signature is checked under the certificate in its own header,
|
|
1105
|
+
* that chain is validated to one of `opts.rootCertificates`, and only THEN is the
|
|
1106
|
+
* payload read -- a BLOB that does not verify never reaches the JSON parser. `no` must
|
|
1107
|
+
* exceed `opts.previousNo` (rollback) and `nextUpdate` must not have passed
|
|
1108
|
+
* (freshness). Every failure is a typed `webauthn/metadata-*` throw, never a partial
|
|
1109
|
+
* result.
|
|
1110
|
+
*
|
|
1111
|
+
* @intro No FIDO root ships with this toolkit and there is no trust-on-first-use:
|
|
1112
|
+
* which metadata authority to trust is the operator's decision, exactly as a root
|
|
1113
|
+
* store is for `pki.path.validate`. Supply the FIDO Alliance root you pin.
|
|
1114
|
+
*
|
|
1115
|
+
* @opts
|
|
1116
|
+
* - `rootCertificates` -- REQUIRED. The trust anchors the BLOB's own signing chain
|
|
1117
|
+
* must reach. A certificate, PEM, or DER bytes.
|
|
1118
|
+
* - `time` -- the instant freshness is judged at. Defaults to now.
|
|
1119
|
+
* - `previousNo` -- the sequence number of the BLOB you already hold. A BLOB whose
|
|
1120
|
+
* `no` is not greater is refused as a rollback.
|
|
1121
|
+
* - `requireRollbackCheck` -- require `previousNo`, so a caller cannot skip the
|
|
1122
|
+
* rollback check by forgetting to pass it.
|
|
1123
|
+
* - `allowStale` -- accept a BLOB past its `nextUpdate`. Off by default.
|
|
1124
|
+
* - `statusPolicy` -- which status reports disqualify an authenticator: `"any"`
|
|
1125
|
+
* (default -- any disqualifying report ever filed), `"latest-by-date"` (only the
|
|
1126
|
+
* most recent report counts, so a later remediation clears an earlier revocation),
|
|
1127
|
+
* or a function receiving the raw report array and returning true to deny.
|
|
1128
|
+
* - `rejectUnknownStatus` -- treat a status this toolkit does not recognise as
|
|
1129
|
+
* disqualifying. Off by default: the specification requires an unknown status be
|
|
1130
|
+
* ignored rather than failed on.
|
|
1131
|
+
*
|
|
1132
|
+
* @example
|
|
1133
|
+
* var md = await pki.webauthn.verifyMetadataBlob(mdsBlobBytes, {
|
|
1134
|
+
* rootCertificates: [fidoRootDer],
|
|
1135
|
+
* previousNo: 41, // refuse a replay of a BLOB you have already superseded
|
|
1136
|
+
* });
|
|
1137
|
+
* md.no; // 42 (the sequence number this BLOB carries)
|
|
1138
|
+
* md.entries.length; // every authenticator model the catalogue lists
|
|
1139
|
+
* // then bind an attestation to the roots its own model registered:
|
|
1140
|
+
* // await pki.webauthn.verify(attestationObject, clientDataHash, { metadata: md });
|
|
1141
|
+
*/
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* @primitive pki.webauthn.metadataFor
|
|
1145
|
+
* @signature pki.webauthn.metadataFor(metadata, identifier) -> entry | null
|
|
1146
|
+
* @since 0.4.11
|
|
1147
|
+
* @status experimental
|
|
1148
|
+
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1149
|
+
* @related pki.webauthn.verifyMetadataBlob, pki.webauthn.metadataAnchors
|
|
1150
|
+
*
|
|
1151
|
+
* The verified metadata entry for an authenticator model, or `null` when the BLOB
|
|
1152
|
+
* lists none. `metadata` is a `verifyMetadataBlob` RESULT, never raw bytes -- so a
|
|
1153
|
+
* lookup can never be answered out of a BLOB nobody verified.
|
|
1154
|
+
*
|
|
1155
|
+
* `identifier` is whichever of the catalogue's two key spaces names the authenticator:
|
|
1156
|
+
* its aaguid, or -- for a U2F authenticator, which carries none -- the key identifier
|
|
1157
|
+
* of its attestation certificate (RFC 5280 sec. 4.2.1.2 method 1, 40 hex digits). The
|
|
1158
|
+
* two are disjoint by shape, so the form is dispatched on rather than guessed at, and
|
|
1159
|
+
* anything matching neither is a miss. The all-zero aaguid means "this authenticator
|
|
1160
|
+
* declares no model identity" and matches nothing.
|
|
1161
|
+
*
|
|
1162
|
+
* @example
|
|
1163
|
+
* var entry = pki.webauthn.metadataFor(mdsMetadata, mdsAaguid);
|
|
1164
|
+
* entry.statusReports[0].status; // "FIDO_CERTIFIED_L1"
|
|
1165
|
+
* pki.webauthn.metadataFor(mdsMetadata, "00000000-0000-0000-0000-000000000000"); // null
|
|
1166
|
+
*/
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* @primitive pki.webauthn.metadataAnchors
|
|
1170
|
+
* @signature pki.webauthn.metadataAnchors(entry) -> [certificate]
|
|
1171
|
+
* @since 0.4.11
|
|
1172
|
+
* @status experimental
|
|
1173
|
+
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1174
|
+
* @related pki.webauthn.metadataFor, pki.path.validate
|
|
1175
|
+
*
|
|
1176
|
+
* The parsed attestation root certificates a metadata entry registers -- the anchors an
|
|
1177
|
+
* attestation from that model must chain to. Decoding is per entry rather than for the
|
|
1178
|
+
* whole BLOB on purpose: a handful of certificates in the live metadata do not parse
|
|
1179
|
+
* under a strict decoder, and decoding everything up front would let one vendor's
|
|
1180
|
+
* malformed root refuse the entire catalogue for every other authenticator in it.
|
|
1181
|
+
*
|
|
1182
|
+
* @example
|
|
1183
|
+
* var anchors = pki.webauthn.metadataAnchors(mdsEntry);
|
|
1184
|
+
* anchors.length; // the attestation roots this model registered
|
|
1185
|
+
* anchors[0].subject; // the decoded root DN
|
|
1186
|
+
* // chain an attestation's trustPath to them:
|
|
1187
|
+
* // await pki.path.validate(res.trustPath, { trustAnchors: anchors, time: mdsTime });
|
|
1188
|
+
*/
|
|
1189
|
+
|
|
884
1190
|
module.exports = {
|
|
885
1191
|
parseAttestationObject: parseAttestationObject,
|
|
886
1192
|
verify: verify,
|
|
1193
|
+
verifyMetadataBlob: mds.verifyMetadataBlob,
|
|
1194
|
+
metadataFor: mds.metadataFor,
|
|
1195
|
+
metadataAnchors: mds.metadataAnchors,
|
|
887
1196
|
};
|
package/lib/x509-sign.js
CHANGED
|
@@ -131,8 +131,8 @@ function _buildExtensions(extSpec, ctx) {
|
|
|
131
131
|
if (typeof extSpec !== "object") throw _err("x509/bad-input", "extensions must be an object or an array of pre-encoded Extension DER");
|
|
132
132
|
// Reject a typo'd / unsupported extension key at config-time rather than silently dropping it (a
|
|
133
133
|
// misspelled `keyUsag` would otherwise omit keyUsage). A custom extension goes in the array form.
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
guard.identifier.assertKnownKeys(extSpec, KNOWN_EXT_KEYS, _err, "x509/bad-input", function (k) {
|
|
135
|
+
return "unknown extension " + JSON.stringify(k) + " in the extensions spec; pass a pre-encoded Extension DER via the array form for a custom extension";
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
var bc = extSpec.basicConstraints;
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:37941dc8-becd-4983-b264-d6897e595ed1",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-09T10:47:07.155Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.4.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.11",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
25
|
+
"version": "0.4.11",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.4.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.4.11",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.4.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.11",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|