@blamejs/pki 0.5.4 → 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,48 @@ 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
+
27
+ ## v0.5.5 — 2026-08-15
28
+
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.
30
+
31
+ ### Changed
32
+
33
+ - The package resolves one entry point. require("@blamejs/pki") is unchanged; a path INTO the package, such as require("@blamejs/pki/lib/schema-x509"), no longer resolves. Every module under lib/ carries @internal in its own header and none has ever appeared in the API snapshot that freezes the public surface -- they were reachable because the package declared no exports map, not because they were offered, and one of them mints the provenance record the integrity verbs above rely on. Everything the internals do is on pki.*: the decoders are pki.schema.<format>.parse, the codec is pki.asn1, the OID registry is pki.oid, the error classes are pki.errors. MIGRATING.md carries the recipe.
34
+ - pki.merkle, pki.jose, pki.hpke, pki.smime and pki.est refuse an option they do not recognize, which completes the toolkit -- every module that takes options now does. A misspelled option is the one input that reads as an omission rather than as a value, so the caller who asked for something stricter gets the looser default and is told nothing: a misspelled psk leaves an HPKE psk-mode setup with no pre-shared key, a misspelled key leaves pki.jose.verify accepting whichever key the message names, a misspelled leafIndex leaves a Merkle inclusion proof about a leaf the caller never chose, a misspelled strictMicalg accepts the S/MIME digest mismatch it was set to reject, and a misspelled expectedRecipientKeyId drops the recipient pin on an EST server-generated private key. The accepted set is per verb rather than per module, because the surfaces differ -- form means something to pki.smime.sign and nothing to pki.smime.encrypt, strict cannot run on pki.est.cacerts at all, and HPKE's two ends read the same object from opposite sides, so senderPublicKey does nothing at the sender and senderKey nothing at the recipient. A merged set would accept each verb's options everywhere and reproduce the silence in a wider form, which is why an option passed to the wrong end of an HPKE exchange is refused with a message saying where it belongs rather than ignored -- the caller who passed it usually believes they authenticated something. Four options pki.smime.sign has always forwarded (hcp, sid, signedAttributes, additionalSignedAttributes) and EST's auth object are now documented; they worked before and were absent from the reference.
35
+ - The guards match no patterns. A guard runs on the most hostile input the toolkit sees, and a pattern engine's cost on a rejecting string is a property of the pattern rather than of the length -- the one thing a caller's size cap cannot bound. Nine patterns across five guards are now explicit character walks, each one pass. Three carried a second defect settled by the rewrite: the whitespace fold above, a JSON number grammar written twice (once as the scan and once as a pattern re-matching what the scan had just read), and an RFC 4514 escape that ran a pattern replace and then a second loop over the same attacker-supplied value.
36
+
37
+ ### Fixed
38
+
39
+ - A claimed-parsed structure must carry every field the matching pki.schema parser produces. The rule already existed at one door -- pki.path.build refused a partial claimed-parsed certificate -- while pki.path.validate, which build hands its result to, tested only for a truthy tbsBytes and passed the object into the RFC 5280 sec. 6.1 walk. Eleven doors now share it: pki.path.validate and build, pki.path.crlChecker, pki.crl.verify / isRevoked and the issuer of pki.crl.sign, the issuer of pki.x509.sign, pki.attrcert.sign, pki.ocsp's certificate argument, pki.lint, and the caller root certificates pki.webauthn takes for attestation and for android-safetynet. Completeness is measured against the parser rather than against what any one verb reads, because a field absent from an object is not a field with a safe default: an extension entry with no critical property read as non-critical, so a certificate rejected for an unknown critical extension when passed as bytes validated when passed as an object; a missing serialNumber surfaced as an error from the ASN.1 layer; and a missing issuer.bytes produced an OCSP request whose issuerNameHash covered nothing. Passing bytes, PEM, or the parser's own unmodified output is unaffected.
40
+ - A certificate or CRL a verdict is taken from is re-derived from the bytes its parser read. pki.path.validate and build, pki.path.crlChecker, pki.crl.verify and pki.crl.isRevoked all reach a decision, and completeness alone cannot carry one: a certificate is one signature over one byte range, but a parsed certificate presents that range, the signature, and every field the range encodes as separate properties. Keep a real CA certificate's signed bytes and signature and replace only its subjectPublicKeyInfo, and every field is well-formed, the signature verifies over the original range, and the substituted key is then what verifies the next certificate in the chain -- a forged chain built out of a genuine certificate. Emptying extensions is the same move against basicConstraints, keyUsage, name constraints and the unknown-critical rule; emptying a CRL's revokedCertificates leaves a correctly signed CRL reporting a revoked certificate as good. pki.schema.x509.parse and pki.schema.crl.parse now record what they read, these verbs parse it again from that record, and a certificate or CRL a caller assembled rather than parsed is refused. Passing bytes, PEM, or the parser's own unmodified output is unaffected.
41
+ - pki.ocsp.verify, pki.path.verifyOcspResponse, pki.pkcs12.verifyMac and pki.pkcs12.open compute their verdict over the bytes the parser read. A signature check has three parts -- the signature, the algorithm that verifies it, and the byte range it covers -- and on a parsed response all three are separate properties: pair a real CA's signature over a certificate that CA issued with that certificate's own signed bytes and algorithm, relabel the three, and every part of the check passes for a response the responder never produced. A PKCS#12 store has the same shape with two parts, the range the MAC covers and the bags handed back as verified, so one object could say verify this and return that. The parsers now record what they parsed and these verbs re-derive from that record, so an object edited or rebuilt after parsing is not what the verdict describes. Passing the parser's own result still works and is unchanged.
42
+ - pki.attrcert.sign derives both halves of a Holder's identity from the signed bytes. Issuer and serial together ARE the identity being bound; the issuer was decoded from tbsBytes while the serial was read off the object, so a parsed certificate with one field replaced produced a Holder naming a real issuer with a serial nobody issued.
43
+ - pki.trust.anchor answers from what the store read. A root program's metadata -- these purposes, until this date -- is a statement about a KEY, so an entry rebuilt with a substituted publicKey carried the program's word onto a key it never saw; the (name, key) pair is now re-derived from the certificate the store parsed, and an entry carrying store metadata without that provenance is refused. The purposes and distrust dates come from the same place and are copied on the way out, so neither editing a store entry nor writing through a returned anchor changes what that anchor authorizes -- pki.trust.anchor(entry).purposes.serverAuth = true no longer opens a gate the store never opened, and an anchor reports the store's bits and dates however the caller has since handled the entry. A caller asserting their own bare (name, key) anchor carries no metadata and is unaffected.
44
+ - A private key decoded for the crypto engine is wiped once the engine has imported it. A signer or recipient key may be given as a Buffer, a Uint8Array, or a PEM string; the first is the caller's own memory and is used in place, while the other two are decoded into a new buffer inside the toolkit -- a second copy of a private key, which until now stayed readable in the heap until the garbage collector happened to reuse the page. It is cleared on the failure path too, so a malformed key or a tampered message is not a way to leave one behind. A Buffer you supply is never written to: it is yours, you still hold it, and clearing it would destroy the key rather than protect it.
45
+ - pki.webauthn.verifyAssertion holds both accepted forms of a stored credential key to the same rules. The COSE bytes went through the curve and length rules, the 2048-bit RSA modulus floor and the exponent checks; the object form went through none, so one key was refused in one form and imported for signature verification in the other. Which form a relying party stores is a question about what their datastore round-trips, not about how carefully their credential is checked.
46
+ - A certificate's keyUsage is read the same way at every boundary that asks what the certificate may do. keyUsage is a NamedBitList, so DER drops its trailing zero bits (X.690 sec. 11.2.2) and RFC 5280 sec. 4.2.1.3 requires at least one bit set. Four boundaries read the bits themselves and applied neither rule, so one certificate could be authorized in one place and called malformed everywhere else: pki.crl.verify accepting a CRL signer, pki.tsp.verify accepting a timestamp authority, pki.cms.encrypt accepting a recipient, and the FIDO metadata reader accepting the leaf that signs a catalogue.
47
+ - The distinguished-name comparison that decides name chaining, revocation-issuer matching and name constraints folds the four ASCII whitespace characters X.520's caseIgnoreMatch names, and no others. It had been collapsing whitespace with a pattern, which also folds vertical tab, form feed, no-break space and every Unicode space separator -- equating names X.520 keeps distinct.
48
+
7
49
  ## v0.5.4 — 2026-08-15
