@blamejs/pki 0.2.15 → 0.2.17

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,32 @@ 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.2.17 — 2026-07-14
8
+
9
+ Post-quantum SLH-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now sign and verify with all twelve FIPS 205 SLH-DSA parameter sets (RFC 9814), freely mixed with the classical and ML-DSA signers in one message.
10
+
11
+ ### Added
12
+
13
+ - pki.cms.sign and pki.cms.verify sign and verify a CMS SignedData with the twelve pure FIPS 205 SLH-DSA parameter sets (id-slh-dsa-sha2-128s/f, -192s/f, -256s/f and the SHAKE equivalents), RFC 9814: pure mode, empty context, AlgorithmIdentifier parameters absent, over attached or detached content and single or multiple signers -- freely mixed with RSA, RSASSA-PSS, ECDSA, EdDSA, and ML-DSA signers in one message. The signer identifier is issuerAndSerialNumber or subjectKeyIdentifier, and the output is a DER Buffer or PEM.
14
+ - The CMS message-digest algorithm for an SLH-DSA signer is fixed to the parameter set's paired digest (RFC 9814 section 4); signing emits it automatically and rejects a caller digest that contradicts the set, so the SignedData carries the conformant digest for the chosen parameter set.
15
+
16
+ ### Changed
17
+
18
+ - CMS signature verification's one-shot signer-key agreement check (a single algorithm identifier naming both the key and the signature) now covers SLH-DSA alongside EdDSA and ML-DSA: an SLH-DSA SignerInfo whose signer certificate public-key parameter set disagrees with the signatureAlgorithm fails closed with a typed error.
19
+
20
+ ## v0.2.16 — 2026-07-13
21
+
22
+ Post-quantum ML-DSA joins CMS SignedData: pki.cms.sign and pki.cms.verify now sign and verify with ML-DSA-44/65/87 (RFC 9882), freely mixed with the classical signers in one message.
23
+
24
+ ### Added
25
+
26
+ - pki.cms.sign and pki.cms.verify sign and verify a CMS SignedData with the post-quantum ML-DSA-44, ML-DSA-65, and ML-DSA-87 (RFC 9882): pure mode, empty context, AlgorithmIdentifier parameters absent, over attached or detached content and single or multiple signers -- freely mixed with RSA, RSASSA-PSS, ECDSA, and EdDSA signers in one message. The signer identifier is issuerAndSerialNumber or subjectKeyIdentifier, and the output is a DER Buffer or PEM.
27
+ - The CMS message-digest algorithm for an ML-DSA signer is held to the parameter set's security strength (RFC 9882 section 3.3): SHA-512 by default and SHAKE256 optional, with SHA-256 accepted only for ML-DSA-44. A below-strength digest is refused fail-closed on both signing and verification, so a weak message digest cannot cap the collision resistance of a strong ML-DSA signature.
28
+
29
+ ### Changed
30
+
31
+ - CMS signature verification now requires a one-shot signer (EdDSA or ML-DSA, where a single algorithm identifier names both the key and the signature) to present a signer certificate whose public-key algorithm matches the SignerInfo signatureAlgorithm; a disagreement fails closed with a typed error rather than surfacing an opaque import failure.
32
+
7
33
  ## v0.2.15 — 2026-07-13
8
34
 
9
35
  CMS SignedData signing arrives as pki.cms.sign, and RFC 3161 timestamp token creation as pki.tsp.sign -- the producing sides of the CMS and timestamp verifiers, emitting exactly what pki.cms.verify and OpenSSL cms -verify accept.
package/README.md CHANGED
@@ -221,7 +221,7 @@ is callable today; nothing below is a stub.
221
221
  | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
223
  | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
- | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. Fail-closed with typed `cms/*` errors — `sign`, `verify` |
224
+ | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA and the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814)); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. Fail-closed with typed `cms/*` errors — `sign`, `verify` |
225
225
  | `pki.tsp` | RFC 3161 timestamp token creation — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate. The producing side of `pki.schema.tsp.parseToken`; SHA-2 imprints, and any `pki.cms.sign` TSA key — `sign` |
