@blamejs/pki 0.5.5 → 0.5.6

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 CHANGED
@@ -4,6 +4,26 @@ 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.6 — 2026-08-16
8
+
9
+ A CMS SignedData is verified over the bytes it was parsed from, an omitted PKCS#12 password is refused rather than encoded as the empty one, and a verb documented as returning a Promise rejects instead of throwing past your .catch.
10
+
11
+ ### Changed
12
+
13
+ - A verb documented as returning a Promise now rejects; it never throws synchronously. An operator reads -> Promise<...> in the reference and writes the documented shape, pki.cms.sign(content, signers).catch(handleIt) -- and a validation that ran before the promise was created threw straight past that .catch, so a misspelled option or a malformed input became an uncaught exception in code that already handles errors. Nothing in the shape of the call said which verbs did it. Eleven did: pki.cms.verify, pki.cms.sign, pki.cms.countersign, pki.ocsp.sign, pki.tsp.sign, and six pki.acme verbs. Nine more on the client pki.acme.client(...) returns did the same, including getAuthorization, which an ordinary ACME loop reaches with a URL that came from the CA rather than from the caller. The checks still run synchronously, so a caller's mutable options are read before any turn passes; only the way a fault leaves has changed. If you wrapped one of these verbs in a synchronous try/catch without awaiting it -- which the documented signature never supported, but which worked by accident on exactly these verbs -- that catch no longer fires and the rejection surfaces as an unhandled one instead. Await the call, or attach .catch to it.
14
+ - pki.tsp.verify reports whether revocation was established, not only whether the timestamp authority chained. Revocation runs only when a revocationChecker is supplied, so a token whose TSA was never checked against a CRL or an OCSP responder read identically to one established un-revoked -- and that is the default. The verdict now carries revocationChecked and anchorConstraints from the path validation, in pki.path.validate's own vocabulary, so a timestamp archived to be re-read years later can still answer what was actually checked. The trustAnchor documentation no longer lists revocation unconditionally.
15
+
16
+ ### Fixed
17
+
18
+ - pki.cms.verify computes its verdict over the bytes the parser read. A SignedData's meaning is a signature over a byte range, but a parsed one presents that range, the signature, the algorithms and the certificates as separate properties, and the verb read them as though the parser had produced them together. The forgery that follows is concrete: take a message a trusted signer really signed, keep its SignerInfo and signature untouched, set signedAttrsBytes to null, and present the signature's own preimage -- the signed attributes re-tagged as the SET OF the signature covers -- as the encapsulated content under any content type you like. With no signed attributes the signature is checked against the content directly, which is exactly those bytes, and neither the content-type nor the message-digest check runs, so the message verifies as valid content the signer never signed. pki.schema.cms.parse now records what it read, pki.cms.verify re-derives from that record, and a SignedData a caller assembled rather than parsed is refused with a typed error instead of dereferenced into a raw TypeError. Passing DER, PEM, or the parser's own unmodified result is unaffected.
19
+ - pki.pkcs12.build refuses a store with no password instead of building one under the empty password. An omitted password and the empty password are different credentials, and the difference was invisible at the call site: pki.pkcs12.build(spec) with no options at all, or with the option name misspelled, returned a well-formed store whose shrouded private key opened under "" -- a key protected by nothing, with no error anywhere. The empty password is still available; it has to be asked for, as "".
20
+ - pki.pkcs12.build validates opts.integrity.mode against the value it accepts. Compared against a single literal, any other spelling read as "not public-key" and silently selected password integrity, dropping the signer with it: a caller who wrote mode: 'publicKey' asked for a CMS signature over the AuthenticatedSafe and got a password MAC. Combined with the previous item, a caller who misspelled both options got a store MACed under the empty password while believing it was signature-protected. An unrecognized key on opts.integrity is refused too.
21
+ - pki.pkcs12.open bounds the key-derivation work of a modern store, not only a legacy one. The aggregate budget existed because a per-bag iteration cap resets on every bag, so a store that repeats a costly bag up to the parser's element limit multiplies the cap by that limit -- but it was charged only on the RFC 7292 Appendix C path, leaving the RFC 8018 PBES2 path -- the one OpenSSL and NSS emit, and the one this toolkit itself builds -- free to do exactly that. Measured before the fix: ten bags at a million iterations each ran ten million aggregate rounds of blocking PBKDF2 with no refusal. Both schemes now charge one budget, and it is charged before the derivation runs rather than after.
22
+ - A PKCS#12 password this toolkit encoded is cleared once the derivation has consumed it, on the PBES2 and PBMAC1 paths as well as the classic ones. The Appendix B.1 copy taken from a password argument was wiped while the UTF-8 copy taken from the same argument in the same call was not, leaving a plaintext password in the heap after every modern bag encryption, bag decryption and MAC. A Buffer you supply is never written to: it is yours, and clearing it would destroy your credential rather than protect it.
23
+ - pki.tsp.request validates a pre-encoded extension instead of splicing it in. Elements of opts.extensions were checked only for being byte-like and then concatenated straight into the request, so a caller relaying an extension blob it did not author put fully chosen bytes into the structure -- and the encoder emitted requests its own pki.tsp.parseRequest refuses: an undecodable value, a repeated extension identifier, an explicit critical=FALSE that DER requires be omitted. Each element now goes through the same pre-encoded-Extension gate every other request builder in the toolkit applies, including the duplicate-identifier check.
24
+ - pki.tsp.sign, pki.tsp.request and pki.tsp.verify report a bad serial number or nonce as a typed tsp/bad-input. A value that is not an integer reached BigInt() directly and raised a raw SyntaxError or RangeError out of a public verb -- an untyped fault a caller handling tsp/* codes cannot catch.
25
+ - pki.sigstore.verifyBundle refuses an option it does not recognize. The identity policy one level down already did, for the reason that applies just as much at the top: the signer pin goes by other names in other Sigstore tooling, and a spelling this verb swallowed checked nothing under a name the operator believed pinned the signer. At the top level the same slip also loses the SLSA predicate pin, and unlike the identity fields there is no report field to reveal it -- the verdict said verified: true with the signer and the predicate entirely unpinned.
26
+
7
27
  ## v0.5.5 — 2026-08-15
8
28
 
9
29
  A verdict is computed over the bytes the parser read, an identity is derived from the bytes that carry it, and the guards match no patterns.
package/MIGRATING.md CHANGED
@@ -14,6 +14,47 @@ The toolkit has no `deprecate()`-marked surface awaiting removal.
14
14
 
15
15
  Listed newest-first.
16
16
 
17
+ ### v0.5.6 — `try { pki.<verb>(...) } catch`
18
+
19
+ A verb documented `-> Promise` rejects on a bad input instead of throwing before the promise exists.
20
+
21
+ If you awaited the call, or attached `.catch`, nothing changes and there is nothing to do.
22
+
23
+ What changes is the undocumented shape: a synchronous `try`/`catch` that never consumed the
24
+ returned promise.
25
+
26
+ ```js
27
+ try {
28
+ pki.cms.verify(bytes); // no await, no .catch
29
+ } catch (e) { /* used to fire on a malformed input */ }
30
+ ```
31
+
32
+ That `catch` no longer runs, and the rejection surfaces as an unhandled one. It worked by
33
+ accident on exactly the verbs where a check happened to run before the promise existed --
34
+ `pki.cms.verify`, `pki.cms.sign`, `pki.cms.countersign`, `pki.ocsp.sign`, `pki.tsp.sign`, six
35
+ `pki.acme` verbs, and nine verbs on the client `pki.acme.client(...)` returns. Which verbs
36
+ those were was not visible from the call, which is why they are now uniform.
37
+
38
+ ```js
39
+ await pki.cms.verify(bytes); // or pki.cms.verify(bytes).catch(handleIt)
40
+ ```
41
+
42
+ ### v0.5.6 — `pki.pkcs12.build(spec)`
43
+
44
+ An omitted password is refused rather than encoded as the empty one.
45
+
46
+ A store whose password option was missing or misspelled no longer builds silently under `""`.
47
+
48
+ ```js
49
+ await pki.pkcs12.build(spec); // now pkcs12/bad-input
50
+ await pki.pkcs12.build(spec, { password: "" }); // the empty password, asked for
51
+ ```
52
+
53
+ If you were relying on the default, the second form restores the previous output byte for
54
+ byte. `opts.integrity.mode` is validated the same way: a spelling other than `"public-key"`
55
+ is now `pkcs12/bad-integrity-mode` instead of silently selecting password integrity and
56
+ dropping the signer.
57
+
17
58
  ### v0.5.5 — `require("@blamejs/pki/lib/...")`
18
59
 
19
60
  The package resolves one entry point; a path into the package no longer resolves.
package/lib/acme.js CHANGED
@@ -684,6 +684,12 @@ function _payloadBuf(obj) {
684
684
  if (obj === undefined) return Buffer.alloc(0); // POST-as-GET
685
685
  return Buffer.from(JSON.stringify(obj), "utf8");
686
686
  }
687
+ // A verb documented `-> Promise` refuses by REJECTING, never by throwing (guard-async).
688
+ //
689
+ // The validation stays SYNCHRONOUS: these verbs read a caller's options object -- the key, the
690
+ // nonce, the identifiers -- and resolving those before any turn passes is what stops a value being
691
+ // swapped between the check and the use. Only the exit changes.
692
+ var _promised = guard.async.deferred;
687
693
  function _signOuter(o, payloadObj) {
688
694
  if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
689
695
  if (!o.key) throw E("acme/bad-input", "a signing key (opts.key) is required");
@@ -707,10 +713,12 @@ function _signOuter(o, payloadObj) {
707
713
  * await pki.acme.postAsGet({ key, alg: "ES256", nonce, url: orderUrl, kid });
708
714
  */
709
715
  function postAsGet(o) {
710
- if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
711
- // An authenticated read is ALWAYS kid-signed; copy only the kid-mode fields so a
712
- // leftover jwk (e.g. reused from a newAccount options object) cannot embed a key.
713
- return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, undefined);
716
+ return _promised(function () {
717
+ if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
718
+ // An authenticated read is ALWAYS kid-signed; copy only the kid-mode fields so a
719
+ // leftover jwk (e.g. reused from a newAccount options object) cannot embed a key.
720
+ return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, undefined);
721
+ });
714
722
  }
715
723
 
716
724
  // A contact URL (RFC 8555 sec. 7.3): a `mailto:` addr-spec carries no header
@@ -750,6 +758,7 @@ function _assertContacts(contacts) {
750
758
  * await pki.acme.newAccount({ key, alg: "ES256", nonce, url, jwk, termsOfServiceAgreed: true });
751
759
  */
752
760
  function newAccount(o) {
761
+ return _promised(function () {
753
762
  if (!_isObject(o) || !_isObject(o.jwk)) throw E("acme/bad-input", "newAccount must embed the account public jwk (RFC 8555 sec. 7.3)");
754
763
  var payload = {};
755
764
  if (o.contact !== undefined) { _assertContacts(o.contact); payload.contact = o.contact; }
@@ -769,6 +778,7 @@ function newAccount(o) {
769
778
  payload.externalAccountBinding = o.externalAccountBinding;
770
779
  }
771
780
  return jose.sign({ protected: { alg: o.alg, nonce: o.nonce, url: o.url, jwk: o.jwk }, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
781
+ });
772
782
  }
773
783
 
774
784
  var _HMAC_HASH = { HS256: "SHA-256", HS384: "SHA-384", HS512: "SHA-512" };
@@ -855,6 +865,7 @@ function _validateOrderIdentifier(id) {
855
865
  * await pki.acme.newOrder({ key, alg: "ES256", nonce, url, kid, identifiers: [{ type: "dns", value: "example.org" }] });
856
866
  */
857
867
  function newOrder(o) {
868
+ return _promised(function () {
858
869
  if (!_isObject(o) || !Array.isArray(o.identifiers) || o.identifiers.length === 0) throw E("acme/bad-order", "newOrder requires a non-empty identifiers array (RFC 8555 sec. 7.4)");
859
870
  // Serialize the CANONICAL { type, value } each validator returns, never the caller's objects (which may carry
860
871
  // getter-backed / inherited fields JSON.stringify would drop, or extra enumerable fields it would send).
@@ -863,6 +874,7 @@ function newOrder(o) {
863
874
  if (o.notAfter !== undefined) { if (!_isRfc3339(o.notAfter)) throw E("acme/bad-order", "notAfter must be an RFC 3339 date-time"); payload.notAfter = o.notAfter; }
864
875
  if (o.replaces !== undefined) { if (!_isString(o.replaces)) throw E("acme/bad-order", "replaces must be an ARI certID string (RFC 9773 sec. 5)"); payload.replaces = o.replaces; }
865
876
  return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
877
+ });
866
878
  }
867
879
 
868
880
  /**
@@ -1035,10 +1047,12 @@ async function finalize(o) {
1035
1047
  * await pki.acme.challengeResponse({ key, alg: "ES256", nonce, url: challUrl, kid });
1036
1048
  */
1037
1049
  function challengeResponse(o) {
1038
- if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
1039
- var payload = o.payload !== undefined ? o.payload : {};
1040
- if (!_isObject(payload)) throw E("acme/bad-input", "a challenge response payload must be a JSON object (RFC 8555 sec. 7.5.1)");
1041
- return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
1050
+ return _promised(function () {
1051
+ if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
1052
+ var payload = o.payload !== undefined ? o.payload : {};
1053
+ if (!_isObject(payload)) throw E("acme/bad-input", "a challenge response payload must be a JSON object (RFC 8555 sec. 7.5.1)");
1054
+ return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, payload);
1055
+ });
1042
1056
  }
1043
1057
 
1044
1058
  /**
@@ -1061,8 +1075,10 @@ function challengeResponse(o) {
1061
1075
  * await pki.acme.deactivate({ key, alg: "ES256", nonce, url: authzUrl, kid });
1062
1076
  */
1063
1077
  function deactivate(o) {
1064
- if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
1065
- return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, { status: "deactivated" });
1078
+ return _promised(function () {
1079
+ if (!_isObject(o)) throw E("acme/bad-input", "a request options object is required");
1080
+ return _signOuter({ key: o.key, alg: o.alg, nonce: o.nonce, url: o.url, kid: o.kid }, { status: "deactivated" });
1081
+ });
1066
1082
  }
1067
1083
 
1068
1084
  /**
@@ -1091,6 +1107,7 @@ function deactivate(o) {
1091
1107
  * await pki.acme.revokeCert({ key, alg: "ES256", nonce, url, kid, certificate: certDer, reason: 1 });
1092
1108
  */
1093
1109
  function revokeCert(o) {
1110
+ return _promised(function () {
1094
1111
  if (!_isObject(o) || !Buffer.isBuffer(o.certificate)) throw E("acme/bad-input", "revokeCert requires a DER certificate Buffer (opts.certificate)");
1095
1112
  x509.parse(o.certificate); // structural validation of the target
1096
1113
  var hasKid = Object.prototype.hasOwnProperty.call(o, "kid");
@@ -1106,6 +1123,7 @@ function revokeCert(o) {
1106
1123
  var header = { alg: o.alg, nonce: o.nonce, url: o.url };
1107
1124
  if (hasKid) header.kid = o.kid; else header.jwk = o.jwk;
1108
1125
  return jose.sign({ protected: header, payload: _payloadBuf(payload), key: o.key, jwk: o.jwk, profile: "acme-outer" });
1126
+ });
1109
1127
  }
1110
1128
 
1111
1129
  /**
@@ -2015,12 +2033,18 @@ function client(directoryUrl, opts) {
2015
2033
  });
2016
2034
  });
2017
2035
  }
2018
- function _getOrder(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("order", _json(res)); }); }
2019
- function _getAuthorization(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("authorization", _json(res)); }); }
2020
- function _getChallenge(url) { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("challenge", _json(res)); }); }
2021
- function _respondToChallenge(url) { return _post(_clientUrl(url), challengeResponse, null, "kid").then(function (res) { return validate("challenge", _json(res)); }); }
2022
-
2023
- function _finalize(order, o) {
2036
+ // A bad URL leaves as a REJECTION, like every other verb on this client. It is not only a caller
2037
+ // typo that reaches here: the message layer deliberately accepts http as well as https, so an
2038
+ // order whose `authorizations` array carries an http URL validates, and the very next step --
2039
+ // getAuthorization(order.authorizations[0]) -- threw out of the middle of an async ACME loop
2040
+ // rather than rejecting into the handler already wrapped around it.
2041
+ function _getOrder(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("order", _json(res)); }); }); }
2042
+ function _getAuthorization(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("authorization", _json(res)); }); }); }
2043
+ function _getChallenge(url) { return _promised(function () { return _postAsGet(_clientUrl(url)).then(function (res) { return validate("challenge", _json(res)); }); }); }
2044
+ function _respondToChallenge(url) { return _promised(function () { return _post(_clientUrl(url), challengeResponse, null, "kid").then(function (res) { return validate("challenge", _json(res)); }); }); }
2045
+
2046
+ function _finalize(order, o) { return _promised(function () { return _finalizeBody(order, o); }); }
2047
+ function _finalizeBody(order, o) {
2024
2048
  o = o || {};
2025
2049
  if (!_isObject(order) || !_isString(order.finalize)) throw E("acme/bad-input", "finalize requires the order object with its finalize URL");
2026
2050
  if (!Array.isArray(order.identifiers)) throw E("acme/bad-input", "finalize requires the order's identifiers to enforce the RFC 8555 sec. 7.4 CSR-set match");
@@ -2155,10 +2179,11 @@ function client(directoryUrl, opts) {
2155
2179
  });
2156
2180
  }
2157
2181
 
2158
- function _deactivateAccount() { return _post(_requireKid(), deactivate, null, "kid").then(function (res) { return validate("account", _json(res)); }); }
2159
- function _deactivateAuthorization(url) { return _post(_clientUrl(url), deactivate, null, "kid").then(function (res) { return validate("authorization", _json(res)); }); }
2182
+ function _deactivateAccount() { return _promised(function () { return _post(_requireKid(), deactivate, null, "kid").then(function (res) { return validate("account", _json(res)); }); }); }
2183
+ function _deactivateAuthorization(url) { return _promised(function () { return _post(_clientUrl(url), deactivate, null, "kid").then(function (res) { return validate("authorization", _json(res)); }); }); }
2160
2184
 
2161
- function _keyChange(o) {
2185
+ function _keyChange(o) { return _promised(function () { return _keyChangeBody(o); }); }
2186
+ function _keyChangeBody(o) {
2162
2187
  o = o || {};
2163
2188
  if (!o.newKey || !_isObject(o.newJwk) || !_isString(o.newAlg)) throw E("acme/bad-input", "keyChange requires newKey, newJwk, and newAlg");
2164
2189
  var account = _requireKid();
@@ -2181,6 +2206,9 @@ function client(directoryUrl, opts) {
2181
2206
 
2182
2207
  // ARI RenewalInfo (RFC 9773 sec. 4.1) -- the SOLE UNAUTHENTICATED GET (no JWS, no nonce).
2183
2208
  function _renewalInfo(certDer, clockFn, retryAfterCapSeconds) {
2209
+ return _promised(function () { return _renewalInfoBody(certDer, clockFn, retryAfterCapSeconds); });
2210
+ }
2211
+ function _renewalInfoBody(certDer, clockFn, retryAfterCapSeconds) {
2184
2212
  if (!Buffer.isBuffer(certDer)) throw E("acme/bad-input", "renewalInfo requires a DER certificate Buffer");
2185
2213
  var _rawClk = typeof clockFn === "function" ? clockFn : clock; // renewalWindow may pass a per-call clock
2186
2214
  // The clock MUST return finite epoch ms: a NaN / Infinity would make the expiry comparison below silently
package/lib/cms-sign.js CHANGED
@@ -337,7 +337,13 @@ function _pemToDer(text) {
337
337
  }
338
338
 
339
339
  // pki.cms.sign -- documented by the @primitive block in cms-verify.js (the @module pki.cms home).
340
+ // Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
341
+ // synchronous because they read the caller's mutable content and signer list.
340
342
  function sign(content, signers, opts) {
343
+ return guard.async.deferred(function () { return _sign(content, signers, opts); });
344
+ }
345
+
346
+ function _sign(content, signers, opts) {
341
347
  opts = opts || {};
342
348
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.sign options must be an object");
343
349
  var contentBuf = _toBuf(content, "content");
@@ -533,7 +539,12 @@ function _targetPreimage(siNode, opts) {
533
539
  }
534
540
 
535
541
  // pki.cms.countersign -- documented by the @primitive block in cms-verify.js (the @module pki.cms home).
542
+ // Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async).
536
543
  function countersign(cmsInput, signers, opts) {
544
+ return guard.async.deferred(function () { return _countersign(cmsInput, signers, opts); });
545
+ }
546
+
547
+ function _countersign(cmsInput, signers, opts) {
537
548
  opts = opts || {};
538
549
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.countersign options must be an object");
539
550
  var list = Array.isArray(signers) ? signers : [signers];
package/lib/cms-verify.js CHANGED
@@ -538,7 +538,10 @@ function _surfaceUnsignedAttrs(si) {
538
538
  // Verify every countersignature attached to `si` (RFC 5652 sec. 11.4): each id-countersignature
539
539
  // value is a SignerInfo over `si`'s signature octets. Returns per-countersignature verdicts; a
540
540
  // countersignature's OWN countersignatures verify over ITS signature octets (recursive). A
541
- // present-but-invalid countersignature is surfaced ok:false -- never silently dropped. The recursion
541
+ // countersignature that FAILS TO VERIFY is surfaced ok:false -- never silently dropped, and never
542
+ // allowed to change the primary verdict. A countersignature value that is not a well-formed
543
+ // SignerInfo does not reach here at all: the decoder validates every id-countersignature value by
544
+ // content rather than on the attribute type, so such a message is refused whole. The recursion
542
545
  // terminates because it only walks the FINITE parsed structure: each nested countersignature value
543
546
  // is a sub-encoding of its parent, and the strict decoder already bounds total nesting by
544
547
  // C.LIMITS.DER_MAX_DEPTH at parse (CWE-834/770), so a hostile deep chain fails closed before verify.
@@ -650,7 +653,14 @@ function _snapshotIfBytes(input, label) {
650
653
  // capability cannot arrive with its option silently ignored at this boundary.
651
654
  var _VERIFY_OPTS = { certs: 1, content: 1, trustAnchors: 1, time: 1, requiredEku: 1, checkPurpose: 1 };
652
655
 
656
+ // Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async). The checks below stay
657
+ // synchronous -- they read a caller's mutable options and bytes, and resolving those before any turn
658
+ // passes is what stops a value being swapped between the check and the use.
653
659
  function verify(input, opts) {
660
+ return guard.async.deferred(function () { return _verify(input, opts); });
661
+ }
662
+
663
+ function _verify(input, opts) {
654
664
  opts = opts || {};
655
665
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cms/bad-input", "pki.cms.verify options must be an object");
656
666
  // An unrecognized option is refused, not swallowed. This is what kept the missing trust seam
@@ -663,13 +673,17 @@ function verify(input, opts) {
663
673
  // the parse surfaced -- the signed content above all -- stays a view into the
664
674
  // caller's memory across that await, and a buffer rewritten in the gap would leave
665
675
  // the result describing one message while the signature was checked over another.
666
- // Only a Buffer / Uint8Array can change underneath us: a PEM string is immutable
667
- // and passes through untouched, and an already-parsed object is a lifetime the
668
- // caller chose. Narrow rather than blanket, so neither input form is refused here
669
- // that pki.cms.parse itself accepts.
670
- var parsed = (input && typeof input === "object" && !Buffer.isBuffer(input) && Array.isArray(input.signerInfos))
671
- ? input
672
- : cms.parse(_snapshotIfBytes(input, "pki.cms.verify"));
676
+ // Re-derived from the bytes the parser read, never trusted as the object it arrives as. A
677
+ // SignedData's meaning is a signature over a byte range, but a parsed one presents that range
678
+ // (`signedAttrsBytes`, or the encapsulated `eContent` when there are no signed attributes), the
679
+ // signature, the algorithms and the certificates as SEPARATE properties. Keep a genuine signer's
680
+ // signature and signed attributes and put different content beside them and every part of this
681
+ // check passes for a message that signer never signed -- the forgery this verb's own block claims
682
+ // to defend (CWE-347). A duck-type test on `signerInfos` cannot see that, because every field is
683
+ // individually well-formed; only re-deriving them all from one byte string can.
684
+ var parsed = guard.parsed.acceptDerived(input, "cms", function (bytes) {
685
+ return cms.parse(_snapshotIfBytes(bytes, "pki.cms.verify"));
686
+ }, _err, "cms/bad-input", "the SignedData");
673
687
  if (!Array.isArray(parsed.signerInfos)) throw _err("cms/bad-input", "input is not a CMS SignedData");
674
688
  var content = parsed.encapContentInfo.eContent;
675
689
  if (content == null) {
package/lib/guard-all.js CHANGED
@@ -65,6 +65,7 @@ var header = require("./guard-header");
65
65
  var compress = require("./guard-compress");
66
66
  var secret = require("./guard-secret");
67
67
  var parsed = require("./guard-parsed");
68
+ var async_ = require("./guard-async");
68
69
 
69
70
  module.exports = {
70
71
  bytes: bytes,
@@ -81,4 +82,5 @@ module.exports = {
81
82
  compress: compress,
82
83
  secret: secret,
83
84
  parsed: parsed,
85
+ async: async_,
84
86
  };
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is every verb whose
6
+ // @signature says `-> Promise<...>`; this is how each of them refuses.
7
+ //
8
+ // guard-async -- a verb documented as Promise-returning refuses by REJECTING, never by throwing.
9
+ //
10
+ // The failure is invisible at the call site, which is what makes it worth a choke point. An operator
11
+ // reads `-> Promise<...>` in the reference and writes the documented shape:
12
+ //
13
+ // pki.acme.newOrder(opts).catch(handleIt);
14
+ //
15
+ // A validation that runs BEFORE the promise is created throws straight past that `.catch`, so a
16
+ // misspelled option or a malformed input becomes an uncaught exception in code that already handles
17
+ // errors -- and nothing in the shape of the call tells the caller which verbs do that. Eleven verbs
18
+ // across five modules had it (pki.cms.verify / sign / countersign, pki.ocsp.sign, pki.tsp.sign and
19
+ // six pki.acme verbs), each having grown the same way: a cheap synchronous check added at the top of
20
+ // a function that returns a promise further down.
21
+ //
22
+ // What this does NOT change is WHEN the work happens. The body still runs synchronously, because
23
+ // several of these verbs must resolve a caller's mutable options object before any turn passes --
24
+ // reading a key, a nonce, or a request's bytes a turn later is a different value than the one that
25
+ // was checked. Only the exit changes: a fault leaves as a rejection instead of a throw.
26
+ //
27
+ // @enforced-by behavioral -- the rule has no rename-proof code shape (it is the ABSENCE of a wrapper
28
+ // around a synchronous prefix, which no lexical pattern can see). The guard is the derived test
29
+ // test/layer-0-primitives/promise-contract.test.js, which reads every `-> Promise<` @signature out
30
+ // of lib/ and calls each verb to prove the refusal arrives as a rejection -- so a new verb is in
31
+ // scope the day it is documented, and no list here can go stale.
32
+ function deferred(body) {
33
+ try { return Promise.resolve(body()); }
34
+ catch (e) { return Promise.reject(e); }
35
+ }
36
+
37
+ module.exports = { deferred: deferred };
@@ -342,12 +342,45 @@ function recordingParser(kind, parse, ErrorClass, code, label) {
342
342
  };
343
343
  }
344
344
 
345
- // The recorded bytes for `obj`, or undefined. @internal to this module: touches no
345
+ // recordingWalker(kind, walkNode, decodeBytes) -> function (node) -> the walked structure.
346
+ //
347
+ // The recordingParser sibling for a producer handed an already-DECODED node rather than bytes.
348
+ // One structure needs it: the RFC 7292 authSafe's SignedData. A PFX is decoded BER-tolerantly
349
+ // because real stores carry the indefinite-length encoding, so its inner SignedData cannot be
350
+ // re-derived by the strict `parse` entry the byte doors use -- that entry would refuse a store
351
+ // this toolkit accepts today. The record therefore names the bytes AND how to walk them again.
352
+ //
353
+ // The same three properties hold as for recordingParser, and for the same reasons. The record can
354
+ // only be obtained by actually running the walk, so no code can assert provenance for a structure
355
+ // it did not produce. The recorded bytes are a private COPY of the node's, because the walked
356
+ // result surfaces views onto the buffer the PFX was decoded from and a caller holding one could
357
+ // otherwise write through it into the very bytes the record names. And `derive` re-runs THIS
358
+ // walker over that copy, so a re-derivation reproduces the structure exactly -- the decode is the
359
+ // producer's own, never a stricter one substituted at the door.
360
+ //
361
+ // @enforced-by guard-shape-reinlined -- shares recordingParser's shape below: the PROVENANCE.set
362
+ // that binds a structure to the bytes it was derived from appears only in this module, so no
363
+ // producer elsewhere can mint a record for a structure it did not itself walk.
364
+ function recordingWalker(kind, walkNode, decodeBytes) {
365
+ return function (node) {
366
+ var out = walkNode(node);
367
+ if (out && typeof out === "object" && node && _isBytes(node.bytes)) {
368
+ PROVENANCE.set(out, {
369
+ kind: kind,
370
+ source: Buffer.from(node.bytes),
371
+ derive: function (src) { return walkNode(decodeBytes(src)); },
372
+ });
373
+ }
374
+ return out;
375
+ };
376
+ }
377
+
378
+ // The provenance record for `obj`, or undefined. @internal to this module: touches no
346
379
  // property of `obj` at all -- the lookup is on the object's identity.
347
- function _sourceOf(obj, kind) {
380
+ function _recordOf(obj, kind) {
348
381
  if (!obj || typeof obj !== "object") return undefined;
349
382
  var rec = PROVENANCE.get(obj);
350
- return (rec && rec.kind === kind) ? rec.source : undefined;
383
+ return (rec && rec.kind === kind) ? rec : undefined;
351
384
  }
352
385
 
353
386
  // fromTrustedSource(input, kind, claimFields, parse, E, code, why) -> the parsed
@@ -368,8 +401,10 @@ function _sourceOf(obj, kind) {
368
401
  // @guard-via guard\.parsed\.(?:fromTrustedSource|recordingParser)\(
369
402
  function fromTrustedSource(input, kind, claimFields, parse, E, code, why) {
370
403
  if (input && typeof input === "object" && !_isBytes(input)) {
371
- var source = _sourceOf(input, kind);
372
- if (source !== undefined) return parse(source);
404
+ // A structure the walker recorded carries its own way back; the byte doors re-derive with the
405
+ // parser they were given.
406
+ var rec = _recordOf(input, kind);
407
+ if (rec !== undefined) return (rec.derive || parse)(rec.source);
373
408
  for (var i = 0; i < claimFields.length; i++) {
374
409
  // A claim field can be an accessor that throws; a guard answers, it does not relay.
375
410
  var claims;
@@ -404,10 +439,12 @@ function fromTrustedSource(input, kind, claimFields, parse, E, code, why) {
404
439
  var _CLAIMS = {
405
440
  certificate: ["tbsBytes", "subjectPublicKeyInfo", "serialNumberHex"],
406
441
  crl: ["tbsBytes", "revokedCertificates", "crlExtensions"],
442
+ cms: ["signerInfos", "encapContentInfo"],
407
443
  };
408
444
  var _WHY = {
409
445
  certificate: "the signed byte range, the signature and the fields that range encodes are separate properties of a parsed object, so a REBUILT certificate (Object.assign, spread, a JSON round-trip) could have them describe different certificates -- keep a real CA certificate's signed bytes and signature and replace only its public key and every field is still well-formed",
410
446
  crl: "the signed byte range, the revocation list and the scope extensions are separate properties of a parsed object, so a REBUILT CRL could have them describe different CRLs -- empty the revocation list and a correctly signed CRL reports a revoked certificate as good",
447
+ cms: "the signed attribute bytes, the signature, the encapsulated content and the certificates that verify it are separate properties of a parsed object, so a REBUILT SignedData could have them describe different messages -- keep a genuine signer's signature and signed attributes and put other content beside them, and every part of the check passes for content that signer never signed",
411
448
  };
412
449
  // @enforced-by guard-shape-reinlined -- shares the fromTrustedSource shape it composes: a door that
413
450
  // tests the claim fields itself, rather than routing here, is the re-inline both replace.
@@ -431,13 +468,15 @@ function acceptDerived(input, kind, parse, E, code, label) {
431
468
  throw E(code, who + " must be a " + kind + " DER Buffer, a PEM string, or a parsed " + kind);
432
469
  }
433
470
  }
471
+ var ns = { certificate: "x509", crl: "crl", cms: "cms" }[kind];
434
472
  return fromTrustedSource(input, kind, claims, parse, E, code,
435
- who + " must be its DER bytes, a PEM string, or an unmodified pki.schema." +
436
- (kind === "crl" ? "crl" : "x509") + ".parse result: " + _WHY[kind]);
473
+ who + " must be its DER bytes, a PEM string, or an unmodified pki.schema." + ns +
474
+ ".parse result: " + _WHY[kind]);
437
475
  }
438
476
 
439
477
  module.exports = {
440
478
  accept: accept, acceptDerived: acceptDerived,
441
479
  fromTrustedSource: fromTrustedSource, recordingParser: recordingParser,
480
+ recordingWalker: recordingWalker,
442
481
  isCert: certShape, isCrl: crlShape,
443
482
  };
package/lib/ocsp.js CHANGED
@@ -256,7 +256,13 @@ function _normCertDer(cert, what) {
256
256
  * { responderID: "byName", responses: [{ cert: leafDer, issuer: caDer, status: "good" }] },
257
257
  * { cert: responderCertDer, key: responderPkcs8 });
258
258
  */
259
+ // Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
260
+ // synchronous because they read the responder's mutable cert and key.
259
261
  function sign(responseData, responder, opts) {
262
+ return guard.async.deferred(function () { return _sign(responseData, responder, opts); });
263
+ }
264
+
265
+ function _sign(responseData, responder, opts) {
260
266
  opts = opts || {};
261
267
  responseData = responseData || {};
262
268
  if (!responder || responder.cert == null || responder.key == null) throw _err("ocsp/bad-input", "a responder must be { cert, key }");
package/lib/pbes2.js CHANGED
@@ -177,7 +177,12 @@ function pbes2Encrypt(pwBytes, plaintext, opts, E, prefix) {
177
177
  // <prefix>/bad-input a param guard raises is normalized to the structural <prefix>/bad-algorithm-parameters.
178
178
  // A wrong key / bad PKCS#7 pad collapses to the UNIFORM <prefix>/decrypt-failed (RFC 8018 sec. 8). The
179
179
  // plaintext integrity re-check (re-parse as a PrivateKeyInfo / SafeContents) is the CALLER's step.
180
- function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix) {
180
+ // `budget` (optional) is a shared { rounds } tally a caller decrypting MANY structures under one
181
+ // call charges against, so the per-structure iteration cap cannot simply reset each time: PBKDF2
182
+ // runs on the event loop, and a store that repeats a costly bag up to the parser's element limit
183
+ // otherwise multiplies the cap by that limit. A caller decrypting exactly one structure passes
184
+ // nothing and is bounded by the cap alone.
185
+ function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix, budget) {
181
186
  var keyBits, iv, pb;
182
187
  try {
183
188
  var p = seqChildren(params, 2, "PBES2 parameters", E, prefix);
@@ -197,6 +202,11 @@ function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix) {
197
202
  }
198
203
  throw E(prefix + "/bad-algorithm-parameters", "malformed PBES2 parameters", e);
199
204
  }
205
+ // Charge the shared budget BEFORE deriving, so the work is refused rather than performed.
206
+ if (budget) {
207
+ budget.rounds -= pb.iterations;
208
+ if (budget.rounds < 0) throw E(prefix + "/iteration-limit", "the aggregate PBKDF2 key-derivation work exceeds the budget (a hostile many-element input)");
209
+ }
200
210
  var dk = nodeCrypto.pbkdf2Sync(pwBytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
201
211
  try { return cbcDecrypt(dk, iv, ciphertext, keyBits); }
202
212
  catch (_e) { throw E(prefix + "/decrypt-failed", "decryption failed"); }
@@ -69,12 +69,14 @@ var MAX_PBMAC1_KEYLEN = 1024; // an HMAC key beyond a hash block is pointless
69
69
  // pkcs12-local (its KDF is bespoke here) while PBMAC1 reuses the toolkit-wide PBKDF2 ceiling. 1e6 is ~500x
70
70
  // the OpenSSL default (2048) yet still bounds the loop to ~1 second.
71
71
  var CLASSIC_MAC_MAX_ITERATIONS = 1000000;
72
- // The AGGREGATE cap on synchronous legacy-PBE App. B KDF work across a single open() -- SHA-1 rounds summed
73
- // over every legacy bag (its KDF block count x iterations). The per-bag iteration cap resets per bag, so a
74
- // hostile store that duplicates a costly legacy bag up to the parser's 1024-bag limit would otherwise block
75
- // the event loop for minutes; this bounds the total to ~the classic MAC ceiling. A conforming store runs only
76
- // a few thousand rounds (its handful of bags at ~2048 iterations).
77
- var LEGACY_KDF_MAX_ROUNDS = CLASSIC_MAC_MAX_ITERATIONS;
72
+ // The AGGREGATE cap on synchronous KDF work across a single open() -- rounds summed over every encrypted
73
+ // bag and safe, whichever scheme it uses: the legacy App. B KDF (its block count x iterations) and PBKDF2
74
+ // alike. The per-bag iteration cap resets per bag, so a hostile store that duplicates a costly bag up to the
75
+ // parser's 1024-element limit would otherwise block the event loop for minutes; this bounds the total to
76
+ // ~the classic MAC ceiling. It covers BOTH schemes because covering only the legacy one left the modern
77
+ // path -- the one every current producer emits -- free to multiply its cap by the element limit.
78
+ // A conforming store runs only a few thousand rounds (its handful of bags at ~2048 iterations).
79
+ var KDF_MAX_ROUNDS = CLASSIC_MAC_MAX_ITERATIONS;
78
80
 
79
81
  // The classic App. B.2 KDF (u = hash output bytes, v = compression block bytes) per RFC 7292 App. B.2.
80
82
  var P12_KDF_UV = {
@@ -102,6 +104,18 @@ var DIGEST_NAME = { sha1: "sha1", sha256: "sha256", sha384: "sha384", sha512: "s
102
104
  // is taken verbatim as already-formatted bytes (an escape hatch for a caller that pre-encodes).
103
105
  function _p12Password(pw) { return _p12PasswordOwned(pw).bytes; }
104
106
 
107
+ // An ABSENT password is refused rather than encoded as the empty one. The two are not the same
108
+ // credential, and the difference is invisible at the call site: a caller who misspells the option,
109
+ // or threads it through a layer that drops it, otherwise gets a store whose private key is
110
+ // protected by nothing and no error anywhere saying so. The empty password remains available --
111
+ // it just has to be asked for, as "".
112
+ var _MISSING_PASSWORD = "a password must be a string, Buffer, or Uint8Array -- an omitted password " +
113
+ "is not the empty password; pass \"\" to use the empty one deliberately";
114
+
115
+ // Every field opts.integrity reads. Adding one here is the only way to make it accepted, so a
116
+ // capability cannot arrive with its option silently ignored at this boundary.
117
+ var _INTEGRITY_OPTS = { mode: 1, signer: 1, signers: 1, certificates: 1, sid: 1, signingTime: 1 };
118
+
105
119
  // The same encoding, reporting OWNERSHIP -- mirroring pbes2.passwordBytesOwned. A caller-supplied
106
120
  // Buffer is returned AS-IS and is BORROWED: clearing it would destroy the caller's own credential,
107
121
  // which is a worse defect than leaving a copy readable. Every other input is re-encoded into a
@@ -111,10 +125,9 @@ function _p12PasswordOwned(pw) {
111
125
  return { bytes: _p12Encode(pw), owned: true };
112
126
  }
113
127
  function _p12Encode(pw) {
114
- if (pw == null) pw = "";
115
128
  if (Buffer.isBuffer(pw)) return pw;
116
129
  if (pw instanceof Uint8Array) return Buffer.from(pw);
117
- if (typeof pw !== "string") throw _err("pkcs12/bad-input", "a password must be a string, Buffer, or Uint8Array");
130
+ if (typeof pw !== "string") throw _err("pkcs12/bad-input", _MISSING_PASSWORD);
118
131
  var out = Buffer.alloc(pw.length * 2 + 2); // + the 2-byte NULL terminator
119
132
  for (var i = 0; i < pw.length; i++) {
120
133
  var u = pw.charCodeAt(i);
@@ -130,12 +143,19 @@ function _p12Encode(pw) {
130
143
  // PBKDF2 the raw UTF-8 password for these modern schemes (confirmed byte-for-byte against `openssl pkcs12`),
131
144
  // reserving the BMPString+NULL form for the bespoke Appendix B KDF only. A file we emit must open in OpenSSL,
132
145
  // so the modern schemes use UTF-8 here; only the classic Appendix B MAC uses `_p12Password`.
133
- function _pbePassword(pw) {
134
- if (pw == null) pw = "";
135
- if (Buffer.isBuffer(pw)) return pw;
136
- if (pw instanceof Uint8Array) return Buffer.from(pw);
137
- if (typeof pw !== "string") throw _err("pkcs12/bad-input", "a password must be a string, Buffer, or Uint8Array");
138
- return Buffer.from(pw, "utf8");
146
+ // The UTF-8 encoding, reporting OWNERSHIP -- the sibling of _p12PasswordOwned, and it exists
147
+ // for the same reason. A caller-supplied Buffer is BORROWED and left alone; every other input is
148
+ // re-encoded into a buffer this module allocated, and a plaintext password copy this module made
149
+ // is cleared once the derivation has consumed it. Without it the App. B.1 copy taken from the same
150
+ // argument in the same call was wiped while this one was not.
151
+ function _pbePasswordOwned(pw) {
152
+ if (Buffer.isBuffer(pw)) return { bytes: pw, owned: false };
153
+ if (pw instanceof Uint8Array) return { bytes: Buffer.from(pw), owned: true };
154
+ if (typeof pw !== "string") throw _err("pkcs12/bad-input", _MISSING_PASSWORD);
155
+ return { bytes: Buffer.from(pw, "utf8"), owned: true };
156
+ }
157
+ function _wipePw(owned) {
158
+ if (owned.owned) guard.secret.zeroize(owned.bytes, Pkcs12Error, "pkcs12/bad-input", "the password encoding");
139
159
  }
140
160
 
141
161
  // Concatenate copies of `src` to the smallest positive multiple of `blockSize` (>= src length), truncating
@@ -267,8 +287,10 @@ function _buildBag(bag, opts, depth) {
267
287
  var kDer = _coerceDer(bag.key, "shroudedKey key");
268
288
  try { pkcs8.parse(kDer); } catch (e2) { throw _err("pkcs12/bad-input", "shroudedKey key is not a well-formed PKCS#8 PrivateKeyInfo", e2); }
269
289
  var enc = bag.encrypt || {};
270
- var pw = _pbePassword(enc.password != null ? enc.password : opts.password);
271
- var r = pbes2.pbes2Encrypt(pw, kDer, _pbeOpts(enc), _err, "pkcs12");
290
+ var pw = _pbePasswordOwned(enc.password != null ? enc.password : opts.password);
291
+ var r;
292
+ try { r = pbes2.pbes2Encrypt(pw.bytes, kDer, _pbeOpts(enc), _err, "pkcs12"); }
293
+ finally { _wipePw(pw); }
272
294
  return _safeBag("pkcs8ShroudedKeyBag", b.sequence([r.algId, b.octetString(r.ct)]), bag); // EncryptedPrivateKeyInfo
273
295
  }
274
296
  case "cert": {
@@ -376,8 +398,10 @@ async function _buildAuthSafeElement(sc, opts) {
376
398
  return b.sequence([b.oid(O("data")), b.explicit(0, b.octetString(safeContentsDer))]);
377
399
  }
378
400
  if (!sc.encrypt || typeof sc.encrypt !== "object") throw _err("pkcs12/bad-input", "safeContents.encrypt must be an object { password? } (RFC 7292 sec. 5.1) -- omit it entirely for a plaintext safe");
379
- var pw = _pbePassword(sc.encrypt.password != null ? sc.encrypt.password : opts.password);
380
- var r = pbes2.pbes2Encrypt(pw, safeContentsDer, _pbeOpts(sc.encrypt), _err, "pkcs12");
401
+ var pw = _pbePasswordOwned(sc.encrypt.password != null ? sc.encrypt.password : opts.password);
402
+ var r;
403
+ try { r = pbes2.pbes2Encrypt(pw.bytes, safeContentsDer, _pbeOpts(sc.encrypt), _err, "pkcs12"); }
404
+ finally { _wipePw(pw); }
381
405
  var eci = b.sequence([b.oid(O("data")), r.algId, b.contextPrimitive(0, r.ct)]); // EncryptedContentInfo, [0] IMPLICIT ct
382
406
  var encData = b.sequence([b.integer(0n), eci]); // EncryptedData { version 0, eci }
383
407
  return b.sequence([b.oid(O("encryptedData")), b.explicit(0, encData)]);
@@ -420,7 +444,10 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
420
444
  var iter2 = _assertMacIter(macOpts.iterations == null ? DEFAULT_PBMAC1_ITER : macOpts.iterations, C.LIMITS.PBKDF2_MAX_ITERATIONS);
421
445
  var keyLen = macOpts.keyLength != null ? macOpts.keyLength : prf.keyLen;
422
446
  if (typeof keyLen !== "number" || !Number.isInteger(keyLen) || keyLen < 20 || keyLen > MAX_PBMAC1_KEYLEN) throw _err("pkcs12/bad-input", "PBMAC1 keyLength must be an integer in [20, " + MAX_PBMAC1_KEYLEN + "] (RFC 9579 sec. 9)");
423
- var mac = await pbes2.pbmac1(_pbePassword(password), salt, iter2, keyLen, prf.wc, prf.wc, authSafeDer); // PBKDF2 -> UTF-8; prf == messageAuthScheme on build
447
+ var macPw2 = _pbePasswordOwned(password);
448
+ var mac;
449
+ try { mac = await pbes2.pbmac1(macPw2.bytes, salt, iter2, keyLen, prf.wc, prf.wc, authSafeDer); } // PBKDF2 -> UTF-8; prf == messageAuthScheme on build
450
+ finally { _wipePw(macPw2); }
424
451
  var desc = { salt: salt, iterationCount: iter2, keyLength: keyLen, prfName: prf.prfName, macName: prf.prfName };
425
452
  var digestInfo2 = b.sequence([pbes2.pbmac1AlgId(desc), b.octetString(mac)]);
426
453
  // MacData.macSalt + iterations are ignored on a PBMAC1 verify but MUST be present + non-1 (RFC 9579 4c/4d).
@@ -498,7 +525,18 @@ function _normalizeSpec(spec, opts) {
498
525
  */
499
526
  async function build(spec, opts) {
500
527
  opts = opts || {};
501
- var pubKey = opts.integrity != null && opts.integrity.mode === "public-key";
528
+ // The integrity mode is checked against the permitted set rather than compared to one literal.
529
+ // Compared, any other spelling reads as "not public-key" and silently selects password integrity,
530
+ // dropping the signer with it: a caller who wrote "publicKey" gets a MAC where they asked for a
531
+ // signature, and nothing in the store or the call says which they got.
532
+ if (opts.integrity != null) {
533
+ if (typeof opts.integrity !== "object" || Array.isArray(opts.integrity)) throw _err("pkcs12/bad-input", "opts.integrity must be an object { mode, signer|signers, ... }");
534
+ guard.identifier.assertKnownKeys(opts.integrity, _INTEGRITY_OPTS, _err, "pkcs12/bad-input", "opts.integrity has an unknown option ");
535
+ if (opts.integrity.mode !== "public-key") {
536
+ throw _err("pkcs12/bad-integrity-mode", "opts.integrity.mode must be \"public-key\" (the only mode it selects); omit opts.integrity for password integrity, got " + JSON.stringify(opts.integrity.mode));
537
+ }
538
+ }
539
+ var pubKey = opts.integrity != null;
502
540
  // RFC 7292 sec. 4: public-key integrity OMITS MacData entirely -- a caller combining opts.mac with it is a
503
541
  // config-time reject (the self-check re-parse would otherwise fail the coherence rule anyway).
504
542
  if (pubKey && opts.mac != null && opts.mac !== false) throw _err("pkcs12/bad-integrity-mode", "public-key integrity has no MacData -- do not combine opts.mac with opts.integrity.mode 'public-key' (RFC 7292 sec. 4)");
@@ -590,7 +628,9 @@ async function _verifyMacOfStore(m, password, opts) {
590
628
  // downgraded store cannot pass under a weak MAC even though the algorithm identifiers parse.
591
629
  if (prfWc === "SHA-1" || macWc === "SHA-1") throw _err("pkcs12/unsupported-algorithm", "PBMAC1 with a <= 160-bit digest (SHA-1) is refused (RFC 9579 sec. 5/7)");
592
630
  _capWork(kdf.iterationCount, kdf.salt, opts, kdf.keyLength, C.LIMITS.PBKDF2_MAX_ITERATIONS);
593
- computed = await pbes2.pbmac1(_pbePassword(password), kdf.salt, kdf.iterationCount, kdf.keyLength, prfWc, macWc, m.macedBytes); // PBKDF2 -> UTF-8
631
+ var vPw = _pbePasswordOwned(password);
632
+ try { computed = await pbes2.pbmac1(vPw.bytes, kdf.salt, kdf.iterationCount, kdf.keyLength, prfWc, macWc, m.macedBytes); } // PBKDF2 -> UTF-8
633
+ finally { _wipePw(vPw); }
594
634
  }
595
635
  return computed.length === expected.length && guard.crypto.constantTimeEqual(computed, expected);
596
636
  }
@@ -687,7 +727,7 @@ async function open(pfx, password, opts) {
687
727
  // wrong bag password fails at the first encrypted bag as the uniform pkcs12/decrypt-failed.
688
728
  var out = { integrityMode: m.integrityMode, macVerified: macVerified, signers: signers, keys: [], certs: [], crls: [], secrets: [] };
689
729
  var i;
690
- var kdfBudget = { rounds: LEGACY_KDF_MAX_ROUNDS }; // aggregate legacy-PBE KDF work budget for this whole open()
730
+ var kdfBudget = { rounds: KDF_MAX_ROUNDS }; // aggregate KDF work budget for this whole open(), both schemes
691
731
  for (i = 0; i < m.safeBags.length; i++) _openBag(m.safeBags[i], password, opts, out, 0, kdfBudget);
692
732
  for (i = 0; i < m.encryptedSafes.length; i++) await _openEncryptedSafe(m.encryptedSafes[i], password, opts, out, 0, kdfBudget);
693
733
  if (opts.keys === "crypto") {
@@ -801,7 +841,15 @@ function _decryptLegacyPbe(ea, ct, password, opts, budget) {
801
841
  // Decrypt a PBES2 (RFC 8018) or legacy-PBE (RFC 7292 App. C) bag/safe -- dispatch on the encryptionAlgorithm
802
842
  // OID. PBES2 uses the UTF-8 password (the pinned interop convention); legacy PBE uses the App. B.1 BMPString.
803
843
  function _decryptBag(ea, ct, password, opts, budget) {
804
- if (ea.oid === O("pbes2")) return pbes2.pbes2Decrypt(_pbePassword(password), ea.parameters, ct, opts, _err, "pkcs12");
844
+ // BOTH arms charge the one shared budget. Charging only the legacy arm left the modern one --
845
+ // the arm every current producer emits -- able to reset its per-bag cap on every bag, so a store
846
+ // that repeats a costly PBES2 bag up to the parser's element limit multiplied the cap by that
847
+ // limit in blocking pbkdf2Sync work.
848
+ if (ea.oid === O("pbes2")) {
849
+ var pw = _pbePasswordOwned(password);
850
+ try { return pbes2.pbes2Decrypt(pw.bytes, ea.parameters, ct, opts, _err, "pkcs12", budget); }
851
+ finally { _wipePw(pw); }
852
+ }
805
853
  return _decryptLegacyPbe(ea, ct, password, opts, budget);
806
854
  }
807
855
 
package/lib/schema-cms.js CHANGED
@@ -47,6 +47,7 @@
47
47
  var asn1 = require("./asn1-der");
48
48
  var schema = require("./schema-engine");
49
49
  var pkix = require("./schema-pkix");
50
+ var guard = require("./guard-all");
50
51
  var oid = require("./oid");
51
52
  var frameworkError = require("./framework-error");
52
53
  var schemaX509 = require("./schema-x509");
@@ -1150,7 +1151,15 @@ var CONTENT_INFO = schema.seq([
1150
1151
  * cms.signerInfos[0].sid.serialNumberHex; // -> "0a1b"
1151
1152
  * cms.encapContentInfo.eContent; // -> Buffer | null (detached)
1152
1153
  */
1153
- var parse = pkix.makeParser({ pemLabel: "CMS", PemError: PemError, ErrorClass: CmsError, prefix: "cms", what: "CMS ContentInfo", topSchema: CONTENT_INFO, ns: NS });
1154
+ // Recording, for the reason the certificate and CRL parsers are: a SignedData is one or more
1155
+ // signatures over byte ranges, and a parsed one presents those ranges (`signedAttrsBytes`, the
1156
+ // encapsulated `eContent`), the signatures, and the certificates that verify them as separate
1157
+ // properties of one object. Keep a genuine signer's `signature` and `signedAttrsBytes` and replace
1158
+ // the `eContent` beside them, and every part of the check still passes for content that signer never
1159
+ // signed -- which is exactly the forgery pki.cms.verify's own block claims to defend. The verdict
1160
+ // verbs re-derive from what is recorded here, so the object a caller passes names bytes rather than
1161
+ // asserting facts.
1162
+ var parse = pkix.makeRecordingParser({ pemLabel: "CMS", PemError: PemError, ErrorClass: CmsError, prefix: "cms", what: "CMS ContentInfo", topSchema: CONTENT_INFO, ns: NS }, "cms");
1154
1163
 
1155
1164
  /**
1156
1165
  * @primitive pki.schema.cms.pemDecode
@@ -1220,7 +1229,13 @@ function walkEnvelopedData(node) { return schema.walk(ENVELOPED_DATA, node, NS).
1220
1229
  // (an RFC 7292 PFX authSafe or encrypted safe, whose wire encoding may be BER
1221
1230
  // that the strict `parse` entry would refuse). Same contract as
1222
1231
  // walkEnvelopedData: the node is the bare structure, typed cms/* on rejection.
1223
- function walkSignedData(node) { return schema.walk(SIGNED_DATA, node, NS).result; }
1232
+ // Records provenance, like parse: the structure a consumer hands to pki.cms.verify must be
1233
+ // re-derivable from the bytes it was walked from, and a PFX authSafe reaches verify this way. The
1234
+ // walker's own BER-tolerant decode is what replays it -- the strict `parse` entry would refuse the
1235
+ // indefinite-length encoding real stores carry.
1236
+ var walkSignedData = guard.parsed.recordingWalker("cms", function (node) {
1237
+ return schema.walk(SIGNED_DATA, node, NS).result;
1238
+ }, function (der) { return asn1.decode(der, { ber: true }); });
1224
1239
  function walkEncryptedData(node) { return schema.walk(ENCRYPTED_DATA, node, NS).result; }
1225
1240
  // Validate + surface one countersignature value (RFC 5652 sec. 11.4, Countersignature ::=
1226
1241
  // SignerInfo) into the same parsed shape parse() gives a top-level SignerInfo -- so pki.cms.verify
package/lib/sigstore.js CHANGED
@@ -522,6 +522,9 @@ var IDENTITY_FIELDS = ["san", "issuer", "sourceRepositoryURI"];
522
522
  // The guard tests membership with hasOwnProperty, so the permitted set is a lookup object --
523
523
  // an array would treat "0"/"1" as the known keys and reject every real field name.
524
524
  var IDENTITY_KEYS = { san: 1, issuer: 1, sourceRepositoryURI: 1 };
525
+ // Every option pki.sigstore.verifyBundle reads. Adding one here is the only way to make it
526
+ // accepted, so a capability cannot arrive with its option silently ignored at this boundary.
527
+ var _VERIFY_BUNDLE_OPTS = { fulcioRoots: 1, rekorKeys: 1, identity: 1, predicateType: 1, time: 1 };
525
528
 
526
529
  // Returns WHICH fields were actually compared. A bundle verifies its own signature and log
527
530
  // inclusion whoever signed it -- Fulcio issues to anyone who completes an OIDC flow -- so
@@ -633,6 +636,13 @@ async function verifyBundle(bundle, opts) {
633
636
  throw new TypeError("verifyBundle: bundle must be an object, JSON string, or Buffer");
634
637
  }
635
638
  opts = opts || {};
639
+ // An unrecognized option is refused, not swallowed. The identity policy one level down already
640
+ // closes this door for exactly the reason it needs closing here too: cosign spells the signer pin
641
+ // `certificateIdentity`, and a swallowed spelling checks nothing under a name the operator
642
+ // believes pins the signer. At the top level the same slip loses the SLSA predicate pin as well,
643
+ // and `predicateType` has no `identityChecked`-style field, so nothing in the verdict reveals
644
+ // that the pin never ran.
645
+ guard.identifier.assertKnownKeys(opts, _VERIFY_BUNDLE_OPTS, _err, "sigstore/bad-input", "pki.sigstore.verifyBundle has an unknown option ");
636
646
  var b = parseBundle(bundle);
637
647
  var vm = b.verificationMaterial;
638
648
  var env = b.dsseEnvelope;
package/lib/tsp-sign.js CHANGED
@@ -27,18 +27,40 @@ var guard = require("./guard-all");
27
27
  var frameworkError = require("./framework-error");
28
28
 
29
29
  var pkix = require("./schema-pkix");
30
+ var pkiBuild = require("./pki-build");
30
31
  var TspError = frameworkError.TspError;
31
32
  var _NS = pkix.makeNS("tsp", TspError, oid);
32
33
  var b = asn1.build;
33
34
  function _err(code, message, cause) { return new TspError(code, message, cause); }
34
35
  function O(name) { return oid.byName(name); }
35
36
 
37
+ // The shared authoring-side builder, for the same pre-encoded-Extension validation every other
38
+ // request builder uses (a CSR's extensionRequest, a CRMF CertTemplate, a CRL's entry extensions).
39
+ var _b = pkiBuild.makeBuilder({
40
+ ErrorClass: TspError, prefix: "tsp", O: O, NS: _NS,
41
+ NAME_SCHEMA: pkix.name(_NS), SPKI_SCHEMA: pkix.spki(_NS),
42
+ });
43
+
36
44
  // Digest names whose imprint / certHash this producer supports (the SHA-2 family).
37
45
  var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
38
46
  // The message-imprint hash MUST be exactly the digest algorithm's output length (RFC 3161 sec.
39
47
  // 2.4.1 -- hashedMessage is "the hash of the datum to be time-stamped").
40
48
  var HASH_LEN = { sha256: 32, sha384: 48, sha512: 64 };
41
49
 
50
+ // How much a `pki.path.validate` revocationChecked outcome ESTABLISHED, ordered. `false` is "no
51
+ // checker ran at all"; "undetermined" is "one ran and could not answer"; "waived" is an
52
+ // undetermined one a caller chose to pass; "determined" is an explicit good-or-revoked answer.
53
+ // Ranking them lets a caller trying several candidate paths keep the most established outcome any
54
+ // of them reached, rather than whichever happened to run last.
55
+ var _REVOCATION_RANK = { "false": 0, "undetermined": 1, "waived": 2, "determined": 3 };
56
+ function _rankRevocation(v) {
57
+ var r = _REVOCATION_RANK[String(v)];
58
+ return r === undefined ? 0 : r; // an unrecognized value establishes nothing
59
+ }
60
+ function _moreEstablished(candidate, current) {
61
+ return _rankRevocation(candidate) > _rankRevocation(current);
62
+ }
63
+
42
64
  // A hash AlgorithmIdentifier { OID, NULL } -- messageImprint and ESSCertIDv2 hash algorithms
43
65
  // carry an explicit NULL parameter (the form RFC 3161 / RFC 5035 producers emit).
44
66
  function _hashAlgId(name) {
@@ -109,7 +131,13 @@ function _signingCertV2(certDer, hashName) {
109
131
  * var token = await pki.tsp.sign(imprint, { cert: signerCertDer, key: signerKeyPkcs8 }, { policy: "1.3.6.1.4.1.1", serialNumber: 1 });
110
132
  * (await pki.cms.verify(token)).valid; // true
111
133
  */
134
+ // Documented `-> Promise`, so a fault leaves as a REJECTION (guard-async); the checks stay
135
+ // synchronous because they read the caller's mutable imprint, TSA material and options.
112
136
  function sign(messageImprint, tsa, opts) {
137
+ return guard.async.deferred(function () { return _sign(messageImprint, tsa, opts); });
138
+ }
139
+
140
+ function _sign(messageImprint, tsa, opts) {
113
141
  opts = opts || {};
114
142
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.sign options must be an object");
115
143
  var mi = messageImprint || {};
@@ -130,10 +158,14 @@ function sign(messageImprint, tsa, opts) {
130
158
  // non-Date or an Invalid Date that would encode a garbage GeneralizedTime).
131
159
  if (opts.genTime != null) guard.time.assertValid(opts.genTime, _err, "tsp/bad-input", "genTime");
132
160
  var genTime = opts.genTime instanceof Date ? opts.genTime : new Date();
133
- var fields = [b.integer(1n), _policy(opts.policy), imprint, b.integer(BigInt(opts.serialNumber)), b.generalizedTime(genTime)];
161
+ // A caller-authored INTEGER is coerced through the shared guard, so a value that is not one is a
162
+ // typed tsp/bad-input rather than a raw SyntaxError or RangeError out of BigInt() -- an untyped
163
+ // fault escaping a public verb, which a caller cannot catch by code.
164
+ var serial = guard.range.authoredInteger(opts.serialNumber, _err, "tsp/bad-input", "serialNumber");
165
+ var fields = [b.integer(1n), _policy(opts.policy), imprint, b.integer(serial), b.generalizedTime(genTime)];
134
166
  if (opts.accuracy) fields.push(_accuracy(opts.accuracy));
135
167
  if (opts.ordering === true) fields.push(b.boolean(true));
136
- if (opts.nonce != null) fields.push(b.integer(BigInt(opts.nonce)));
168
+ if (opts.nonce != null) fields.push(b.integer(guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "nonce")));
137
169
  var tstInfo = b.sequence(fields);
138
170
 
139
171
  var signCert = { type: "signingCertificateV2", values: [_signingCertV2(certDer, certHashAlg)] };
@@ -237,12 +269,26 @@ function request(messageImprint, opts) {
237
269
  // extensions in schema order. certReq DEFAULT FALSE -> only an explicit TRUE is encoded (DER).
238
270
  var fields = [b.integer(1n), imprint];
239
271
  if (opts.reqPolicy != null) fields.push(_policy(opts.reqPolicy));
240
- if (opts.nonce != null) fields.push(b.integer(BigInt(opts.nonce)));
272
+ if (opts.nonce != null) fields.push(b.integer(guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "nonce")));
241
273
  if (opts.certReq != null && typeof opts.certReq !== "boolean") throw _err("tsp/bad-input", "certReq must be a boolean");
242
274
  if (opts.certReq === true) fields.push(b.boolean(true));
243
275
  if (opts.extensions != null) {
244
276
  if (!Array.isArray(opts.extensions) || !opts.extensions.every(function (e) { return Buffer.isBuffer(e) || e instanceof Uint8Array; })) throw _err("tsp/bad-input", "extensions must be an array of encoded Extension DER buffers");
245
- if (opts.extensions.length) fields.push(b.contextConstructed(0, Buffer.concat(opts.extensions.map(function (e) { return Buffer.from(e); }))));
277
+ // Each pre-encoded Extension is validated as one, and its extnID must not repeat -- the same
278
+ // gate every other request builder applies. Spliced in unchecked, a caller relaying a blob it
279
+ // did not author put fully chosen bytes inside [0] and this encoder emitted DER its own
280
+ // parseRequest refuses: an undecodable value, a duplicate extnID, an explicit critical=FALSE
281
+ // the DER DEFAULT rule forbids.
282
+ var seenExt = {};
283
+ var encoded = opts.extensions.map(function (e, i) {
284
+ var der = Buffer.from(e);
285
+ _b.assertValidExtension(der, i);
286
+ var extnId = asn1.read.oid(asn1.decode(der).children[0]);
287
+ if (seenExt[extnId]) throw _err("tsp/bad-input", "duplicate request extension " + extnId + " (RFC 5280 sec. 4.2)");
288
+ seenExt[extnId] = true;
289
+ return der;
290
+ });
291
+ if (encoded.length) fields.push(b.contextConstructed(0, Buffer.concat(encoded)));
246
292
  }
247
293
  var der = b.sequence(fields);
248
294
  return opts.pem ? schemaTsp.pemEncode(der, "TIMESTAMP REQUEST") : der;
@@ -564,7 +610,8 @@ function _buildTsaChains(leaf, pool) {
564
610
  * PEM (never a parsed object -- every checked field is read from the CMS-verified eContent, so a
565
611
  * mutated parsed structure cannot desynchronize the checks from the signed bytes). `data` is the
566
612
  * original bytes (hashed under the token's messageImprint algorithm) or a precomputed
567
- * `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, trusted, genTime, accuracy, serialNumber,
613
+ * `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, trusted, revocationChecked,
614
+ * anchorConstraints, genTime, accuracy, serialNumber,
568
615
  * serialNumberHex, policy, nonce, tsa, tstInfo, signer, code?, reason? }`. `valid` is true only
