@blamejs/pki 0.2.15 → 0.2.16

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,19 @@ 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.16 — 2026-07-14
8
+
9
+ 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.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Changed
17
+
18
+ - 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.
19
+
7
20
  ## v0.2.15 — 2026-07-13
8
21
 
9
22
  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 per RFC 9882); 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/cms-sign.js CHANGED
@@ -44,6 +44,22 @@ 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
+ };
47
63
 
48
64
  var OID_DATA = O("data");
49
65
  var OID_SIGNED_DATA = O("signedData");
@@ -98,7 +114,7 @@ function _digest(digestName, content) {
98
114
  // Resolve the sign scheme from the signer certificate's public-key algorithm + per-signer opts:
99
115
  // the digest, the digestAlgorithm and signatureAlgorithm AlgorithmIdentifiers (with the exact
100
116
  // parameter shape cms.verify requires), and the WebCrypto import + sign algorithms.
101
- function _scheme(cert, so) {
117
+ function _scheme(cert, so, noSignedAttrs) {
102
118
  var alg = cert.subjectPublicKeyInfo.algorithm;
103
119
  var keyOid = alg.oid;
104
120
  if (keyOid === O("rsaEncryption") || keyOid === O("rsassaPss")) {
@@ -131,6 +147,26 @@ function _scheme(cert, so) {
131
147
  if (!NODE_DIGEST[dd]) throw _err("cms/unsupported-algorithm", "unsupported " + name + " digest algorithm " + JSON.stringify(dd));
132
148
  return { digest: dd, digestAlgId: _algId(dd, "absent"), sigAlgId: _algId(name, "absent"), imp: { name: name }, sign: { name: name }, ecdsaDer: false };
133
149
  }
150
+ if (MLDSA_BY_OID[keyOid]) {
151
+ // ML-DSA (RFC 9882): a one-shot post-quantum signature, pure mode, empty context. The signature
152
+ // covers the same sec. 5.4 preimage as every other scheme; digestAlgorithm names the message-
153
+ // digest algorithm (SHA-512 by default -- the sec. 3.3 MUST / interop default), which must be
154
+ // suitable for the parameter set (Q1 -- enforced on sign, refusing a below-strength digest).
155
+ var mlName = MLDSA_BY_OID[keyOid];
156
+ // RFC 9882 sec. 3.3: without signed attributes the digestAlgorithm has no meaning, but the
157
+ // signer MUST still emit SHA-512 to avoid an interoperability failure -- so force it (ignoring
158
+ // any caller digestAlgorithm) rather than carry a value a strict peer would reject. With signed
159
+ // attributes the digest must be suitable for the parameter set (Q1 -- enforced on sign).
160
+ var md;
161
+ if (noSignedAttrs) {
162
+ md = "sha512";
163
+ } else {
164
+ md = so.digestAlgorithm || "sha512";
165
+ if (!NODE_DIGEST[md]) throw _err("cms/unsupported-algorithm", "unsupported ML-DSA message digest " + JSON.stringify(md));
166
+ 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)");
167
+ }
168
+ return { digest: md, digestAlgId: _algId(md, "absent"), sigAlgId: _algId(oid.name(keyOid), "absent"), imp: { name: mlName }, sign: { name: mlName }, ecdsaDer: false };
169
+ }
134
170
  throw _err("cms/unsupported-algorithm", "unsupported signer key algorithm " + keyOid);
135
171
  }
136
172
 
@@ -182,7 +218,7 @@ function _buildSignerInfo(signer, content, eContentType, opts) {
182
218
  var so = signer || {};
183
219
  var certDer = _normCertDer(so.cert);
184
220
  var cert = x509.parse(certDer);
185
- var scheme = _scheme(cert, so);
221
+ var scheme = _scheme(cert, so, opts.signedAttributes === false);
186
222
  var useSki = opts.sid === "ski";
187
223
  var sid = useSki
188
224
  ? b.contextPrimitive(0, _skiValue(cert)) // [0] IMPLICIT SubjectKeyIdentifier
@@ -321,4 +357,7 @@ function _toBuf(v, what) {
321
357
  // * `_assertKeyMatchesScheme`'s `!ka.hash` guard -- an `imp.hash` is set only for an RSA
322
358
  // scheme, which requires `ka.name` to already equal the RSA name (else the earlier name
323
359
  // check throws); an RSA CryptoKey always carries a `hash`, so `!ka.hash` never fires.
324
- module.exports = { sign: sign };
360
+ // MLDSA_SUITABLE_DIGEST is shared with cms-verify (which requires this module) so the per-
361
+ // parameter-set digest-strength policy has ONE home -- a digest accepted on sign is exactly the
362
+ // set accepted on verify, and neither side can drift from the other (RFC 9882 sec. 3.3).
363
+ module.exports = { sign: sign, MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST };
package/lib/cms-verify.js CHANGED
@@ -13,10 +13,12 @@
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) are covered. It reports a per-signer verdict;
18
+ * it does NOT chain the signer certificate to a trust anchor -- that is the caller's step through
19
+ * `pki.path.validate`.
19
20
  * @spec RFC 5652
21
+ * @spec RFC 9882
20
22
  * @card Verify a CMS SignedData signature (S/MIME, timestamps, code signing).
21
23
  */
22
24
 
@@ -29,6 +31,7 @@ var webcrypto = require("./webcrypto");
29
31
  var subtle = webcrypto.webcrypto.subtle;
30
32
  var edwardsPoint = require("./edwards-point");
31
33
  var cmsSign = require("./cms-sign");
34
+ var MLDSA_SUITABLE_DIGEST = cmsSign.MLDSA_SUITABLE_DIGEST; // shared sign/verify digest-strength policy (RFC 9882 sec. 3.3)
32
35
  var validator = require("./validator-all");
33
36
  var guard = require("./guard-all");
34
37
  var frameworkError = require("./framework-error");
@@ -61,8 +64,14 @@ var SIG_SCHEME = {
61
64
  rsaEncryption: { kind: "rsa", params: "null" },
62
65
  rsassaPss: { kind: "rsapss" },
63
66
  ecPublicKey: { kind: "ec", params: "absent" },
64
- Ed25519: { kind: "eddsa", name: "Ed25519", params: "absent" },
65
- Ed448: { kind: "eddsa", name: "Ed448", params: "absent" },
67
+ // One-shot families (EdDSA, ML-DSA): the same OID identifies the key and the signature, so the
68
+ // signer cert SPKI algorithm OID MUST equal the signatureAlgorithm OID -- `sameKeyOid` enables
69
+ // that agreement check (RFC 8410 / RFC 9882; enforced in _verifyAgainstCandidates).
70
+ Ed25519: { kind: "eddsa", name: "Ed25519", params: "absent", sameKeyOid: true },
71
+ Ed448: { kind: "eddsa", name: "Ed448", params: "absent", sameKeyOid: true },
72
+ "id-ml-dsa-44": { kind: "mldsa", name: "ML-DSA-44", params: "absent", sameKeyOid: true },
73
+ "id-ml-dsa-65": { kind: "mldsa", name: "ML-DSA-65", params: "absent", sameKeyOid: true },
74
+ "id-ml-dsa-87": { kind: "mldsa", name: "ML-DSA-87", params: "absent", sameKeyOid: true },
66
75
  sha1WithRSAEncryption: { kind: "rsa", hash: "SHA-1", params: "null" },
67
76
  sha256WithRSAEncryption: { kind: "rsa", hash: "SHA-256", params: "null" },
68
77
  sha384WithRSAEncryption: { kind: "rsa", hash: "SHA-384", params: "null" },
@@ -111,7 +120,7 @@ function _findSignerCerts(sid, parsedCerts) {
111
120
  // through to the next -- so a colliding certificate cannot make a valid signature read invalid.
112
121
  // When no candidate verifies, a plain false is a code-less verdict; the last structural fault's
113
122
  // code is surfaced if one occurred (so the diagnostic is not lost).
114
- function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, candidates, pssSalt) {
123
+ function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, candidates, pssSalt, expectedKeyOid) {
115
124
  var lastErr = null;
116
125
  function attempt(idx) {
117
126
  if (idx >= candidates.length) {
@@ -119,6 +128,13 @@ function _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, sid, c
119
128
  : { ok: false, sid: sid, cert: candidates[0].der };
120
129
  }
121
130
  var c = candidates[idx];
131
+ // One-shot family (EdDSA/ML-DSA): the signer cert public-key algorithm OID MUST equal the
132
+ // SignerInfo signatureAlgorithm OID. A candidate whose SPKI disagrees is skipped with a precise
133
+ // verdict, rather than a foreign webcrypto/data throw from importing under the wrong name.
134
+ if (expectedKeyOid && c.cert.subjectPublicKeyInfo.algorithm.oid !== expectedKeyOid) {
135
+ lastErr = _err("cms/unsupported-algorithm", "the signer certificate public-key algorithm does not match the SignerInfo signatureAlgorithm");
136
+ return attempt(idx + 1);
137
+ }
122
138
  return Promise.resolve()
123
139
  .then(function () { return _verifySignature(scheme, sigHash, sigBytes, c.cert.subjectPublicKeyInfo.bytes, signedBytes, _certCurveOid(c.cert), pssSalt); })
124
140
  .then(function (ok) { return ok === true ? { ok: true, sid: sid, cert: c.der } : attempt(idx + 1); },
@@ -225,6 +241,13 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
225
241
  return subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: ec.curve }, false, ["verify"])
226
242
  .then(function (k) { return subtle.verify({ name: "ECDSA", hash: hashName }, k, raw, signedBytes); });
227
243
  }
244
+ if (scheme.kind === "mldsa") {
245
+ // ML-DSA (RFC 9882): a one-shot post-quantum verify, pure mode, empty context -- no external
246
+ // signature hash and no Edwards-point validation (FIPS 204 sec. 5.3; node structurally
247
+ // validates the SPKI on import). The signature is raw and fixed-length (not ECDSA-DER).
248
+ return subtle.importKey("spki", spki, { name: scheme.name }, false, ["verify"])
249
+ .then(function (k) { return subtle.verify({ name: scheme.name }, k, sigBytes, signedBytes); });
250
+ }
228
251
  // EdDSA -- the WebCrypto name follows the signing key's SPKI OID (Ed25519 / Ed448). node/
229
252
  // OpenSSL imports any Ed25519/Ed448 SPKI without validating the point, and a low-order (e.g.
230
253
  // all-zeroes) key verifies a forged signature -- so reject a non-full-order point first.
@@ -288,15 +311,29 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
288
311
  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
312
  // The digestAlgorithm parameters, when present, MUST be a DER NULL (RFC 5754 sec. 2 accepts
290
313
  // absent OR NULL; a present non-NULL is malformed and, being outside the signed preimage,
291
- // must fail closed like the signatureAlgorithm parameters above).
314
+ // must fail closed like the signatureAlgorithm parameters above). EXCEPT for an ML-DSA
315
+ // SignerInfo WITHOUT signed attributes: RFC 9882 sec. 3.3 says the whole digestAlgorithm field
316
+ // has no meaning there and MUST be ignored, so neither its parameters nor its name may reject.
292
317
  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)" });
318
+ var mldsaNoAttrs = scheme.kind === "mldsa" && !si.signedAttrsBytes;
319
+ 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)" });
320
+ // The SHAKE256 digest algorithm identifier carries NO parameters at all -- not even a DER NULL
321
+ // (RFC 8702 sec. 3.1) -- so for an ML-DSA SignerInfo with signed attributes a present shake256
322
+ // parameter fails closed. The SHA-2 ML-DSA digests (id-sha256/384/512) follow RFC 5754, which
323
+ // requires a verifier to accept absent OR NULL (the generic rule above); RFC 9882's omit-on-encode
324
+ // rule binds the signer, not the verifier, so a SHA-2 NULL is not tightened here.
325
+ if (scheme.kind === "mldsa" && si.signedAttrsBytes && si.digestAlgorithm.name === "shake256" && dp !== null && dp !== undefined) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the SHAKE256 digestAlgorithm parameters must be absent (RFC 8702 sec. 3.1)" });
294
326
  var sigHash = pss ? pss.hash : (scheme.hash || digestHash);
295
327
  // The signature hash is required for every non-EdDSA scheme (EdDSA hashes internally);
296
328
  // the content digest is required whenever signed attributes are present, for every scheme
297
329
  // (the message-digest attribute is computed under digestAlgorithm). Either gap is a
298
330
  // 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) });
331
+ if ((scheme.kind !== "eddsa" && scheme.kind !== "mldsa" && !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) });
332
+ // ML-DSA (RFC 9882 sec. 3.3): with signed attributes present, the message-digest algorithm MUST
333
+ // meet the parameter set's security strength -- a below-strength digest is the weaker link and is
334
+ // rejected fail-closed (the RFC's "verifiers MAY reject", taken by the strict-verifier posture).
335
+ // With signed attributes absent the digestAlgorithm has no meaning (sec. 3.3) and is not checked.
336
+ 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)" });
300
337
  var signers = _findSignerCerts(si.sid, parsedCerts);
