@blamejs/pki 0.5.19 → 0.5.20
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 +24 -0
- package/lib/cms-verify.js +18 -5
- package/lib/guard-all.js +2 -0
- package/lib/guard-list.js +85 -0
- package/lib/ocsp-verify.js +9 -2
- package/lib/path-validate.js +53 -27
- package/lib/sign-scheme.js +16 -1
- package/lib/tsp-sign.js +12 -4
- package/lib/validator-attcert.js +10 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,30 @@ All notable changes to `@blamejs/pki` are documented here. The format
|
|
|
4
4
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
|
|
5
5
|
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## v0.5.20 — 2026-08-20
|
|
8
|
+
|
|
9
|
+
Five verdicts that turned on list membership now decide it by comparison, so replacing an array method after the module loads cannot change what a certificate, a responder or a signed message is found to be.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `guard-list`, an internal choke point for membership decided by comparison. Four verbs cover the rule shapes a verdict needs: is this value present, are all of these present, does any member satisfy a test, do all of them. An absent list is honestly empty rather than a throw, because these run on verdict paths where a throw would read as a different verdict. It is not on the public namespace; the surface is the verdicts above.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Certification-path building asks its loop-detection and fetch-dedupe sets through captured methods. These bound the work a hostile chain can cause, and a replaced `Set.prototype.has` answering false let a cycle be walked and a URL be fetched repeatedly until a size cap stopped it.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `pki.path.validate` holds its `requiredEku` gate against a replaced `Array.prototype.indexOf`. The purpose test IS the decision about what the certificate may be used for, and dispatched live it reported `serverAuth` present on a leaf whose extended key usage carried only `emailProtection`, returning a valid chain for a certificate the caller had asked it to refuse. Certificate-policy membership and the `anyPolicy` scans in the policy tree are decided the same way.
|
|
22
|
+
- `pki.cms.verify` keeps `trusted: false` for a message no supplied anchor covers. The message-level verdict combined the per-signer answers with `every`, and a replaced one reported the whole set trusted without consulting a single signer.
|
|
23
|
+
- An OCSP response is still refused when its delegate lacks id-kp-OCSPSigning. Responder authorization is the decision about who may answer for an issuer, and reading the purpose list through the prototype let a delegate the CA never authorized speak for the whole issuer.
|
|
24
|
+
- A TPM attestation certificate still needs the tcg-kp-AIKCertificate purpose. That test is what makes a leaf an attestation key, so reading it live let an ordinary certificate be accepted as one.
|
|
25
|
+
- A timestamp token carrying an unrecognized critical extension is still refused (RFC 3161 sec. 2.4.2). The scan for a critical extension IS the gate, and a replaced `Array.prototype.some` answering false made the extension invisible and the unusable token acceptable.
|
|
26
|
+
- `pki.cms.verify` keeps `valid: false` for a message whose signature does not verify. The soundness verdict combined the per-signer answers the same way the trust verdict did, so a corrupted signature reported `valid: true`.
|
|
27
|
+
- `pki.path.validate` asks its processed-extension tables for their OWN entries. Freezing a table prevents a write to it and does not prevent an inherited lookup, so a name planted on `Object.prototype` answered for every OID the table did not carry and an unrecognized critical extension counted as processed. On a CA certificate only one of the two tables is consulted, so the chain was accepted rather than refused (RFC 5280 sec. 6.1.4).
|
|
28
|
+
- A trust anchor's `purposes` map is read for its own entries too. The map is the operator's restriction, and an inherited `true` granted the purpose on every anchor whose map did not name it. `distrustAfter` is read the same way.
|
|
29
|
+
- The ML-DSA digest-strength rule (RFC 9882 sec. 3.3) is one question both the signer and the CMS verifier ask, and it answers from the policy table's own entries. A digest name planted on `Object.prototype` answered for every name a parameter set's row omits, so a below-strength digest passed the strength check: `sha256` was accepted for ML-DSA-87 on both paths. The check also had two copies, which is why it now lives in the module that owns the table.
|
|
30
|
+
|
|
7
31
|
## v0.5.19 — 2026-08-20
|
|
8
32
|
|
|
9
33
|
The crypto engine and the JWS signer decide with operations captured at load, so code that runs after the module does cannot change what a permission check concludes.
|
package/lib/cms-verify.js
CHANGED
|
@@ -39,7 +39,9 @@ var cmsEncrypt = require("./cms-encrypt");
|
|
|
39
39
|
var cmsDecrypt = require("./cms-decrypt");
|
|
40
40
|
var cmsCompress = require("./cms-compress");
|
|
41
41
|
var signScheme = require("./sign-scheme");
|
|
42
|
-
|
|
42
|
+
// The digest-strength question is asked of sign-scheme, which owns the policy table, so the signer
|
|
43
|
+
// and this verifier cannot drift (RFC 9882 sec. 3.3).
|
|
44
|
+
var _mldsaDigestSuitable = signScheme.mldsaDigestSuitable;
|
|
43
45
|
var SLHDSA_BY_OID = signScheme.SLHDSA_BY_OID; // shared SLH-DSA set -> { wc, digest } (RFC 9814 sec. 4 pinned digest)
|
|
44
46
|
var validator = require("./validator-all");
|
|
45
47
|
var compositeSig = require("./composite-sig");
|
|
@@ -71,7 +73,7 @@ var DIGEST_HASH = {
|
|
|
71
73
|
// foreign fault and loses this module's own unsupported-algorithm verdict.
|
|
72
74
|
var SIG_HASH = { sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
|
|
73
75
|
// Is `name` a message-digest algorithm this verifier supports?
|
|
74
|
-
function _supportedDigest(name) { return !!DIGEST_HASH[name]; }
|
|
76
|
+
function _supportedDigest(name) { return guard.intrinsic.hasOwn(DIGEST_HASH, name) && !!DIGEST_HASH[name]; }
|
|
75
77
|
// The digest of `content` under the named algorithm, resolved to a Buffer.
|
|
76
78
|
function _computeDigest(name, content) {
|
|
77
79
|
return subtle.digest(DIGEST_HASH[name], content).then(function (d) { return Buffer.from(d); });
|
|
@@ -366,7 +368,7 @@ function _verifyOne(si, content, eContentType, parsedCerts, csTarget) {
|
|
|
366
368
|
// meet the parameter set's security strength -- a below-strength digest is the weaker link and is
|
|
367
369
|
// rejected fail-closed (the RFC's "verifiers MAY reject", taken by the strict-verifier posture).
|
|
368
370
|
// With signed attributes absent the digestAlgorithm has no meaning (sec. 3.3) and is not checked.
|
|
369
|
-
if (scheme.kind === "mldsa" && si.signedAttrsBytes && !
|
|
371
|
+
if (scheme.kind === "mldsa" && si.signedAttrsBytes && !_mldsaDigestSuitable(scheme.name, si.digestAlgorithm.name)) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the " + si.digestAlgorithm.name + " message digest is below the security strength of " + scheme.name + " (RFC 9882 sec. 3.3)" });
|
|
370
372
|
// RFC 9814 sec. 4: an SLH-DSA SignerInfo's message-digest algorithm is the one paired with the
|
|
371
373
|
// parameter set (the sec. 4 list, always at least twice the tree-hash size); the signer emits it
|
|
372
374
|
// and this strict verifier requires it, so a digest that does not match the set's paired hash fails
|
|
@@ -768,7 +770,9 @@ function _verify(input, opts) {
|
|
|
768
770
|
// message a second time to learn it, and a check that needs a second parse is a check most
|
|
769
771
|
// callers will not write.
|
|
770
772
|
var res = {
|
|
771
|
-
|
|
773
|
+
// Signature soundness is decided by comparison for the same reason the trust verdict below
|
|
774
|
+
// is: `every` replaced after load reports the whole set sound without reading one signer.
|
|
775
|
+
valid: signers.length > 0 && guard.list.allMatch(signers, function (s) { return s.ok === true; }),
|
|
772
776
|
eContentType: parsed.encapContentInfo.eContentType,
|
|
773
777
|
signers: signers,
|
|
774
778
|
};
|
|
@@ -873,6 +877,12 @@ function _keyUsagePermitsSigning(parsedCerts, der) {
|
|
|
873
877
|
return true; // absent: unconstrained (sec. 4.2.1.3)
|
|
874
878
|
}
|
|
875
879
|
|
|
880
|
+
// Every signer trusted? The answer IS the message-level trust verdict, so it is decided by
|
|
881
|
+
// comparison instead of by an `every`. See guard-list.
|
|
882
|
+
function _allTrusted(signers) {
|
|
883
|
+
return guard.list.allMatch(signers, function (s) { return s.trusted === true; });
|
|
884
|
+
}
|
|
885
|
+
|
|
876
886
|
// `cfg` is verify's SNAPSHOT, never the caller's options object -- the parameter is named for that
|
|
877
887
|
// so a future caller cannot hand it the live one without noticing.
|
|
878
888
|
function _applyTrust(res, parsedCerts, cfg) {
|
|
@@ -963,7 +973,10 @@ function _applyTrust(res, parsedCerts, cfg) {
|
|
|
963
973
|
});
|
|
964
974
|
});
|
|
965
975
|
}, Promise.resolve()).then(function () {
|
|
966
|
-
|
|
976
|
+
// The trust verdict is decided by comparison, not by a prototype method: `every` replaced after
|
|
977
|
+
// load answers true without consulting a single signer, and a message no anchor covers reports
|
|
978
|
+
// trusted:true.
|
|
979
|
+
res.trusted = res.valid && res.signers.length > 0 && _allTrusted(res.signers);
|
|
967
980
|
});
|
|
968
981
|
}
|
|
969
982
|
// Parse a candidate cert DER and index its SKI; a cert that will not parse is skipped (it
|
package/lib/guard-all.js
CHANGED
|
@@ -55,6 +55,7 @@ var bytes = require("./guard-bytes");
|
|
|
55
55
|
var text = require("./guard-text");
|
|
56
56
|
var limits = require("./guard-limits");
|
|
57
57
|
var crypto = require("./guard-crypto");
|
|
58
|
+
var list = require("./guard-list");
|
|
58
59
|
var range = require("./guard-range");
|
|
59
60
|
var time = require("./guard-time");
|
|
60
61
|
var name = require("./guard-name");
|
|
@@ -74,6 +75,7 @@ module.exports = {
|
|
|
74
75
|
text: text,
|
|
75
76
|
limits: limits,
|
|
76
77
|
crypto: crypto,
|
|
78
|
+
list: list,
|
|
77
79
|
range: range,
|
|
78
80
|
time: time,
|
|
79
81
|
name: name,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal, so no operator-facing namespace. The documented surface is every verdict whose rule
|
|
6
|
+
// composes this guard: pki.path.validate (requiredEku, the policy tree), pki.cms.verify (the
|
|
7
|
+
// message trust verdict), the OCSP delegated-responder check, the TPM AIK purpose check, and the
|
|
8
|
+
// timestamp unknown-critical-extension gate.
|
|
9
|
+
//
|
|
10
|
+
// guard-list, membership decided by comparison and never by a prototype method.
|
|
11
|
+
//
|
|
12
|
+
// Defends the intrinsic-substitution class (CWE-349 / CWE-693: a control the caller can neutralize).
|
|
13
|
+
// Written `list.indexOf(v) !== -1` or `list.some(fn)`, a membership test asks the runtime, at the
|
|
14
|
+
// moment of the call, which function `indexOf` or `some` is. A caller who receives control after
|
|
15
|
+
// this package loads, through an option accessor or a `toJSON` or while a verb is suspended on an
|
|
16
|
+
// await, can replace it. The answer becomes whatever they chose, while every check around it still
|
|
17
|
+
// runs and still passes.
|
|
18
|
+
//
|
|
19
|
+
// The distinction that matters is what the answer IS. A scan over a local accumulator produces a
|
|
20
|
+
// value. A scan that decides whether a certificate carries a purpose, whether a policy is expected,
|
|
21
|
+
// or whether every signer is trusted IS the rule. Measured on the tree before this guard existed:
|
|
22
|
+
// with `Array.prototype.indexOf` replaced to report every value present, a certificate restricted
|
|
23
|
+
// to emailProtection satisfied a requiredEku of serverAuth, and pki.path.validate went from
|
|
24
|
+
// valid:false to valid:true.
|
|
25
|
+
//
|
|
26
|
+
// Capturing the method at load (guard-intrinsic) is the other half of the same defense, and is what
|
|
27
|
+
// a module already inside that discipline uses. This guard is for the rule sites themselves. A loop
|
|
28
|
+
// over `===` has nothing to replace, so it holds whatever the prototype says, in a module that has
|
|
29
|
+
// been swept and in one that has not.
|
|
30
|
+
|
|
31
|
+
// contains(list, value) -> boolean. Strict-equality membership, the `indexOf(v) !== -1` rule.
|
|
32
|
+
// A null / undefined list is honestly empty, never a throw: these run on verdict paths.
|
|
33
|
+
// @enforced-by behavioral -- `.indexOf(x) !== -1` has no rename-proof form that separates a
|
|
34
|
+
// membership RULE from its legitimate siblings: the identical token is a substring search
|
|
35
|
+
// (`authority.indexOf(":")`, `addr.indexOf("@")`) and a local index lookup, and a shape matching
|
|
36
|
+
// all of them fired on 37 sites of which most were strings. What makes a site this guard's is
|
|
37
|
+
// what the answer IS -- a decision about a certificate, a policy or a signer -- and that is a
|
|
38
|
+
// judgment no regex makes. The RED vectors are the guard: a codeSigning-only certificate still
|
|
39
|
+
// fails a serverAuth requiredEku, and an unauthorized responder is still refused, each with
|
|
40
|
+
// Array.prototype.indexOf replaced after load.
|
|
41
|
+
function contains(list, value) {
|
|
42
|
+
if (!list) return false;
|
|
43
|
+
for (var i = 0, n = list.length; i < n; i++) if (list[i] === value) return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// containsAll(list, values) -> boolean. Every one of `values` present in `list` -- the shape a
|
|
48
|
+
// "these purposes are all required" rule needs. No `values` is vacuously satisfied.
|
|
49
|
+
// @enforced-by behavioral -- the composition of contains() has no rename-proof shape of its own;
|
|
50
|
+
// the RED vectors (a codeSigning-only certificate still fails a serverAuth requiredEku with
|
|
51
|
+
// Array.prototype.indexOf replaced) are the guard.
|
|
52
|
+
function containsAll(list, values) {
|
|
53
|
+
if (!values) return true;
|
|
54
|
+
for (var i = 0, n = values.length; i < n; i++) if (!contains(list, values[i])) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// anyMatches(list, predicate) -> boolean. The `some(fn)` rule: does any member satisfy it? The
|
|
59
|
+
// predicate is the caller's own function, so what this guard removes is the DISPATCH, not the test.
|
|
60
|
+
// @enforced-by behavioral -- `.some(` has legitimate non-rule siblings (building a list, reporting
|
|
61
|
+
// a count), so a blanket detector would false-fire; the RED vectors (a critical extension is
|
|
62
|
+
// still seen, an untrusted signer still reported) are the guard.
|
|
63
|
+
function anyMatches(list, predicate) {
|
|
64
|
+
if (!list) return false;
|
|
65
|
+
for (var i = 0, n = list.length; i < n; i++) if (predicate(list[i], i)) return true;
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// allMatch(list, predicate) -> boolean. The `every(fn)` rule, same reasoning as anyMatches. An
|
|
70
|
+
// empty list is vacuously true, matching Array.prototype.every -- a caller that needs "non-empty
|
|
71
|
+
// AND all" tests the length itself, as pki.cms.verify does for the trust verdict.
|
|
72
|
+
// @enforced-by behavioral -- see anyMatches; the RED vector (an untrusted signer keeps
|
|
73
|
+
// trusted:false with Array.prototype.every replaced) is the guard.
|
|
74
|
+
function allMatch(list, predicate) {
|
|
75
|
+
if (!list) return true;
|
|
76
|
+
for (var i = 0, n = list.length; i < n; i++) if (!predicate(list[i], i)) return false;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = {
|
|
81
|
+
contains: contains,
|
|
82
|
+
containsAll: containsAll,
|
|
83
|
+
anyMatches: anyMatches,
|
|
84
|
+
allMatch: allMatch,
|
|
85
|
+
};
|
package/lib/ocsp-verify.js
CHANGED
|
@@ -45,6 +45,9 @@ OCSP_CERTID_HASHES[oid.byName("sha256")] = "SHA-256";
|
|
|
45
45
|
OCSP_CERTID_HASHES[oid.byName("sha384")] = "SHA-384";
|
|
46
46
|
OCSP_CERTID_HASHES[oid.byName("sha512")] = "SHA-512";
|
|
47
47
|
var OID_OCSP_SIGNING = oid.byName("ocspSigning");
|
|
48
|
+
// Does this extended-key-usage list assert the given purpose? The answer IS an authorization
|
|
49
|
+
// decision, so it goes through the membership guard rather than a prototype method. See guard-list.
|
|
50
|
+
var _asserts = guard.list.contains;
|
|
48
51
|
var OID_OCSP_NOCHECK = oid.byName("ocspNoCheck");
|
|
49
52
|
var OID_EKU = oid.byName("extKeyUsage");
|
|
50
53
|
var OID_KEY_USAGE = oid.byName("keyUsage");
|
|
@@ -105,7 +108,7 @@ function makeOcspVerify(deps) {
|
|
|
105
108
|
// 5280 sec. 7.1-equal byte encoding of the validated issuer DN to try.
|
|
106
109
|
async function ocspCertIdMatches(certID, cert, issuerNameCandidates, issuerKeyBits) {
|
|
107
110
|
if (certID.serialNumberHex !== cert.serialNumberHex) return false;
|
|
108
|
-
var hashName = OCSP_CERTID_HASHES[certID.hashAlgorithm.oid];
|
|
111
|
+
var hashName = guard.intrinsic.hasOwn(OCSP_CERTID_HASHES, certID.hashAlgorithm.oid) ? OCSP_CERTID_HASHES[certID.hashAlgorithm.oid] : null;
|
|
109
112
|
if (!hashName) return false;
|
|
110
113
|
var keyHash = await ocspDigest(hashName, issuerKeyBits);
|
|
111
114
|
if (!certID.issuerKeyHash.equals(keyHash)) return false;
|
|
@@ -154,7 +157,11 @@ function makeOcspVerify(deps) {
|
|
|
154
157
|
var eku;
|
|
155
158
|
try { eku = decodeExt(rc, OID_EKU); }
|
|
156
159
|
catch (_e) { continue; }
|
|
157
|
-
|
|
160
|
+
// The membership test IS the delegation rule, so it is decided by comparison rather than by a
|
|
161
|
+
// prototype method: dispatched live, an `Array.prototype.indexOf` replaced after load reports
|
|
162
|
+
// the purpose present on ANY certificate, and a cert an attacker put in the response's own
|
|
163
|
+
// certs bag is accepted as the authorized responder.
|
|
164
|
+
if (!eku || !_asserts(eku.value, OID_OCSP_SIGNING)) continue;
|
|
158
165
|
// A delegate asserting keyUsage MUST permit digitalSignature; an absent keyUsage is
|
|
159
166
|
// unrestricted, an unreadable one is not authoritative.
|
|
160
167
|
var ku;
|
package/lib/path-validate.js
CHANGED
|
@@ -67,6 +67,20 @@ function pathCode(e, fallback) {
|
|
|
67
67
|
return (e && typeof e.code === "string" && e.code.indexOf("path/") === 0) ? e.code : fallback;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Membership decided by comparison rather than by a prototype method, through the shared guard.
|
|
71
|
+
// Every use below is a RULE: whether a certificate's extended key usages cover the ones the caller
|
|
72
|
+
// required, whether a policy is already expected at a node, whether anyPolicy is in the user's
|
|
73
|
+
// initial set. See guard-list for what a replaced Array.prototype.indexOf did to this validator.
|
|
74
|
+
var contains = guard.list.contains;
|
|
75
|
+
var containsAll = guard.list.containsAll;
|
|
76
|
+
// RFC 5280 6.1.4(a): a policyMappings extension MUST NOT map to or from anyPolicy. The scan for a
|
|
77
|
+
// violating pair is the rule, so it routes through the guard for the same reason.
|
|
78
|
+
function mapsAnyPolicy(mappings) {
|
|
79
|
+
return guard.list.anyMatches(mappings, function (m) {
|
|
80
|
+
return !!m && (m.issuerDomainPolicy === OID.anyPolicy || m.subjectDomainPolicy === OID.anyPolicy);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
var NS = pkix.makeNS("path", PathError, oid);
|
|
71
85
|
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
72
86
|
|
|
@@ -828,7 +842,7 @@ function processPolicies(state, cert, i, checks) {
|
|
|
828
842
|
if (p.policyIdentifier === OID.anyPolicy) { anyPolicyPresent = true; anyPolicyQualifiers = p.qualifiersBytes; return; }
|
|
829
843
|
var matched = false;
|
|
830
844
|
leavesAt(state.validPolicyTree, depth).forEach(function (node) {
|
|
831
|
-
if (node.expectedPolicySet
|
|
845
|
+
if (contains(node.expectedPolicySet, p.policyIdentifier)) {
|
|
832
846
|
addChild(state, node, p.policyIdentifier, p.qualifiersBytes, [p.policyIdentifier], checks);
|
|
833
847
|
matched = true;
|
|
834
848
|
}
|
|
@@ -849,7 +863,7 @@ function processPolicies(state, cert, i, checks) {
|
|
|
849
863
|
// certificate's own anyPolicy entry ("set the qualifier_set to AP-Q").
|
|
850
864
|
leavesAt(state.validPolicyTree, depth).forEach(function (node) {
|
|
851
865
|
node.expectedPolicySet.forEach(function (ep) {
|
|
852
|
-
var already = node.children
|
|
866
|
+
var already = guard.list.anyMatches(node.children, function (ch) { return ch.validPolicy === ep; });
|
|
853
867
|
if (!already) addChild(state, node, ep, anyPolicyQualifiers, [ep], checks);
|
|
854
868
|
});
|
|
855
869
|
});
|
|
@@ -929,8 +943,7 @@ function ekuPurposeFails(cert, requiredEku, checks) {
|
|
|
929
943
|
catch (e) { checks.push({ name: "extendedKeyUsage", ok: false, code: pathCode(e, "path/bad-extension-value") }); return true; }
|
|
930
944
|
if (!eku) return false; // absent EKU: unrestricted (4.2.1.12 restricts only when present)
|
|
931
945
|
var purposes = eku.value;
|
|
932
|
-
var ok = purposes
|
|
933
|
-
requiredEku.every(function (p) { return purposes.indexOf(p) !== -1; });
|
|
946
|
+
var ok = contains(purposes, OID.anyExtendedKeyUsage) || containsAll(purposes, requiredEku);
|
|
934
947
|
checks.push({ name: "extendedKeyUsage", ok: ok, code: ok ? undefined : "path/eku-not-permitted" });
|
|
935
948
|
return !ok;
|
|
936
949
|
}
|
|
@@ -950,7 +963,10 @@ function prepareNext(state, cert, i, checks) {
|
|
|
950
963
|
try { pm = decodeExt(cert, OID.policyMappings); }
|
|
951
964
|
catch (e) { checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); return { fatal: true, error: e }; }
|
|
952
965
|
if (pm) {
|
|
953
|
-
|
|
966
|
+
// The scan for a forbidden anyPolicy mapping is the rule itself (RFC 5280 6.1.4(a)), so it is a
|
|
967
|
+
// loop rather than a `some`: one answering false makes the forbidden mapping invisible and the
|
|
968
|
+
// certificate is accepted carrying it.
|
|
969
|
+
var badAny = mapsAnyPolicy(pm.value);
|
|
954
970
|
if (badAny) { checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); return { fatal: true, error: E("path/bad-policy", "policyMappings must not map to or from anyPolicy (RFC 5280 6.1.4(a))") }; }
|
|
955
971
|
if (state.validPolicyTree) applyPolicyMappings(state, pm.value, i);
|
|
956
972
|
}
|
|
@@ -1084,12 +1100,16 @@ var TARGET_UNPROCESSED_IF_CRITICAL = {};
|
|
|
1084
1100
|
TARGET_UNPROCESSED_IF_CRITICAL[OID.policyMappings] = true;
|
|
1085
1101
|
Object.freeze(TARGET_UNPROCESSED_IF_CRITICAL);
|
|
1086
1102
|
|
|
1103
|
+
// Membership is asked of the table's OWN properties. Freezing the table stops a write to it and
|
|
1104
|
+
// stops nothing about the prototype chain: a name planted on Object.prototype answers for every OID
|
|
1105
|
+
// the table does not carry, and read live this reported an unrecognized critical extension as
|
|
1106
|
+
// processed. On a CA certificate only the first table is consulted, so the chain was accepted.
|
|
1087
1107
|
function unrecognizedCriticalExtension(cert, isTarget) {
|
|
1088
1108
|
for (var i = 0; i < cert.extensions.length; i++) {
|
|
1089
1109
|
var ext = cert.extensions[i];
|
|
1090
1110
|
if (!ext.critical) continue;
|
|
1091
|
-
if (!PROCESSED_EXTENSIONS
|
|
1092
|
-
if (isTarget && TARGET_UNPROCESSED_IF_CRITICAL
|
|
1111
|
+
if (!guard.intrinsic.hasOwn(PROCESSED_EXTENSIONS, ext.oid)) return ext.oid;
|
|
1112
|
+
if (isTarget && guard.intrinsic.hasOwn(TARGET_UNPROCESSED_IF_CRITICAL, ext.oid)) return ext.oid;
|
|
1093
1113
|
}
|
|
1094
1114
|
return null;
|
|
1095
1115
|
}
|
|
@@ -1222,7 +1242,7 @@ async function validate(path, opts) {
|
|
|
1222
1242
|
// as a SUBSTRING match -- validated to an array of strings instead.
|
|
1223
1243
|
if (opts.userInitialPolicySet !== undefined) {
|
|
1224
1244
|
var uipsOk = Array.isArray(opts.userInitialPolicySet) && opts.userInitialPolicySet.length > 0 &&
|
|
1225
|
-
opts.userInitialPolicySet
|
|
1245
|
+
guard.list.allMatch(opts.userInitialPolicySet, function (p) { return typeof p === "string" && p.length > 0; });
|
|
1226
1246
|
if (!uipsOk) throw E("path/bad-input", "validate: opts.userInitialPolicySet must be a non-empty array of policy OID strings");
|
|
1227
1247
|
// Each entry is compared (indexOf) against canonical decoder output, so a
|
|
1228
1248
|
// non-canonical key would silently never match -- fail the typo closed here.
|
|
@@ -1427,7 +1447,7 @@ async function validate(path, opts) {
|
|
|
1427
1447
|
var lpm;
|
|
1428
1448
|
try { lpm = decodeExt(cert, OID.policyMappings); }
|
|
1429
1449
|
catch (_e) { lpm = null; checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); failed = true; }
|
|
1430
|
-
if (lpm && lpm.value
|
|
1450
|
+
if (lpm && mapsAnyPolicy(lpm.value)) {
|
|
1431
1451
|
checks.push({ name: "policyMappings", ok: false, code: "path/bad-policy" }); failed = true;
|
|
1432
1452
|
}
|
|
1433
1453
|
// 4.2.1.10 / 4.2.1.14: nameConstraints and inhibitAnyPolicy MUST be
|
|
@@ -1470,7 +1490,9 @@ async function validate(path, opts) {
|
|
|
1470
1490
|
}
|
|
1471
1491
|
if (checkPurpose && ta.purposes) {
|
|
1472
1492
|
anchorPurposeApplied = true;
|
|
1473
|
-
|
|
1493
|
+
// The map is the operator's RESTRICTION, so it is asked for its own entry: an inherited
|
|
1494
|
+
// `true` would grant the purpose on every anchor whose map does not name it.
|
|
1495
|
+
if (!guard.intrinsic.hasOwn(ta.purposes, checkPurpose) || ta.purposes[checkPurpose] !== true) {
|
|
1474
1496
|
checks.push({ name: "purposeTrust", ok: false, code: "path/purpose-not-trusted" }); failed = true;
|
|
1475
1497
|
}
|
|
1476
1498
|
}
|
|
@@ -1502,7 +1524,7 @@ async function validate(path, opts) {
|
|
|
1502
1524
|
var policyOk = state.explicitPolicy > 0 || ucps.length > 0;
|
|
1503
1525
|
if (!policyOk) {
|
|
1504
1526
|
var last = state.results[state.results.length - 1];
|
|
1505
|
-
if (!last.checks
|
|
1527
|
+
if (!guard.list.anyMatches(last.checks, function (c) { return c.code === "path/policy-required"; })) {
|
|
1506
1528
|
last.checks.push({ name: "policy", ok: false, code: "path/policy-required" });
|
|
1507
1529
|
}
|
|
1508
1530
|
failed = true;
|
|
@@ -1539,7 +1561,7 @@ async function validate(path, opts) {
|
|
|
1539
1561
|
function userConstrainedPolicies(state, n) {
|
|
1540
1562
|
if (!state.validPolicyTree) return [];
|
|
1541
1563
|
var uips = state.userInitialPolicySet;
|
|
1542
|
-
var anyUser = uips
|
|
1564
|
+
var anyUser = contains(uips, OID.anyPolicy);
|
|
1543
1565
|
var leaves = leavesAt(state.validPolicyTree, n);
|
|
1544
1566
|
var explicit = {}, hasAnyLeaf = false;
|
|
1545
1567
|
leaves.forEach(function (node) {
|
|
@@ -1547,7 +1569,7 @@ function userConstrainedPolicies(state, n) {
|
|
|
1547
1569
|
else explicit[node.validPolicy] = true;
|
|
1548
1570
|
});
|
|
1549
1571
|
var set = {};
|
|
1550
|
-
Object.keys(explicit).forEach(function (p) { if (anyUser || uips
|
|
1572
|
+
Object.keys(explicit).forEach(function (p) { if (anyUser || contains(uips, p)) set[p] = true; });
|
|
1551
1573
|
// 6.1.5(g) step 3: a depth-n anyPolicy node under a restrictive user set
|
|
1552
1574
|
// expands to each user policy (the intersection of anyPolicy with the user
|
|
1553
1575
|
// set is the user set itself).
|
|
@@ -2193,7 +2215,7 @@ function crlChecker(crls, opts) {
|
|
|
2193
2215
|
// nothing can never erase a revocation the checker would otherwise report.
|
|
2194
2216
|
for (var dj = 0; dj < classified.deltas.length; dj++) {
|
|
2195
2217
|
var dRec = classified.deltas[dj];
|
|
2196
|
-
if (consumedDeltas
|
|
2218
|
+
if (contains(consumedDeltas, dRec)) continue;
|
|
2197
2219
|
// A delta that was MERGEABLE with some complete CRL but lost the sec. 5.2.4 @3580 selection
|
|
2198
2220
|
// does not speak for its scope at all -- the selected delta does, and it was evaluated
|
|
2199
2221
|
// against the base. Letting a superseded delta contribute its revocation while ignoring its
|
|
@@ -2365,7 +2387,7 @@ function ocspChecker(responses) {
|
|
|
2365
2387
|
// certificate's own subject encoding -- sec. 7.1-equal but not byte-identical).
|
|
2366
2388
|
var issuerNameCandidates = [cert.issuer.bytes];
|
|
2367
2389
|
function addNameCandidate(nm) {
|
|
2368
|
-
if (nm && nm.bytes && !
|
|
2390
|
+
if (nm && nm.bytes && !guard.list.anyMatches(issuerNameCandidates, function (e) { return e.equals(nm.bytes); })) issuerNameCandidates.push(nm.bytes);
|
|
2369
2391
|
}
|
|
2370
2392
|
if (issuer.issuerCert) addNameCandidate(issuer.issuerCert.subject);
|
|
2371
2393
|
addNameCandidate(issuer.workingIssuerName);
|
|
@@ -2525,7 +2547,7 @@ function _verifyOcspParsed(parsedResponse, cert, issuerCert, time, opts) {
|
|
|
2525
2547
|
if (!sig.ok) return unbound("the target certificate's signature does not verify under the supplied issuer certificate's key");
|
|
2526
2548
|
var issuerCtx = { workingPublicKey: issuerCert.subjectPublicKeyInfo.bytes, workingIssuerName: issuerCert.subject, issuerCert: issuerCert };
|
|
2527
2549
|
var issuerNameCandidates = [cert.issuer.bytes];
|
|
2528
|
-
function add(nm) { if (nm && nm.bytes && !
|
|
2550
|
+
function add(nm) { if (nm && nm.bytes && !guard.list.anyMatches(issuerNameCandidates, function (e) { return e.equals(nm.bytes); })) issuerNameCandidates.push(nm.bytes); }
|
|
2529
2551
|
add(issuerCert.subject);
|
|
2530
2552
|
var issuerKeyBits;
|
|
2531
2553
|
try { issuerKeyBits = ocspVerify.ocspKeyValue(issuerCert.subjectPublicKeyInfo.bytes); }
|
|
@@ -2604,14 +2626,15 @@ function coerceCert(input, label) {
|
|
|
2604
2626
|
// inert without one. An EMPTY map states no constraint and is not a reason to refuse.
|
|
2605
2627
|
function _hasPurposeScopedMetadata(ta) {
|
|
2606
2628
|
if (!ta || typeof ta !== "object") return false;
|
|
2607
|
-
return ["distrustAfter", "purposes"]
|
|
2629
|
+
return guard.list.anyMatches(["distrustAfter", "purposes"], function (k) {
|
|
2608
2630
|
var m = ta[k];
|
|
2609
2631
|
return !!m && typeof m === "object" && Object.keys(m).length > 0;
|
|
2610
2632
|
});
|
|
2611
2633
|
}
|
|
2612
2634
|
|
|
2613
2635
|
function assertAnchorConstraints(ta, checkPurpose) {
|
|
2614
|
-
var d = (checkPurpose && ta && ta.distrustAfter
|
|
2636
|
+
var d = (checkPurpose && ta && ta.distrustAfter && guard.intrinsic.hasOwn(ta.distrustAfter, checkPurpose))
|
|
2637
|
+
? ta.distrustAfter[checkPurpose] : null;
|
|
2615
2638
|
if (d == null) return null;
|
|
2616
2639
|
return guard.time.assertValid(d, E, "path/bad-input", "trustAnchor.distrustAfter." + checkPurpose);
|
|
2617
2640
|
}
|
|
@@ -2728,9 +2751,12 @@ function _pushCandidates(frame, scored, stack, counter) {
|
|
|
2728
2751
|
counter.tick(); // breadth / total-work cap -> throws path/build-limit
|
|
2729
2752
|
n += 1;
|
|
2730
2753
|
var cand = scored[ci].cand, candKey = identityKey(cand);
|
|
2731
|
-
|
|
2754
|
+
// Loop detection and the dedupe sets below are resource bounds, so they are asked through the
|
|
2755
|
+
// captured methods: a replaced `Set.prototype.has` answering false never prunes and the build
|
|
2756
|
+
// walks a cycle until a cap stops it.
|
|
2757
|
+
if (guard.intrinsic.setHas(frame.keys, candKey)) continue;
|
|
2732
2758
|
var childKeys = new Set(frame.keys);
|
|
2733
|
-
|
|
2759
|
+
guard.intrinsic.setAdd(childKeys, candKey);
|
|
2734
2760
|
stack.push({ chain: [cand].concat(frame.chain), hop: frame.hop + 1, keys: childKeys });
|
|
2735
2761
|
}
|
|
2736
2762
|
return n;
|
|
@@ -2821,16 +2847,16 @@ async function _fetchAiaIssuers(current, aia) {
|
|
|
2821
2847
|
// IP literals only (already validated above), never a hostname whose resolved address we cannot verify.
|
|
2822
2848
|
if (net.isIP(u.hostname.replace(/^\[(.*)\]$/, "$1")) === 0 && !aia.transportGuardsAddresses) continue;
|
|
2823
2849
|
u.hash = ""; // the fragment is never sent on the wire -> not part of the request/dedupe identity
|
|
2824
|
-
if (aia.fetchedUrls
|
|
2850
|
+
if (guard.intrinsic.setHas(aia.fetchedUrls, u.href) || guard.intrinsic.setHas(seenThisCert, u.href)) continue; // dedupe (build-wide OR same-cert) on the normalized URL
|
|
2825
2851
|
if (seenThisCert.size >= aia.maxPerCert) break; // per-cert DISTINCT-url cap, checked BEFORE appending so maxAiaPerCert:0 collects nothing (no fetch at all)
|
|
2826
|
-
|
|
2852
|
+
guard.intrinsic.setAdd(seenThisCert, u.href);
|
|
2827
2853
|
uris.push(u.href);
|
|
2828
2854
|
}
|
|
2829
2855
|
var out = [];
|
|
2830
2856
|
for (var k = 0; k < uris.length; k++) {
|
|
2831
|
-
if (aia.fetchedUrls
|
|
2857
|
+
if (guard.intrinsic.setHas(aia.fetchedUrls, uris[k])) continue; // a same-cert normalization-equal duplicate
|
|
2832
2858
|
if (aia.fetches >= aia.maxFetches) break; // total budget reached -> STOP fetching (a SILENT cap, never a throw that denies a buildable path)
|
|
2833
|
-
aia.fetchedUrls
|
|
2859
|
+
guard.intrinsic.setAdd(aia.fetchedUrls, uris[k]); // mark BEFORE the fetch -> fetched at most once, even on failure
|
|
2834
2860
|
aia.fetches += 1;
|
|
2835
2861
|
var certs;
|
|
2836
2862
|
try { certs = await _aiaFetchOne(uris[k], aia); }
|
|
@@ -2958,7 +2984,7 @@ async function build(leaf, opts) {
|
|
|
2958
2984
|
// repeating CMS returning the same issuer) is appended AT MOST ONCE -- it otherwise inflates the pool, charging
|
|
2959
2985
|
// the candidate budget and the ceiling for each copy before the issuer is ever evaluated.
|
|
2960
2986
|
var poolDerKeys = new Set();
|
|
2961
|
-
for (var pdk = 0; pdk < pool.length; pdk++)
|
|
2987
|
+
for (var pdk = 0; pdk < pool.length; pdk++) guard.intrinsic.setAdd(poolDerKeys, certDerKey(pool[pdk]));
|
|
2962
2988
|
|
|
2963
2989
|
if (!Array.isArray(opts.trustAnchors) || opts.trustAnchors.length === 0) throw E("path/bad-input", "build: opts.trustAnchors must be a non-empty array of anchor tuples or root certificates");
|
|
2964
2990
|
var anchors = opts.trustAnchors.map(toAnchor);
|
|
@@ -3120,8 +3146,8 @@ async function build(leaf, opts) {
|
|
|
3120
3146
|
var fetched = await _fetchAiaIssuers(fbCur, aiaCtx); // append newly-fetched issuer(s) to the SHARED pool
|
|
3121
3147
|
for (var fj = 0; fj < fetched.length; fj++) {
|
|
3122
3148
|
var fdk = certDerKey(fetched[fj]);
|
|
3123
|
-
if (
|
|
3124
|
-
|
|
3149
|
+
if (guard.intrinsic.setHas(poolDerKeys, fdk)) continue; // a byte-duplicate (mirror URL / repeating CMS) -> add once, never re-charge the budget/ceiling
|
|
3150
|
+
guard.intrinsic.setAdd(poolDerKeys, fdk);
|
|
3125
3151
|
if (pool.length < poolCeiling) pool.push(fetched[fj]);
|
|
3126
3152
|
}
|
|
3127
3153
|
}
|
package/lib/sign-scheme.js
CHANGED
|
@@ -52,6 +52,20 @@ var MLDSA_SUITABLE_DIGEST = {
|
|
|
52
52
|
"ML-DSA-65": { sha384: 1, sha512: 1, shake256: 1 },
|
|
53
53
|
"ML-DSA-87": { sha512: 1, shake256: 1 },
|
|
54
54
|
};
|
|
55
|
+
// mldsaDigestSuitable(mlName, md) -- is `md` listed as meeting `mlName`'s security strength?
|
|
56
|
+
// Both levels are asked for their OWN entries. The gate rejects on a falsy lookup, so a digest name
|
|
57
|
+
// planted on Object.prototype answers for every name the row omits and a below-strength digest
|
|
58
|
+
// passes the RFC 9882 sec. 3.3 check. This is the single home for that question: the signer and the
|
|
59
|
+
// verifier both ask it, and a copy at either would let the two drift.
|
|
60
|
+
// @enforced-by behavioral -- an own-property table read has no rename-proof shape distinct from a
|
|
61
|
+
// legitimate one; the RED vectors (a below-strength digest still refused with its name planted on
|
|
62
|
+
// Object.prototype, on both the sign and verify paths) are the guard.
|
|
63
|
+
function mldsaDigestSuitable(mlName, md) {
|
|
64
|
+
if (!guard.intrinsic.hasOwn(MLDSA_SUITABLE_DIGEST, mlName)) return false;
|
|
65
|
+
var row = MLDSA_SUITABLE_DIGEST[mlName];
|
|
66
|
+
return !!row && guard.intrinsic.hasOwn(row, md) && !!row[md];
|
|
67
|
+
}
|
|
68
|
+
|
|
55
69
|
var SLHDSA_BY_OID = {};
|
|
56
70
|
[["sha2-128s", "sha256"], ["sha2-128f", "sha256"], ["sha2-192s", "sha512"], ["sha2-192f", "sha512"],
|
|
57
71
|
["sha2-256s", "sha512"], ["sha2-256f", "sha512"], ["shake-128s", "shake128"], ["shake-128f", "shake128"],
|
|
@@ -173,7 +187,7 @@ function resolveSignScheme(cert, so, noSignedAttrs, E) {
|
|
|
173
187
|
} else {
|
|
174
188
|
md = so.digestAlgorithm || "sha512";
|
|
175
189
|
if (!NODE_DIGEST[md]) throw E("unsupported-algorithm", "unsupported ML-DSA message digest " + JSON.stringify(md));
|
|
176
|
-
if (!
|
|
190
|
+
if (!mldsaDigestSuitable(mlName, md)) throw E("unsupported-algorithm", "the " + md + " message digest is below the security strength of " + mlName + " (RFC 9882 sec. 3.3)");
|
|
177
191
|
}
|
|
178
192
|
return { digest: md, digestAlgId: _algId(md, "absent"), sigAlgId: _algId(oid.name(keyOid), "absent"), imp: { name: mlName }, sign: { name: mlName }, ecdsaDer: false };
|
|
179
193
|
}
|
|
@@ -266,5 +280,6 @@ module.exports = {
|
|
|
266
280
|
pssSpkiPinnedHash: pssSpkiPinnedHash,
|
|
267
281
|
signOverTbs: signOverTbs,
|
|
268
282
|
MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST,
|
|
283
|
+
mldsaDigestSuitable: mldsaDigestSuitable,
|
|
269
284
|
SLHDSA_BY_OID: SLHDSA_BY_OID,
|
|
270
285
|
};
|
package/lib/tsp-sign.js
CHANGED
|
@@ -336,7 +336,7 @@ function request(messageImprint, opts) {
|
|
|
336
336
|
if (opts.certReq != null && typeof opts.certReq !== "boolean") throw _err("tsp/bad-input", "certReq must be a boolean");
|
|
337
337
|
if (opts.certReq === true) fields.push(b.boolean(true));
|
|
338
338
|
if (opts.extensions != null) {
|
|
339
|
-
if (!Array.isArray(opts.extensions) || !opts.extensions
|
|
339
|
+
if (!Array.isArray(opts.extensions) || !guard.list.allMatch(opts.extensions, function (e) { return Buffer.isBuffer(e) || e instanceof Uint8Array; })) throw _err("tsp/bad-input", "extensions must be an array of encoded Extension DER buffers");
|
|
340
340
|
// Each pre-encoded Extension is validated as one, and its extnID must not repeat -- the same
|
|
341
341
|
// gate every other request builder applies. Spliced in unchecked, a caller relaying a blob it
|
|
342
342
|
// did not author put fully chosen bytes inside [0] and this encoder emitted DER its own
|
|
@@ -507,7 +507,7 @@ var _BIND_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
|
|
|
507
507
|
// tsp/* verdict code; a config-shape error throws (the caller's contract).
|
|
508
508
|
function _imprintMatches(mi, data) {
|
|
509
509
|
var name = mi.hashAlgorithm && mi.hashAlgorithm.name;
|
|
510
|
-
var nodeAlg = name && _VERIFY_DIGEST[name];
|
|
510
|
+
var nodeAlg = (name && guard.intrinsic.hasOwn(_VERIFY_DIGEST, name)) ? _VERIFY_DIGEST[name] : null;
|
|
511
511
|
if (!nodeAlg) return "tsp/unsupported-algorithm"; // cannot recompute -> never assume a match
|
|
512
512
|
var wantLen = _VERIFY_DIGEST_LEN[name];
|
|
513
513
|
// The token's messageImprint MUST be a full-length digest for its algorithm. A short/empty
|
|
@@ -732,6 +732,12 @@ function _buildTsaChains(leaf, pool) {
|
|
|
732
732
|
// Every option pki.tsp.verify reads. Adding one here is the only way to make it accepted.
|
|
733
733
|
var _VERIFY_OPTS = { certs: 1, trustAnchor: 1, nonce: 1, reqPolicy: 1, revocationChecker: 1 };
|
|
734
734
|
|
|
735
|
+
// Does any extension carry the critical flag? The answer IS the unknown-critical-extension gate, so
|
|
736
|
+
// it is decided by comparison instead of by a `some`. See guard-list.
|
|
737
|
+
function _anyCritical(extensions) {
|
|
738
|
+
return guard.list.anyMatches(extensions, function (e) { return !!e && !!e.critical; });
|
|
739
|
+
}
|
|
740
|
+
|
|
735
741
|
async function verify(token, data, opts) {
|
|
736
742
|
opts = opts || {};
|
|
737
743
|
if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
|
|
@@ -742,7 +748,7 @@ async function verify(token, data, opts) {
|
|
|
742
748
|
// nothing to notice it by. Naming the difference at the boundary is the only place it is cheap.
|
|
743
749
|
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "tsp/bad-input",
|
|
744
750
|
"pki.tsp.verify has an unknown option (note the anchor option here is `trustAnchor`, singular, an anchor tuple -- not the `trustAnchors` certificate list pki.cms.verify takes) ");
|
|
745
|
-
if (opts.certs != null && (!Array.isArray(opts.certs) || !opts.certs
|
|
751
|
+
if (opts.certs != null && (!Array.isArray(opts.certs) || !guard.list.allMatch(opts.certs, function (c) { return Buffer.isBuffer(c) || c instanceof Uint8Array; }))) {
|
|
746
752
|
throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
|
|
747
753
|
}
|
|
748
754
|
var tokenDer = _tokenDer(token); // DER Buffer / PEM -> DER; a non-token input throws tsp/bad-input
|
|
@@ -789,7 +795,9 @@ async function verify(token, data, opts) {
|
|
|
789
795
|
// A TSTInfo carrying a CRITICAL extension the verifier cannot process is unusable: RFC 3161 sec.
|
|
790
796
|
// 2.4.2 defers extension handling to RFC 5280 sec. 4.2, and defines no TSTInfo extension, so any
|
|
791
797
|
// critical one is unrecognized (the unknown-critical-extension gate the cert path also applies).
|
|
792
|
-
|
|
798
|
+
// The scan for a critical extension IS the gate, so it is a loop rather than a `some`: one
|
|
799
|
+
// answering false makes the unrecognized critical extension invisible and the token is accepted.
|
|
800
|
+
if (tst.extensions && _anyCritical(tst.extensions)) {
|
|
793
801
|
return fail("tsp/unknown-critical-extension");
|
|
794
802
|
}
|
|
795
803
|
// M13 -- the ESSCertID(V2) binding: the verifying cert MUST be the one the signer identified.
|
package/lib/validator-attcert.js
CHANGED
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
|
|
30
30
|
var oid = require("./oid");
|
|
31
31
|
var asn1 = require("./asn1-der");
|
|
32
|
+
var guard = require("./guard-all");
|
|
33
|
+
|
|
34
|
+
// Does this extended-key-usage list assert the given purpose? The answer decides whether a
|
|
35
|
+
// certificate qualifies as the kind the rule names, so it goes through the membership guard
|
|
36
|
+
// instead of a prototype method. See guard-list.
|
|
37
|
+
var _asserts = guard.list.contains;
|
|
32
38
|
|
|
33
39
|
// requireV3(cert, E, code) -- the leaf attestation certificate MUST be X.509 v3.
|
|
34
40
|
// @enforced-by behavioral -- a version-field check has no rename-proof code shape; the RED
|
|
@@ -73,7 +79,10 @@ function aikCert(cert, exts, E, code) {
|
|
|
73
79
|
assertNotCa(cert, exts, E, code);
|
|
74
80
|
if ((cert.subject.rdns || []).length !== 0) throw new E(code, "the tpm AIK certificate subject MUST be empty (WebAuthn 8.3.1)");
|
|
75
81
|
var eku = exts.decode(cert, "extKeyUsage");
|
|
76
|
-
|
|
82
|
+
// The purpose test IS the rule that makes this an AIK certificate, so it is decided by comparison
|
|
83
|
+
// rather than by a prototype method: a replaced `indexOf` reports the purpose present on any
|
|
84
|
+
// certificate and an ordinary leaf is accepted as a TPM attestation key.
|
|
85
|
+
if (!eku || !Array.isArray(eku.value) || !_asserts(eku.value, oid.byName("tcgKpAikCertificate"))) {
|
|
77
86
|
throw new E(code, "the tpm AIK certificate lacks the tcg-kp-AIKCertificate extended key purpose (WebAuthn 8.3.1)");
|
|
78
87
|
}
|
|
79
88
|
var san = exts.decode(cert, "subjectAltName");
|
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:e979b712-f27c-4c30-ba8e-4e9ab88715fd",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-20T23:29:06.438Z",
|
|
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.5.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.5.20",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.5.
|
|
25
|
+
"version": "0.5.20",
|
|
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.5.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.5.20",
|
|
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.5.
|
|
57
|
+
"ref": "@blamejs/pki@0.5.20",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|