@blamejs/pki 0.2.17 → 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,20 @@ 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
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
8
21
 
9
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.
10
23
 
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 (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` |
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/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;
@@ -126,6 +127,17 @@ function _digest(digestName, content) {
126
127
  function _scheme(cert, so, noSignedAttrs) {
127
128
  var alg = cert.subjectPublicKeyInfo.algorithm;
128
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
+ }
129
141
  if (keyOid === O("rsaEncryption") || keyOid === O("rsassaPss")) {
130
142
  var isPssKey = keyOid === O("rsassaPss");
131
143
  // An id-RSASSA-PSS key MAY pin its permitted hash in the SPKI params (RFC 4055 sec. 1.2): honor
@@ -242,47 +254,76 @@ function _buildSignerInfo(signer, content, eContentType, opts) {
242
254
  : b.sequence([b.raw(_issuerBytes(cert)), b.integer(cert.serialNumber)]); // IssuerAndSerialNumber
243
255
  var version = useSki ? 3 : 1;
244
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
+ }
245
302
  return _importKey(so.key, scheme.imp).then(function (priv) {
246
- return Promise.resolve().then(function () {
247
- if (opts.signedAttributes === false) return content; // sign the content directly (no signed attributes)
248
- // Signed attributes (RFC 5652 sec. 5.3): content-type == eContentType, message-digest ==
249
- // digest(content), and (by default) signing-time. build.set canonical-DER SET-OF-sorts them.
250
- // Each attribute type appears AT MOST ONCE across the whole set (RFC 5652 sec. 5.3);
251
- // `seenTypes` catches a caller-supplied attribute that duplicates a built-in or another.
252
- var seenTypes = {};
253
- function _pushAttr(typeOid, values) {
254
- if (seenTypes[typeOid]) throw _err("cms/bad-input", "signedAttrs must not repeat an attribute type (RFC 5652 sec. 5.3): " + typeOid);
255
- seenTypes[typeOid] = 1;
256
- attrs.push(b.sequence([b.oid(typeOid), b.set(values)]));
257
- }
258
- var attrs = [];
259
- _pushAttr(O("contentType"), [b.oid(eContentType)]);
260
- _pushAttr(O("messageDigest"), [b.octetString(_digest(scheme.digest, content))]);
261
- if (opts.signingTime !== false) _pushAttr(O("signingTime"), [_timeValue(opts.signingTime)]);
262
- // Caller-supplied signed attributes (e.g. an RFC 3161 signing-certificate attribute): each
263
- // { type: <OID name or dotted string>, values: [<DER value Buffer>] }. build.set sorts them in.
264
- (opts.additionalSignedAttributes || []).forEach(function (a) {
265
- var vals = (a.values || []).map(function (v) { return _toBuf(v, "a signed attribute value"); });
266
- 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))");
267
- _pushAttr(/^\d+(\.\d+)+$/.test(a.type) ? a.type : O(a.type), vals);
268
- });
269
- var setOf = b.set(attrs); // SET OF (tag 0x31) -- the exact bytes the signature covers (sec. 5.4)
270
- var wire = Buffer.from(setOf); wire[0] = 0xA0; // the on-wire [0] IMPLICIT tag
271
- return { setOf: setOf, wire: wire };
272
- }).then(function (toSign) {
273
- var signedBytes = toSign.setOf ? toSign.setOf : toSign; // SET-OF form for signing (sec. 5.4)
274
- return subtle.sign(scheme.sign, priv, signedBytes).then(function (sigRaw) {
275
- var sig = Buffer.from(sigRaw);
276
- if (scheme.ecdsaDer) sig = validator.sig.rawToEcdsaDer(sig, scheme.coordLen);
277
- var fields = [b.integer(BigInt(version)), sid, scheme.digestAlgId];
278
- if (toSign.wire) fields.push(toSign.wire); // [0] IMPLICIT signedAttrs
279
- fields.push(scheme.sigAlgId, b.octetString(sig));
280
- return { si: b.sequence(fields), digestAlgId: scheme.digestAlgId, version: version, certDer: certDer };
281
- });
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;
282
307
  });
283
308
  });
284
309
  }
285
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
+
286
327
  // A signing-time Time value: UTCTime before 2050, GeneralizedTime from 2050 (RFC 5652 sec. 11.3 /
287
328
  // RFC 5280 sec. 4.1.2.5). A caller Date overrides; false omits the attribute (handled above).
288
329
  function _timeValue(when) {
package/lib/cms-verify.js CHANGED
@@ -13,9 +13,11 @@
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) plus SLH-DSA (the twelve FIPS 205 sets, RFC 9814)
18
- * 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;
19
21
  * it does NOT chain the signer certificate to a trust anchor -- that is the caller's step through
20
22
  * `pki.path.validate`.
21
23
  * @spec RFC 5652
@@ -36,6 +38,7 @@ var cmsSign = require("./cms-sign");
36
38
  var MLDSA_SUITABLE_DIGEST = cmsSign.MLDSA_SUITABLE_DIGEST; // shared sign/verify digest-strength policy (RFC 9882 sec. 3.3)
37
39
  var SLHDSA_BY_OID = cmsSign.SLHDSA_BY_OID; // shared SLH-DSA set -> { wc, digest } (RFC 9814 sec. 4 pinned digest)
38
40
  var validator = require("./validator-all");
41
+ var compositeSig = require("./composite-sig");
39
42
  var guard = require("./guard-all");
40
43
  var frameworkError = require("./framework-error");
41
44
 
@@ -266,18 +269,11 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
266
269
  .then(function (k) { return subtle.verify({ name: scheme.name }, k, sigBytes, signedBytes); });
267
270
  }
268
271
 
269
- // The raw Edwards point an OKP SPKI carries (its BIT STRING body, past the unused-bits octet)
270
- // MUST be a valid, canonical, full-order point -- reject an off-curve or low-order key before
271
- // 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.
272
275
  function _requireValidEdPoint(spkiBytes, name) {
273
- var content;
274
- try {
275
- content = asn1.decode(spkiBytes).children[1].content;
276
- } catch (e) { throw _err("cms/bad-signature", "the EdDSA public key is not a well-formed SPKI", e); }
277
- var point = content && content.length ? content.subarray(1) : Buffer.alloc(0);
278
- if (!edwardsPoint.validate(point, name === "Ed25519" ? 6 : 7)) {
279
- throw _err("cms/bad-signature", "the EdDSA public key is not a valid, full-order Edwards point");
280
- }
276
+ edwardsPoint.validateSpki(spkiBytes, name === "Ed25519" ? 6 : 7, CmsError, "cms/bad-signature");
281
277
  }
282
278
 
283
279
  // The EC named-curve OID carried in a signer cert's SubjectPublicKeyInfo, or null (non-EC).
@@ -308,6 +304,11 @@ function _decodeSignedAttrs(setOfBytes) {
308
304
  // Verify one SignerInfo. Returns { ok, sid, cert, ... } -- a resolved false is a verdict; a
309
305
  // structural failure carries a code.
310
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);
311
312
  var scheme = SIG_SCHEME[si.signatureAlgorithm.name];
312
313
  if (!scheme) return Promise.resolve({ ok: false, code: "cms/unsupported-algorithm", sid: si.sid, message: "unsupported signature algorithm " + JSON.stringify(si.signatureAlgorithm.name) });
313
314
  var digestHash = DIGEST_HASH[si.digestAlgorithm.name];
@@ -355,6 +356,17 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
355
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" });
356
357
  var sigBytes = _toBuf(si.signature, "the SignerInfo signature");
357
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) {
358
370
  return Promise.resolve().then(function () {
359
371
  if (!si.signedAttrsBytes) return content; // no signed attributes: sign over the content directly
360
372
  // With signed attributes: decode them from the EXACT bytes the signature covers -- the
@@ -389,12 +401,79 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
389
401
  if (!d.equals(declared)) return { mismatch: { code: "cms/message-digest-mismatch", message: "the message-digest attribute does not match the content digest" } };
390
402
  return reTagged;
391
403
  });
392
- }).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) {
393
445
  if (signedBytes && signedBytes.mismatch) return { ok: false, code: signedBytes.mismatch.code, sid: si.sid, cert: signers[0].der, message: signedBytes.mismatch.message };
394
- 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);
395
447
  });
396
448
  }
397
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
+
398
477
  /**
399
478
  * @primitive pki.cms.verify
400
479
  * @signature pki.cms.verify(input, opts?) -> Promise<{ valid, signers }>
@@ -412,7 +491,9 @@ function _verifyOne(si, content, eContentType, parsedCerts) {
412
491
  * a `code` on a structural failure; `valid` is true when there is at least one signer and
413
492
  * every signer verified. RSA (PKCS#1 v1.5 and RSASSA-PSS), ECDSA, EdDSA, and the post-quantum
414
493
  * 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.
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).
416
497
  *
417
498
  * @opts content The detached content (a `Buffer`) when the SignedData carries no
418
499
  * encapsulated eContent. Required for a detached signature.
@@ -459,10 +540,6 @@ function _addCert(out, der) {
459
540
  // because an upstream contract already narrows the shape:
460
541
  // * `cert.extensions || []` -- x509.parse always surfaces `extensions` as an array (empty
461
542
  // when absent), so the `|| []` fallback never fires.
462
- // * `_requireValidEdPoint`'s SPKI-decode catch and its empty-content `Buffer.alloc(0)`
463
- // fallback -- a signer certificate accepted by x509.parse always carries a well-formed,
464
- // non-empty SubjectPublicKeyInfo BIT STRING, so the decode never throws and the content is
465
- // never empty here.
466
543
  // * `_decodeSignedAttrs`'s `tagNumber !== SET` / `!children` guard -- the caller forces the
467
544
  // leading byte to a universal SET OF before decoding, so `asn1.decode` yields a SET whose
468
545
  // `children` is always an array; the guard is belt-and-suspenders against a future caller.
@@ -485,13 +562,15 @@ function _addCert(out, der) {
485
562
  * S/MIME signed mail, RFC 3161 timestamp tokens, and code signing rest on, and exactly what
486
563
  * `pki.cms.verify` consumes and OpenSSL `cms -verify` validates. Each `signers[i]` is
487
564
  * `{ cert, key, digestAlgorithm?, pss? }`: `cert` the signer certificate (PEM or DER), `key`
488
- * 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
489
567
  * signature covers the RFC 5652 sec. 5.4 preimage: with signed attributes (the default) the
490
568
  * message-digest attribute is bound to the content digest and the signature is over the
491
569
  * canonical DER SET OF SignedAttributes; otherwise over the content directly. RSA (PKCS#1 v1.5
492
570
  * and, with `pss`, RSASSA-PSS), ECDSA (P-256/384/521), Ed25519, Ed448, and the post-quantum ML-DSA
493
571
  * (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.
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.
495
574
  *
496
575
  * @opts detached Omit the encapsulated content (a detached signature; the verifier
497
576
  * supplies the content). Default false.
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The composite ML-DSA signature engine
6
+ // (draft-ietf-lamps-pq-composite-sigs for the construction; draft-ietf-lamps-cms-composite-sigs
7
+ // binds it to CMS), shared by certification-path validation (X.509 composite certificates,
8
+ // lib/path-validate.js) and CMS (composite SignerInfo, lib/cms-verify.js / lib/cms-sign.js).
9
+ //
10
+ // A composite signature pairs a post-quantum ML-DSA with a traditional RSA / ECDSA / EdDSA so the
11
+ // signature stays trustworthy if EITHER primitive is later broken. The public key is the raw
12
+ // concatenation mldsaPK || tradPK (sec. 4.1) in the SPKI BIT STRING; the signature is
13
+ // mldsaSig || tradSig (sec. 4.3). Verification (sec. 2) reconstructs
14
+ // M' = Prefix || Label || len(ctx) || ctx || PH(M),
15
+ // verifies the ML-DSA component over M' with ctx = the composite Label, verifies the traditional
16
+ // component over M' under its own hash, and accepts IFF BOTH pass (THREAT-MODEL: all components
17
+ // must verify -- never an AND-to-OR downgrade). The ML-DSA component is the fixed-length FIRST
18
+ // half; the split point is its length.
19
+ //
20
+ // Error-parameterized like the guard / validator families: each consumer passes its own typed
21
+ // error CONSTRUCTOR `E` and its domain codes (path validation passes PathError + path/*; CMS
22
+ // passes CmsError + cms/*), so the shared engine keeps no domain of its own. compositeVerify keeps
23
+ // the { ok, code, error } verdict shape; the codes are caller-supplied.
24
+
25
+ var asn1 = require("./asn1-der");
26
+ var oid = require("./oid");
27
+ var webcrypto = require("./webcrypto");
28
+ var validator = require("./validator-all");
29
+ var edwardsPoint = require("./edwards-point");
30
+ var subtle = webcrypto.webcrypto.subtle;
31
+ var _b = asn1.build;
32
+
33
+ // A caught error's OWN code if it shares the caller's domain prefix (the fallback's namespace,
34
+ // e.g. path/ or cms/), else the fallback -- a foreign code (asn1/*) maps to the fallback so the
35
+ // verdict stays in the caller's error namespace. Mirrors path-validate's original pathCode when
36
+ // the fallback is a path/* code.
37
+ function _codeOf(e, fallback) {
38
+ var prefix = fallback.slice(0, fallback.indexOf("/") + 1);
39
+ return (e && typeof e.code === "string" && e.code.indexOf(prefix) === 0) ? e.code : fallback;
40
+ }
41
+
42
+ var COMPOSITE_PREFIX = Buffer.from("CompositeAlgorithmSignatures2025", "ascii");
43
+ var MLDSA_COMPONENT = {
44
+ "ML-DSA-44": { pk: 1312, sig: 2420, oid: "id-ml-dsa-44" },
45
+ "ML-DSA-65": { pk: 1952, sig: 3309, oid: "id-ml-dsa-65" },
46
+ "ML-DSA-87": { pk: 2592, sig: 4627, oid: "id-ml-dsa-87" },
47
+ };
48
+ var EC_CURVE_OID = { "P-256": "prime256v1", "P-384": "secp384r1", "P-521": "secp521r1" };
49
+ // The WebCrypto pre-hash name (ph) -> the CMS digest-algorithm name (the SignerInfo digestAlgorithm,
50
+ // draft-ietf-lamps-cms-composite-sigs Table 1). The SINGLE source of truth for the CMS binding's
51
+ // digest -- both the verify coherence gate and the sign digestAlgorithm read phCms, never a second
52
+ // name map inlined at the CMS boundary.
53
+ var PH_CMS = { "SHA-256": "sha256", "SHA-512": "sha512", "SHAKE256": "shake256" };
54
+ var COMPOSITE_ALGS = {};
55
+ // _comp(name, mldsa, ph, label, trad). `trad` is exactly one component shape:
56
+ // { ec, hash } | { eddsa } | { rsaPss, hash, salt } | { rsaPkcs1, hash } |
57
+ // { unsupported } for the arms Node's WebCrypto surface cannot verify (brainpool
58
+ // curves; the SHAKE256/64 pre-hash) -- registered + params-guarded, deferred at
59
+ // verify to the caller's unsupported-algorithm code rather than silently accepted.
60
+ function _comp(name, mldsa, ph, label, trad) {
61
+ var sz = MLDSA_COMPONENT[mldsa];
62
+ COMPOSITE_ALGS[oid.byName(name)] = {
63
+ name: name, mldsa: mldsa, mldsaPk: sz.pk, mldsaSig: sz.sig, mldsaOid: sz.oid,
64
+ ph: ph, phCms: PH_CMS[ph], label: Buffer.from(label, "ascii"), trad: trad,
65
+ };
66
+ }
67
+ _comp("id-MLDSA44-RSA2048-PSS-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PSS-SHA256", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 2048 });
68
+ _comp("id-MLDSA44-RSA2048-PKCS15-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PKCS15-SHA256", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 2048 });
69
+ _comp("id-MLDSA44-Ed25519-SHA512", "ML-DSA-44", "SHA-512", "COMPSIG-MLDSA44-Ed25519-SHA512", { eddsa: "Ed25519" });
70
+ _comp("id-MLDSA44-ECDSA-P256-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", { ec: "P-256", hash: "SHA-256" });
71
+ _comp("id-MLDSA65-RSA3072-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
72
+ _comp("id-MLDSA65-RSA3072-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 3072 });
73
+ _comp("id-MLDSA65-RSA4096-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
74
+ _comp("id-MLDSA65-RSA4096-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-384", rsaBits: 4096 });
75
+ _comp("id-MLDSA65-ECDSA-P256-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P256-SHA512", { ec: "P-256", hash: "SHA-256" });
76
+ _comp("id-MLDSA65-ECDSA-P384-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
77
+ _comp("id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-BP256-SHA512", { unsupported: "brainpoolP256r1 is not in the WebCrypto ECDSA curve set" });
78
+ _comp("id-MLDSA65-Ed25519-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-Ed25519-SHA512", { eddsa: "Ed25519" });
79
+ _comp("id-MLDSA87-ECDSA-P384-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
80
+ _comp("id-MLDSA87-ECDSA-brainpoolP384r1-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-BP384-SHA512", { unsupported: "brainpoolP384r1 is not in the WebCrypto ECDSA curve set" });
81
+ _comp("id-MLDSA87-Ed448-SHAKE256", "ML-DSA-87", "SHAKE256", "COMPSIG-MLDSA87-Ed448-SHAKE256", { unsupported: "the SHAKE256/64 pre-hash is not in the WebCrypto digest set" });
82
+ _comp("id-MLDSA87-RSA3072-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
83
+ _comp("id-MLDSA87-RSA4096-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
84
+ _comp("id-MLDSA87-ECDSA-P521-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P521-SHA512", { ec: "P-521", hash: "SHA-512" });
85
+
86
+ // Wrap a raw component public key in the SPKI its WebCrypto import expects, so each half is
87
+ // verified through the SAME import + verify seam the classical path uses (no second parallel
88
+ // verify path). tradPK for RSA is the RSAPublicKey DER; for EC the uncompressed point; for EdDSA
89
+ // the raw public key.
90
+ function _spkiFor(algNode, keyBytes) { return _b.sequence([algNode, _b.bitString(keyBytes, 0)]); }
91
+ function _mldsaSpki(mldsaOid, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(mldsaOid))]), raw); }
92
+ function _ecSpki(curve, point) { return _spkiFor(_b.sequence([_b.oid(oid.byName("ecPublicKey")), _b.oid(oid.byName(EC_CURVE_OID[curve]))]), point); }
93
+ function _edSpki(name, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(name))]), raw); }
94
+ function _rsaSpki(rsaPub) { return _spkiFor(_b.sequence([_b.oid(oid.byName("rsaEncryption")), _b.nullValue()]), rsaPub); }
95
+
96
+ // The exact modulus bit length of an RSAPublicKey (SEQUENCE { modulus, exponent }).
97
+ function _rsaModulusBits(rsaPubDer) {
98
+ return asn1.read.integer(asn1.decode(rsaPubDer).children[0]).toString(2).length;
99
+ }
100
+
101
+ // M' = Prefix || Label || len(ctx) || ctx || PH(M) (draft sec. 2 / RFC 8410-style domain
102
+ // separation). ctx is a single-octet-length-prefixed application context; X.509 path validation
103
+ // and CMS both use the empty context. Shared by verify AND sign so the two cannot diverge.
104
+ function compositeMprime(d, phBuf, ctx) {
105
+ ctx = ctx || Buffer.alloc(0);
106
+ return Buffer.concat([COMPOSITE_PREFIX, d.label, Buffer.from([ctx.length]), ctx, Buffer.from(phBuf)]);
107
+ }
108
+
109
+ function _verifyTradComponent(trad, tradPK, tradSig, mprime, E, badSig) {
110
+ if (trad.ec) {
111
+ // draft sec. 5.1: the EC point MUST be the uncompressed X9.62 form (leading 0x04).
112
+ // A compressed / hybrid point is a non-conforming composite component encoding.
113
+ if (tradPK.length < 1 || tradPK[0] !== 0x04) return Promise.resolve(false);
114
+ return subtle.importKey("spki", _ecSpki(trad.ec, tradPK), { name: "ECDSA" }, false, ["verify"]).then(function (k) {
115
+ // The traditional ECDSA signature is DER Ecdsa-Sig-Value; convert to P1363 through the shared
116
+ // order-aware reader that also rejects r/s outside [1,n-1] (CVE-2022-21449).
117
+ var p1363 = validator.sig.ecdsaDerToP1363(tradSig, trad.ec, E, badSig);
118
+ return subtle.verify({ name: "ECDSA", hash: trad.hash }, k, p1363, mprime);
119
+ });
120
+ }
121
+ if (trad.eddsa) {
122
+ // node/OpenSSL imports any Ed25519/Ed448 SPKI without validating the point, and a low-order
123
+ // (e.g. all-zeroes) key verifies a forged signature -- which would collapse the composite AND
124
+ // to ML-DSA-only. Reject a non-full-order point before verify, through the same shared
125
+ // edwards-point gate every other EdDSA verify path in the toolkit routes through, surfacing a
126
+ // coded fault (like the ECDSA order-bound belt) rather than a silent false.
127
+ if (!edwardsPoint.validate(tradPK, trad.eddsa === "Ed25519" ? 6 : 7)) {
128
+ throw new E(badSig, "the composite EdDSA component public key is not a valid, full-order Edwards point");
129
+ }
130
+ return subtle.importKey("spki", _edSpki(trad.eddsa, tradPK), { name: trad.eddsa }, false, ["verify"])
131
+ .then(function (k) { return subtle.verify({ name: trad.eddsa }, k, tradSig, mprime); });
132
+ }
133
+ if (trad.rsaPss || trad.rsaPkcs1) {
134
+ // The composite OID fixes the RSA modulus size: a downgraded or mismatched modulus under the
135
+ // declared OID (an id-MLDSA44-RSA2048-* whose component is really 1024-bit) is rejected BEFORE
136
+ // verify, so a weak RSA component cannot satisfy an arm that promises 2048/3072/4096 bits. A
137
+ // malformed RSAPublicKey rejects the same way.
138
+ var bits;
139
+ try { bits = _rsaModulusBits(tradPK); }
140
+ catch (_e) { return Promise.resolve(false); }
141
+ if (bits !== trad.rsaBits) return Promise.resolve(false);
142
+ if (trad.rsaPss) {
143
+ return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSA-PSS", hash: trad.hash }, false, ["verify"])
144
+ .then(function (k) { return subtle.verify({ name: "RSA-PSS", saltLength: trad.salt }, k, tradSig, mprime); });
145
+ }
146
+ return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSASSA-PKCS1-v1_5", hash: trad.hash }, false, ["verify"])
147
+ .then(function (k) { return subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, k, tradSig, mprime); });
148
+ }
149
+ // Coverage residual -- unreachable: compositeVerify returns early for a trad.unsupported arm, so
150
+ // _verifyTradComponent runs only for arms that set exactly one of ec/eddsa/rsaPss/rsaPkcs1.
151
+ return Promise.resolve(false);
152
+ }
153
+
154
+ // Verify a composite signature: `spkiBytes` the issuer SPKI DER, `sigBytes` the raw signatureValue
155
+ // (mldsaSig || tradSig), `message` the signed region. `E` the caller's error constructor;
156
+ // `unsupported`/`badSig` its domain codes. Returns { ok, code?, error? } (a false is a verdict).
157
+ function compositeVerify(spkiBytes, sigBytes, message, d, E, unsupported, badSig) {
158
+ if (d.trad.unsupported) {
159
+ return Promise.resolve({ ok: false, code: unsupported,
160
+ error: new E(unsupported, "composite " + d.name + ": " + d.trad.unsupported) });
161
+ }
162
+ var rawKey;
163
+ try {
164
+ var bs = asn1.read.bitString(asn1.decode(spkiBytes).children[1]);
165
+ // The composite subjectPublicKey is an octet-aligned concatenation (no unused bits); a non-zero
166
+ // unused-bit count is malformed.
167
+ if (bs.unusedBits !== 0) throw new E(badSig, "composite subjectPublicKey has unused bits");
168
+ rawKey = bs.bytes;
169
+ } catch (e) { return Promise.resolve({ ok: false, code: _codeOf(e, badSig), error: e }); }
170
+ // The ML-DSA half is fixed-length and FIRST; the traditional half is the remainder. Both must be
171
+ // non-empty for a well-formed composite.
172
+ if (rawKey.length <= d.mldsaPk || sigBytes.length <= d.mldsaSig) {
173
+ return Promise.resolve({ ok: false, code: badSig,
174
+ error: new E(badSig, "composite key/signature shorter than the fixed ML-DSA component") });
175
+ }
176
+ var mldsaPK = rawKey.subarray(0, d.mldsaPk), tradPK = rawKey.subarray(d.mldsaPk);
177
+ var mldsaSig = sigBytes.subarray(0, d.mldsaSig), tradSig = sigBytes.subarray(d.mldsaSig);
178
+ return subtle.digest({ name: d.ph }, message).then(function (phBuf) {
179
+ var mprime = compositeMprime(d, phBuf);
180
+ var mldsaP = subtle.importKey("spki", _mldsaSpki(d.mldsaOid, mldsaPK), { name: d.mldsa }, false, ["verify"])
181
+ .then(function (mk) { return subtle.verify({ name: d.mldsa, context: d.label }, mk, mldsaSig, mprime); });
182
+ var tradP = _verifyTradComponent(d.trad, tradPK, tradSig, mprime, E, badSig);
183
+ return Promise.all([mldsaP, tradP]).then(function (r) { return { ok: r[0] === true && r[1] === true }; });
184
+ }).catch(function (e) { return { ok: false, code: _codeOf(e, badSig), error: e }; });
185
+ }
186
+
187
+ // Resolve the composite descriptor for a signatureAlgorithm, or null if the OID is not composite.
188
+ // A composite OID MUST carry absent parameters (draft-ietf-lamps-pq-composite-sigs sec. 5.3); the
189
+ // public-key algorithm OID equals the signature OID, so the caller enforces sameKeyOid.
190
+ function resolveCompositeDescriptor(sigAlg, E, unsupported) {
191
+ var comp = COMPOSITE_ALGS[sigAlg.oid];
192
+ if (!comp) return null;
193
+ // Coverage residual -- unreachable through the shipped consumer: the only caller (path-validate)
194
+ // reaches here with a sigAlg from a parsed structure whose shared AlgorithmIdentifier decoder
195
+ // already rejects a composite OID carrying parameters (the _PARAMS_ABSENT gate), so `parameters`
196
+ // is always absent here. Kept as defense-in-depth. (CMS does its own inline params check.)
197
+ if (sigAlg.parameters !== null && sigAlg.parameters !== undefined) {
198
+ throw new E(unsupported, "composite signature algorithm parameters must be absent (draft-ietf-lamps-pq-composite-sigs sec. 5.3)");
199
+ }
200
+ return { composite: comp, sameKeyOid: true };
201
+ }
202
+
203
+ // Sign a composite signature over `message`: `keys` = { mldsa, trad } component private keys as
204
+ // PKCS#8 DER (Node has no composite private-key type). Signs the ML-DSA half over M' with ctx =
205
+ // the composite Label (the same domain-separated preimage compositeVerify reconstructs), signs the
206
+ // traditional half over M', and returns mldsaSig || tradSig. The caller rejects an unsupported arm
207
+ // before calling (d.trad.unsupported).
208
+ function compositeSign(d, keys, message, ctx) {
209
+ return subtle.digest({ name: d.ph }, message).then(function (phBuf) {
210
+ var mprime = compositeMprime(d, phBuf, ctx);
211
+ var mldsaP = subtle.importKey("pkcs8", keys.mldsa, { name: d.mldsa }, false, ["sign"])
212
+ .then(function (k) { return subtle.sign({ name: d.mldsa, context: d.label }, k, mprime); });
213
+ var tradP = _signTradComponent(d.trad, keys.trad, mprime);
214
+ return Promise.all([mldsaP, tradP]).then(function (s) { return Buffer.concat([Buffer.from(s[0]), Buffer.from(s[1])]); });
215
+ });
216
+ }
217
+ function _signTradComponent(trad, pkcs8, mprime) {
218
+ if (trad.ec) {
219
+ return subtle.importKey("pkcs8", pkcs8, { name: "ECDSA", namedCurve: trad.ec }, false, ["sign"])
220
+ .then(function (k) { return subtle.sign({ name: "ECDSA", hash: trad.hash }, k, mprime); })
221
+ // WebCrypto emits P1363 r||s; the composite trad-ECDSA signature is DER Ecdsa-Sig-Value, so
222
+ // re-encode through the shared canonical-DER gate (the inverse of the verify path's read).
223
+ .then(function (raw) { var r = Buffer.from(raw); return validator.sig.rawToEcdsaDer(r, r.length / 2); });
224
+ }
225
+ if (trad.eddsa) {
226
+ return subtle.importKey("pkcs8", pkcs8, { name: trad.eddsa }, false, ["sign"])
227
+ .then(function (k) { return subtle.sign({ name: trad.eddsa }, k, mprime); }).then(function (s) { return Buffer.from(s); });
228
+ }
229
+ var imp = trad.rsaPss ? { name: "RSA-PSS", hash: trad.hash } : { name: "RSASSA-PKCS1-v1_5", hash: trad.hash };
230
+ var alg = trad.rsaPss ? { name: "RSA-PSS", saltLength: trad.salt } : { name: "RSASSA-PKCS1-v1_5" };
231
+ return subtle.importKey("pkcs8", pkcs8, imp, false, ["sign"]).then(function (k) { return subtle.sign(alg, k, mprime); }).then(function (s) { return Buffer.from(s); });
232
+ }
233
+
234
+ module.exports = {
235
+ COMPOSITE_ALGS: COMPOSITE_ALGS,
236
+ compositeVerify: compositeVerify,
237
+ resolveCompositeDescriptor: resolveCompositeDescriptor,
238
+ compositeMprime: compositeMprime,
239
+ compositeSign: compositeSign,
240
+ };
@@ -18,6 +18,8 @@
18
18
  // Pure BigInt modular arithmetic (constant-time is not a goal here: the input is a public
19
19
  // key, and the verdict is a public validity bit, not a secret).
20
20
 
21
+ var asn1 = require("./asn1-der");
22
+
21
23
  // ---- field parameters ------------------------------------------------------
22
24
  var P25519 = (1n << 255n) - 19n;
23
25
  // d = -121665 / 121666 (mod p); sqrt(-1) = 2^((p-1)/4) (mod p).
@@ -88,4 +90,21 @@ function validate(raw, crv) {
88
90
  return true;
89
91
  }
90
92
 
91
- module.exports = { validate: validate };
93
+ // validateSpki(spkiBytes, crv, E, code) -> throws new E(code, ...) unless the
94
+ // SubjectPublicKeyInfo's subjectPublicKey (its BIT STRING body, past the unused-bits octet) is a
95
+ // canonical, on-curve, full-order Edwards point for the OKP curve (6 = Ed25519, 7 = Ed448). This
96
+ // is the one home every EdDSA verify path routes an SPKI through before importKey/verify -- node
97
+ // imports a low-order (e.g. all-zeroes) OKP SPKI without complaint and such a key verifies a
98
+ // forged signature. Error-parameterized like the guard family: the caller passes its own typed
99
+ // error CONSTRUCTOR `E` and domain `code`, so the shared gate keeps no error domain of its own.
100
+ function validateSpki(spkiBytes, crv, E, code) {
101
+ var point;
102
+ try {
103
+ point = asn1.decode(spkiBytes).children[1].content.subarray(1);
104
+ } catch (e) { throw new E(code, "the EdDSA public key is not a well-formed SubjectPublicKeyInfo", e); }
105
+ if (!validate(point, crv)) {
106
+ throw new E(code, "the EdDSA public key is not a valid, full-order Edwards point");
107
+ }
108
+ }
109
+
110
+ module.exports = { validate: validate, validateSpki: validateSpki };
package/lib/jose.js CHANGED
@@ -39,6 +39,7 @@ var constants = require("./constants");
39
39
  var webcrypto = require("./webcrypto").webcrypto;
40
40
  var frameworkError = require("./framework-error");
41
41
  var guard = require("./guard-all");
42
+ var edwardsPoint = require("./edwards-point");
42
43
 
43
44
  var JoseError = frameworkError.JoseError;
44
45
  function E(code, message, cause) { return new JoseError(code, message, cause); }
@@ -267,6 +268,12 @@ function _assertKeyType(algRow, jwk) {
267
268
  if (jwk.kty !== algRow.kty) throw E("jose/bad-alg", "the key type " + JSON.stringify(jwk.kty) + " does not match the algorithm");
268
269
  if (algRow.kty === "EC" && jwk.crv !== algRow.crv) throw E("jose/bad-alg", "the EC curve does not match the algorithm");
269
270
  if (algRow.kty === "OKP" && jwk.crv !== "Ed25519" && jwk.crv !== "Ed448") throw E("jose/bad-alg", "unsupported OKP curve " + JSON.stringify(jwk.crv));
271
+ // node/OpenSSL import a low-order (e.g. identity) OKP point without complaint and it verifies a
272
+ // forged signature; reject a non-full-order Edwards point through the shared gate, the same check
273
+ // every EdDSA verify path in the toolkit applies.
274
+ if (algRow.kty === "OKP" && typeof jwk.x === "string" && !edwardsPoint.validate(b64uDecode(jwk.x), jwk.crv === "Ed25519" ? 6 : 7)) {
275
+ throw E("jose/bad-key", "the OKP public key is not a valid, full-order Edwards point");
276
+ }
270
277
  // An AKP (ML-DSA) JWK carries its parameter set in `alg` (RFC 9964); it MUST match
271
278
  // the algorithm's set, or an ML-DSA-44 key could embed an ML-DSA-65 public JWK.
272
279
  if (algRow.kty === "AKP" && jwk.alg !== algRow.subtle) throw E("jose/bad-alg", "the AKP parameter set " + JSON.stringify(jwk.alg) + " does not match the algorithm " + algRow.subtle);
@@ -40,6 +40,9 @@ var crl = require("./schema-crl");
40
40
  var ocsp = require("./schema-ocsp");
41
41
  var guard = require("./guard-all");
42
42
  var constants = require("./constants");
43
+ var validator = require("./validator-all");
44
+ var compositeSig = require("./composite-sig");
45
+ var edwardsPoint = require("./edwards-point");
43
46
 
44
47
  var PathError = errors.PathError;
45
48
  function E(code, message, cause) { return new PathError(code, message, cause); }
@@ -114,6 +117,10 @@ function _sig(name, verify, imp, params, ecdsa, sameKeyOid) {
114
117
  var entry = { verify: verify, imp: imp, params: params };
115
118
  if (ecdsa) entry.ecdsa = true;
116
119
  if (sameKeyOid) entry.sameKeyOid = true;
120
+ // EdDSA descriptors carry the Edwards curve id (6 = Ed25519, 7 = Ed448) so the verify path
121
+ // validates the issuer point through the shared gate without re-branching on the algorithm name.
122
+ if (verify.name === "Ed25519") entry.eddsa = 6;
123
+ else if (verify.name === "Ed448") entry.eddsa = 7;
117
124
  SIG_ALGS[oid.byName(name)] = entry;
118
125
  }
119
126
  // RSASSA-PKCS1-v1_5 -- parameters MUST be NULL.
@@ -151,13 +158,8 @@ HASH_BY_OID[oid.byName("sha256")] = "SHA-256";
151
158
  HASH_BY_OID[oid.byName("sha384")] = "SHA-384";
152
159
  HASH_BY_OID[oid.byName("sha512")] = "SHA-512";
153
160
 
154
- var CURVE_FIELD_BYTES = { "P-256": 32, "P-384": 48, "P-521": 66 };
155
- // Curve group orders n -- a valid ECDSA signature has r,s in [1, n-1].
156
- var CURVE_ORDER = {
157
- "P-256": BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"),
158
- "P-384": BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973"),
159
- "P-521": BigInt("0x01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409"),
160
- };
161
+ // The order-aware ECDSA DER->P1363 converter + its CURVE_FIELD_BYTES / CURVE_ORDER tables now
162
+ // live in validator-sig.js (validator.sig.ecdsaDerToP1363), shared with the composite engine.
161
163
 
162
164
  // The algorithm OID of an AlgorithmIdentifier SEQUENCE { algorithm OID,
163
165
  // parameters OPTIONAL }. STRICT: a universal SEQUENCE with the OID and AT MOST
@@ -261,20 +263,11 @@ function isDerNull(p) { return p && p.length === 2 && p[0] === 0x05 && p[1] ===
261
263
 
262
264
  function resolveDescriptor(sigAlg) {
263
265
  if (sigAlg.oid === OID_RSA_PSS) return resolveRsaPss(sigAlg.parameters);
264
- var comp = COMPOSITE_ALGS[sigAlg.oid];
265
- if (comp) {
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.
271
- if (sigAlg.parameters !== null && sigAlg.parameters !== undefined) {
272
- throw E("path/unsupported-algorithm", "composite signature algorithm parameters must be absent (draft-ietf-lamps-pq-composite-sigs sec. 5.3)");
273
- }
274
- // The composite public-key algorithm OID equals the signature OID; enforce
275
- // the RFC 9814-style key<->signature consistency structurally (sameKeyOid).
276
- return { composite: comp, sameKeyOid: true };
277
- }
266
+ // Composite ML-DSA: the OID-keyed registry + the parameters-absent check
267
+ // (draft-ietf-lamps-pq-composite-sigs sec. 5.3) live in composite-sig.js, shared with
268
+ // CMS. sameKeyOid enforces the RFC 9814 sec. 4 key<->signature OID consistency.
269
+ var comp = compositeSig.resolveCompositeDescriptor(sigAlg, PathError, "path/unsupported-algorithm");
270
+ if (comp) return comp;
278
271
  var d = SIG_ALGS[sigAlg.oid];
279
272
  if (!d) throw E("path/unsupported-algorithm", "no verify descriptor for signature algorithm " + (sigAlg.name || sigAlg.oid));
280
273
  // The signatureAlgorithm's parameters MUST match the algorithm's fixed shape:
@@ -304,177 +297,15 @@ function assertKeyMatchesSigAlg(spkiBytes, sigOid, d) {
304
297
  }
305
298
  }
306
299
 
307
- // DER Ecdsa-Sig-Value SEQUENCE { r, s } -> fixed-width r||s (P1363), rejecting
308
- // r or s outside [1, n-1] (CVE-2022-21449 "Psychic Signatures").
309
- function ecdsaDerToP1363(der, curve) {
310
- var width = CURVE_FIELD_BYTES[curve];
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.
315
- if (!width || !order) throw E("path/bad-signature", "unsupported ECDSA curve " + curve);
316
- var n;
317
- try { n = asn1.decode(der); }
318
- catch (e) { throw E("path/bad-signature", "ECDSA signature is not a DER SEQUENCE(r,s)", e); }
319
- if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children || n.children.length !== 2) {
320
- throw E("path/bad-signature", "ECDSA signature must be a SEQUENCE of exactly two INTEGERs");
321
- }
322
- var r = asn1.read.integer(n.children[0]);
323
- var s = asn1.read.integer(n.children[1]);
324
- if (r < 1n || s < 1n || r >= order || s >= order) {
325
- throw E("path/bad-signature", "ECDSA signature component out of range [1, n-1] (CVE-2022-21449)");
326
- }
327
- function pad(v) {
328
- var hex = v.toString(16);
329
- if (hex.length % 2) hex = "0" + hex;
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.
333
- if (buf.length > width) throw E("path/bad-signature", "ECDSA signature component wider than the curve field");
334
- var out = Buffer.alloc(width);
335
- buf.copy(out, width - buf.length);
336
- return out;
337
- }
338
- return Buffer.concat([pad(r), pad(s)]);
339
- }
300
+ // ecdsaDerToP1363 relocated to validator-sig.js; the composite trad-ECDSA and classical ECDSA
301
+ // paths call validator.sig.ecdsaDerToP1363(sig, curve, PathError, "path/bad-signature").
340
302
 
341
303
  // ---- composite ML-DSA signatures (draft-ietf-lamps-pq-composite-sigs) -------
342
- // A composite signature pairs a post-quantum ML-DSA with a traditional RSA /
343
- // ECDSA / EdDSA. The public key is the raw concatenation mldsaPK || tradPK
344
- // (sec. 4.1) in the SPKI BIT STRING; the signature is mldsaSig || tradSig
345
- // (sec. 4.3) in the signatureValue BIT STRING. Both are surfaced RAW by the
346
- // x509 parser (no sub-walk). Verification (sec. 2): reconstruct
347
- // M' = Prefix || Label || len(ctx) || ctx || PH(M),
348
- // verify the ML-DSA component over M' with ctx = the composite Label, verify the
349
- // traditional component over M' under its own hash, and accept IFF BOTH pass
350
- // (THREAT-MODEL: all components must verify -- never an AND-to-OR downgrade). The
351
- // ML-DSA component is the fixed-length FIRST half; the split point is its length.
352
- var COMPOSITE_PREFIX = Buffer.from("CompositeAlgorithmSignatures2025", "ascii");
353
- var MLDSA_COMPONENT = {
354
- "ML-DSA-44": { pk: 1312, sig: 2420, oid: "id-ml-dsa-44" },
355
- "ML-DSA-65": { pk: 1952, sig: 3309, oid: "id-ml-dsa-65" },
356
- "ML-DSA-87": { pk: 2592, sig: 4627, oid: "id-ml-dsa-87" },
357
- };
358
- var EC_CURVE_OID = { "P-256": "prime256v1", "P-384": "secp384r1", "P-521": "secp521r1" };
359
- var COMPOSITE_ALGS = {};
360
- // _comp(name, mldsa, ph, label, trad). `trad` is exactly one component shape:
361
- // { ec, hash } | { eddsa } | { rsaPss, hash, salt } | { rsaPkcs1, hash } |
362
- // { unsupported } for the arms Node's WebCrypto surface cannot verify (brainpool
363
- // curves; the SHAKE256/64 pre-hash) -- registered + params-guarded, deferred at
364
- // verify to path/unsupported-algorithm rather than silently accepted.
365
- function _comp(name, mldsa, ph, label, trad) {
366
- var sz = MLDSA_COMPONENT[mldsa];
367
- COMPOSITE_ALGS[oid.byName(name)] = {
368
- name: name, mldsa: mldsa, mldsaPk: sz.pk, mldsaSig: sz.sig, mldsaOid: sz.oid,
369
- ph: ph, label: Buffer.from(label, "ascii"), trad: trad,
370
- };
371
- }
372
- _comp("id-MLDSA44-RSA2048-PSS-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PSS-SHA256", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 2048 });
373
- _comp("id-MLDSA44-RSA2048-PKCS15-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PKCS15-SHA256", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 2048 });
374
- _comp("id-MLDSA44-Ed25519-SHA512", "ML-DSA-44", "SHA-512", "COMPSIG-MLDSA44-Ed25519-SHA512", { eddsa: "Ed25519" });
375
- _comp("id-MLDSA44-ECDSA-P256-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", { ec: "P-256", hash: "SHA-256" });
376
- _comp("id-MLDSA65-RSA3072-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
377
- _comp("id-MLDSA65-RSA3072-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 3072 });
378
- _comp("id-MLDSA65-RSA4096-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
379
- _comp("id-MLDSA65-RSA4096-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-384", rsaBits: 4096 });
380
- _comp("id-MLDSA65-ECDSA-P256-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P256-SHA512", { ec: "P-256", hash: "SHA-256" });
381
- _comp("id-MLDSA65-ECDSA-P384-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
382
- _comp("id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-BP256-SHA512", { unsupported: "brainpoolP256r1 is not in the WebCrypto ECDSA curve set" });
383
- _comp("id-MLDSA65-Ed25519-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-Ed25519-SHA512", { eddsa: "Ed25519" });
384
- _comp("id-MLDSA87-ECDSA-P384-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
385
- _comp("id-MLDSA87-ECDSA-brainpoolP384r1-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-BP384-SHA512", { unsupported: "brainpoolP384r1 is not in the WebCrypto ECDSA curve set" });
386
- _comp("id-MLDSA87-Ed448-SHAKE256", "ML-DSA-87", "SHAKE256", "COMPSIG-MLDSA87-Ed448-SHAKE256", { unsupported: "the SHAKE256/64 pre-hash is not in the WebCrypto digest set" });
387
- _comp("id-MLDSA87-RSA3072-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
388
- _comp("id-MLDSA87-RSA4096-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
389
- _comp("id-MLDSA87-ECDSA-P521-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P521-SHA512", { ec: "P-521", hash: "SHA-512" });
390
-
391
- var _b = asn1.build;
392
- // Wrap a raw component public key in the SPKI its WebCrypto import expects, so
393
- // each half is verified through the SAME import + verify seam the classical path
394
- // uses (no second parallel verify path). tradPK for RSA is the RSAPublicKey DER;
395
- // for EC the uncompressed point; for EdDSA the raw public key.
396
- function _spkiFor(algNode, keyBytes) { return _b.sequence([algNode, _b.bitString(keyBytes, 0)]); }
397
- function _mldsaSpki(mldsaOid, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(mldsaOid))]), raw); }
398
- function _ecSpki(curve, point) { return _spkiFor(_b.sequence([_b.oid(oid.byName("ecPublicKey")), _b.oid(oid.byName(EC_CURVE_OID[curve]))]), point); }
399
- function _edSpki(name, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(name))]), raw); }
400
- function _rsaSpki(rsaPub) { return _spkiFor(_b.sequence([_b.oid(oid.byName("rsaEncryption")), _b.nullValue()]), rsaPub); }
401
-
402
- // The exact modulus bit length of an RSAPublicKey (SEQUENCE { modulus, exponent }).
403
- function _rsaModulusBits(rsaPubDer) {
404
- return asn1.read.integer(asn1.decode(rsaPubDer).children[0]).toString(2).length;
405
- }
406
-
407
- function _verifyTradComponent(trad, tradPK, tradSig, mprime) {
408
- if (trad.ec) {
409
- // draft sec. 5.1: the EC point MUST be the uncompressed X9.62 form (leading 0x04).
410
- // A compressed / hybrid point is a non-conforming composite component encoding.
411
- if (tradPK.length < 1 || tradPK[0] !== 0x04) return Promise.resolve(false);
412
- return subtle.importKey("spki", _ecSpki(trad.ec, tradPK), { name: "ECDSA" }, false, ["verify"]).then(function (k) {
413
- // The traditional ECDSA signature is DER Ecdsa-Sig-Value; convert to P1363
414
- // through the shared reader that also rejects r/s outside [1,n-1] (CVE-2022-21449).
415
- var p1363 = ecdsaDerToP1363(tradSig, trad.ec);
416
- return subtle.verify({ name: "ECDSA", hash: trad.hash }, k, p1363, mprime);
417
- });
418
- }
419
- if (trad.eddsa) {
420
- return subtle.importKey("spki", _edSpki(trad.eddsa, tradPK), { name: trad.eddsa }, false, ["verify"])
421
- .then(function (k) { return subtle.verify({ name: trad.eddsa }, k, tradSig, mprime); });
422
- }
423
- if (trad.rsaPss || trad.rsaPkcs1) {
424
- // The composite OID fixes the RSA modulus size: a downgraded or mismatched modulus
425
- // under the declared OID (an id-MLDSA44-RSA2048-* whose component is really 1024-bit)
426
- // is rejected BEFORE verify, so a weak RSA component cannot satisfy an arm that
427
- // promises 2048/3072/4096 bits. A malformed RSAPublicKey rejects the same way.
428
- var bits;
429
- try { bits = _rsaModulusBits(tradPK); }
430
- catch (_e) { return Promise.resolve(false); }
431
- if (bits !== trad.rsaBits) return Promise.resolve(false);
432
- if (trad.rsaPss) {
433
- return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSA-PSS", hash: trad.hash }, false, ["verify"])
434
- .then(function (k) { return subtle.verify({ name: "RSA-PSS", saltLength: trad.salt }, k, tradSig, mprime); });
435
- }
436
- return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSASSA-PKCS1-v1_5", hash: trad.hash }, false, ["verify"])
437
- .then(function (k) { return subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, k, tradSig, mprime); });
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.
442
- return Promise.resolve(false);
443
- }
444
-
445
- // Verify a composite signature: `spkiBytes` the issuer SPKI DER, `sigBytes` the
446
- // raw signatureValue (mldsaSig || tradSig), `message` the signed region (tbsBytes).
447
- function compositeVerify(spkiBytes, sigBytes, message, d) {
448
- if (d.trad.unsupported) {
449
- return Promise.resolve({ ok: false, code: "path/unsupported-algorithm",
450
- error: E("path/unsupported-algorithm", "composite " + d.name + ": " + d.trad.unsupported) });
451
- }
452
- var rawKey;
453
- try {
454
- var bs = asn1.read.bitString(asn1.decode(spkiBytes).children[1]);
455
- // The composite subjectPublicKey is an octet-aligned concatenation (no unused
456
- // bits); a non-zero unused-bit count is malformed.
457
- if (bs.unusedBits !== 0) throw E("path/bad-signature", "composite subjectPublicKey has unused bits");
458
- rawKey = bs.bytes;
459
- } catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/bad-signature"), error: e }); }
460
- // The ML-DSA half is fixed-length and FIRST; the traditional half is the
461
- // remainder. Both must be non-empty for a well-formed composite.
462
- if (rawKey.length <= d.mldsaPk || sigBytes.length <= d.mldsaSig) {
463
- return Promise.resolve({ ok: false, code: "path/bad-signature",
464
- error: E("path/bad-signature", "composite key/signature shorter than the fixed ML-DSA component") });
465
- }
466
- var mldsaPK = rawKey.subarray(0, d.mldsaPk), tradPK = rawKey.subarray(d.mldsaPk);
467
- var mldsaSig = sigBytes.subarray(0, d.mldsaSig), tradSig = sigBytes.subarray(d.mldsaSig);
468
- return subtle.digest({ name: d.ph }, message).then(function (phBuf) {
469
- // M' = Prefix || Label || len(ctx)=0 || ctx="" || PH(M). In X.509 path
470
- // validation the application context is empty (a caller ctx is out of scope).
471
- var mprime = Buffer.concat([COMPOSITE_PREFIX, d.label, Buffer.from([0]), Buffer.from(phBuf)]);
472
- var mldsaP = subtle.importKey("spki", _mldsaSpki(d.mldsaOid, mldsaPK), { name: d.mldsa }, false, ["verify"])
473
- .then(function (mk) { return subtle.verify({ name: d.mldsa, context: d.label }, mk, mldsaSig, mprime); });
474
- var tradP = _verifyTradComponent(d.trad, tradPK, tradSig, mprime);
475
- return Promise.all([mldsaP, tradP]).then(function (r) { return { ok: r[0] === true && r[1] === true }; });
476
- }).catch(function (e) { return { ok: false, code: pathCode(e, "path/bad-signature"), error: e }; });
477
- }
304
+ // The composite verify/sign engine + the COMPOSITE_ALGS OID-keyed registry live in
305
+ // composite-sig.js (shared with CMS composite SignerInfo). Path validation composes it:
306
+ // resolveDescriptor (above) delegates to compositeSig.resolveCompositeDescriptor, the
307
+ // certificate + OCSP verify paths to compositeSig.compositeVerify, and
308
+ // compositeKeyUsageCheck (below) enforces the sec. 5.2 signature-only keyUsage restriction.
478
309
 
479
310
  // draft-ietf-lamps-pq-composite-sigs sec. 5.2: a certificate whose SubjectPublicKeyInfo
480
311
  // carries a composite ML-DSA OID, IF it has a keyUsage extension, MUST assert at least
@@ -500,6 +331,19 @@ function compositeKeyUsageCheck(cert) {
500
331
  return { ok: true };
501
332
  }
502
333
 
334
+ // Import a descriptor's verification key, validating an EdDSA point FIRST: node/OpenSSL import a
335
+ // low-order (e.g. identity or all-zeroes) Ed25519/Ed448 SPKI without complaint and such a key
336
+ // verifies a forged signature. This is the ONE seam both the certificate path and the revocation
337
+ // (CRL / OCSP-response) path import through, so neither can skip the point gate -- a low-order
338
+ // issuer / responder key fails the caller closed (a rejected promise the caller maps to a bad
339
+ // verdict), never verifying a forged chain or a forged revocation.
340
+ function _importVerifyKey(spkiBytes, d) {
341
+ try {
342
+ if (d.eddsa) edwardsPoint.validateSpki(spkiBytes, d.eddsa, PathError, "path/bad-signature");
343
+ } catch (e) { return Promise.reject(e); }
344
+ return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]);
345
+ }
346
+
503
347
  // Verify cert.signatureValue over cert.tbsBytes with the working public key.
504
348
  function builtinVerify(state, cert) {
505
349
  var d;
@@ -513,12 +357,12 @@ function builtinVerify(state, cert) {
513
357
  // A composite signature verifies its ML-DSA and traditional halves and accepts
514
358
  // IFF both pass -- delegated to the composite combinator (which reuses this
515
359
  // file's ECDSA range-check + the same import/verify seam).
516
- if (d.composite) return compositeVerify(state.workingPublicKey, cert.signatureValue.bytes, cert.tbsBytes, d.composite);
360
+ if (d.composite) return compositeSig.compositeVerify(state.workingPublicKey, cert.signatureValue.bytes, cert.tbsBytes, d.composite, PathError, "path/unsupported-algorithm", "path/bad-signature");
517
361
  var key;
518
- return subtle.importKey("spki", state.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
362
+ return _importVerifyKey(state.workingPublicKey, d).then(function (k) {
519
363
  key = k;
520
364
  var sig = cert.signatureValue.bytes;
521
- if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
365
+ if (d.ecdsa) sig = validator.sig.ecdsaDerToP1363(sig, key.algorithm.namedCurve, PathError, "path/bad-signature");
522
366
  return subtle.verify(d.verify, key, sig, cert.tbsBytes);
523
367
  }).then(function (ok) {
524
368
  return { ok: ok === true };
@@ -1365,7 +1209,7 @@ async function validate(path, opts) {
1365
1209
  // draft-ietf-lamps-pq-composite-sigs sec. 5.2: a composite-keyed certificate's
1366
1210
  // keyUsage must be signature-only (no dual-usage). Runs for the target AND every
1367
1211
  // intermediate whose own subject key is composite.
1368
- if (COMPOSITE_ALGS[cert.subjectPublicKeyInfo.algorithm.oid]) {
1212
+ if (compositeSig.COMPOSITE_ALGS[cert.subjectPublicKeyInfo.algorithm.oid]) {
1369
1213
  var cku = compositeKeyUsageCheck(cert);
1370
1214
  checks.push({ name: "compositeKeyUsage", ok: cku.ok, code: cku.ok ? undefined : cku.code });
1371
1215
  if (!cku.ok) failed = true;
@@ -1946,10 +1790,10 @@ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
1946
1790
  // A composite-signed CRL / OCSP response verifies through the same combinator
1947
1791
  // (both halves must pass) that the certificate path uses -- one composite verify,
1948
1792
  // never a second parallel one.
1949
- if (d.composite) return compositeVerify(spkiBytes, rawSig, tbsBytes, d.composite).then(function (r) { return r.ok === true; });
1950
- return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]).then(function (key) {
1793
+ if (d.composite) return compositeSig.compositeVerify(spkiBytes, rawSig, tbsBytes, d.composite, PathError, "path/unsupported-algorithm", "path/bad-signature").then(function (r) { return r.ok === true; });
1794
+ return _importVerifyKey(spkiBytes, d).then(function (key) {
1951
1795
  var sig = rawSig;
1952
- if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
1796
+ if (d.ecdsa) sig = validator.sig.ecdsaDerToP1363(sig, key.algorithm.namedCurve, PathError, "path/bad-signature");
1953
1797
  return subtle.verify(d.verify, key, sig, tbsBytes);
1954
1798
  }).then(function (ok) { return ok === true; }, function () { return false; });
1955
1799
  }
@@ -2113,7 +1957,7 @@ async function ocspAuthorizeResponder(basicResponse, cert, issuer, issuerKeyBits
2113
1957
  // A composite-keyed delegate is an out-of-path signer cert and gets the same
2114
1958
  // composite keyUsage gate the path certificates do (draft sec. 5.2): a dual-usage
2115
1959
  // composite responder key (a forbidden encryption bit set) is not authorized.
2116
- if (COMPOSITE_ALGS[rc.subjectPublicKeyInfo.algorithm.oid] && !compositeKeyUsageCheck(rc).ok) continue;
1960
+ if (compositeSig.COMPOSITE_ALGS[rc.subjectPublicKeyInfo.algorithm.oid] && !compositeKeyUsageCheck(rc).ok) continue;
2117
1961
  return ocspResponderSpki(rc, issuer);
2118
1962
  }
2119
1963
  return null;
@@ -71,4 +71,51 @@ function rawToEcdsaDer(raw, coordLen) {
71
71
  return asn1.build.sequence([asn1.build.integer(r), asn1.build.integer(s)]);
72
72
  }
73
73
 
74
- module.exports = { ecdsaSigToRaw: ecdsaSigToRaw, rawToEcdsaDer: rawToEcdsaDer };
74
+ // The curve group orders n -- a valid ECDSA signature has r, s in [1, n-1].
75
+ var CURVE_FIELD_BYTES = { "P-256": 32, "P-384": 48, "P-521": 66 };
76
+ var CURVE_ORDER = {
77
+ "P-256": BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551"),
78
+ "P-384": BigInt("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC7634D81F4372DDF581A0DB248B0A77AECEC196ACCC52973"),
79
+ "P-521": BigInt("0x01FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFA51868783BF2F966B7FCC0148F709A5D03BB5C9B8899C47AEBB6FB71E91386409"),
80
+ };
81
+
82
+ // ecdsaDerToP1363(der, curve, E, code) -> the DER ECDSA-Sig-Value converted to raw r||s (P1363),
83
+ // each coordinate left-padded to the curve field width, rejecting r or s outside [1, n-1] against
84
+ // the curve ORDER (CVE-2022-21449 "Psychic Signatures" -- the r/s = 0 case AND the >= n upper
85
+ // bound). `curve` is a WebCrypto namedCurve (P-256/384/521). This is the ORDER-AWARE gate a
86
+ // verifier that knows the curve order MUST use; it is STRICTER than ecdsaSigToRaw above, which
87
+ // bounds r/s only by the field SIZE (>= 1, <= coordLen bytes) and does not know the order. A
88
+ // signature whose r or s is >= n is rejected here but passes ecdsaSigToRaw -- do not conflate them.
89
+ // @enforced-by behavioral -- the RED conformance vectors (r/s = 0, r/s >= n, non-minimal, over-size)
90
+ // and the composite-signature + path-validation KATs are the guard.
91
+ function ecdsaDerToP1363(der, curve, E, code) {
92
+ var width = CURVE_FIELD_BYTES[curve];
93
+ var order = CURVE_ORDER[curve];
94
+ if (!width || !order) throw new E(code, "unsupported ECDSA curve " + curve);
95
+ var n;
96
+ try { n = asn1.decode(der); }
97
+ catch (e) { throw new E(code, "ECDSA signature is not a DER SEQUENCE(r,s)", e); }
98
+ if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children || n.children.length !== 2) {
99
+ throw new E(code, "ECDSA signature must be a SEQUENCE of exactly two INTEGERs");
100
+ }
101
+ var r = asn1.read.integer(n.children[0]);
102
+ var s = asn1.read.integer(n.children[1]);
103
+ if (r < 1n || s < 1n || r >= order || s >= order) {
104
+ throw new E(code, "ECDSA signature component out of range [1, n-1] (CVE-2022-21449)");
105
+ }
106
+ function pad(v) {
107
+ var hex = v.toString(16);
108
+ if (hex.length % 2) hex = "0" + hex;
109
+ var buf = Buffer.from(hex, "hex");
110
+ // Coverage residual -- unreachable: a component needing more than `width` bytes is >=
111
+ // 2^(8*width) > n (the curve order n < 2^(8*width) for P-256/384/521), so it is already
112
+ // rejected by the r/s >= order check above; this width guard is a defense-in-depth backstop.
113
+ if (buf.length > width) throw new E(code, "ECDSA signature component wider than the curve field");
114
+ var out = Buffer.alloc(width);
115
+ buf.copy(out, width - buf.length);
116
+ return out;
117
+ }
118
+ return Buffer.concat([pad(r), pad(s)]);
119
+ }
120
+
121
+ module.exports = { ecdsaSigToRaw: ecdsaSigToRaw, rawToEcdsaDer: rawToEcdsaDer, ecdsaDerToP1363: ecdsaDerToP1363 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.17",
3
+ "version": "0.2.18",
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:e4cc4e90-fbb7-4869-9baf-d6ce16b67d7f",
5
+ "serialNumber": "urn:uuid:bec63d3e-7332-4a32-bcc8-ac8614e7402a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T04:28:56.898Z",
8
+ "timestamp": "2026-07-14T07:20:40.274Z",
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.17",
22
+ "bom-ref": "@blamejs/pki@0.2.18",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.17",
25
+ "version": "0.2.18",
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.17",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.18",
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.17",
57
+ "ref": "@blamejs/pki@0.2.18",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]