@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 +14 -1
- package/README.md +1 -1
- package/lib/cms-sign.js +77 -36
- package/lib/cms-verify.js +102 -23
- package/lib/composite-sig.js +240 -0
- package/lib/edwards-point.js +20 -1
- package/lib/jose.js +7 -0
- package/lib/path-validate.js +42 -198
- 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,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.
|
|
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
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
17
|
-
* post-quantum ML-DSA (ML-DSA-44/65/87, RFC 9882)
|
|
18
|
-
*
|
|
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
|
|
270
|
-
//
|
|
271
|
-
//
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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
|
+
};
|
package/lib/edwards-point.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/path-validate.js
CHANGED
|
@@ -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
|
-
|
|
155
|
-
//
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
//
|
|
308
|
-
//
|
|
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
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// (
|
|
345
|
-
//
|
|
346
|
-
//
|
|
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
|
|
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
|
|
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;
|
package/lib/validator-sig.js
CHANGED
|
@@ -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
|
-
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:bec63d3e-7332-4a32-bcc8-ac8614e7402a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.18",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.18",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|