226
226
  | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct` |
227
227
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
package/lib/asn1-der.js CHANGED
@@ -109,6 +109,7 @@ function _className(bits) {
109
109
  case CLASS_APPLICATION: return "application";
110
110
  case CLASS_CONTEXT: return "context";
111
111
  case CLASS_PRIVATE: return "private";
112
+ // Coverage residual -- tagClassBits is (first & 0xc0), always one of the 4 enumerated class values; the default arm is unreachable defensive code.
112
113
  default: return "universal";
113
114
  }
114
115
  }
@@ -394,6 +395,7 @@ function _readIntegerLikeContent(node, typeName, who) {
394
395
  if (c[0] === 0xff && (c[1] & 0x80) !== 0) throw new Asn1Error("asn1/non-minimal-integer", "non-minimal negative " + typeName);
395
396
  }
396
397
  var neg = (c[0] & 0x80) !== 0;
398
+ // Coverage residual -- the c.length===0 guard above throws, so c.length is always >=1 here; the 0n alternate is unreachable.
397
399
  var mag = c.length ? BigInt("0x" + Buffer.from(c).toString("hex")) : 0n;
398
400
  return neg ? mag - (1n << BigInt(c.length * 8)) : mag;
399
401
  }
@@ -734,6 +736,7 @@ function readTime(node, opts) {
734
736
  var d = new Date(0);
735
737
  d.setUTCFullYear(year, month - 1, day);
736
738
  d.setUTCHours(hour, min, sec, ms);
739
+ // Coverage residual -- regex-validated fields are finite integers and Date rolls over (never NaN); out-of-range is caught by the component round-trip check below.
737
740
  if (isNaN(d.getTime())) throw new Asn1Error("asn1/bad-time", "unparseable time " + JSON.stringify(s));
738
741
  // Date/setUTC* silently roll over out-of-range fields (Feb 30 -> Mar 2,
739
742
  // month 13 -> next Jan, hour 25 -> +1 day). Reject unless every component
@@ -767,6 +770,7 @@ function encodeLength(n) {
767
770
  // `children` (not `content`), so the content is those children's DER concatenated;
768
771
  // the identifier becomes the SEQUENCE tag and the length is canonical DER.
769
772
  function sequenceTlv(node) {
773
+ // Coverage residual -- a decoded node with content==null is always constructed with a children array; the || [] guards a non-decoder-built node and is unreachable via decode.
770
774
  var content = node.content != null ? node.content : Buffer.concat((node.children || []).map(function (c) { return c.bytes; }));
771
775
  return Buffer.concat([Buffer.from([TAGS.SEQUENCE | CONSTRUCTED_BIT]), encodeLength(content.length), content]);
772
776
  }
package/lib/cms-sign.js CHANGED
@@ -32,8 +32,8 @@ function O(name) { return oid.byName(name); }
32
32
  // A digest-algorithm name -> the WebCrypto hash (sign path). SHAKE256 has no WebCrypto hash, so
33
33
  // the message digest is always computed with node:crypto (below), uniform across the family.
34
34
  var HASH = { sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
35
- var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512", shake256: "shake256" };
36
- var SHAKE_OUT = { shake256: 64 }; // RFC 8419 sec. 2.3 -- Ed448 uses SHAKE256 with 512-bit output
35
+ var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512", shake128: "shake128", shake256: "shake256" };
36
+ var SHAKE_OUT = { shake128: 32, shake256: 64 }; // SHAKE output lengths: SHAKE128 256-bit (RFC 9814 sec. 4), SHAKE256 512-bit (RFC 8419 sec. 2.3 / RFC 9814)
37
37
  var PSS_SALT = { "SHA-256": 32, "SHA-384": 48, "SHA-512": 64 };
38
38
  var ECDSA_ALG = { sha256: "ecdsaWithSHA256", sha384: "ecdsaWithSHA384", sha512: "ecdsaWithSHA512" };
39
39
  var HASH_NAME_BY_OID = {};
@@ -44,6 +44,31 @@ var EC_BY_CURVE_OID = {};
44
44
  EC_BY_CURVE_OID[O("prime256v1")] = { curve: "P-256", coordLen: 32 };
45
45
  EC_BY_CURVE_OID[O("secp384r1")] = { curve: "P-384", coordLen: 48 };
46
46
  EC_BY_CURVE_OID[O("secp521r1")] = { curve: "P-521", coordLen: 66 };
47
+ // ML-DSA (RFC 9882): the signer cert SPKI algorithm OID -> its WebCrypto name. Registry-keyed
48
+ // (Hard rule #2, no dotted-decimal literal). The same OID identifies both the key and the signature.
49
+ var MLDSA_BY_OID = {};
50
+ MLDSA_BY_OID[O("id-ml-dsa-44")] = "ML-DSA-44";
51
+ MLDSA_BY_OID[O("id-ml-dsa-65")] = "ML-DSA-65";
52
+ MLDSA_BY_OID[O("id-ml-dsa-87")] = "ML-DSA-87";
53
+ // The message-digest algorithms suitable for each ML-DSA parameter set (RFC 9882 sec. 3.3 /
54
+ // Table 1): a digest MUST offer at least the parameter set's collision strength (lambda) and at
55
+ // least 2*lambda bits of output. SHA-512 is suitable for all sets; SHAKE256 (512-bit output) too.
56
+ // Below-strength digests downgrade the signature to the weaker of the two (the whichever-is-lower
57
+ // rule), so they are refused fail-closed on sign and verify -- the RFC's "verifiers MAY reject".
58
+ var MLDSA_SUITABLE_DIGEST = {
59
+ "ML-DSA-44": { sha256: 1, sha384: 1, sha512: 1, shake256: 1 },
60
+ "ML-DSA-65": { sha384: 1, sha512: 1, shake256: 1 },
61
+ "ML-DSA-87": { sha512: 1, shake256: 1 },
62
+ };
63
+ // SLH-DSA (RFC 9814): the twelve pure FIPS 205 sets. The signer cert SPKI algorithm OID -> its
64
+ // WebCrypto set name ("SLH-DSA-"+SET, mirroring webcrypto's SLH_DSA_NODE / path-validate) + the
65
+ // RFC 9814 sec. 4 pinned message digest for that set. The sig and key share one OID; the digest is
66
+ // FIXED per set (no caller choice, unlike ML-DSA's suitable-set). Registry-keyed (Hard rule #2).
67
+ var SLHDSA_BY_OID = {};
68
+ [["sha2-128s", "sha256"], ["sha2-128f", "sha256"], ["sha2-192s", "sha512"], ["sha2-192f", "sha512"],
69
+ ["sha2-256s", "sha512"], ["sha2-256f", "sha512"], ["shake-128s", "shake128"], ["shake-128f", "shake128"],
70
+ ["shake-192s", "shake256"], ["shake-192f", "shake256"], ["shake-256s", "shake256"], ["shake-256f", "shake256"]
71
+ ].forEach(function (r) { SLHDSA_BY_OID[O("id-slh-dsa-" + r[0])] = { wc: "SLH-DSA-" + r[0].toUpperCase(), digest: r[1] }; });
47
72
 
48
73
  var OID_DATA = O("data");
49
74
  var OID_SIGNED_DATA = O("signedData");
@@ -98,7 +123,7 @@ function _digest(digestName, content) {
98
123
  // Resolve the sign scheme from the signer certificate's public-key algorithm + per-signer opts:
99
124
  // the digest, the digestAlgorithm and signatureAlgorithm AlgorithmIdentifiers (with the exact
100
125
  // parameter shape cms.verify requires), and the WebCrypto import + sign algorithms.
101
- function _scheme(cert, so) {
126
+ function _scheme(cert, so, noSignedAttrs) {
102
127
  var alg = cert.subjectPublicKeyInfo.algorithm;
103
128
  var keyOid = alg.oid;
104
129
  if (keyOid === O("rsaEncryption") || keyOid === O("rsassaPss")) {
@@ -131,6 +156,34 @@ function _scheme(cert, so) {
131
156
  if (!NODE_DIGEST[dd]) throw _err("cms/unsupported-algorithm", "unsupported " + name + " digest algorithm " + JSON.stringify(dd));
132
157
  return { digest: dd, digestAlgId: _algId(dd, "absent"), sigAlgId: _algId(name, "absent"), imp: { name: name }, sign: { name: name }, ecdsaDer: false };
133
158
  }
159
+ if (MLDSA_BY_OID[keyOid]) {
160
+ // ML-DSA (RFC 9882): a one-shot post-quantum signature, pure mode, empty context. The signature
161
+ // covers the same sec. 5.4 preimage as every other scheme; digestAlgorithm names the message-
162
+ // digest algorithm (SHA-512 by default -- the sec. 3.3 MUST / interop default), which must be
163
+ // suitable for the parameter set (Q1 -- enforced on sign, refusing a below-strength digest).
164
+ var mlName = MLDSA_BY_OID[keyOid];
165
+ // RFC 9882 sec. 3.3: without signed attributes the digestAlgorithm has no meaning, but the
166
+ // signer MUST still emit SHA-512 to avoid an interoperability failure -- so force it (ignoring
167
+ // any caller digestAlgorithm) rather than carry a value a strict peer would reject. With signed
168
+ // attributes the digest must be suitable for the parameter set (Q1 -- enforced on sign).
169
+ var md;
170
+ if (noSignedAttrs) {
171
+ md = "sha512";
172
+ } else {
173
+ md = so.digestAlgorithm || "sha512";
174
+ if (!NODE_DIGEST[md]) throw _err("cms/unsupported-algorithm", "unsupported ML-DSA message digest " + JSON.stringify(md));
175
+ if (!MLDSA_SUITABLE_DIGEST[mlName][md]) throw _err("cms/unsupported-algorithm", "the " + md + " message digest is below the security strength of " + mlName + " (RFC 9882 sec. 3.3)");
176
+ }
177
+ return { digest: md, digestAlgId: _algId(md, "absent"), sigAlgId: _algId(oid.name(keyOid), "absent"), imp: { name: mlName }, sign: { name: mlName }, ecdsaDer: false };
178
+ }
179
+ if (SLHDSA_BY_OID[keyOid]) {
180
+ // SLH-DSA (RFC 9814): a one-shot post-quantum signature, pure mode, empty context. The message
181
+ // digest is FIXED per parameter set (sec. 4) -- emit it, and reject a contradicting caller
182
+ // digestAlgorithm fail-closed rather than carry a non-conformant digest.
183
+ var slh = SLHDSA_BY_OID[keyOid];
184
+ if (so.digestAlgorithm && so.digestAlgorithm !== slh.digest) throw _err("cms/bad-input", "SLH-DSA " + slh.wc + " requires the " + slh.digest + " message digest (RFC 9814 sec. 4); digestAlgorithm " + JSON.stringify(so.digestAlgorithm) + " conflicts");
185
+ return { digest: slh.digest, digestAlgId: _algId(slh.digest, "absent"), sigAlgId: _algId(oid.name(keyOid), "absent"), imp: { name: slh.wc }, sign: { name: slh.wc }, ecdsaDer: false };
186
+ }
134
187
  throw _err("cms/unsupported-algorithm", "unsupported signer key algorithm " + keyOid);
135
188
  }
136
189
 
@@ -182,7 +235,7 @@ function _buildSignerInfo(signer, content, eContentType, opts) {
182
235
  var so = signer || {};
183
236
  var certDer = _normCertDer(so.cert);
184
237
  var cert = x509.parse(certDer);
185
- var scheme = _scheme(cert, so);
238
+ var scheme = _scheme(cert, so, opts.signedAttributes === false);
186
239
  var useSki = opts.sid === "ski";
187
240
  var sid = useSki
188
241
  ? b.contextPrimitive(0, _skiValue(cert)) // [0] IMPLICIT SubjectKeyIdentifier
@@ -321,4 +374,7 @@ function _toBuf(v, what) {
321
374
  // * `_assertKeyMatchesScheme`'s `!ka.hash` guard -- an `imp.hash` is set only for an RSA
322
375
  // scheme, which requires `ka.name` to already equal the RSA name (else the earlier name
323
376
  // check throws); an RSA CryptoKey always carries a `hash`, so `!ka.hash` never fires.
324
- module.exports = { sign: sign };
377
+ // MLDSA_SUITABLE_DIGEST is shared with cms-verify (which requires this module) so the per-
378
+ // parameter-set digest-strength policy has ONE home -- a digest accepted on sign is exactly the
379
+ // set accepted on verify, and neither side can drift from the other (RFC 9882 sec. 3.3).
380
+ module.exports = { sign: sign, MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST, SLHDSA_BY_OID: SLHDSA_BY_OID };
package/lib/cms-verify.js CHANGED
@@ -13,10 +13,14 @@
13
13
  * confirms the message-digest attribute equals the digest of the content and verifies the
14
14
  * signature over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced
15
15
  * by a universal SET OF); otherwise it verifies directly over the content. Attached and
16
- * detached content, single and multiple signers, and RSA / RSASSA-PSS / ECDSA / EdDSA are
17
- * covered. It reports a per-signer verdict; it does NOT chain the signer certificate to a
18
- * trust anchor -- that is the caller's step through `pki.path.validate`.
16
+ * detached content, single and multiple signers, and RSA / RSASSA-PSS / ECDSA / EdDSA and the
17
+ * post-quantum ML-DSA (ML-DSA-44/65/87, RFC 9882) plus SLH-DSA (the twelve FIPS 205 sets, RFC 9814)
18
+ * are covered. It reports a per-signer verdict;
19
+ * it does NOT chain the signer certificate to a trust anchor -- that is the caller's step through
20
+ * `pki.path.validate`.
19
21
  * @spec RFC 5652
22
+ * @spec RFC 9882
23
+ * @spec RFC 9814
20
24
  * @card Verify a CMS SignedData signature (S/MIME, timestamps, code signing).
21
25
  */
22
26
 
@@ -29,6 +33,8 @@ var webcrypto = require("./webcrypto");
29
33
  var subtle = webcrypto.webcrypto.subtle;
30
34
  var edwardsPoint = require("./edwards-point");
31
35
  var cmsSign = require("./cms-sign");
36
+ var MLDSA_SUITABLE_DIGEST = cmsSign.MLDSA_SUITABLE_DIGEST; // shared sign/verify digest-strength policy (RFC 9882 sec. 3.3)
37
+ var SLHDSA_BY_OID = cmsSign.SLHDSA_BY_OID; // shared SLH-DSA set -> { wc, digest } (RFC 9814 sec. 4 pinned digest)
32
38
  var validator = require("./validator-all");
33
39
  var guard = require("./guard-all");
34
40
  var frameworkError = require("./framework-error");
@@ -43,7 +49,7 @@ var OID_CONTENT_TYPE = oid.byName("contentType");
43
49
  var DIGEST_HASH = { sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
44
50
  // SHAKE256 (RFC 8419 sec. 2.3, the Ed448 message digest) has no WebCrypto hash, so it is computed
45
51
  // with node:crypto; its 512-bit (64-byte) output length is fixed by the profile.
46
- var SHAKE_OUT = { shake256: 64 };
52
+ var SHAKE_OUT = { shake128: 32, shake256: 64 }; // SHAKE128 256-bit (RFC 9814 sec. 4), SHAKE256 512-bit
47
53
  // Is `name` a message-digest algorithm this verifier supports (SHA-2 family or SHAKE256)?
48
54
  function _supportedDigest(name) { return !!(DIGEST_HASH[name] || SHAKE_OUT[name]); }
49
55
  // The digest of `content` under the named algorithm, resolved to a Buffer.
@@ -61,8 +67,14 @@ var SIG_SCHEME = {
61
67
  rsaEncryption: { kind: "rsa", params: "null" },
62
68
  rsassaPss: { kind: "rsapss" },
63
69
  ecPublicKey: { kind: "ec", params: "absent" },
64
- Ed25519: { kind: "eddsa", name: "Ed25519", params: "absent" },
65
- Ed448: { kind: "eddsa", name: "Ed448", params: "absent" },
70
+ // One-shot families (EdDSA, ML-DSA): the same OID identifies the key and the signature, so the
71
+ // signer cert SPKI algorithm OID MUST equal the signatureAlgorithm OID -- `sameKeyOid` enables
72
+ // that agreement check (RFC 8410 / RFC 9882; enforced in _verifyAgainstCandidates).
73
+ Ed25519: { kind: "eddsa", name: "Ed25519", params: "absent", sameKeyOid: true },
74
+ Ed448: { kind: "eddsa", name: "Ed448", params: "absent", sameKeyOid: true },
75
+ "id-ml-dsa-44": { kind: "mldsa", name: "ML-DSA-44", params: "absent", sameKeyOid: true },
76
+ "id-ml-dsa-65": { kind: "mldsa", name: "ML-DSA-65", params: "absent", sameKeyOid: true },
77
+ "id-ml-dsa-87": { kind: "mldsa", name: "ML-DSA-87", params: "absent", sameKeyOid: true },
66
78
  sha1WithRSAEncryption: { kind: "rsa", hash: "SHA-1", params: "null" },
67
79
  sha256WithRSAEncryption: { kind: "rsa", hash: "SHA-256", params: "null" },
68
80
  sha384WithRSAEncryption: { kind: "rsa", hash: "SHA-384", params: "null" },
@@ -72,6 +84,12 @@ var SIG_SCHEME = {
72
84
  ecdsaWithSHA384: { kind: "ec", hash: "SHA-384", params: "absent" },
73
85
  ecdsaWithSHA512: { kind: "ec", hash: "SHA-512", params: "absent" },
74
86
  };
87
+ // SLH-DSA (RFC 9814): the twelve pure FIPS 205 sets, seeded like webcrypto's SLH_DSA_NODE. Each is
88
+ // a one-shot family (kind "slhdsa"), params absent, sig-OID == key-OID (sameKeyOid). The WebCrypto
89
+ // name is "SLH-DSA-"+SET, matching path-validate's transform (drift rule sec. 5 -- one name map).
90
+ ["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
91
+ "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
92
+ ].forEach(function (s) { SIG_SCHEME["id-slh-dsa-" + s] = { kind: "slhdsa", name: "SLH-DSA-" + s.toUpperCase(), params: "absent", sameKeyOid: true, digest: SLHDSA_BY_OID[oid.byName("id-slh-dsa-" + s)].digest }; });
75
93
  // The signatureAlgorithm parameters match the algorithm's fixed shape (RFC 5754). A DER NULL
76
94
  // is exactly `05 00`; "absent" is the parameters field omitted entirely.
77
95
  function _isDerNull(p) { return Buffer.isBuffer(p) && p.length === 2 && p[0] === 0x05 && p[1] === 0x00; }
@@ -111,7 +129,7 @@ function _findSignerCerts(sid, parsedCerts) {
111
129
  // through to the next -- so a colliding certificate cannot make a valid signature read invalid.
112
130
  // When no candidate verifies, a plain false is a code-less verdict; the last structural fault's
113
131
  // code is surfaced if one occurred (so the diagnostic is not lost).
114
- function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, candidates, pssSalt) {
132
+ function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, candidates, pssSalt, expectedKeyOid) {
115
133
  var lastErr = null;
116
134
  function attempt(idx) {
117
135
  if (idx >= candidates.length) {
@@ -119,6 +137,13 @@ function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, c
119
137
  : { ok: false, sid: sid, cert: candidates[0].der };
120
138
  }
121
139
  var c = candidates[idx];
140
+ // One-shot family (EdDSA/ML-DSA): the signer cert public-key algorithm OID MUST equal the
141
+ // SignerInfo signatureAlgorithm OID. A candidate whose SPKI disagrees is skipped with a precise
142
+ // verdict, rather than a foreign webcrypto/data throw from importing under the wrong name.
143
+ if (expectedKeyOid && c.cert.subjectPublicKeyInfo.algorithm.oid !== expectedKeyOid) {
144
+ lastErr = _err("cms/unsupported-algorithm", "the signer certificate public-key algorithm does not match the SignerInfo signatureAlgorithm");
145
+ return attempt(idx + 1);
146
+ }
122
147
  return Promise.resolve()
123
148
  .then(function () { return _verifySignature(scheme, sigHash, sigBytes, c.cert.subjectPublicKeyInfo.bytes, signedBytes, _certCurveOid(c.cert), pssSalt); })
124
149
  .then(function (ok) { return ok === true ? { ok: true, sid: sid, cert: c.der } : attempt(idx + 1); },
@@ -225,6 +250,14 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
225
250
  return subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: ec.curve }, false, ["verify"])
226
251
  .then(function (k) { return subtle.verify({ name: "ECDSA", hash: hashName }, k, raw, signedBytes); });
227
252
  }
253
+ if (scheme.kind === "mldsa" || scheme.kind === "slhdsa") {
254
+ // ML-DSA (RFC 9882) / SLH-DSA (RFC 9814): a one-shot post-quantum verify, pure mode, empty
255
+ // context -- no external signature hash and no Edwards-point validation (FIPS 204 sec. 5.3 /
256
+ // FIPS 205 sec. 10.3; node structurally validates the SPKI on import). The signature is raw and
257
+ // fixed-length (not ECDSA-DER).
258
+ return subtle.importKey("spki", spki, { name: scheme.name }, false, ["verify"])
259
+ .then(function (k) { return subtle.verify({ name: scheme.name }, k, sigBytes, signedBytes); });
260
+ }
228
261
  // EdDSA -- the WebCrypto name follows the signing key's SPKI OID (Ed25519 / Ed448). node/
229
262
  // OpenSSL imports any Ed25519/Ed448 SPKI without validating the point, and a low-order (e.g.
230
263
  // all-zeroes) key verifies a forged signature -- so reject a non-full-order point first.
@@ -288,15 +321,36 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
288
321
  if (scheme.params && !_algParamsOk(scheme.params, si.signatureAlgorithm.parameters)) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the " + si.signatureAlgorithm.name + " signature algorithm parameters must be " + (scheme.params === "null" ? "DER NULL (RFC 4055)" : "absent (RFC 5758/8410)") });
289
322
  // The digestAlgorithm parameters, when present, MUST be a DER NULL (RFC 5754 sec. 2 accepts
290
323
  // absent OR NULL; a present non-NULL is malformed and, being outside the signed preimage,
291
- // must fail closed like the signatureAlgorithm parameters above).
324
+ // must fail closed like the signatureAlgorithm parameters above). EXCEPT for an ML-DSA
325
+ // SignerInfo WITHOUT signed attributes: RFC 9882 sec. 3.3 says the whole digestAlgorithm field
326
+ // has no meaning there and MUST be ignored, so neither its parameters nor its name may reject.
292
327
  var dp = si.digestAlgorithm.parameters;
293
- if (dp !== null && dp !== undefined && !_isDerNull(dp)) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the " + si.digestAlgorithm.name + " digest algorithm parameters must be absent or DER NULL (RFC 5754 sec. 2)" });
328
+ var mldsaNoAttrs = scheme.kind === "mldsa" && !si.signedAttrsBytes;
329
+ if (!mldsaNoAttrs && dp !== null && dp !== undefined && !_isDerNull(dp)) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the " + si.digestAlgorithm.name + " digest algorithm parameters must be absent or DER NULL (RFC 5754 sec. 2)" });
330
+ // A SHAKE digest algorithm identifier (id-shake128 / id-shake256) carries NO parameters at all --
331
+ // not even a DER NULL (RFC 8702 sec. 3.1) -- so for a one-shot PQC SignerInfo (ML-DSA per RFC 9882,
332
+ // SLH-DSA per RFC 9814 sec. 4, both citing RFC 8702) with signed attributes a present SHAKE
333
+ // parameter fails closed. The SHA-2 digests (id-sha256/384/512) follow RFC 5754, which requires a
334
+ // verifier to accept absent OR NULL (the generic rule above); the RFCs' omit-on-encode rule binds
335
+ // the signer, not the verifier, so a SHA-2 NULL is not tightened here.
336
+ if ((scheme.kind === "mldsa" || scheme.kind === "slhdsa") && si.signedAttrsBytes && (si.digestAlgorithm.name === "shake128" || si.digestAlgorithm.name === "shake256") && dp !== null && dp !== undefined) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "a SHAKE digestAlgorithm carries no parameters (RFC 8702 sec. 3.1)" });
294
337
  var sigHash = pss ? pss.hash : (scheme.hash || digestHash);
295
338
  // The signature hash is required for every non-EdDSA scheme (EdDSA hashes internally);
296
339
  // the content digest is required whenever signed attributes are present, for every scheme
297
340
  // (the message-digest attribute is computed under digestAlgorithm). Either gap is a
298
341
  // fail-closed unsupported-algorithm verdict, never a foreign-domain throw from the digest.
299
- if ((scheme.kind !== "eddsa" && !sigHash) || (si.signedAttrsBytes && !_supportedDigest(si.digestAlgorithm.name))) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "unsupported digest algorithm " + JSON.stringify(si.digestAlgorithm.name) });
342
+ if ((scheme.kind !== "eddsa" && scheme.kind !== "mldsa" && scheme.kind !== "slhdsa" && !sigHash) || (si.signedAttrsBytes && !_supportedDigest(si.digestAlgorithm.name))) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "unsupported digest algorithm " + JSON.stringify(si.digestAlgorithm.name) });
343
+ // ML-DSA (RFC 9882 sec. 3.3): with signed attributes present, the message-digest algorithm MUST
344
+ // meet the parameter set's security strength -- a below-strength digest is the weaker link and is
345
+ // rejected fail-closed (the RFC's "verifiers MAY reject", taken by the strict-verifier posture).
346
+ // With signed attributes absent the digestAlgorithm has no meaning (sec. 3.3) and is not checked.
347
+ if (scheme.kind === "mldsa" && si.signedAttrsBytes && !MLDSA_SUITABLE_DIGEST[scheme.name][si.digestAlgorithm.name]) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the " + si.digestAlgorithm.name + " message digest is below the security strength of " + scheme.name + " (RFC 9882 sec. 3.3)" });
348
+ // RFC 9814 sec. 4: an SLH-DSA SignerInfo's message-digest algorithm is the one paired with the
349
+ // parameter set (the sec. 4 list, always at least twice the tree-hash size); the signer emits it
350
+ // and this strict verifier requires it, so a digest that does not match the set's paired hash fails
351
+ // closed -- otherwise a signer could compute the message-digest attribute under a weaker hash the
352
+ // set does not pair. Gated on signed attributes (absent -> the digest is not consulted, sec. 4).
353
+ if (scheme.kind === "slhdsa" && si.signedAttrsBytes && si.digestAlgorithm.name !== scheme.digest) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "SLH-DSA " + scheme.name + " requires the " + scheme.digest + " message digest (RFC 9814 sec. 4)" });
300
354
  var signers = _findSignerCerts(si.sid, parsedCerts);
301
355
  if (!signers.length) return Promise.resolve({ ok: false, code: "cms/signer-cert-not-found", sid: si.sid, message: "no certificate matches this SignerInfo's signer identifier" });
302
356
  var sigBytes = _toBuf(si.signature, "the SignerInfo signature");
@@ -337,7 +391,7 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
337
391
  });
338
392
  }).then(function (signedBytes) {
339
393
  if (signedBytes && signedBytes.mismatch) return { ok: false, code: signedBytes.mismatch.code, sid: si.sid, cert: signers[0].der, message: signedBytes.mismatch.message };
340
- return _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, si.sid, signers, pss ? pss.saltLength : 0);
394
+ return _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, si.sid, signers, pss ? pss.saltLength : 0, scheme.sameKeyOid ? oid.byName(si.signatureAlgorithm.name) : null);
341
395
  });
342
396
  }
343
397
 
@@ -347,6 +401,8 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
347
401
  * @since 0.2.14
348
402
  * @status experimental
349
403
  * @spec RFC 5652
404
+ * @spec RFC 9882
405
+ * @spec RFC 9814
350
406
  * @defends cms-signature-forgery (CWE-347)
351
407
  * @related pki.schema.cms.parse, pki.path.validate
352
408
  *
@@ -354,7 +410,9 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
354
410
  * `Buffer`, or a parsed `pki.schema.cms` object. Returns `{ valid, signers }` where each
355
411
  * `signers[i]` is `{ ok, sid, cert }` (`cert` the matched signer certificate DER) or carries
356
412
  * a `code` on a structural failure; `valid` is true when there is at least one signer and
357
- * every signer verified.
413
+ * every signer verified. RSA (PKCS#1 v1.5 and RSASSA-PSS), ECDSA, EdDSA, and the post-quantum
414
+ * ML-DSA (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814) -- pure mode,
415
+ * empty context -- signatures are recognized.
358
416
  *
359
417
  * @opts content The detached content (a `Buffer`) when the SignedData carries no
360
418
  * encapsulated eContent. Required for a detached signature.
@@ -419,6 +477,8 @@ function _addCert(out, der) {
419
477
  * @since 0.2.15
420
478
  * @status experimental
421
479
  * @spec RFC 5652
480
+ * @spec RFC 9882
481
+ * @spec RFC 9814
422
482
  * @related pki.cms.verify, pki.schema.cms.parse
423
483
  *
424
484
  * Produce a CMS SignedData (RFC 5652 sec. 5) over `content` (a `Buffer`) -- the structure
@@ -429,7 +489,9 @@ function _addCert(out, der) {
429
489
  * signature covers the RFC 5652 sec. 5.4 preimage: with signed attributes (the default) the
430
490
  * message-digest attribute is bound to the content digest and the signature is over the
431
491
  * canonical DER SET OF SignedAttributes; otherwise over the content directly. RSA (PKCS#1 v1.5
432
- * and, with `pss`, RSASSA-PSS), ECDSA (P-256/384/521), Ed25519, and Ed448 are covered.
492
+ * and, with `pss`, RSASSA-PSS), ECDSA (P-256/384/521), Ed25519, Ed448, and the post-quantum ML-DSA
493
+ * (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814 -- the message digest
494
+ * pinned per parameter set) are covered.
433
495
  *
434
496
  * @opts detached Omit the encapsulated content (a detached signature; the verifier
435
497
  * supplies the content). Default false.
package/lib/inspect.js CHANGED
@@ -63,6 +63,12 @@ function _hexColon(buf, opts) {
63
63
  return lines.join("\n");
64
64
  }
65
65
 
66
+ // Coverage residual -- two _hexColon default arms are unreachable through the public API:
67
+ // * `opts = opts || {}` -- all call sites pass an explicit opts object literal, so the
68
+ // `|| {}` default never fires.
69
+ // * `" ".repeat(opts.indent || 0)` -- every wrap-mode caller passes a positive indent
70
+ // (pad.length + 8 >= 8, or inner.length == 16), so `opts.indent` is never falsy.
71
+
66
72
  // Control-byte neutralization for a GeneralName string value routes through the
67
73
  // guard family (the guard-shape-reinlined detector protects the shape).
68
74
  // @guard-via guard\.name\.escape
@@ -217,6 +223,28 @@ function _gnRaw(buf) {
217
223
  } catch (_e) { return _hexColon(buf, {}); }
218
224
  }
219
225
 
226
+ // Coverage residual -- the GeneralName render fallbacks are unreachable because the strict
227
+ // generalName decoder and each caller already narrow the shape (the _gnRaw catch above is
228
+ // separately documented):
229
+ // * _ipString `if (!Buffer.isBuffer(buf)) return "";` -- every caller passes a Buffer (the
230
+ // recursion slices a Buffer, _gn guards Buffer.isBuffer(g.value), _gnRaw passes
231
+ // asn1.decode(...).content).
232
+ // * _ipString trailing `return _hexColon(buf, {});` -- generalName enforces iPAddress to
233
+ // 4/16 octets (8/32 for a name-constraints subtree base), so length is only ever 4/8/16/32.
234
+ // * _gn `if (!g || typeof g !== "object") return "";` -- every _gn call maps a decoder-produced
235
+ // GeneralName object; the decoders never emit a null element.
236
+ // * _gn directoryName `: ((g.value && g.value.dn) || "")` -- a decoded directoryName [4] always
237
+ // carries a Name with an rdns array, so the rdns arm is always taken.
238
+ // * _gn otherName `: "<unsupported>"` -- a decoded GeneralName always carries its raw bytes TLV.
239
+ // * _gn `|| ("tag" + t)` -- GeneralName CHOICE tags are 0..8, all mapped in NAMES.GENERAL_NAME;
240
+ // generalName rejects a tag outside 0..8.
241
+ // * _gn `? _hexColon(g.value, {})` -- iPAddress [7] is the only choice whose decoded value is a
242
+ // Buffer, handled at the t === 7 branch before this ternary; other tags' value is string or null.
243
+ // * _gn trailing `: ""` -- a decoded GeneralName always carries a bytes Buffer, so the
244
+ // Buffer.isBuffer(g.bytes) arm is always taken.
245
+ // * _gnRaw `if (!Buffer.isBuffer(buf)) return "";` -- the sole caller (the CRL-DP fullName loop)
246
+ // guards Buffer.isBuffer(nm) before calling _gnRaw.
247
+
220
248
  // Shared value renderers reused by more than one extension key.
221
249
  function _renderAltName(decoded, inner) {
222
250
  return inner + (decoded.names || []).map(_gn).join(", ");
@@ -254,6 +282,18 @@ function _renderCrlDp(decoded, inner) {
254
282
  return dpLines.join("\n");
255
283
  }
256
284
 
285
+ // Coverage residual -- the shared alt-name / CRL-DP render fallbacks are unreachable because the
286
+ // strict decoders already narrow the shape:
287
+ // * _renderAltName `(decoded.names || [])` -- the subjectAltName/issuerAltName decoder always
288
+ // yields a names array.
289
+ // * _renderCrlDp `(decoded || [])` -- the cRLDistributionPoints/freshestCRL decoder always
290
+ // yields an array.
291
+ // * _renderCrlDp `: _gn(nm)` -- distributionPointName surfaces fullName entries as raw
292
+ // GeneralName Buffers, so Buffer.isBuffer(nm) is always true.
293
+ // * _renderCrlDp `if (!wrote) ... "(distribution point)"` -- crlDistributionPoints throws unless
294
+ // a distributionPoint (always fullName/rdn) or cRLIssuer (sets wrote) is present, so !wrote
295
+ // never holds.
296
+
257
297
  // Declarative extension-value renderer registry: extension name -> (decoded, inner) ->
258
298
  // text. Data-driven dispatch (the schema family's "registry, not switch" shape) so a
259
299
  // new extension is a row rather than another hand-coded branch, and the set an
@@ -365,6 +405,35 @@ var EXT_RENDERERS = {
365
405
  },
366
406
  };
367
407
 
408
+ // Coverage residual -- the EXT_RENDERERS entry fallbacks are unreachable because each shared
409
+ // decoder (and asn1.read.oid) already narrows the shape before the renderer runs:
410
+ // * extKeyUsage `catch (_e) { /* unregistered EKU OID */ }` and certificatePolicies
411
+ // `catch (_e) { /* unregistered qualifier */ }` -- oid.name returns undefined (never throws)
412
+ // for a well-formed unregistered OID; it throws only on a non-dotted argument, and both OIDs
413
+ // come from asn1.read.oid (always a valid dotted OID).
414
+ // * certificatePolicies `(asn1.decode(p.qualifiersBytes).children || [])` -- asn1.decode of the
415
+ // assertPolicyQualifiers-validated qualifiers SEQUENCE always yields a children array.
416
+ // * certificatePolicies `: Buffer.alloc(0)` -- assertPolicyQualifiers requires every
417
+ // PolicyQualifierInfo to be a 2-child SEQUENCE, so pqi.children[1] is always a present node
418
+ // carrying a bytes Buffer.
419
+ // * certificatePolicies outer `catch (_e) { ... _hexColon(p.qualifiersBytes, {}) ... }` --
420
+ // certificatePolicies already validated qualifiersBytes as a SEQUENCE of 2-child PQIs each
421
+ // leading with a valid OID, so the re-decode + asn1.read.oid cannot throw.
422
+ // * policyConstraints `: inner + "(empty)"` -- policyConstraints rejects an empty SEQUENCE
423
+ // (>= 1 context field), so requireExplicitPolicy or inhibitPolicyMapping is non-null;
424
+ // pc.length is never 0.
425
+ // * SCT `(decoded.scts || [])` / `(decoded.unknownScts || [])` -- ct.parseSctList always returns
426
+ // both arrays.
427
+ // * SCT `: 0` -- every scts entry is a decoded v1 SCT with numeric version 0 (unknown-version
428
+ // SCTs go to unknownScts and are not iterated here).
429
+ // * SCT `: inner + "(empty SCT list)"` -- ct.parseSctList rejects an empty list and routes every
430
+ // SerializedSCT into scts/unknownScts, so at least one line is always emitted.
431
+ // * subjectKeyIdentifier `: (decoded.bytes || Buffer.alloc(0))` -- the subjectKeyIdentifier
432
+ // decoder returns the KeyIdentifier as a Buffer, so Buffer.isBuffer(decoded) is always true.
433
+ // * authorityKeyIdentifier `: BigInt(decoded.authorityCertSerialNumber)` -- the AKI decoder reads
434
+ // authorityCertSerialNumber via asn1.read.integerImplicit (a bigint), so typeof === "bigint"
435
+ // is always true.
436
+
368
437
  function _renderExtValue(ext, decoded, inner) {
369
438
  var fn = EXT_RENDERERS[ext.name];
370
439
  return fn ? fn(decoded, inner) : null; // no registered renderer -> caller hex-dumps
@@ -476,6 +545,8 @@ function certificate(input) {
476
545
  L.push(" Subject: " + _dnString(c.subject));
477
546
  L.push(" Subject Public Key Info:");
478
547
  L.push(_keyBlock(c.subjectPublicKeyInfo, " "));
548
+ // Coverage residual -- `c.extensions || []` is unreachable: both input paths guarantee an array
549
+ // (x509.parse yields one; the pre-parsed fast path requires Array.isArray(extensions) in _looksParsed).
479
550
  if ((c.extensions || []).length) {
480
551
  L.push(" X509v3 extensions:");
481
552
  c.extensions.forEach(function (ext) { L.push(_extension(ext, " ")); });
package/lib/oid.js CHANGED
@@ -284,7 +284,7 @@ var FAMILIES = {
284
284
 
285
285
  // NIST hash functions (SHA-2, SHA-3, SHAKE).
286
286
  nistHash: { base: [2, 16, 840, 1, 101, 3, 4, 2], of: {
287
- sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
287
+ sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake128: 11, shake256: 12 } },
288
288
 
289
289
  // NIST signature algorithms -- FIPS 204 ML-DSA + FIPS 205 SLH-DSA share the
290
290
  // signature arc 2.16.840.1.101.3.4.3. RFC 9909 sec. 3 assigns the 12 Pure SLH-DSA
@@ -187,6 +187,9 @@ function hashAlgOid(seq) {
187
187
  }
188
188
  // The hash OID inside an EXPLICIT [n] wrapper around a hash AlgorithmIdentifier.
189
189
  function explicitHashAlgOid(wrapper) {
190
+ // Coverage residual -- unreachable: the sole caller (resolveRsaPss) already
191
+ // asserts the EXPLICIT wrapper carries exactly one child before calling this, so
192
+ // this identical inner check cannot fire; it is a local defense-in-depth backstop.
190
193
  if (!wrapper.children || wrapper.children.length !== 1) throw E("path/unsupported-algorithm", "malformed EXPLICIT hash AlgorithmIdentifier");
191
194
  return hashAlgOid(wrapper.children[0]);
192
195
  }
@@ -261,6 +264,10 @@ function resolveDescriptor(sigAlg) {
261
264
  var comp = COMPOSITE_ALGS[sigAlg.oid];
262
265
  if (comp) {
263
266
  // draft-ietf-lamps-pq-composite-sigs sec. 5.3: parameters MUST be absent.
267
+ // Coverage residual -- unreachable: every sigAlg reaching resolveDescriptor is a
268
+ // field of a parsed x509/CRL/OCSP structure, and the shared AlgorithmIdentifier
269
+ // decoder (schema-pkix) rejects a composite OID carrying parameters at parse time
270
+ // (oid.paramsMustBeAbsent), so `parameters` is always absent here. Defense in depth.
264
271
  if (sigAlg.parameters !== null && sigAlg.parameters !== undefined) {
265
272
  throw E("path/unsupported-algorithm", "composite signature algorithm parameters must be absent (draft-ietf-lamps-pq-composite-sigs sec. 5.3)");
266
273
  }
@@ -302,6 +309,9 @@ function assertKeyMatchesSigAlg(spkiBytes, sigOid, d) {
302
309
  function ecdsaDerToP1363(der, curve) {
303
310
  var width = CURVE_FIELD_BYTES[curve];
304
311
  var order = CURVE_ORDER[curve];
312
+ // Coverage residual -- unreachable: `curve` is a WebCrypto-imported ECDSA
313
+ // namedCurve (P-256/384/521) or a composite trad.ec from the same set; all three
314
+ // are present in the width/order tables, so no import yields an untabulated curve.
305
315
  if (!width || !order) throw E("path/bad-signature", "unsupported ECDSA curve " + curve);
306
316
  var n;
307
317
  try { n = asn1.decode(der); }
@@ -318,6 +328,8 @@ function ecdsaDerToP1363(der, curve) {
318
328
  var hex = v.toString(16);
319
329
  if (hex.length % 2) hex = "0" + hex;
320
330
  var buf = Buffer.from(hex, "hex");
331
+ // Coverage residual -- unreachable: the preceding [1, order-1] range gate bounds
332
+ // r and s below the curve order, which fits within `width` bytes.
321
333
  if (buf.length > width) throw E("path/bad-signature", "ECDSA signature component wider than the curve field");
322
334
  var out = Buffer.alloc(width);
323
335
  buf.copy(out, width - buf.length);
@@ -424,6 +436,9 @@ function _verifyTradComponent(trad, tradPK, tradSig, mprime) {
424
436
  return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSASSA-PKCS1-v1_5", hash: trad.hash }, false, ["verify"])
425
437
  .then(function (k) { return subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, k, tradSig, mprime); });
426
438
  }
439
+ // Coverage residual -- unreachable: compositeVerify returns early for a
440
+ // trad.unsupported arm, so _verifyTradComponent runs only for arms that set
441
+ // exactly one of ec/eddsa/rsaPss/rsaPkcs1; this final fallthrough is a backstop.
427
442
  return Promise.resolve(false);
428
443
  }
429
444
 
@@ -714,6 +729,9 @@ function certNameForms(cert) {
714
729
  // constraint check fails such a form closed (name-constraint-unsupported).
715
730
  san.value.names.forEach(function (nm) {
716
731
  if (nm.tagNumber === 1) hasRfc822San = true; // an rfc822Name SAN carries the email identity
732
+ // Coverage residual -- the `undefined -> null` arm is unreachable: the SAN
733
+ // decoder (schema-pkix altName, decodeValue:true) routes through generalName,
734
+ // which already maps an undecoded value to null, so nm.value is never undefined.
717
735
  forms.push({ tag: nm.tagNumber, value: nm.value === undefined ? null : nm.value });
718
736
  });
719
737
  }
@@ -808,6 +826,8 @@ function treeWithoutParent(node) {
808
826
  }
809
827
  function leavesAt(tree, depth) {
810
828
  var out = [];
829
+ // Coverage residual -- unreachable: every call site guards state.validPolicyTree
830
+ // truthy before calling leavesAt; the null-tree early return is a backstop.
811
831
  if (!tree) return out; // a pruned-empty tree has no nodes
812
832
  (function walk(node) {
813
833
  if (node.depth === depth) { out.push(node); return; }
@@ -1135,6 +1155,10 @@ function applyPolicyMappings(state, mappings, i) {
1135
1155
  // extension may have already emptied the tree -- stop if it is gone.
1136
1156
  var mappedSet = {};
1137
1157
  mappings.forEach(function (m) { mappedSet[m.issuerDomainPolicy] = true; });
1158
+ // Coverage residual -- unreachable today: applyPolicyMappings is invoked once per
1159
+ // certificate under an `if (state.validPolicyTree)` guard and does not null the
1160
+ // tree before this point. Retained as a cheap correctness backstop for a future
1161
+ // per-mapping-batch refactor; do not remove.
1138
1162
  if (!state.validPolicyTree) return;
1139
1163
  leavesAt(state.validPolicyTree, depth).forEach(function (node) {
1140
1164
  if (mappedSet[node.validPolicy] && node.parent) {
@@ -1773,6 +1797,8 @@ function crlChecker(crls) {
1773
1797
  // process (anything but reasonCode) makes the CRL unusable for ANY
1774
1798
  // certificate, not just the entry that carries it.
1775
1799
  for (var ry = 0; ry < theCrl.revokedCertificates.length && !unhandledCritical; ry++) {
1800
+ // Coverage residual -- the `|| []` fallback is unreachable: schema-crl sets
1801
+ // crlEntryExtensions to [] when absent, so it is always an array.
1776
1802
  var ees = theCrl.revokedCertificates[ry].crlEntryExtensions || [];
1777
1803
  for (var ex = 0; ex < ees.length; ex++) {
1778
1804
  // Key on the stable OID only -- a display name is registry-dependent
@@ -1893,6 +1919,8 @@ function crlChecker(crls) {
1893
1919
  // number), or null when absent.
1894
1920
  var OID_REASON_CODE = oid.byName("reasonCode");
1895
1921
  function crlEntryReason(entry) {
1922
+ // Coverage residual -- the `|| []` fallback is unreachable: schema-crl guarantees
1923
+ // crlEntryExtensions is an array (empty when absent).
1896
1924
  var exts = entry.crlEntryExtensions || [];
1897
1925
  for (var i = 0; i < exts.length; i++) {
1898
1926
  if (exts[i].oid === OID_REASON_CODE) return exts[i].value; // stable OID, not the display name
@@ -1963,6 +1991,9 @@ function ocspResponderSpki(rc, issuer) {
1963
1991
  var keyAlg = rc.subjectPublicKeyInfo.algorithm;
1964
1992
  if (!isNullOrAbsentParams(keyAlg.parameters)) return rc.subjectPublicKeyInfo.bytes;
1965
1993
  var issuerOid, issuerParams;
1994
+ // Coverage residual -- the catch is unreachable: this function runs only after the
1995
+ // delegate's signature verified under issuer.workingPublicKey, so that SPKI already
1996
+ // imported and decodes cleanly here. (The children[1] ? : null false side IS covered.)
1966
1997
  try {
1967
1998
  var alg = asn1.decode(issuer.workingPublicKey).children[0];
1968
1999
  issuerOid = asn1.read.oid(alg.children[0]);
@@ -2149,6 +2180,9 @@ function ocspChecker(responses) {
2149
2180
  var resp = parsed[k];
2150
2181
  if (resp.responseStatus.code !== 0) continue; // non-successful -> conveys no status
2151
2182
  var br = resp.basicResponse;
2183
+ // Coverage residual -- unreachable for a parsed response: schema-ocsp enforces
2184
+ // the successful-status <-> responseBytes biconditional, so a code-0 response
2185
+ // always carries a basicResponse. Backstop for a hand-built object.
2152
2186
  if (!br) continue;
2153
2187
  var signerSpki = await ocspAuthorizeResponder(br, cert, issuer, issuerKeyBits, time);
2154
2188
  if (!signerSpki) continue; // unauthorized responder
package/lib/schema-cms.js CHANGED
@@ -146,6 +146,9 @@ function _checkAttrPlacement(attrs, place) {
146
146
  for (var i = 0; i < attrs.length; i++) {
147
147
  var row = ATTR_FORBIDDEN_IN[attrs[i].type];
148
148
  if (row && row[place]) {
149
+ // Coverage residual -- reached only when the OID is a registry entry (the guard above
150
+ // guarantees it), so oid.name() never returns undefined and this fallback is a
151
+ // defensive belt, not selectable through the public parse path.
149
152
  throw NS.E("cms/misplaced-attr", "the " + (oid.name(attrs[i].type) || attrs[i].type) +
150
153
  " attribute must not be " + ATTR_PLACE_LABELS[place] + " (RFC 5652 sec. 11)");
151
154
  }
@@ -250,6 +253,10 @@ function _validateAeadParams(alg, macLen) {
250
253
  // sec. 3.1/sec. 3.2: the parameters field MUST be present and carry the Parameters SEQUENCE.
251
254
  if (alg.parameters === null) throw NS.E("cms/bad-aead-params", "an AES-" + K + " content-encryption algorithm MUST carry its parameters (RFC 5084 sec. 3." + (kind === "gcm" ? "2" : "1") + ")");
252
255
  var node;
256
+ // Coverage residual -- alg.parameters is an AlgorithmIdentifier child already fully
257
+ // validated by the eager-recursive asn1.decode during the enclosing SEQUENCE parse;
258
+ // re-decoding the same TLV cannot fault, so this catch is an unreachable fail-closed
259
+ // belt (malformed parameters are rejected as asn1/* before _validateAeadParams runs).
253
260
  try { node = asn1.decode(alg.parameters); }
254
261
  catch (e) { throw NS.E("cms/bad-aead-params", "malformed AES-" + K + " parameters", e); }
255
262
  if (node.tagClass !== "universal" || node.tagNumber !== T.SEQUENCE || !node.children || node.children.length < 1 || node.children.length > 2) {
@@ -739,12 +746,18 @@ var KEM_RECIPIENT_INFO = schema.seq([
739
746
  // recognized AES key-wrap pins the exact KEK size (registry, not switch).
740
747
  var wrapLen = WRAP_KEK_LENGTHS[wrap.oid];
741
748
  if (wrapLen !== undefined && kekLength !== wrapLen) {
749
+ // Coverage residual -- reached only when the OID is a registry entry (the guard above
750
+ // guarantees it), so oid.name() never returns undefined and this fallback is a
751
+ // defensive belt, not selectable through the public parse path.
742
752
  throw NS.E("cms/kek-length-mismatch", "kekLength " + kekLength + " does not match the " + (oid.name(wrap.oid) || wrap.oid) + " KEK size " + wrapLen + " (RFC 9629 sec. 3)");
743
753
  }
744
754
  // FIPS 203 -- a recognized ML-KEM kem produces a fixed-size ciphertext; any
745
755
  // other kemct length can never decapsulate.
746
756
  var ctLen = KEM_CT_LENGTHS[kem.oid];
747
757
  if (ctLen !== undefined && kemct.length !== ctLen) {
758
+ // Coverage residual -- reached only when the OID is a registry entry (the guard above
759
+ // guarantees it), so oid.name() never returns undefined and this fallback is a
760
+ // defensive belt, not selectable through the public parse path.
748
761
  throw NS.E("cms/bad-kem-ciphertext", "the " + (oid.name(kem.oid) || kem.oid) + " kemct must be exactly " + ctLen + " octets (FIPS 203)");
749
762
  }
750
763
  return {
@@ -1049,6 +1062,9 @@ var CONTENT_INFO = schema.seq([
1049
1062
  else if (ct === OID_AUTH_ENVELOPED_DATA) inner = AUTH_ENVELOPED_DATA;
1050
1063
  if (inner === null) {
1051
1064
  if (DEFERRED.has(ct)) {
1065
+ // Coverage residual -- reached only when the OID is a registry entry (the guard above
1066
+ // guarantees it), so oid.name() never returns undefined and this fallback is a
1067
+ // defensive belt, not selectable through the public parse path.
1052
1068
  throw NS.E("cms/unsupported-content-type", (ctx.oid.name(ct) || ct) + " is recognized but not parsed by this build");
1053
1069
  }
1054
1070
  throw NS.E("cms/unknown-content-type", "unrecognized ContentInfo content type " + ct);
@@ -1058,6 +1074,9 @@ var CONTENT_INFO = schema.seq([
1058
1074
  // a consumer branches on contentTypeName instead of duck-typing the shape.
1059
1075
  var result = schema.walk(inner, m.fields.content.value, ctx).result;
1060
1076
  result.contentType = ct;
1077
+ // Coverage residual -- reached only when the OID is a registry entry (the guard above
1078
+ // guarantees it), so oid.name() never returns undefined and this fallback is a
1079
+ // defensive belt, not selectable through the public parse path.
1061
1080
  result.contentTypeName = ctx.oid.name(ct) || null;
1062
1081
  return result;
1063
1082
  },
package/lib/sigstore.js CHANGED
@@ -205,6 +205,7 @@ function _sha256(buf) { return nodeCrypto.createHash("sha256").update(buf).diges
205
205
  // A validFor bound is an epoch-ms number, a Date, or an ISO-8601 string (the form
206
206
  // a Sigstore trusted_root carries). Returns null for absent/unparseable.
207
207
  function _toMs(x) {
208
+ // Coverage residual -- _inWindow gates every _toMs call behind a != null check; the null guard is dead defensive code.
208
209
  if (x == null) return null;
209
210
  if (typeof x === "number") return Number.isFinite(x) ? x : null;
210
211
  if (x instanceof Date) { var d = x.getTime(); return isNaN(d) ? null : d; }
@@ -418,6 +419,7 @@ async function _verifyChain(leaf, chainDers, fulcioRoots, timeMs) {
418
419
  if (res.valid) return;
419
420
  lastErr = _err("sigstore/chain-invalid", "the Fulcio certificate chain is not valid as of the log time");
420
421
  } catch (e) {
422
+ // Coverage residual -- pathValidate.validate is fail-safe (builtinVerify never throws; every decodeExt wrapped), so no raw throw escapes for a well-formed path; the catch is a defensive re-type.
421
423
  lastErr = _err("sigstore/chain-invalid", "the Fulcio certificate chain failed validation: " + e.message, e);
422
424
  }
423
425
  }
@@ -439,6 +441,7 @@ function _identity(leaf) {
439
441
  // malformed certificate and fails closed with a named reason (never a silent
440
442
  // drop that would hide an identity claim a caller policy relies on).
441
443
  try {
444
+ // Coverage residual -- x509.parse always surfaces extensions as an array.
442
445
  (leaf.extensions || []).forEach(function (ext) {
443
446
  if (ext.oid === SAN_OID) { out.san = _sanValue(ext); return; }
444
447
  if (ext.oid.indexOf(FULCIO_PREFIX) === 0) {
@@ -458,6 +461,7 @@ var GN_TYPE = { 1: "rfc822Name", 2: "dNSName", 6: "uri" };
458
461
  function _sanValue(ext) {
459
462
  var seq = asn1.decode(ext.value);
460
463
  var names = [];
464
+ // Coverage residual -- path.validate's GeneralName gate (context-only, SEQUENCE SAN, strict otherName [0]) rejects the shapes that would trigger these fallbacks before _identity; asn1.decode guarantees a children array on any constructed node.
461
465
  for (var i = 0; i < (seq.children || []).length; i++) {
462
466
  var n = seq.children[i];
463
467
  if (n.tagClass !== "context") continue;
@@ -470,6 +474,7 @@ function _sanValue(ext) {
470
474
  // machine identities here (type-id .1.7, value a UTF8String).
471
475
  var inner = (n.children[1].children || [])[0] || n.children[1];
472
476
  var v;
477
+ // Coverage residual -- asn1.decode gives every node a (truthy) Buffer content, so the || [] fallback is unreachable; the catch itself runs for a non-string otherName inner value.
473
478
  try { v = asn1.read.string(inner); } catch (_e) { v = Buffer.from(inner.content || []).toString("utf8"); }
474
479
  val = { type: "otherName", oid: asn1.read.oid(n.children[0]), value: v };
475
480
  }
@@ -482,6 +487,7 @@ function _sanValue(ext) {
482
487
  return names[0] || null;
483
488
  }
484
489
  function _fulcioExtValue(ext, leafArc) {
490
+ // Coverage residual -- ext.value is read.octetString output, always a Buffer.
485
491
  if (!Buffer.isBuffer(ext.value)) throw _err("sigstore/bad-certificate", "Fulcio extension " + ext.oid + " has no value");
486
492
  // The raw-vs-DER split by member (Fulcio oid-info): .1-.6 legacy are raw UTF-8
487
493
  // strings; .8+ are DER UTF8String -- the arc is open-ended past .22. An
@@ -66,6 +66,9 @@ var ALG_PROFILE = {
66
66
 
67
67
  // The value node of an integer-labelled COSE_Key parameter (labels are ints), or null.
68
68
  function _mapGetInt(node, key) {
69
+ // Coverage residual -- credentialKey validates node is a majorType-5 map (before any
70
+ // _mapGetInt call), and cbor-det always gives a map a (truthy) children array, so this
71
+ // guard cannot fire.
69
72
  if (!node || node.majorType !== 5 || !node.children) return null;
70
73
  for (var i = 0; i < node.children.length; i++) {
71
74
  var k = node.children[i][0];
@@ -146,6 +149,9 @@ function toSpki(key, E, code) {
146
149
  function bad(msg) { return new E(code, msg); }
147
150
  if (key.kty === 2 && key.x && key.y) {
148
151
  var curveOid = EC2_CRV_OID[key.crv];
152
+ // Coverage residual -- toSpki only receives a credentialKey-validated key; EC2 crv is
153
+ // already gated to {1,2,3} by EC2_CRV_LEN, the exact EC2_CRV_OID keyset, so curveOid is
154
+ // always defined.
149
155
  if (!curveOid) throw bad("unsupported EC2 curve " + key.crv);
150
156
  var b = asn1.build;
151
157
  return b.sequence([
@@ -164,6 +170,8 @@ function toSpki(key, E, code) {
164
170
  var eb = asn1.build;
165
171
  return eb.sequence([eb.sequence([eb.oid(oid.byName(OKP_CRV[key.crv].oid))]), eb.bitString(key.x)]);
166
172
  }
173
+ // Coverage residual -- a credentialKey-validated key always matches one of the three forms
174
+ // above; this fallthrough is defensive depth.
167
175
  throw bad("cannot build an SPKI for this COSE key type");
168
176
  }
169
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
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:edfce9fb-f69d-4264-b69d-27177c001bc9",
5
+ "serialNumber": "urn:uuid:e4cc4e90-fbb7-4869-9baf-d6ce16b67d7f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T00:56:05.894Z",
8
+ "timestamp": "2026-07-14T04:28:56.898Z",
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.2.15",
22
+ "bom-ref": "@blamejs/pki@0.2.17",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.15",
25
+ "version": "0.2.17",
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.2.15",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.17",
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.2.15",
57
+ "ref": "@blamejs/pki@0.2.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]