8
50
 
9
51
  A path verdict says whether revocation was ever established, a trust anchor's own distrust metadata can no longer sit inert, and a CRL is asked what only a certificate can answer.
package/MIGRATING.md CHANGED
@@ -7,3 +7,75 @@ Some breaking changes cannot warn at runtime: an on-disk format break or a wire-
7
7
  ## No active deprecations
8
8
 
9
9
  The toolkit has no `deprecate()`-marked surface awaiting removal.
10
+
11
+ ---
12
+
13
+ ## Out-of-band breaking changes
14
+
15
+ Listed newest-first.
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
+
58
+ ### v0.5.5 — `require("@blamejs/pki/lib/...")`
59
+
60
+ The package resolves one entry point; a path into the package no longer resolves.
61
+
62
+ `require("@blamejs/pki")` and `import ... from "@blamejs/pki"` are unchanged. What no
63
+ longer resolves is a path INTO the package:
64
+
65
+ ```
66
+ require("@blamejs/pki/lib/schema-x509") // ERR_PACKAGE_PATH_NOT_EXPORTED
67
+ ```
68
+
69
+ Every module under `lib/` carries `@internal` in its own header and none has ever appeared
70
+ in the API snapshot that freezes the public surface. They were reachable because the package
71
+ declared no `exports` map, not because they were offered -- and one of them mints the
72
+ provenance record the OCSP and PKCS#12 integrity verbs rely on, which reachable from outside
73
+ could be minted for any object.
74
+
75
+ Everything the internals do is on `pki.*`: the decoders are `pki.schema.<format>.parse`, the
76
+ codec is `pki.asn1`, the OID registry is `pki.oid`, the error classes are `pki.errors`. If you
77
+ are reaching for something with no `pki.*` route, that is a gap worth reporting rather than a
78
+ module worth importing -- the internals change shape between patch releases and carry no
79
+ compatibility promise.
80
+
81
+ `require("@blamejs/pki/package.json")` still resolves, for tooling that reads the version.
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
@@ -83,12 +83,13 @@ var _tbsNameBytes = pkiBuild.tbsNameField; // the AA issuerName / holder baseC
83
83
  // Parse a certificate DER/PEM (or accept a parsed certificate), re-typing a raw x509/* parse fault to the