569
616
  * when the CMS signature, the imprint match, the eContentType, the ESSCertID(V2) binding, the
570
617
  * RFC 3161 sec. 2.3 critical single-`timeStamping` extendedKeyUsage rule, the requested nonce (when
@@ -579,11 +626,19 @@ function _buildTsaChains(leaf, pool) {
579
626
  * accepting one. A timestamp is archived precisely to be re-read years later, and one boolean
580
627
  * cannot answer both questions then.
581
628
  *
629
+ * `revocationChecked` is the third claim, for the same reason. Revocation runs only when a
630
+ * `revocationChecker` is supplied, so a `trusted` token whose TSA was never checked against a CRL
631
+ * or an OCSP responder reads identically to one established un-revoked -- unless the verdict says
632
+ * which. It is `false` whenever no path ran at all. `anchorConstraints` carries whatever the anchor
633
+ * itself constrained, from `pki.path.validate`.
634
+ *
582
635
  * @opts trustAnchor Anchor `{ name, publicKey, algorithm }` -- runs `pki.path.validate` on the
583
636
  * TSA certificate chain ordered from the token's embedded certificates
584
- * (validity at genTime, requiredEku timeStamping, revocation), so a TSA under
585
- * an intermediate CA validates, not only one directly under the anchor. Omit
586
- * to verify signature + imprint + binding + EKU only and anchor the cert yourself.
637
+ * (validity at genTime, requiredEku timeStamping, and revocation when a
638
+ * `revocationChecker` is supplied -- `revocationChecked` reports which), so a
639
+ * TSA under an intermediate CA validates, not only one directly under the
640
+ * anchor. Omit to verify signature + imprint + binding + EKU only and anchor
641
+ * the cert yourself.
587
642
  * @opts nonce Require the token's TSTInfo.nonce to equal this (a number/BigInt).
588
643
  * @opts reqPolicy Require the token's policy to equal this (an OID name or dotted string).
589
644
  * @opts certs Out-of-band TSA certificates (an array of DER `Buffer`s) added to the signer
@@ -630,8 +685,15 @@ async function verify(token, data, opts) {
630
685
  // `res.trusted` must get an answer on both branches -- an undefined on the failure path is the
631
686
  // same "cannot tell what was checked" the field was added to remove, and `!res.trusted` reading
632
687
  // true by accident is not the same as its reading true because nothing anchored the TSA.
688
+ // What a path established travels with the REFUSAL as well. Hardcoding these to false/null here
689
+ // would misreport the one case the field matters most for: a path that ran, established the TSA
690
+ // REVOKED, and refused on that basis did check revocation, and saying it did not is the same
691
+ // "cannot tell what was checked" the field was added to remove. They stay false/null until a path
692
+ // actually produces them, which is the honest answer before one runs.
693
+ var revocationChecked = false;
694
+ var anchorConstraints = null;
633
695
  function fail(code, reason) {
634
- return { valid: false, trusted: false, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
696
+ return { valid: false, trusted: false, revocationChecked: revocationChecked, anchorConstraints: anchorConstraints, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
635
697
  }
636
698
  // M12 -- the CMS signature over the exact RFC 5652 sec. 5.4 preimage (message-digest bound to the
637
699
  // authenticated eContent). cms.verify re-parses the same bytes; a failure is a fail-closed verdict.
@@ -647,7 +709,7 @@ async function verify(token, data, opts) {
647
709
  if (mi !== true) return fail(mi);
648
710
  // M14 -- if a request nonce is supplied, the token MUST echo it (BigInt-exact).
649
711
  if (opts.nonce != null) {
650
- var wantNonce = BigInt(opts.nonce);
712
+ var wantNonce = guard.range.authoredInteger(opts.nonce, _err, "tsp/bad-input", "opts.nonce");
651
713
  if (tst.nonce == null || tst.nonce !== wantNonce) return fail("tsp/nonce-mismatch");
652
714
  }
653
715
  // M15 -- if the requested policy is supplied, the token's policy MUST equal it.
@@ -713,23 +775,54 @@ async function verify(token, data, opts) {
713
775
  // end of the chain and not the other, and a root explicitly distrusted for timestamping would
714
776
  // still answer trusted. The purpose is not a caller choice here: this verb validates timestamp
715
777
  // tokens and nothing else, so there is exactly one purpose its anchors can be judged under.
778
+ // EVERY path result goes through this one function, so none can be produced without being
779
+ // accumulated. The alternative -- validating in several places and accumulating afterwards --
780
+ // is what let a result be overwritten before it was counted, first across candidate chains and
781
+ // then across the two endpoints of one candidate. There is now one door and it always counts.
782
+ //
783
+ // The two fields accumulate INDEPENDENTLY because they are independent facts. Whether
784
+ // revocation was established and which anchor constraints were consulted do not imply each
785
+ // other: an anchor that rejects the TSA on its own purposes or distrustAfter metadata, with no
786
+ // revocationChecker configured, establishes constraints while establishing nothing about
787
+ // revocation.
788
+ async function validateAt(chain, when) {
789
+ var res = await pathValidate.validate(chain, {
790
+ time: when, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
791
+ });
792
+ // The most any attempt ESTABLISHED, kept across every attempt: backtracking tries several
793
+ // chains for one TSA certificate and a fractional genTime validates each at two endpoints,
794
+ // so an attempt that fails earlier than another must not erase what that other established.
795
+ if (_moreEstablished(res.revocationChecked, revocationChecked)) revocationChecked = res.revocationChecked;
796
+ // Every attempt validates against the SAME opts.trustAnchor under the same purpose, so any
797
+ // non-null value describes that one anchor; the first to report them is as good as the last.
798
+ if (anchorConstraints == null && res.anchorConstraints != null) anchorConstraints = res.anchorConstraints;
799
+ return res;
800
+ }
716
801
  var chains = _buildTsaChains(pkiX509.parse(tsaCertDer), pool);
717
802
  for (var ci = 0; ci < chains.length && !(pathRes && pathRes.valid); ci++) {
718
- pathRes = await pathValidate.validate(chains[ci], {
719
- time: floorT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
720
- });
721
- if (pathRes.valid && ceilT !== floorT) {
722
- pathRes = await pathValidate.validate(chains[ci], {
723
- time: ceilT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], checkPurpose: "timeStamping", revocationChecker: opts.revocationChecker,
724
- });
725
- }
803
+ pathRes = await validateAt(chains[ci], floorT);
804
+ if (pathRes.valid && ceilT !== floorT) pathRes = await validateAt(chains[ci], ceilT);
726
805
  }
727
806
  } catch (e) { return fail("tsp/untrusted-tsa", (e && e.message) || String(e)); }
807
+ // On a REFUSAL the question is whether ANYTHING established the TSA's revocation status, so the
808
+ // accumulated best answers it: a candidate that reached the checker and was rejected as revoked
809
+ // did check revocation, and saying otherwise would misdescribe the reason for the refusal.
728
810
  if (!pathRes || !pathRes.valid) return fail("tsp/untrusted-tsa", "the TSA certificate did not validate to the trust anchor at genTime");
811
+ // On ACCEPTANCE the question is narrower and the accumulated best would OVERCLAIM: what matters
812
+ // is what was checked on the path actually accepted, not what some rejected candidate managed.
813
+ // The loop stops at the first valid path, so `pathRes` is that path.
814
+ revocationChecked = pathRes.revocationChecked;
815
+ anchorConstraints = pathRes.anchorConstraints;
816
+ // Reduced to a bare `trusted: true`, this answered the same way whether the TSA was established
817
+ // un-revoked or revocation was never consulted at all -- and revocation only runs when a
818
+ // revocationChecker is supplied, so the second case is the DEFAULT. A caller archiving a
819
+ // timestamp verdict could not tell the two apart later, which is the distinction the
820
+ // valid/trusted split exists to keep.
729
821
  trusted = true;
730
822
  }
731
823
  return {
732
- valid: true, trusted: trusted, genTime: tst.genTime, accuracy: tst.accuracy,
824
+ valid: true, trusted: trusted, revocationChecked: revocationChecked, anchorConstraints: anchorConstraints,
825
+ genTime: tst.genTime, accuracy: tst.accuracy,
733
826
  serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex,
734
827
  policy: tst.policy, policyName: tst.policyName, nonce: tst.nonce, tsa: tst.tsa,
735
828
  tstInfo: tst, signer: { cert: tsaCertDer, sid: signer.sid },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:e4afed62-75b0-4b79-b65c-4d16c17ee70b",
5
+ "serialNumber": "urn:uuid:f56149c0-58f9-40e1-a0f0-36a0dd2e4ee9",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-15T23:54:15.184Z",
8
+ "timestamp": "2026-08-16T03:23:32.715Z",
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.5",
22
+ "bom-ref": "@blamejs/pki@0.5.6",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.5.5",
25
+ "version": "0.5.6",
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.5",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.5.6",
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.5",
57
+ "ref": "@blamejs/pki@0.5.6",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]