301
338
  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
339
  var sigBytes = _toBuf(si.signature, "the SignerInfo signature");
@@ -337,7 +374,7 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
337
374
  });
338
375
  }).then(function (signedBytes) {
339
376
  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);
377
+ return _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, si.sid, signers, pss ? pss.saltLength : 0, scheme.sameKeyOid ? oid.byName(si.signatureAlgorithm.name) : null);
341
378
  });
342
379
  }
343
380
 
@@ -347,6 +384,7 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
347
384
  * @since 0.2.14
348
385
  * @status experimental
349
386
  * @spec RFC 5652
387
+ * @spec RFC 9882
350
388
  * @defends cms-signature-forgery (CWE-347)
351
389
  * @related pki.schema.cms.parse, pki.path.validate
352
390
  *
@@ -354,7 +392,8 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
354
392
  * `Buffer`, or a parsed `pki.schema.cms` object. Returns `{ valid, signers }` where each
355
393
  * `signers[i]` is `{ ok, sid, cert }` (`cert` the matched signer certificate DER) or carries
356
394
  * a `code` on a structural failure; `valid` is true when there is at least one signer and
357
- * every signer verified.
395
+ * every signer verified. RSA (PKCS#1 v1.5 and RSASSA-PSS), ECDSA, EdDSA, and the post-quantum
396
+ * ML-DSA (ML-DSA-44/65/87, RFC 9882 -- pure mode, empty context) signatures are recognized.
358
397
  *
359
398
  * @opts content The detached content (a `Buffer`) when the SignedData carries no
360
399
  * encapsulated eContent. Required for a detached signature.
@@ -419,6 +458,7 @@ function _addCert(out, der) {
419
458
  * @since 0.2.15
420
459
  * @status experimental
421
460
  * @spec RFC 5652
461
+ * @spec RFC 9882
422
462
  * @related pki.cms.verify, pki.schema.cms.parse
423
463
  *
424
464
  * Produce a CMS SignedData (RFC 5652 sec. 5) over `content` (a `Buffer`) -- the structure
@@ -429,7 +469,9 @@ function _addCert(out, der) {
429
469
  * signature covers the RFC 5652 sec. 5.4 preimage: with signed attributes (the default) the
430
470
  * message-digest attribute is bound to the content digest and the signature is over the
431
471
  * 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.
472
+ * and, with `pss`, RSASSA-PSS), ECDSA (P-256/384/521), Ed25519, Ed448, and the post-quantum ML-DSA
473
+ * (ML-DSA-44/65/87, RFC 9882 -- pure mode; SHA-512 message digest by default, SHAKE256 optional)
474
+ * are covered.
433
475
  *
434
476
  * @opts detached Omit the encapsulated content (a detached signature; the verifier
435
477
  * supplies the content). Default false.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
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:171a9606-6760-469e-abba-719538a2f2ab",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T00:56:05.894Z",
8
+ "timestamp": "2026-07-14T02:42:50.555Z",
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.16",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.15",
25
+ "version": "0.2.16",
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.16",
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.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]