84
84
  // attrcert domain so a malformed AA cert / holder cert surfaces attrcert/*, not a foreign CertificateError.
85
85
  function _parseCert(cert, what) {
86
- if (!Buffer.isBuffer(cert) && typeof cert !== "string") {
87
- if (!cert || !cert.tbsBytes) throw _err("attrcert/bad-input", what + " must be a certificate DER/PEM or a parsed certificate");
88
- return cert;
89
- }
90
- try { return x509.parse(cert); }
91
- catch (e) { if (e instanceof AttrCertError) throw e; throw _err("attrcert/bad-input", what + " is not a well-formed certificate", e); }
86
+ // Re-derived from the bytes its parser read. Issuer and serial together ARE the Holder's identity,
87
+ // so a certificate assembled from parts could bind an attribute certificate to a holder that no
88
+ // issuer ever named.
89
+ return guard.parsed.acceptDerived(cert, "certificate", function (bytes) {
90
+ try { return x509.parse(bytes); }
91
+ catch (e) { if (e instanceof AttrCertError) throw e; throw _err("attrcert/bad-input", what + " is not a well-formed certificate", e); }
92
+ }, _err, "attrcert/bad-input", what);
92
93
  }
93
94
  // The raw content octets of an OBJECT IDENTIFIER (past its own tag+len) -- the body of a [0] IMPLICIT OID.
