@blamejs/pki 0.2.16 → 0.2.18

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,7 +4,33 @@ 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
7
+ ## v0.2.18 — 2026-07-14
8
+
9
+ Composite ML-DSA signatures join CMS SignedData: pki.cms.sign and pki.cms.verify now produce and verify a composite SignerInfo -- a post-quantum ML-DSA paired with a traditional RSA, ECDSA, or EdDSA -- accepted only when BOTH components verify.
10
+
11
+ ### Added
12
+
13
+ - pki.cms.verify verifies, and pki.cms.sign produces, a composite ML-DSA CMS SignerInfo (draft-ietf-lamps-cms-composite-sigs) pairing ML-DSA-44/65/87 with a traditional RSA (PKCS#1 v1.5 or PSS), ECDSA (P-256/384/521), or EdDSA (Ed25519) component. The signature is accepted only when BOTH the post-quantum and traditional components verify over the domain-separated message representative; the digestAlgorithm is the parameter set's paired pre-hash, and the composite public-key OID must match the signatureAlgorithm. Fifteen algorithm arms verify and sign today; the two brainpool-curve arms and the one SHAKE256-pre-hash arm are recognized but fail closed to a typed error (their curve / digest is outside the WebCrypto surface).
14
+ - pki.cms.sign accepts a composite signer as { cert, key: { mldsa, trad } } -- the two component private keys as PKCS#8 -- since a composite private key has no single native representation; it signs both components over the RFC 5652 section 5.4 preimage and emits the fixed-order composite signature the verifier consumes.
15
+
16
+ ### Fixed
17
+
18
+ - EdDSA (Ed25519 / Ed448) public keys are validated as a canonical, on-curve, full-order Edwards point before any signature is verified with them, across certification-path validation (pki.path.validate) -- a certificate signature or a CRL / OCSP-response signature checked during revocation -- composite CMS SignerInfo components, and JWS verification (pki.jose.verify). A low-order key -- for example the identity point, which the underlying platform imports without complaint and which verifies a forged signature for every message -- is rejected up front, so it can no longer certify a forged certificate chain, forge a CRL or OCSP response, satisfy the traditional half of a composite signature, or make a forged JWS verify. Certificate and revocation verification share one key-import routine, so the check cannot be applied to one surface and skipped on another.
19
+
20
+ ## v0.2.17 — 2026-07-13
21
+
22
+ 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.
23
+
24
+ ### Added
25
+
26
+ - 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.
27
+ - 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.
28
+
29
+ ### Changed
30
+
31
+ - 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.
32
+
33
+ ## v0.2.16 — 2026-07-13
8
34
 
9
35
  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
36
 
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 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` |
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, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA — accepted only when **both** components verify — draft-ietf-lamps-cms-composite-sigs)); 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
@@ -22,6 +22,7 @@ var pkix = require("./schema-pkix");
22
22
  var webcrypto = require("./webcrypto");
23
23
  var subtle = webcrypto.webcrypto.subtle;
24
24
  var validator = require("./validator-all");
25
+ var compositeSig = require("./composite-sig");
25
26
  var frameworkError = require("./framework-error");
26
27
 
27
28
  var CmsError = frameworkError.CmsError;
@@ -32,8 +33,8 @@ function O(name) { return oid.byName(name); }
32
33
  // A digest-algorithm name -> the WebCrypto hash (sign path). SHAKE256 has no WebCrypto hash, so
33
34
  // the message digest is always computed with node:crypto (below), uniform across the family.
34
35
  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
36
+ var NODE_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512", shake128: "shake128", shake256: "shake256" };
37
+ 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
38
  var PSS_SALT = { "SHA-256": 32, "SHA-384": 48, "SHA-512": 64 };
38
39
  var ECDSA_ALG = { sha256: "ecdsaWithSHA256", sha384: "ecdsaWithSHA384", sha512: "ecdsaWithSHA512" };
39
40
  var HASH_NAME_BY_OID = {};
@@ -60,6 +61,15 @@ var MLDSA_SUITABLE_DIGEST = {
60
61
  "ML-DSA-65": { sha384: 1, sha512: 1, shake256: 1 },
61
62
  "ML-DSA-87": { sha512: 1, shake256: 1 },
62
63
  };
64
+ // SLH-DSA (RFC 9814): the twelve pure FIPS 205 sets. The signer cert SPKI algorithm OID -> its
65
+ // WebCrypto set name ("SLH-DSA-"+SET, mirroring webcrypto's SLH_DSA_NODE / path-validate) + the
66
+ // RFC 9814 sec. 4 pinned message digest for that set. The sig and key share one OID; the digest is
67
+ // FIXED per set (no caller choice, unlike ML-DSA's suitable-set). Registry-keyed (Hard rule #2).
68
+ var SLHDSA_BY_OID = {};
69
+ [["sha2-128s", "sha256"], ["sha2-128f", "sha256"], ["sha2-192s", "sha512"], ["sha2-192f", "sha512"],
70
+ ["sha2-256s", "sha512"], ["sha2-256f", "sha512"], ["shake-128s", "shake128"], ["shake-128f", "shake128"],
71
+ ["shake-192s", "shake256"], ["shake-192f", "shake256"], ["shake-256s", "shake256"], ["shake-256f", "shake256"]
72
+ ].forEach(function (r) { SLHDSA_BY_OID[O("id-slh-dsa-" + r[0])] = { wc: "SLH-DSA-" + r[0].toUpperCase(), digest: r[1] }; });
63
73
 
64
74
  var OID_DATA = O("data");
65
75
  var OID_SIGNED_DATA = O("signedData");
@@ -117,6 +127,17 @@ function _digest(digestName, content) {
117
127
  function _scheme(cert, so, noSignedAttrs) {
118
128
  var alg = cert.subjectPublicKeyInfo.algorithm;
119
129
  var keyOid = alg.oid;
130
+ var comp = compositeSig.COMPOSITE_ALGS[keyOid];
131
+ if (comp) {
132
+ // Composite ML-DSA (draft-ietf-lamps-cms-composite-sigs): the arm DICTATES the CMS
133
+ // digestAlgorithm (its pre-hash, Table 1) -- unlike RSA/ECDSA the digest is not a caller
134
+ // choice, so a conflicting so.digestAlgorithm is rejected. The 3 arms Node's WebCrypto surface
135
+ // cannot sign (brainpool curves; the SHAKE256/64 pre-hash) fail closed at config time -- never
136
+ // a partial single-component signature.
137
+ if (comp.trad.unsupported) throw _err("cms/unsupported-algorithm", "composite " + comp.name + ": " + comp.trad.unsupported);
138
+ if (so.digestAlgorithm && so.digestAlgorithm !== comp.phCms) throw _err("cms/bad-input", "composite " + comp.name + " fixes the digestAlgorithm to " + comp.phCms + " (draft-ietf-lamps-cms-composite-sigs sec. 3.4); " + JSON.stringify(so.digestAlgorithm) + " conflicts");
139
+ return { composite: comp, digest: comp.phCms, digestAlgId: _algId(comp.phCms, "absent"), sigAlgId: _algId(comp.name, "absent") };
140
+ }
120
141
  if (keyOid === O("rsaEncryption") || keyOid === O("rsassaPss")) {
121
142
  var isPssKey = keyOid === O("rsassaPss");
122
143
  // An id-RSASSA-PSS key MAY pin its permitted hash in the SPKI params (RFC 4055 sec. 1.2): honor
@@ -167,6 +188,14 @@ function _scheme(cert, so, noSignedAttrs) {
167
188
  }
168
189
  return { digest: md, digestAlgId: _algId(md, "absent"), sigAlgId: _algId(oid.name(keyOid), "absent"), imp: { name: mlName }, sign: { name: mlName }, ecdsaDer: false };
169
190
  }
191
+ if (SLHDSA_BY_OID[keyOid]) {
192
+ // SLH-DSA (RFC 9814): a one-shot post-quantum signature, pure mode, empty context. The message
193
+ // digest is FIXED per parameter set (sec. 4) -- emit it, and reject a contradicting caller
194
+ // digestAlgorithm fail-closed rather than carry a non-conformant digest.
195
+ var slh = SLHDSA_BY_OID[keyOid];
196
+ 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");
197
+ 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 };
198
+ }
170
199
  throw _err("cms/unsupported-algorithm", "unsupported signer key algorithm " + keyOid);
171
200
  }
172
201
 
@@ -225,47 +254,76 @@ function _buildSignerInfo(signer, content, eContentType, opts) {
225
254
  : b.sequence([b.raw(_issuerBytes(cert)), b.integer(cert.serialNumber)]); // IssuerAndSerialNumber
226
255
  var version = useSki ? 3 : 1;
227
256
 
257
+ return Promise.resolve().then(function () {
258
+ if (opts.signedAttributes === false) return content; // sign the content directly (no signed attributes)
259
+ // Signed attributes (RFC 5652 sec. 5.3): content-type == eContentType, message-digest ==
260
+ // digest(content), and (by default) signing-time. build.set canonical-DER SET-OF-sorts them.
261
+ // Each attribute type appears AT MOST ONCE across the whole set (RFC 5652 sec. 5.3);
262
+ // `seenTypes` catches a caller-supplied attribute that duplicates a built-in or another.
263
+ var seenTypes = {};
264
+ function _pushAttr(typeOid, values) {
265
+ if (seenTypes[typeOid]) throw _err("cms/bad-input", "signedAttrs must not repeat an attribute type (RFC 5652 sec. 5.3): " + typeOid);
266
+ seenTypes[typeOid] = 1;
267
+ attrs.push(b.sequence([b.oid(typeOid), b.set(values)]));
268
+ }
269
+ var attrs = [];
270
+ _pushAttr(O("contentType"), [b.oid(eContentType)]);
271
+ _pushAttr(O("messageDigest"), [b.octetString(_digest(scheme.digest, content))]);
272
+ if (opts.signingTime !== false) _pushAttr(O("signingTime"), [_timeValue(opts.signingTime)]);
273
+ // Caller-supplied signed attributes (e.g. an RFC 3161 signing-certificate attribute): each
274
+ // { type: <OID name or dotted string>, values: [<DER value Buffer>] }. build.set sorts them in.
275
+ (opts.additionalSignedAttributes || []).forEach(function (a) {
276
+ var vals = (a.values || []).map(function (v) { return _toBuf(v, "a signed attribute value"); });
277
+ if (!vals.length) throw _err("cms/bad-input", "a signed attribute must carry at least one value (RFC 5652 -- Attribute values is SET SIZE (1..MAX))");
278
+ _pushAttr(/^\d+(\.\d+)+$/.test(a.type) ? a.type : O(a.type), vals);
279
+ });
280
+ var setOf = b.set(attrs); // SET OF (tag 0x31) -- the exact bytes the signature covers (sec. 5.4)
281
+ var wire = Buffer.from(setOf); wire[0] = 0xA0; // the on-wire [0] IMPLICIT tag
282
+ return { setOf: setOf, wire: wire };
283
+ }).then(function (toSign) {
284
+ var signedBytes = toSign.setOf ? toSign.setOf : toSign; // SET-OF form for signing (sec. 5.4)
285
+ return _signScheme(scheme, so, signedBytes).then(function (sig) {
286
+ var fields = [b.integer(BigInt(version)), sid, scheme.digestAlgId];
287
+ if (toSign.wire) fields.push(toSign.wire); // [0] IMPLICIT signedAttrs
288
+ fields.push(scheme.sigAlgId, b.octetString(sig));
289
+ return { si: b.sequence(fields), digestAlgId: scheme.digestAlgId, version: version, certDer: certDer };
290
+ });
291
+ });
292
+ }
293
+
294
+ // Sign the sec. 5.4 preimage under the resolved scheme -> the raw signature bytes for the
295
+ // SignerInfo signature OCTET STRING. The classical path imports the single signer key and signs
296
+ // (re-encoding ECDSA to canonical DER); the composite path signs BOTH component keys over the
297
+ // domain-separated M' and returns mldsaSig || tradSig (composite-sig.js owns the construction).
298
+ function _signScheme(scheme, so, signedBytes) {
299
+ if (scheme.composite) {
300
+ return compositeSig.compositeSign(scheme.composite, _normCompositeKeys(so.key, scheme.composite), signedBytes).then(function (sig) { return Buffer.from(sig); });
301
+ }
228
302
  return _importKey(so.key, scheme.imp).then(function (priv) {
229
- return Promise.resolve().then(function () {
230
- if (opts.signedAttributes === false) return content; // sign the content directly (no signed attributes)
231
- // Signed attributes (RFC 5652 sec. 5.3): content-type == eContentType, message-digest ==
232
- // digest(content), and (by default) signing-time. build.set canonical-DER SET-OF-sorts them.
233
- // Each attribute type appears AT MOST ONCE across the whole set (RFC 5652 sec. 5.3);
234
- // `seenTypes` catches a caller-supplied attribute that duplicates a built-in or another.
235
- var seenTypes = {};
236
- function _pushAttr(typeOid, values) {
237
- if (seenTypes[typeOid]) throw _err("cms/bad-input", "signedAttrs must not repeat an attribute type (RFC 5652 sec. 5.3): " + typeOid);
238
- seenTypes[typeOid] = 1;
239
- attrs.push(b.sequence([b.oid(typeOid), b.set(values)]));
240
- }
241
- var attrs = [];
242
- _pushAttr(O("contentType"), [b.oid(eContentType)]);
243
- _pushAttr(O("messageDigest"), [b.octetString(_digest(scheme.digest, content))]);
244
- if (opts.signingTime !== false) _pushAttr(O("signingTime"), [_timeValue(opts.signingTime)]);
245
- // Caller-supplied signed attributes (e.g. an RFC 3161 signing-certificate attribute): each
246
- // { type: <OID name or dotted string>, values: [<DER value Buffer>] }. build.set sorts them in.
247
- (opts.additionalSignedAttributes || []).forEach(function (a) {
248
- var vals = (a.values || []).map(function (v) { return _toBuf(v, "a signed attribute value"); });
249
- if (!vals.length) throw _err("cms/bad-input", "a signed attribute must carry at least one value (RFC 5652 -- Attribute values is SET SIZE (1..MAX))");
250
- _pushAttr(/^\d+(\.\d+)+$/.test(a.type) ? a.type : O(a.type), vals);
251
- });
252
- var setOf = b.set(attrs); // SET OF (tag 0x31) -- the exact bytes the signature covers (sec. 5.4)
253
- var wire = Buffer.from(setOf); wire[0] = 0xA0; // the on-wire [0] IMPLICIT tag
254
- return { setOf: setOf, wire: wire };
255
- }).then(function (toSign) {
256
- var signedBytes = toSign.setOf ? toSign.setOf : toSign; // SET-OF form for signing (sec. 5.4)
257
- return subtle.sign(scheme.sign, priv, signedBytes).then(function (sigRaw) {
258
- var sig = Buffer.from(sigRaw);
259
- if (scheme.ecdsaDer) sig = validator.sig.rawToEcdsaDer(sig, scheme.coordLen);
260
- var fields = [b.integer(BigInt(version)), sid, scheme.digestAlgId];
261
- if (toSign.wire) fields.push(toSign.wire); // [0] IMPLICIT signedAttrs
262
- fields.push(scheme.sigAlgId, b.octetString(sig));
263
- return { si: b.sequence(fields), digestAlgId: scheme.digestAlgId, version: version, certDer: certDer };
264
- });
303
+ return subtle.sign(scheme.sign, priv, signedBytes).then(function (sigRaw) {
304
+ var sig = Buffer.from(sigRaw);
305
+ if (scheme.ecdsaDer) sig = validator.sig.rawToEcdsaDer(sig, scheme.coordLen);
306
+ return sig;
265
307
  });
266
308
  });
267
309
  }
268
310
 
311
+ // A composite signer key is the two component private keys { mldsa, trad } as PKCS#8 (Node has no
312
+ // single composite key type; the CMS draft is silent on key import). A missing/wrong-typed
313
+ // component fails closed at config time -- never a partial single-component signature.
314
+ function _normCompositeKeys(key, comp) {
315
+ if (!key || typeof key !== "object" || Buffer.isBuffer(key) || key instanceof Uint8Array || key.mldsa == null || key.trad == null) {
316
+ throw _err("cms/bad-input", "a composite " + comp.name + " signer key must be { mldsa: <PKCS#8>, trad: <PKCS#8> }");
317
+ }
318
+ return { mldsa: _normPkcs8(key.mldsa, "the composite ML-DSA component key"), trad: _normPkcs8(key.trad, "the composite traditional component key") };
319
+ }
320
+ function _normPkcs8(k, label) {
321
+ if (Buffer.isBuffer(k)) return k;
322
+ if (k instanceof Uint8Array) return Buffer.from(k);
323
+ if (typeof k === "string") { try { return pkcs8.pemDecode(k); } catch (e) { throw _err("cms/bad-input", label + " PEM could not be decoded", e); } }
324
+ throw _err("cms/bad-input", label + " must be a PKCS#8 DER Buffer, Uint8Array, or PEM string");
325
+ }
326
+
269
327
  // A signing-time Time value: UTCTime before 2050, GeneralizedTime from 2050 (RFC 5652 sec. 11.3 /
270
328
  // RFC 5280 sec. 4.1.2.5). A caller Date overrides; false omits the attribute (handled above).
271
329
  function _timeValue(when) {
@@ -360,4 +418,4 @@ function _toBuf(v, what) {
360
418
  // MLDSA_SUITABLE_DIGEST is shared with cms-verify (which requires this module) so the per-
361
419
  // parameter-set digest-strength policy has ONE home -- a digest accepted on sign is exactly the
362
420
  // 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 };
421
+ module.exports = { sign: sign, MLDSA_SUITABLE_DIGEST: MLDSA_SUITABLE_DIGEST, SLHDSA_BY_OID: SLHDSA_BY_OID };
package/lib/cms-verify.js CHANGED
@@ -13,12 +13,16 @@
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 and the
17
- * post-quantum ML-DSA (ML-DSA-44/65/87, RFC 9882) are covered. It reports a per-signer verdict;
16
+ * detached content, single and multiple signers, and RSA / RSASSA-PSS / ECDSA / EdDSA, the
17
+ * post-quantum ML-DSA (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814),
18
+ * and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA, accepted only when
19
+ * BOTH components verify -- draft-ietf-lamps-cms-composite-sigs) are covered. It reports a
20
+ * per-signer verdict;
18
21
  * it does NOT chain the signer certificate to a trust anchor -- that is the caller's step through
19
22
  * `pki.path.validate`.
20
23
  * @spec RFC 5652
21
24
  * @spec RFC 9882
25
+ * @spec RFC 9814
22
26
  * @card Verify a CMS SignedData signature (S/MIME, timestamps, code signing).
23
27
  */
24
28
 
@@ -32,7 +36,9 @@ var subtle = webcrypto.webcrypto.subtle;
32
36
  var edwardsPoint = require("./edwards-point");
33
37
  var cmsSign = require("./cms-sign");
34
38
  var MLDSA_SUITABLE_DIGEST = cmsSign.MLDSA_SUITABLE_DIGEST; // shared sign/verify digest-strength policy (RFC 9882 sec. 3.3)
39
+ var SLHDSA_BY_OID = cmsSign.SLHDSA_BY_OID; // shared SLH-DSA set -> { wc, digest } (RFC 9814 sec. 4 pinned digest)
35
40
  var validator = require("./validator-all");
41
+ var compositeSig = require("./composite-sig");
36
42
  var guard = require("./guard-all");
37
43
  var frameworkError = require("./framework-error");
38
44
 
@@ -46,7 +52,7 @@ var OID_CONTENT_TYPE = oid.byName("contentType");
46
52
  var DIGEST_HASH = { sha1: "SHA-1", sha256: "SHA-256", sha384: "SHA-384", sha512: "SHA-512" };
47
53
  // SHAKE256 (RFC 8419 sec. 2.3, the Ed448 message digest) has no WebCrypto hash, so it is computed
48
54
  // with node:crypto; its 512-bit (64-byte) output length is fixed by the profile.
49
- var SHAKE_OUT = { shake256: 64 };
55
+ var SHAKE_OUT = { shake128: 32, shake256: 64 }; // SHAKE128 256-bit (RFC 9814 sec. 4), SHAKE256 512-bit
50
56
  // Is `name` a message-digest algorithm this verifier supports (SHA-2 family or SHAKE256)?
51
57
  function _supportedDigest(name) { return !!(DIGEST_HASH[name] || SHAKE_OUT[name]); }
52
58
  // The digest of `content` under the named algorithm, resolved to a Buffer.
@@ -81,6 +87,12 @@ var SIG_SCHEME = {
81
87
  ecdsaWithSHA384: { kind: "ec", hash: "SHA-384", params: "absent" },
82
88
  ecdsaWithSHA512: { kind: "ec", hash: "SHA-512", params: "absent" },
83
89
  };
90
+ // SLH-DSA (RFC 9814): the twelve pure FIPS 205 sets, seeded like webcrypto's SLH_DSA_NODE. Each is
91
+ // a one-shot family (kind "slhdsa"), params absent, sig-OID == key-OID (sameKeyOid). The WebCrypto
92
+ // name is "SLH-DSA-"+SET, matching path-validate's transform (drift rule sec. 5 -- one name map).
93
+ ["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
94
+ "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
95
+ ].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 }; });
84
96
  // The signatureAlgorithm parameters match the algorithm's fixed shape (RFC 5754). A DER NULL
85
97
  // is exactly `05 00`; "absent" is the parameters field omitted entirely.
86
98
  function _isDerNull(p) { return Buffer.isBuffer(p) && p.length === 2 && p[0] === 0x05 && p[1] === 0x00; }
@@ -241,10 +253,11 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
241
253
  return subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: ec.curve }, false, ["verify"])
242
254
  .then(function (k) { return subtle.verify({ name: "ECDSA", hash: hashName }, k, raw, signedBytes); });
243
255
  }
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).
256
+ if (scheme.kind === "mldsa" || scheme.kind === "slhdsa") {
257
+ // ML-DSA (RFC 9882) / SLH-DSA (RFC 9814): a one-shot post-quantum verify, pure mode, empty
258
+ // context -- no external signature hash and no Edwards-point validation (FIPS 204 sec. 5.3 /
259
+ // FIPS 205 sec. 10.3; node structurally validates the SPKI on import). The signature is raw and
260
+ // fixed-length (not ECDSA-DER).
248
261
  return subtle.importKey("spki", spki, { name: scheme.name }, false, ["verify"])
249
262
  .then(function (k) { return subtle.verify({ name: scheme.name }, k, sigBytes, signedBytes); });
250
263
  }
@@ -256,18 +269,11 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
256
269
  .then(function (k) { return subtle.verify({ name: scheme.name }, k, sigBytes, signedBytes); });
257
270
  }
258
271
 
259
- // The raw Edwards point an OKP SPKI carries (its BIT STRING body, past the unused-bits octet)
260
- // MUST be a valid, canonical, full-order point -- reject an off-curve or low-order key before
261
- // it verifies a signature (WebCrypto import does not check it). Curve from the WebCrypto name.
272
+ // The EdDSA signer key's point MUST be a valid, canonical, full-order Edwards point -- reject an
273
+ // off-curve or low-order key before it verifies a signature (WebCrypto import does not check it).
274
+ // Routed through the shared edwards-point gate every EdDSA verify path uses; curve from the name.
262
275
  function _requireValidEdPoint(spkiBytes, name) {
263
- var content;
264
- try {
265
- content = asn1.decode(spkiBytes).children[1].content;
266
- } catch (e) { throw _err("cms/bad-signature", "the EdDSA public key is not a well-formed SPKI", e); }
267
- var point = content && content.length ? content.subarray(1) : Buffer.alloc(0);
268
- if (!edwardsPoint.validate(point, name === "Ed25519" ? 6 : 7)) {
269
- throw _err("cms/bad-signature", "the EdDSA public key is not a valid, full-order Edwards point");
270
- }
276
+ edwardsPoint.validateSpki(spkiBytes, name === "Ed25519" ? 6 : 7, CmsError, "cms/bad-signature");
271
277
  }
272
278
 
273
279
  // The EC named-curve OID carried in a signer cert's SubjectPublicKeyInfo, or null (non-EC).
@@ -298,6 +304,11 @@ function _decodeSignedAttrs(setOfBytes) {
298
304
  // Verify one SignerInfo. Returns { ok, sid, cert, ... } -- a resolved false is a verdict; a
299
305
  // structural failure carries a code.
300
306
  function _verifyOne(si, content, eContentType, parsedCerts) {
307
+ // A composite ML-DSA SignerInfo (draft-ietf-lamps-cms-composite-sigs) is keyed by its composite
308
+ // OID, not a SIG_SCHEME name; intercept it before the classical dispatch (its verify has no
309
+ // single external signature hash, so it never routes through the SIG_SCHEME/sigHash gate below).
310
+ var composite = compositeSig.COMPOSITE_ALGS[si.signatureAlgorithm.oid];
311
+ if (composite) return _verifyComposite(si, composite, content, eContentType, parsedCerts);
301
312
  var scheme = SIG_SCHEME[si.signatureAlgorithm.name];
302
313
  if (!scheme) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "unsupported signature algorithm " + JSON.stringify(si.signatureAlgorithm.name) });
303
314
  var digestHash = DIGEST_HASH[si.digestAlgorithm.name];
@@ -317,27 +328,45 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
317
328
  var dp = si.digestAlgorithm.parameters;
318
329
  var mldsaNoAttrs = scheme.kind === "mldsa" && !si.signedAttrsBytes;
319
330
  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)" });
331
+ // A SHAKE digest algorithm identifier (id-shake128 / id-shake256) carries NO parameters at all --
332
+ // not even a DER NULL (RFC 8702 sec. 3.1) -- so for a one-shot PQC SignerInfo (ML-DSA per RFC 9882,
333
+ // SLH-DSA per RFC 9814 sec. 4, both citing RFC 8702) with signed attributes a present SHAKE
334
+ // parameter fails closed. The SHA-2 digests (id-sha256/384/512) follow RFC 5754, which requires a
335
+ // verifier to accept absent OR NULL (the generic rule above); the RFCs' omit-on-encode rule binds
336
+ // the signer, not the verifier, so a SHA-2 NULL is not tightened here.
337
+ 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)" });
326
338
  var sigHash = pss ? pss.hash : (scheme.hash || digestHash);
327
339
  // The signature hash is required for every non-EdDSA scheme (EdDSA hashes internally);
328
340
  // the content digest is required whenever signed attributes are present, for every scheme
329
341
  // (the message-digest attribute is computed under digestAlgorithm). Either gap is a
330
342
  // fail-closed unsupported-algorithm verdict, never a foreign-domain throw from the digest.
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) });
343
+ 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) });
332
344
  // ML-DSA (RFC 9882 sec. 3.3): with signed attributes present, the message-digest algorithm MUST
333
345
  // meet the parameter set's security strength -- a below-strength digest is the weaker link and is
334
346
  // rejected fail-closed (the RFC's "verifiers MAY reject", taken by the strict-verifier posture).
335
347
  // With signed attributes absent the digestAlgorithm has no meaning (sec. 3.3) and is not checked.
336
348
  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)" });
349
+ // RFC 9814 sec. 4: an SLH-DSA SignerInfo's message-digest algorithm is the one paired with the
350
+ // parameter set (the sec. 4 list, always at least twice the tree-hash size); the signer emits it
351
+ // and this strict verifier requires it, so a digest that does not match the set's paired hash fails
352
+ // closed -- otherwise a signer could compute the message-digest attribute under a weaker hash the
353
+ // set does not pair. Gated on signed attributes (absent -> the digest is not consulted, sec. 4).
354
+ 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)" });
337
355
  var signers = _findSignerCerts(si.sid, parsedCerts);
338
356
  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" });
339
357
  var sigBytes = _toBuf(si.signature, "the SignerInfo signature");
340
358
 
359
+ return _computeSignedBytes(si, content, eContentType).then(function (signedBytes) {
360
+ if (signedBytes && signedBytes.mismatch) return { ok: false, code: signedBytes.mismatch.code, sid: si.sid, cert: signers[0].der, message: signedBytes.mismatch.message };
361
+ return _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, si.sid, signers, pss ? pss.saltLength : 0, scheme.sameKeyOid ? oid.byName(si.signatureAlgorithm.name) : null);
362
+ });
363
+ }
364
+
365
+ // The exact bytes the signature covers (RFC 5652 sec. 5.4), shared by the classical and composite
366
+ // verify paths so both bind the message-digest / content-type checks to the VERIFIED preimage (not
367
+ // the caller-mutable parsed si.signedAttrs). Resolves the signed bytes, or { mismatch } when a
368
+ // content-type / message-digest attribute disagrees.
369
+ function _computeSignedBytes(si, content, eContentType) {
341
370
  return Promise.resolve().then(function () {
342
371
  if (!si.signedAttrsBytes) return content; // no signed attributes: sign over the content directly
343
372
  // With signed attributes: decode them from the EXACT bytes the signature covers -- the
@@ -372,12 +401,79 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
372
401
  if (!d.equals(declared)) return { mismatch: { code: "cms/message-digest-mismatch", message: "the message-digest attribute does not match the content digest" } };
373
402
  return reTagged;
374
403
  });
375
- }).then(function (signedBytes) {
404
+ });
405
+ }
406
+
407
+ // Verify a composite ML-DSA SignerInfo (draft-ietf-lamps-cms-composite-sigs). The composite
408
+ // verify engine lives in composite-sig.js (shared with certification-path validation); this branch
409
+ // enforces the CMS-specific rules the X.509 path does not: params-absent on the signatureAlgorithm
410
+ // (sec. 3.4, defense-in-depth over the parse-time _PARAMS_ABSENT gate), the unsupported-arm refusal,
411
+ // and the sec. 3.4/sec. 5 coherence gate binding the SignerInfo digestAlgorithm to the arm's Table-1
412
+ // pre-hash. It reuses the sec. 5.4 preimage + message-digest binding UNCHANGED, then dispatches
413
+ // compositeVerify per candidate certificate (BOTH components must verify -- never an AND->OR
414
+ // downgrade).
415
+ function _verifyComposite(si, comp, content, eContentType, parsedCerts) {
416
+ if (si.signatureAlgorithm.parameters !== null && si.signatureAlgorithm.parameters !== undefined) {
417
+ return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the composite signatureAlgorithm parameters must be absent (draft-ietf-lamps-cms-composite-sigs sec. 3.4)" });
418
+ }
419
+ // The 3 arms Node's WebCrypto surface cannot verify (brainpool curves; the SHAKE256/64 pre-hash)
420
+ // fail closed -- never a silent single-component accept (the AND->OR downgrade this feature prevents).
421
+ if (comp.trad.unsupported) {
422
+ return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "composite " + comp.name + ": " + comp.trad.unsupported });
423
+ }
424
+ // draft sec. 3.4: for a composite SignerInfo the digestAlgorithm parameters MUST be OMITTED
425
+ // (id-sha256/id-sha512 and id-shake256) -- stricter than the generic RFC 5754 absent-OR-NULL rule
426
+ // (which the classical/ML-DSA paths follow), because the composite draft explicitly requires the
427
+ // field omitted. A present parameter -- even a DER NULL -- is non-conformant and fails closed
428
+ // (it is outside the signed preimage, so a parser differential must not verify).
429
+ var dp = si.digestAlgorithm.parameters;
430
+ if (dp !== null && dp !== undefined) {
431
+ return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the composite " + si.digestAlgorithm.name + " digestAlgorithm parameters must be omitted (draft-ietf-lamps-cms-composite-sigs sec. 3.4)" });
432
+ }
433
+ // sec. 3.4 / sec. 5 (the one CMS-specific MUST the X.509 path does not cover): the SignerInfo
434
+ // digestAlgorithm MUST equal the arm's pre-hash (Table 1) -- compositeVerify takes the pre-hash
435
+ // from the OID-keyed descriptor, so a disagreeing digestAlgorithm would recompute the
436
+ // message-digest attribute under the WRONG algorithm. The sec. 5 SHOULD-reject is taken
437
+ // fail-closed; the MAY-verify-anyway leniency is deliberately not taken.
438
+ if (si.digestAlgorithm.name !== comp.phCms) {
439
+ return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "the SignerInfo digestAlgorithm " + JSON.stringify(si.digestAlgorithm.name) + " is not the composite " + comp.name + " pre-hash " + JSON.stringify(comp.phCms) + " (draft-ietf-lamps-cms-composite-sigs sec. 3.4)" });
440
+ }
441
+ var signers = _findSignerCerts(si.sid, parsedCerts);
442
+ 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" });
443
+ var sigBytes = _toBuf(si.signature, "the SignerInfo signature");
444
+ return _computeSignedBytes(si, content, eContentType).then(function (signedBytes) {
376
445
  if (signedBytes && signedBytes.mismatch) return { ok: false, code: signedBytes.mismatch.code, sid: si.sid, cert: signers[0].der, message: signedBytes.mismatch.message };
377
- return _verifyAgainstCandidates(scheme, sigHash, sigBytes, signedBytes, si.sid, signers, pss ? pss.saltLength : 0, scheme.sameKeyOid ? oid.byName(si.signatureAlgorithm.name) : null);
446
+ return _verifyCompositeAgainstCandidates(comp, sigBytes, signedBytes, si.sid, signers, si.signatureAlgorithm.oid);
378
447
  });
379
448
  }
380
449
 
450
+ // Dispatch compositeVerify against EACH matching candidate; the signer is valid iff one candidate
451
+ // verifies both components. A candidate whose SPKI composite OID != the SignerInfo signatureAlgorithm
452
+ // OID is skipped with a precise verdict (RFC 9814 sec. 4 key<->signature agreement); a candidate that
453
+ // returns a structural fault code (bad split length, unsupported) falls through, its code surfaced if
454
+ // none verifies. A clean both-components-checked failure (the AND-downgrade) is a code-less false.
455
+ function _verifyCompositeAgainstCandidates(comp, sigBytes, signedBytes, sid, candidates, expectedKeyOid) {
456
+ var lastErr = null;
457
+ function attempt(idx) {
458
+ if (idx >= candidates.length) {
459
+ return lastErr ? { ok: false, code: lastErr.code, sid: sid, cert: candidates[0].der, message: lastErr.message }
460
+ : { ok: false, sid: sid, cert: candidates[0].der };
461
+ }
462
+ var c = candidates[idx];
463
+ if (c.cert.subjectPublicKeyInfo.algorithm.oid !== expectedKeyOid) {
464
+ lastErr = _err("cms/unsupported-algorithm", "the signer certificate public-key algorithm does not match the SignerInfo signatureAlgorithm");
465
+ return attempt(idx + 1);
466
+ }
467
+ return compositeSig.compositeVerify(c.cert.subjectPublicKeyInfo.bytes, sigBytes, signedBytes, comp, CmsError, "cms/unsupported-algorithm", "cms/bad-signature")
468
+ .then(function (r) {
469
+ if (r.ok === true) return { ok: true, sid: sid, cert: c.der };
470
+ if (r.code) lastErr = (r.error instanceof CmsError) ? r.error : _err(r.code, r.error && r.error.message ? r.error.message : "the composite signature could not be evaluated");
471
+ return attempt(idx + 1);
472
+ });
473
+ }
474
+ return attempt(0);
475
+ }
476
+
381
477
  /**
382
478
  * @primitive pki.cms.verify
383
479
  * @signature pki.cms.verify(input, opts?) -> Promise<{ valid, signers }>
@@ -385,6 +481,7 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
385
481
  * @status experimental
386
482
  * @spec RFC 5652
387
483
  * @spec RFC 9882
484
+ * @spec RFC 9814
388
485
  * @defends cms-signature-forgery (CWE-347)
389
486
  * @related pki.schema.cms.parse, pki.path.validate
390
487
  *
@@ -393,7 +490,10 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
393
490
  * `signers[i]` is `{ ok, sid, cert }` (`cert` the matched signer certificate DER) or carries
394
491
  * a `code` on a structural failure; `valid` is true when there is at least one signer and
395
492
  * 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.
493
+ * ML-DSA (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814) -- pure mode,
494
+ * empty context -- signatures are recognized, as is composite ML-DSA
495
+ * (draft-ietf-lamps-cms-composite-sigs), which pairs ML-DSA with a traditional RSA / ECDSA / EdDSA
496
+ * and verifies only when BOTH components pass (never an AND-to-OR downgrade).
397
497
  *
398
498
  * @opts content The detached content (a `Buffer`) when the SignedData carries no
399
499
  * encapsulated eContent. Required for a detached signature.
@@ -440,10 +540,6 @@ function _addCert(out, der) {
440
540
  // because an upstream contract already narrows the shape:
441
541
  // * `cert.extensions || []` -- x509.parse always surfaces `extensions` as an array (empty
442
542
  // when absent), so the `|| []` fallback never fires.
443
- // * `_requireValidEdPoint`'s SPKI-decode catch and its empty-content `Buffer.alloc(0)`
444
- // fallback -- a signer certificate accepted by x509.parse always carries a well-formed,
445
- // non-empty SubjectPublicKeyInfo BIT STRING, so the decode never throws and the content is
446
- // never empty here.
447
543
  // * `_decodeSignedAttrs`'s `tagNumber !== SET` / `!children` guard -- the caller forces the
448
544
  // leading byte to a universal SET OF before decoding, so `asn1.decode` yields a SET whose
449
545
  // `children` is always an array; the guard is belt-and-suspenders against a future caller.
@@ -459,19 +555,22 @@ function _addCert(out, der) {
459
555
  * @status experimental
460
556
  * @spec RFC 5652
461
557
  * @spec RFC 9882
558
+ * @spec RFC 9814
462
559
  * @related pki.cms.verify, pki.schema.cms.parse
463
560
  *
464
561
  * Produce a CMS SignedData (RFC 5652 sec. 5) over `content` (a `Buffer`) -- the structure
465
562
  * S/MIME signed mail, RFC 3161 timestamp tokens, and code signing rest on, and exactly what
466
563
  * `pki.cms.verify` consumes and OpenSSL `cms -verify` validates. Each `signers[i]` is
467
564
  * `{ cert, key, digestAlgorithm?, pss? }`: `cert` the signer certificate (PEM or DER), `key`
468
- * its private key (a WebCrypto `CryptoKey` or a PKCS#8 DER `Buffer` / PEM string). The
565
+ * its private key (a WebCrypto `CryptoKey` or a PKCS#8 DER `Buffer` / PEM string; for a composite
566
+ * ML-DSA signer, the two component keys `{ mldsa, trad }`, each PKCS#8). The
469
567
  * signature covers the RFC 5652 sec. 5.4 preimage: with signed attributes (the default) the
470
568
  * message-digest attribute is bound to the content digest and the signature is over the
471
569
  * canonical DER SET OF SignedAttributes; otherwise over the content directly. RSA (PKCS#1 v1.5
472
570
  * 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.
571
+ * (ML-DSA-44/65/87, RFC 9882) and SLH-DSA (the twelve FIPS 205 sets, RFC 9814 -- the message digest
572
+ * pinned per parameter set) are covered, as is composite ML-DSA (draft-ietf-lamps-cms-composite-sigs)
573
+ * -- the arm's pre-hash fixes the digestAlgorithm and the two component keys are signed together.
475
574
  *
476
575
  * @opts detached Omit the encapsulated content (a detached signature; the verifier
477
576
  * supplies the content). Default false.