@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 +27 -1
- package/README.md +1 -1
- package/lib/asn1-der.js +4 -0
- package/lib/cms-sign.js +97 -39
- package/lib/cms-verify.js +134 -35
- package/lib/composite-sig.js +240 -0
- package/lib/edwards-point.js +20 -1
- package/lib/inspect.js +71 -0
- package/lib/jose.js +7 -0
- package/lib/oid.js +1 -1
- package/lib/path-validate.js +64 -186
- package/lib/schema-cms.js +19 -0
- package/lib/sigstore.js +6 -0
- package/lib/validator-cose.js +8 -0
- package/lib/validator-sig.js +48 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.
|
|
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
|
|
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
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
17
|
-
* post-quantum ML-DSA (ML-DSA-44/65/87, RFC 9882)
|
|
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
|
|
246
|
-
// signature hash and no Edwards-point validation (FIPS 204 sec. 5.3
|
|
247
|
-
// validates the SPKI on import). The signature is raw and
|
|
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
|
|
260
|
-
//
|
|
261
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
321
|
-
// (RFC 8702 sec. 3.1) -- so for
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|