94
95
  function _oidContent(name) {
@@ -149,8 +150,11 @@ function _encodeHolder(holder) {
149
150
  if (holder.fromCertificate != null) {
150
151
  // Bind to a public-key certificate's identity: baseCertificateID = { issuer = the PKC's issuer DN as a
151
152
  // directoryName, serial = the PKC serialNumber } (RFC 5755 sec. 4.1 / 7.3).
153
+ // BOTH halves from the signed bytes. issuer and serial together ARE the identity, so deriving
154
+ // the issuer from tbsBytes while reading the serial off the object let the two name different
155
+ // certificates: a Holder with a genuine issuer DN and whatever serial the caller wrote.
152
156
  var pkc = _parseCert(holder.fromCertificate, "holder.fromCertificate");
153
- var content = _issuerSerialContent({ issuer: [{ directoryName: _tbsNameBytes(pkc, "issuer") }], serial: pkc.serialNumber });
157
+ var content = _issuerSerialContent({ issuer: [{ directoryName: _tbsNameBytes(pkc, "issuer") }], serial: pkiBuild.tbsSerialNumber(pkc) });
154
158
  return b.sequence([b.contextConstructed(0, content)]);
155
159
  }
156
160
  // objectDigestInfo [2] IMPLICIT ObjectDigestInfo.
@@ -129,12 +129,24 @@ function _boundedPool(base, added) {
129
129
  // the unsigned extraCerts ordering, so a corrupted-signature copy sharing a valid issuer's TBS must NOT collapse
130
130
  // onto it and evict the valid one. Returns null when the identity cannot be derived; a NON-deduped entry is safe
131
131
  // (a redundant slot), a wrong merge (dropping a distinct or the only valid certificate) is not.
132
+ // The identity comes from the SAME derivation every other certificate door uses: the bytes the
133
+ // parser recorded, never the fields of the object handed in. This is a dedupe rather than a verdict,
134
+ // and no collision attack on it is apparent -- carrying another certificate's exact tbsBytes AND
135
+ // signature means being that certificate. But "no attack is apparent" is the reasoning that put a
136
+ // completeness-only door on nine deciding boundaries, so it is not the reasoning this uses: one
137
+ // derivation for certificates, everywhere, and the exceptions have to argue for themselves.
138
+ //
139
+ // Failure returns null rather than throwing, which is this function's own contract and is why the
140
+ // door is wrapped: an underivable identity is a redundant pool slot, while a wrong merge (dropping
141
+ // a distinct or the only valid certificate) is not. So a rebuilt entry simply does not dedupe.
132
142
  function _certIdentity(cert) {
133
143
  try {
134
- var p = (cert && Buffer.isBuffer(cert.tbsBytes)) ? cert : x509.parse(cert); // x509.parse accepts a DER Buffer OR a PEM string
135
- if (!p || !Buffer.isBuffer(p.tbsBytes)) return null;
136
- return p.tbsBytes.toString("base64") + "|" + (p.signatureValue && p.signatureValue.bytes ? p.signatureValue.bytes.toString("base64") : "");
137
- } catch (_e) { return null; }
144
+ var p = guard.parsed.acceptDerived(cert, "certificate", x509.parse, _err, "cmp/bad-input", "a pool certificate");
145
+ if (!guard.parsed.isCert(p)) return null;
146
+ return p.tbsBytes.toString("base64") + "|" + p.signatureValue.bytes.toString("base64");
147
+ } catch (_e) {
148
+ return null; // underivable: kept as its own slot, never merged onto another certificate's
149
+ }
138
150
  }
139
151
 
140
152
  // A canonical identity for a SubjectPublicKeyInfo: the algorithm OID + the AlgorithmIdentifier parameters +
@@ -383,12 +395,13 @@ function session(opts) {
383
395
  var _es = opts.expectedSender;
384
396
  try {
385
397
  if (_es && Buffer.isBuffer(_es.tbsBytes)) { // the documented already-parsed form (pki.schema.x509.parse output), detected like _certIdentity
386
- // A Buffer tbsBytes alone is NOT a complete parsed certificate: senderBoundToCert dereferences subject /
387
- // subjectAltName, so a partial object (e.g. { tbsBytes }) would pass here and then throw a raw TypeError
388
- // mid-transaction, consuming the one-shot session for a local config error. Validate the FULL shape now
389
- // with the same coerceCert check the path engine applies to every parsed-cert input.
390
- if (_engine && _engine.coerceCert) _engine.coerceCert(_es);
391
- _expectedSenderCert = _es;
398
+ // The door's RETURN is what gets pinned, not the object handed in. coerceCert re-derives the
399
+ // certificate from the bytes its parser read, so calling it only as a check and then storing
400
+ // the caller's object keeps every edit the re-derivation exists to discard: this pin is
401
+ // compared against each response signer's subject and SAN, so an edited one would accept a
402
+ // different CMP signer than the caller meant to pin. A validator that normalizes has its
403
+ // return value as its contract -- using it as a predicate throws that contract away.
404
+ _expectedSenderCert = (_engine && _engine.coerceCert) ? _engine.coerceCert(_es) : _es;
392
405
  }
393
406
  else if (Buffer.isBuffer(_es) || _es instanceof Uint8Array) { _expectedSenderDer = Buffer.from(_es); _expectedSenderCert = x509.parse(_expectedSenderDer); }
394
407
  else if (typeof _es === "string") { _expectedSenderDer = x509.pemDecode(_es); _expectedSenderCert = x509.parse(_expectedSenderDer); }
package/lib/cmp-verify.js CHANGED
@@ -506,11 +506,19 @@ async function _verifySignature(m, protectedPart, protectionAlg, protection, opt
506
506
  // A canonical certificate identity (tbs + signature) for the extraCerts pool dedup: it keys a Buffer /
507
507
  // Uint8Array (parse) and an already-parsed candidate object identically (path.build accepts both forms), so
508
508
  // an extraCert duplicating a caller intermediate is dropped regardless of which representation the caller used.
509
+ // The key is derived from the bytes the parser recorded, through the same door every other
510
+ // certificate boundary uses -- never from the fields of the object handed in. Deriving it from a
511
+ // caller-shaped object would let two different certificates collapse onto one key, which is the one
512
+ // outcome this must not have. Failure returns null (the caller's contract here): an underivable
513
+ // identity leaves a redundant pool slot, which is safe, while a wrong merge is not.
509
514
  function _certKey(c) {
510
- var p = c;
511
- if (Buffer.isBuffer(c) || c instanceof Uint8Array) { try { p = x509.parse(c); } catch (_e) { return null; } }
512
- if (!p || !p.tbsBytes) return null;
513
- return p.tbsBytes.toString("base64") + "|" + (p.signatureValue && p.signatureValue.bytes ? p.signatureValue.bytes.toString("base64") : "");
515
+ try {
516
+ var p = guard.parsed.acceptDerived(c, "certificate", x509.parse, _err, "cmp/bad-input", "a pool certificate");
517
+ if (!guard.parsed.isCert(p)) return null;
518
+ return p.tbsBytes.toString("base64") + "|" + p.signatureValue.bytes.toString("base64");
519
+ } catch (_e) {
520
+ return null; // underivable: kept as its own slot, never merged onto another certificate's
521
+ }
514
522
  }
515
523
 
516
524
  async function _chainSigner(signer, m, opts, extra) {
@@ -211,41 +211,55 @@ async function _acquireCek(ri, km, opts) {
211
211
  // ktri: OAEP or PKCS#1 v1.5 (v1.5 = decrypt-only + RFC 3218 implicit rejection).
212
212
  async function _ktriCek(ri, km) {
213
213
  var kea = ri.keyEncryptionAlgorithm;
214
- var keyDer = _normKeyDer(km.key);
215
- if (kea.oid === O("rsaesOaep")) {
216
- var hash = _oaepHashFromParams(kea.parameters);
217
- var pub = await subtle.importKey("pkcs8", keyDer, { name: "RSA-OAEP", hash: hash }, false, ["decrypt"]);
218
- return Buffer.from(await subtle.decrypt({ name: "RSA-OAEP" }, pub, ri.encryptedKey));
219
- }
220
- if (kea.oid === O("rsaEncryption")) {
221
- // RFC 3218 sec. 2.3.2 implicit rejection: NEVER surface a v1.5 failure here. Any decode fault
222
- // yields a fresh random CEK of the content-alg length; the mismatch emerges at stage 3.
223
- var keyObj = nodeCrypto.createPrivateKey({ key: keyDer, format: "der", type: "pkcs8" });
224
- try { return nodeCrypto.privateDecrypt({ key: keyObj, padding: nodeCrypto.constants.RSA_PKCS1_PADDING }, ri.encryptedKey); }
225
- catch (_e) { return null; } // signal: use a random CEK (length decided at open time)
214
+ var k = _normKeyDer(km.key);
215
+ // The wipe is in a `finally` so it happens on the reject path too: a message crafted to fail the
216
+ // unwrap must not be a way to leave the key copy in memory.
217
+ try {
218
+ if (kea.oid === O("rsaesOaep")) {
219
+ var hash = _oaepHashFromParams(kea.parameters);
220
+ var pub = await subtle.importKey("pkcs8", k.der, { name: "RSA-OAEP", hash: hash }, false, ["decrypt"]);
221
+ return Buffer.from(await subtle.decrypt({ name: "RSA-OAEP" }, pub, ri.encryptedKey));
222
+ }
223
+ if (kea.oid === O("rsaEncryption")) {
224
+ // RFC 3218 sec. 2.3.2 implicit rejection: NEVER surface a v1.5 failure here. Any decode fault
225
+ // yields a fresh random CEK of the content-alg length; the mismatch emerges at stage 3.
226
+ var keyObj = nodeCrypto.createPrivateKey({ key: k.der, format: "der", type: "pkcs8" });
227
+ try { return nodeCrypto.privateDecrypt({ key: keyObj, padding: nodeCrypto.constants.RSA_PKCS1_PADDING }, ri.encryptedKey); }
228
+ catch (_e) {
229
+ return null; // signal: use a random CEK (length decided at open time)
230
+ }
231
+ }
232
+ // Coverage residual: reachable only from a hostile message (our encrypt emits only OAEP; OpenSSL
233
+ // emits OAEP or rsaEncryption) -- a fail-closed reject the fuzz harness exercises.
234
+ throw _err("cms/unsupported-algorithm", "unsupported ktri keyEncryptionAlgorithm " + kea.oid);
235
+ } finally {
236
+ _releaseKeyDer(k);
226
237
  }
227
- // Coverage residual: reachable only from a hostile message (our encrypt emits only OAEP; OpenSSL
228
- // emits OAEP or rsaEncryption) -- a fail-closed reject the fuzz harness exercises.
229
- throw _err("cms/unsupported-algorithm", "unsupported ktri keyEncryptionAlgorithm " + kea.oid);
230
238
  }
231
239
 
232
240
  // kari: reconstruct Z from the originatorKey + recipient private key, KDF -> KEK, AES-KW unwrap.
233
241
  async function _kariCek(ri, km) {
234
- var keyDer = _normKeyDer(km.key);
235
- var kea = ri.keyEncryptionAlgorithm;
236
- var wrapAlg = _kariWrap(kea);
237
- var scheme = kea.oid;
238
- var origSpki = _originatorSpki(ri.originator);
239
- // Unwrap THIS recipient's RecipientEncryptedKey (matched by rid), not element 0 -- a kari may list
240
- // several recipients under one ephemeral key.
241
- var rek = (km.cert != null && _kariRekFor(ri, x509.parse(_normCertDer(km.cert)))) || ri.recipientEncryptedKeys[0];
242
- var kekBytes = WRAP_KEK_LENGTHS[wrapAlg.oid];
243
- if (!kekBytes) throw _err("cms/unsupported-algorithm", "unsupported kari key-wrap");
244
- var ukm = ri.ukm || null;
242
+ // The copy is taken and the protected region opens IMMEDIATELY. Everything between them would
243
+ // otherwise be an unprotected window, and it is not a narrow one: reading the wrap algorithm, the
244
+ // originator's key, the recipient's certificate and the matching RecipientEncryptedKey all parse
245
+ // attacker-supplied structure and all throw on malformed input. A message crafted to fail any one
246
+ // of them would be a way to leave the key copy in the heap -- the exact outcome the wipe is for.
247
+ var k = _normKeyDer(km.key);
245
248
  // The agreement secret and the KEK derived from it are both allocated here; one `finally` clears
246
249
  // whichever branch produced them, including when the unwrap below throws on a tampered key.
247
250
  var kek, z, mz;
248
251
  try {
252
+ var keyDer = k.der;
253
+ var kea = ri.keyEncryptionAlgorithm;
254
+ var wrapAlg = _kariWrap(kea);
255
+ var scheme = kea.oid;
256
+ var origSpki = _originatorSpki(ri.originator);
257
+ // Unwrap THIS recipient's RecipientEncryptedKey (matched by rid), not element 0 -- a kari may list
258
+ // several recipients under one ephemeral key.
259
+ var rek = (km.cert != null && _kariRekFor(ri, x509.parse(_normCertDer(km.cert)))) || ri.recipientEncryptedKeys[0];
260
+ var kekBytes = WRAP_KEK_LENGTHS[wrapAlg.oid];
261
+ if (!kekBytes) throw _err("cms/unsupported-algorithm", "unsupported kari key-wrap");
262
+ var ukm = ri.ukm || null;
249
263
  if (_isMont(origSpki)) {
250
264
  var mont = _montName(origSpki);
251
265
  var recipPriv = await subtle.importKey("pkcs8", keyDer, { name: mont.name }, false, ["deriveBits"]);
@@ -275,6 +289,7 @@ async function _kariCek(ri, km) {
275
289
  return await _aesKwUnwrap(kek, rek.encryptedKey);
276
290
  } finally {
277
291
  guard.secret.zeroizeAll([z, mz, kek], CmsError, "cms/bad-input", "the key-agreement shared secret");
292
+ _releaseKeyDer(k);
278
293
  }
279
294
  }
280
295
 
@@ -333,7 +348,12 @@ async function _kemriCek(ri, km) {
333
348
  var kekBytes = Number(k.kekLength);
334
349
  var wrapAlg = k.wrap;
335
350
  if (WRAP_KEK_LENGTHS[wrapAlg.oid] !== kekBytes) throw _fail(); // M29 re-check on the consumer path
336
- var priv = await subtle.importKey("pkcs8", _normKeyDer(km.key), { name: wcName }, false, ["decapsulateBits"]);
351
+ // The decapsulation key copy is released as soon as the engine has imported it -- the import is
352
+ // the only thing that reads it, so its lifetime does not need to span the decapsulation below.
353
+ var keyCopy = _normKeyDer(km.key);
354
+ var priv;
355
+ try { priv = await subtle.importKey("pkcs8", keyCopy.der, { name: wcName }, false, ["decapsulateBits"]); }
356
+ finally { _releaseKeyDer(keyCopy); }
337
357
  var ss = null, kek = null, ssAb = null, kekAb = null;
338
358
  try {
339
359
  // The engine hands back an ArrayBuffer it allocated, and the Buffer below is a copy of it. Both
@@ -706,12 +726,33 @@ async function _verifyAuthenticatedData(parsed, km, opts) {
706
726
  // * the `macKey == null` half of the random-key substitution fires only for a hand-crafted RSA v1.5
707
727
  // ktri (this producer emits OAEP); its behaviour is identical to the tested below-floor path (a
708
728
  // random key -> the MAC verify fails uniformly), so the < 16 vector covers the substitution.
729
+ // _normKeyDer(key) -> { der, owned } -- the recipient private key as PKCS#8 DER, and whether the
730
+ // buffer is one THIS module made.
731
+ //
732
+ // The distinction decides who may wipe it. A caller handing in their own Buffer keeps a live
733
+ // reference and will use it again; wiping that would destroy the key out from under them. The other
734
+ // two forms produce a NEW buffer here -- a Uint8Array is copied, a PEM string is decoded -- and that
735
+ // buffer is a second copy of a private key which nothing else can reach, so it lives until the
736
+ // garbage collector happens to reuse the page unless this module clears it.
737
+ //
738
+ // Returning the flag rather than always copying keeps the caller's buffer un-duplicated: making our
739
+ // own copy of every key so we could uniformly wipe it would ADD a copy of the secret to solve the
740
+ // problem of having one.
709
741
  function _normKeyDer(key) {
710
- if (Buffer.isBuffer(key)) return key;
711
- if (key instanceof Uint8Array) return Buffer.from(key);
712
- if (typeof key === "string") { try { return pkcs8.pemDecode(key); } catch (e) { throw _err("cms/bad-input", "the recipient private-key PEM could not be decoded", e); } }
742
+ if (Buffer.isBuffer(key)) return { der: key, owned: false };
743
+ if (key instanceof Uint8Array) return { der: Buffer.from(key), owned: true };
744
+ if (typeof key === "string") {
745
+ var der;
746
+ try { der = pkcs8.pemDecode(key); }
747
+ catch (e) { throw _err("cms/bad-input", "the recipient private-key PEM could not be decoded", e); }
748
+ return { der: der, owned: true };
749
+ }
713
750
  throw _err("cms/bad-input", "the recipient private key must be a PKCS#8 DER Buffer or PEM string");
714
751
  }
752
+ // Wipe a key buffer this module owns. A no-op for the caller's own Buffer, which is theirs.
753
+ function _releaseKeyDer(k) {
754
+ if (k && k.owned) guard.secret.zeroize(k.der, CmsError, "cms/bad-input", "the recipient private-key copy");
755
+ }
715
756
  function _normCertDer(cert) {
716
757
  if (Buffer.isBuffer(cert)) return cert;
717
758
  if (cert instanceof Uint8Array) return Buffer.from(cert);
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];