@blamejs/pki 0.2.8 → 0.2.9
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 +9 -0
- package/README.md +1 -1
- package/lib/oid.js +24 -1
- package/lib/path-validate.js +190 -0
- package/lib/webcrypto.js +24 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ 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.9 — 2026-07-13
|
|
8
|
+
|
|
9
|
+
Certification-path validation verifies composite ML-DSA signatures, accepting a certificate only when both its post-quantum and traditional components verify.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.path.validate verifies composite ML-DSA certificate signatures (draft-ietf-lamps-pq-composite-sigs): a post-quantum ML-DSA paired with a traditional RSA / ECDSA / EdDSA, accepted only when BOTH components verify over the domain-separated message representative -- an all-components-must-verify rule (an OR would be a downgrade). The same combinator verifies a composite-signed CRL or OCSP response. Proven against the draft's official known-answer test vectors.
|
|
14
|
+
- The 18 composite algorithm identifiers (1.3.6.1.5.5.7.6.37-54) are registered in the OID registry and their AlgorithmIdentifier parameters-absent requirement is enforced across every format that carries a signature algorithm.
|
|
15
|
+
|
|
7
16
|
## v0.2.8 — 2026-07-13
|
|
8
17
|
|
|
9
18
|
pki.webcrypto rejects an AES key of invalid length at import instead of deferring the failure to first use.
|
package/README.md
CHANGED
|
@@ -220,7 +220,7 @@ is callable today; nothing below is a stub.
|
|
|
220
220
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
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
|
-
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, 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` |
|
|
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
224
|
| `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `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 for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
|
|
225
225
|
| `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` |
|
|
226
226
|
| `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
package/lib/oid.js
CHANGED
|
@@ -222,7 +222,19 @@ var FAMILIES = {
|
|
|
222
222
|
// identifiers (RFC 9802 sec. 4). HSS/LMS additionally has the SMIME
|
|
223
223
|
// id-alg-hss-lms-hashsig OID above (RFC 9708 / RFC 9802 share it).
|
|
224
224
|
pkixAlg: { base: [1, 3, 6, 1, 5, 5, 7, 6], of: {
|
|
225
|
-
"id-alg-xmss-hashsig": 34, "id-alg-xmssmt-hashsig": 35
|
|
225
|
+
"id-alg-xmss-hashsig": 34, "id-alg-xmssmt-hashsig": 35,
|
|
226
|
+
// Composite ML-DSA signature algorithms (draft-ietf-lamps-pq-composite-sigs
|
|
227
|
+
// sec. 6): a PQ ML-DSA paired with a traditional RSA / ECDSA / EdDSA so the
|
|
228
|
+
// certificate stays trustworthy if EITHER primitive is later broken.
|
|
229
|
+
"id-MLDSA44-RSA2048-PSS-SHA256": 37, "id-MLDSA44-RSA2048-PKCS15-SHA256": 38,
|
|
230
|
+
"id-MLDSA44-Ed25519-SHA512": 39, "id-MLDSA44-ECDSA-P256-SHA256": 40,
|
|
231
|
+
"id-MLDSA65-RSA3072-PSS-SHA512": 41, "id-MLDSA65-RSA3072-PKCS15-SHA512": 42,
|
|
232
|
+
"id-MLDSA65-RSA4096-PSS-SHA512": 43, "id-MLDSA65-RSA4096-PKCS15-SHA512": 44,
|
|
233
|
+
"id-MLDSA65-ECDSA-P256-SHA512": 45, "id-MLDSA65-ECDSA-P384-SHA512": 46,
|
|
234
|
+
"id-MLDSA65-ECDSA-brainpoolP256r1-SHA512": 47, "id-MLDSA65-Ed25519-SHA512": 48,
|
|
235
|
+
"id-MLDSA87-ECDSA-P384-SHA512": 49, "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512": 50,
|
|
236
|
+
"id-MLDSA87-Ed448-SHAKE256": 51, "id-MLDSA87-RSA3072-PSS-SHA512": 52,
|
|
237
|
+
"id-MLDSA87-RSA4096-PSS-SHA512": 53, "id-MLDSA87-ECDSA-P521-SHA512": 54 } },
|
|
226
238
|
|
|
227
239
|
// RSA-KEM key-transport algorithm (RFC 9690, obsoletes RFC 5990) on the ISO
|
|
228
240
|
// 18033-2 arc -- the kem OID an RSA KEMRecipientInfo carries (distinct from the
|
|
@@ -549,6 +561,17 @@ var _PARAMS_ABSENT = new Set();
|
|
|
549
561
|
// Stateful hash-based signatures (RFC 9802 sec. 4 / RFC 9708): the parameters
|
|
550
562
|
// field MUST be absent for HSS/LMS, XMSS, and XMSS^MT public keys and signatures.
|
|
551
563
|
"id-alg-hss-lms-hashsig", "id-alg-xmss-hashsig", "id-alg-xmssmt-hashsig",
|
|
564
|
+
// Composite ML-DSA (draft-ietf-lamps-pq-composite-sigs sec. 5.3 + Figure 1:
|
|
565
|
+
// the parameters field MUST be absent for every composite AlgorithmIdentifier).
|
|
566
|
+
"id-MLDSA44-RSA2048-PSS-SHA256", "id-MLDSA44-RSA2048-PKCS15-SHA256",
|
|
567
|
+
"id-MLDSA44-Ed25519-SHA512", "id-MLDSA44-ECDSA-P256-SHA256",
|
|
568
|
+
"id-MLDSA65-RSA3072-PSS-SHA512", "id-MLDSA65-RSA3072-PKCS15-SHA512",
|
|
569
|
+
"id-MLDSA65-RSA4096-PSS-SHA512", "id-MLDSA65-RSA4096-PKCS15-SHA512",
|
|
570
|
+
"id-MLDSA65-ECDSA-P256-SHA512", "id-MLDSA65-ECDSA-P384-SHA512",
|
|
571
|
+
"id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "id-MLDSA65-Ed25519-SHA512",
|
|
572
|
+
"id-MLDSA87-ECDSA-P384-SHA512", "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512",
|
|
573
|
+
"id-MLDSA87-Ed448-SHAKE256", "id-MLDSA87-RSA3072-PSS-SHA512",
|
|
574
|
+
"id-MLDSA87-RSA4096-PSS-SHA512", "id-MLDSA87-ECDSA-P521-SHA512",
|
|
552
575
|
].forEach(function (nm) {
|
|
553
576
|
var d = byName(nm);
|
|
554
577
|
// A seed-list typo must fail at module load -- admitting undefined would
|
package/lib/path-validate.js
CHANGED
|
@@ -258,6 +258,16 @@ function isDerNull(p) { return p && p.length === 2 && p[0] === 0x05 && p[1] ===
|
|
|
258
258
|
|
|
259
259
|
function resolveDescriptor(sigAlg) {
|
|
260
260
|
if (sigAlg.oid === OID_RSA_PSS) return resolveRsaPss(sigAlg.parameters);
|
|
261
|
+
var comp = COMPOSITE_ALGS[sigAlg.oid];
|
|
262
|
+
if (comp) {
|
|
263
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.3: parameters MUST be absent.
|
|
264
|
+
if (sigAlg.parameters !== null && sigAlg.parameters !== undefined) {
|
|
265
|
+
throw E("path/unsupported-algorithm", "composite signature algorithm parameters must be absent (draft-ietf-lamps-pq-composite-sigs sec. 5.3)");
|
|
266
|
+
}
|
|
267
|
+
// The composite public-key algorithm OID equals the signature OID; enforce
|
|
268
|
+
// the RFC 9814-style key<->signature consistency structurally (sameKeyOid).
|
|
269
|
+
return { composite: comp, sameKeyOid: true };
|
|
270
|
+
}
|
|
261
271
|
var d = SIG_ALGS[sigAlg.oid];
|
|
262
272
|
if (!d) throw E("path/unsupported-algorithm", "no verify descriptor for signature algorithm " + (sigAlg.name || sigAlg.oid));
|
|
263
273
|
// The signatureAlgorithm's parameters MUST match the algorithm's fixed shape:
|
|
@@ -316,6 +326,165 @@ function ecdsaDerToP1363(der, curve) {
|
|
|
316
326
|
return Buffer.concat([pad(r), pad(s)]);
|
|
317
327
|
}
|
|
318
328
|
|
|
329
|
+
// ---- composite ML-DSA signatures (draft-ietf-lamps-pq-composite-sigs) -------
|
|
330
|
+
// A composite signature pairs a post-quantum ML-DSA with a traditional RSA /
|
|
331
|
+
// ECDSA / EdDSA. The public key is the raw concatenation mldsaPK || tradPK
|
|
332
|
+
// (sec. 4.1) in the SPKI BIT STRING; the signature is mldsaSig || tradSig
|
|
333
|
+
// (sec. 4.3) in the signatureValue BIT STRING. Both are surfaced RAW by the
|
|
334
|
+
// x509 parser (no sub-walk). Verification (sec. 2): reconstruct
|
|
335
|
+
// M' = Prefix || Label || len(ctx) || ctx || PH(M),
|
|
336
|
+
// verify the ML-DSA component over M' with ctx = the composite Label, verify the
|
|
337
|
+
// traditional component over M' under its own hash, and accept IFF BOTH pass
|
|
338
|
+
// (THREAT-MODEL: all components must verify -- never an AND-to-OR downgrade). The
|
|
339
|
+
// ML-DSA component is the fixed-length FIRST half; the split point is its length.
|
|
340
|
+
var COMPOSITE_PREFIX = Buffer.from("CompositeAlgorithmSignatures2025", "ascii");
|
|
341
|
+
var MLDSA_COMPONENT = {
|
|
342
|
+
"ML-DSA-44": { pk: 1312, sig: 2420, oid: "id-ml-dsa-44" },
|
|
343
|
+
"ML-DSA-65": { pk: 1952, sig: 3309, oid: "id-ml-dsa-65" },
|
|
344
|
+
"ML-DSA-87": { pk: 2592, sig: 4627, oid: "id-ml-dsa-87" },
|
|
345
|
+
};
|
|
346
|
+
var EC_CURVE_OID = { "P-256": "prime256v1", "P-384": "secp384r1", "P-521": "secp521r1" };
|
|
347
|
+
var COMPOSITE_ALGS = {};
|
|
348
|
+
// _comp(name, mldsa, ph, label, trad). `trad` is exactly one component shape:
|
|
349
|
+
// { ec, hash } | { eddsa } | { rsaPss, hash, salt } | { rsaPkcs1, hash } |
|
|
350
|
+
// { unsupported } for the arms Node's WebCrypto surface cannot verify (brainpool
|
|
351
|
+
// curves; the SHAKE256/64 pre-hash) -- registered + params-guarded, deferred at
|
|
352
|
+
// verify to path/unsupported-algorithm rather than silently accepted.
|
|
353
|
+
function _comp(name, mldsa, ph, label, trad) {
|
|
354
|
+
var sz = MLDSA_COMPONENT[mldsa];
|
|
355
|
+
COMPOSITE_ALGS[oid.byName(name)] = {
|
|
356
|
+
name: name, mldsa: mldsa, mldsaPk: sz.pk, mldsaSig: sz.sig, mldsaOid: sz.oid,
|
|
357
|
+
ph: ph, label: Buffer.from(label, "ascii"), trad: trad,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
_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 });
|
|
361
|
+
_comp("id-MLDSA44-RSA2048-PKCS15-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PKCS15-SHA256", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 2048 });
|
|
362
|
+
_comp("id-MLDSA44-Ed25519-SHA512", "ML-DSA-44", "SHA-512", "COMPSIG-MLDSA44-Ed25519-SHA512", { eddsa: "Ed25519" });
|
|
363
|
+
_comp("id-MLDSA44-ECDSA-P256-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", { ec: "P-256", hash: "SHA-256" });
|
|
364
|
+
_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 });
|
|
365
|
+
_comp("id-MLDSA65-RSA3072-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 3072 });
|
|
366
|
+
_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 });
|
|
367
|
+
_comp("id-MLDSA65-RSA4096-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-384", rsaBits: 4096 });
|
|
368
|
+
_comp("id-MLDSA65-ECDSA-P256-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P256-SHA512", { ec: "P-256", hash: "SHA-256" });
|
|
369
|
+
_comp("id-MLDSA65-ECDSA-P384-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
|
|
370
|
+
_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" });
|
|
371
|
+
_comp("id-MLDSA65-Ed25519-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-Ed25519-SHA512", { eddsa: "Ed25519" });
|
|
372
|
+
_comp("id-MLDSA87-ECDSA-P384-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
|
|
373
|
+
_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" });
|
|
374
|
+
_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" });
|
|
375
|
+
_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 });
|
|
376
|
+
_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 });
|
|
377
|
+
_comp("id-MLDSA87-ECDSA-P521-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P521-SHA512", { ec: "P-521", hash: "SHA-512" });
|
|
378
|
+
|
|
379
|
+
var _b = asn1.build;
|
|
380
|
+
// Wrap a raw component public key in the SPKI its WebCrypto import expects, so
|
|
381
|
+
// each half is verified through the SAME import + verify seam the classical path
|
|
382
|
+
// uses (no second parallel verify path). tradPK for RSA is the RSAPublicKey DER;
|
|
383
|
+
// for EC the uncompressed point; for EdDSA the raw public key.
|
|
384
|
+
function _spkiFor(algNode, keyBytes) { return _b.sequence([algNode, _b.bitString(keyBytes, 0)]); }
|
|
385
|
+
function _mldsaSpki(mldsaOid, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(mldsaOid))]), raw); }
|
|
386
|
+
function _ecSpki(curve, point) { return _spkiFor(_b.sequence([_b.oid(oid.byName("ecPublicKey")), _b.oid(oid.byName(EC_CURVE_OID[curve]))]), point); }
|
|
387
|
+
function _edSpki(name, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(name))]), raw); }
|
|
388
|
+
function _rsaSpki(rsaPub) { return _spkiFor(_b.sequence([_b.oid(oid.byName("rsaEncryption")), _b.nullValue()]), rsaPub); }
|
|
389
|
+
|
|
390
|
+
// The exact modulus bit length of an RSAPublicKey (SEQUENCE { modulus, exponent }).
|
|
391
|
+
function _rsaModulusBits(rsaPubDer) {
|
|
392
|
+
return asn1.read.integer(asn1.decode(rsaPubDer).children[0]).toString(2).length;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function _verifyTradComponent(trad, tradPK, tradSig, mprime) {
|
|
396
|
+
if (trad.ec) {
|
|
397
|
+
// draft sec. 5.1: the EC point MUST be the uncompressed X9.62 form (leading 0x04).
|
|
398
|
+
// A compressed / hybrid point is a non-conforming composite component encoding.
|
|
399
|
+
if (tradPK.length < 1 || tradPK[0] !== 0x04) return Promise.resolve(false);
|
|
400
|
+
return subtle.importKey("spki", _ecSpki(trad.ec, tradPK), { name: "ECDSA" }, false, ["verify"]).then(function (k) {
|
|
401
|
+
// The traditional ECDSA signature is DER Ecdsa-Sig-Value; convert to P1363
|
|
402
|
+
// through the shared reader that also rejects r/s outside [1,n-1] (CVE-2022-21449).
|
|
403
|
+
var p1363 = ecdsaDerToP1363(tradSig, trad.ec);
|
|
404
|
+
return subtle.verify({ name: "ECDSA", hash: trad.hash }, k, p1363, mprime);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (trad.eddsa) {
|
|
408
|
+
return subtle.importKey("spki", _edSpki(trad.eddsa, tradPK), { name: trad.eddsa }, false, ["verify"])
|
|
409
|
+
.then(function (k) { return subtle.verify({ name: trad.eddsa }, k, tradSig, mprime); });
|
|
410
|
+
}
|
|
411
|
+
if (trad.rsaPss || trad.rsaPkcs1) {
|
|
412
|
+
// The composite OID fixes the RSA modulus size: a downgraded or mismatched modulus
|
|
413
|
+
// under the declared OID (an id-MLDSA44-RSA2048-* whose component is really 1024-bit)
|
|
414
|
+
// is rejected BEFORE verify, so a weak RSA component cannot satisfy an arm that
|
|
415
|
+
// promises 2048/3072/4096 bits. A malformed RSAPublicKey rejects the same way.
|
|
416
|
+
var bits;
|
|
417
|
+
try { bits = _rsaModulusBits(tradPK); }
|
|
418
|
+
catch (_e) { return Promise.resolve(false); }
|
|
419
|
+
if (bits !== trad.rsaBits) return Promise.resolve(false);
|
|
420
|
+
if (trad.rsaPss) {
|
|
421
|
+
return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSA-PSS", hash: trad.hash }, false, ["verify"])
|
|
422
|
+
.then(function (k) { return subtle.verify({ name: "RSA-PSS", saltLength: trad.salt }, k, tradSig, mprime); });
|
|
423
|
+
}
|
|
424
|
+
return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSASSA-PKCS1-v1_5", hash: trad.hash }, false, ["verify"])
|
|
425
|
+
.then(function (k) { return subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, k, tradSig, mprime); });
|
|
426
|
+
}
|
|
427
|
+
return Promise.resolve(false);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Verify a composite signature: `spkiBytes` the issuer SPKI DER, `sigBytes` the
|
|
431
|
+
// raw signatureValue (mldsaSig || tradSig), `message` the signed region (tbsBytes).
|
|
432
|
+
function compositeVerify(spkiBytes, sigBytes, message, d) {
|
|
433
|
+
if (d.trad.unsupported) {
|
|
434
|
+
return Promise.resolve({ ok: false, code: "path/unsupported-algorithm",
|
|
435
|
+
error: E("path/unsupported-algorithm", "composite " + d.name + ": " + d.trad.unsupported) });
|
|
436
|
+
}
|
|
437
|
+
var rawKey;
|
|
438
|
+
try {
|
|
439
|
+
var bs = asn1.read.bitString(asn1.decode(spkiBytes).children[1]);
|
|
440
|
+
// The composite subjectPublicKey is an octet-aligned concatenation (no unused
|
|
441
|
+
// bits); a non-zero unused-bit count is malformed.
|
|
442
|
+
if (bs.unusedBits !== 0) throw E("path/bad-signature", "composite subjectPublicKey has unused bits");
|
|
443
|
+
rawKey = bs.bytes;
|
|
444
|
+
} catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/bad-signature"), error: e }); }
|
|
445
|
+
// The ML-DSA half is fixed-length and FIRST; the traditional half is the
|
|
446
|
+
// remainder. Both must be non-empty for a well-formed composite.
|
|
447
|
+
if (rawKey.length <= d.mldsaPk || sigBytes.length <= d.mldsaSig) {
|
|
448
|
+
return Promise.resolve({ ok: false, code: "path/bad-signature",
|
|
449
|
+
error: E("path/bad-signature", "composite key/signature shorter than the fixed ML-DSA component") });
|
|
450
|
+
}
|
|
451
|
+
var mldsaPK = rawKey.subarray(0, d.mldsaPk), tradPK = rawKey.subarray(d.mldsaPk);
|
|
452
|
+
var mldsaSig = sigBytes.subarray(0, d.mldsaSig), tradSig = sigBytes.subarray(d.mldsaSig);
|
|
453
|
+
return subtle.digest({ name: d.ph }, message).then(function (phBuf) {
|
|
454
|
+
// M' = Prefix || Label || len(ctx)=0 || ctx="" || PH(M). In X.509 path
|
|
455
|
+
// validation the application context is empty (a caller ctx is out of scope).
|
|
456
|
+
var mprime = Buffer.concat([COMPOSITE_PREFIX, d.label, Buffer.from([0]), Buffer.from(phBuf)]);
|
|
457
|
+
var mldsaP = subtle.importKey("spki", _mldsaSpki(d.mldsaOid, mldsaPK), { name: d.mldsa }, false, ["verify"])
|
|
458
|
+
.then(function (mk) { return subtle.verify({ name: d.mldsa, context: d.label }, mk, mldsaSig, mprime); });
|
|
459
|
+
var tradP = _verifyTradComponent(d.trad, tradPK, tradSig, mprime);
|
|
460
|
+
return Promise.all([mldsaP, tradP]).then(function (r) { return { ok: r[0] === true && r[1] === true }; });
|
|
461
|
+
}).catch(function (e) { return { ok: false, code: pathCode(e, "path/bad-signature"), error: e }; });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.2: a certificate whose SubjectPublicKeyInfo
|
|
465
|
+
// carries a composite ML-DSA OID, IF it has a keyUsage extension, MUST assert at least
|
|
466
|
+
// one signature bit (digitalSignature / nonRepudiation / keyCertSign / cRLSign) and MUST
|
|
467
|
+
// NOT assert any encryption or key-establishment bit -- a composite ML-DSA key is a
|
|
468
|
+
// signature-only key (ML-DSA cannot encrypt or agree, so a "dual usage" key is forbidden
|
|
469
|
+
// even when the traditional component could encrypt). The caller invokes this only for a
|
|
470
|
+
// composite-keyed certificate; an absent keyUsage places no restriction (RFC 5280 4.2.1.3).
|
|
471
|
+
function compositeKeyUsageCheck(cert) {
|
|
472
|
+
var ku;
|
|
473
|
+
try { ku = decodeExt(cert, OID.keyUsage); }
|
|
474
|
+
catch (e) { return { ok: false, code: "path/composite-key-usage", error: e }; }
|
|
475
|
+
if (!ku) return { ok: true };
|
|
476
|
+
var v = ku.value;
|
|
477
|
+
if (v.keyEncipherment || v.dataEncipherment || v.keyAgreement || v.encipherOnly || v.decipherOnly) {
|
|
478
|
+
return { ok: false, code: "path/composite-key-usage",
|
|
479
|
+
error: E("path/composite-key-usage", "a composite ML-DSA key asserts a forbidden encryption/key-establishment keyUsage bit (draft-ietf-lamps-pq-composite-sigs sec. 5.2)") };
|
|
480
|
+
}
|
|
481
|
+
if (!(v.digitalSignature || v.nonRepudiation || v.keyCertSign || v.cRLSign)) {
|
|
482
|
+
return { ok: false, code: "path/composite-key-usage",
|
|
483
|
+
error: E("path/composite-key-usage", "a composite ML-DSA key's keyUsage asserts no signature bit (draft-ietf-lamps-pq-composite-sigs sec. 5.2)") };
|
|
484
|
+
}
|
|
485
|
+
return { ok: true };
|
|
486
|
+
}
|
|
487
|
+
|
|
319
488
|
// Verify cert.signatureValue over cert.tbsBytes with the working public key.
|
|
320
489
|
function builtinVerify(state, cert) {
|
|
321
490
|
var d;
|
|
@@ -326,6 +495,10 @@ function builtinVerify(state, cert) {
|
|
|
326
495
|
// The signature is an octet-aligned BIT STRING (no unused bits) for every
|
|
327
496
|
// supported algorithm; a non-zero unused-bit count is malformed.
|
|
328
497
|
if (!guard.crypto.isOctetAligned(cert.signatureValue)) return Promise.resolve({ ok: false, code: "path/bad-signature" });
|
|
498
|
+
// A composite signature verifies its ML-DSA and traditional halves and accepts
|
|
499
|
+
// IFF both pass -- delegated to the composite combinator (which reuses this
|
|
500
|
+
// file's ECDSA range-check + the same import/verify seam).
|
|
501
|
+
if (d.composite) return compositeVerify(state.workingPublicKey, cert.signatureValue.bytes, cert.tbsBytes, d.composite);
|
|
329
502
|
var key;
|
|
330
503
|
return subtle.importKey("spki", state.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
|
|
331
504
|
key = k;
|
|
@@ -1165,6 +1338,15 @@ async function validate(path, opts) {
|
|
|
1165
1338
|
checks.push({ name: "signature", ok: sigRes.ok, code: sigRes.ok ? undefined : (sigRes.code || "path/bad-signature") });
|
|
1166
1339
|
if (!sigRes.ok) failed = true;
|
|
1167
1340
|
|
|
1341
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.2: a composite-keyed certificate's
|
|
1342
|
+
// keyUsage must be signature-only (no dual-usage). Runs for the target AND every
|
|
1343
|
+
// intermediate whose own subject key is composite.
|
|
1344
|
+
if (COMPOSITE_ALGS[cert.subjectPublicKeyInfo.algorithm.oid]) {
|
|
1345
|
+
var cku = compositeKeyUsageCheck(cert);
|
|
1346
|
+
checks.push({ name: "compositeKeyUsage", ok: cku.ok, code: cku.ok ? undefined : cku.code });
|
|
1347
|
+
if (!cku.ok) failed = true;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1168
1350
|
// 6.1.3(a)(2) validity window.
|
|
1169
1351
|
var t = opts.time;
|
|
1170
1352
|
var vOk = true, vCode;
|
|
@@ -1733,6 +1915,10 @@ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
|
|
|
1733
1915
|
d = resolveDescriptor(sigAlg);
|
|
1734
1916
|
assertKeyMatchesSigAlg(spkiBytes, sigAlg.oid, d);
|
|
1735
1917
|
} catch (_e) { return Promise.resolve(false); }
|
|
1918
|
+
// A composite-signed CRL / OCSP response verifies through the same combinator
|
|
1919
|
+
// (both halves must pass) that the certificate path uses -- one composite verify,
|
|
1920
|
+
// never a second parallel one.
|
|
1921
|
+
if (d.composite) return compositeVerify(spkiBytes, rawSig, tbsBytes, d.composite).then(function (r) { return r.ok === true; });
|
|
1736
1922
|
return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]).then(function (key) {
|
|
1737
1923
|
var sig = rawSig;
|
|
1738
1924
|
if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
|
|
@@ -1893,6 +2079,10 @@ async function ocspAuthorizeResponder(basicResponse, cert, issuer, issuerKeyBits
|
|
|
1893
2079
|
// responder's own status opts in through a future checker; absent nocheck, fail
|
|
1894
2080
|
// closed (unknown) rather than trust an unvalidated responder.
|
|
1895
2081
|
if (!findExt(rc, OID_OCSP_NOCHECK)) continue;
|
|
2082
|
+
// A composite-keyed delegate is an out-of-path signer cert and gets the same
|
|
2083
|
+
// composite keyUsage gate the path certificates do (draft sec. 5.2): a dual-usage
|
|
2084
|
+
// composite responder key (a forbidden encryption bit set) is not authorized.
|
|
2085
|
+
if (COMPOSITE_ALGS[rc.subjectPublicKeyInfo.algorithm.oid] && !compositeKeyUsageCheck(rc).ok) continue;
|
|
1896
2086
|
return ocspResponderSpki(rc, issuer);
|
|
1897
2087
|
}
|
|
1898
2088
|
return null;
|
package/lib/webcrypto.js
CHANGED
|
@@ -267,6 +267,16 @@ function _generateKeyPair(alg) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
function _hashObj(h) { if (!h) return undefined; return { name: (typeof h === "string" ? h : h.name) }; }
|
|
270
|
+
// The node key argument for an ML-DSA sign/verify: the bare handle, or a
|
|
271
|
+
// { key, context } when the caller supplies an ML-DSA context. FIPS 204 bounds the
|
|
272
|
+
// context to 0..255 bytes; a longer one is a DataError at the API boundary, not a
|
|
273
|
+
// raw ERR_OUT_OF_RANGE from node.
|
|
274
|
+
function _mldsaKeyArg(alg, keyHandle, who) {
|
|
275
|
+
if (alg.context == null) return keyHandle;
|
|
276
|
+
var ctx = _toBuf(alg.context, who + " context");
|
|
277
|
+
if (ctx.length > 255) throw new WebCryptoError("webcrypto/data", who + ": an ML-DSA context must be at most 255 bytes (FIPS 204)");
|
|
278
|
+
return { key: keyHandle, context: ctx };
|
|
279
|
+
}
|
|
270
280
|
// publicExponent arrives as a W3C BigInteger octet string but node:crypto
|
|
271
281
|
// takes a JS number, so the value is bounds-checked BEFORE the Number()
|
|
272
282
|
// narrowing: an empty buffer has no integer value (BigInt("0x") is a raw
|
|
@@ -305,7 +315,13 @@ SubtleCrypto.prototype.sign = async function sign(algorithm, key, data) {
|
|
|
305
315
|
if (name === "ECDSA") {
|
|
306
316
|
return _toArrayBuffer(nodeCrypto.sign(_hashNode(alg.hash, "sign"), buf, { key: key._handle, dsaEncoding: "ieee-p1363" }));
|
|
307
317
|
}
|
|
308
|
-
if (
|
|
318
|
+
if (ML_DSA_NODE[name]) {
|
|
319
|
+
// ML-DSA (FIPS 204) accepts an optional context octet string; thread it through
|
|
320
|
+
// signing symmetrically with verify so a context signature round-trips (a
|
|
321
|
+
// signature made with a context verifies only under the SAME context).
|
|
322
|
+
return _toArrayBuffer(nodeCrypto.sign(null, buf, _mldsaKeyArg(alg, key._handle, "sign")));
|
|
323
|
+
}
|
|
324
|
+
if (name === "ED25519" || name === "ED448" || SLH_DSA_NODE[name]) {
|
|
309
325
|
return _toArrayBuffer(nodeCrypto.sign(null, buf, key._handle));
|
|
310
326
|
}
|
|
311
327
|
if (name === "HMAC") {
|
|
@@ -336,7 +352,13 @@ SubtleCrypto.prototype.verify = async function verify(algorithm, key, signature,
|
|
|
336
352
|
if (name === "ECDSA") {
|
|
337
353
|
return nodeCrypto.verify(_hashNode(alg.hash, "verify"), buf, { key: key._handle, dsaEncoding: "ieee-p1363" }, sig);
|
|
338
354
|
}
|
|
339
|
-
if (
|
|
355
|
+
if (ML_DSA_NODE[name]) {
|
|
356
|
+
// ML-DSA (FIPS 204) accepts an optional context octet string (the composite
|
|
357
|
+
// signature construction passes the composite Domain as the ML-DSA context,
|
|
358
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 2). A context mismatch fails.
|
|
359
|
+
return nodeCrypto.verify(null, buf, _mldsaKeyArg(alg, key._handle, "verify"), sig);
|
|
360
|
+
}
|
|
361
|
+
if (name === "ED25519" || name === "ED448" || SLH_DSA_NODE[name]) {
|
|
340
362
|
return nodeCrypto.verify(null, buf, key._handle, sig);
|
|
341
363
|
}
|
|
342
364
|
if (name === "HMAC") {
|
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:a09496c1-2301-420c-a786-1cb883b3f77c",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-13T10:49:09.267Z",
|
|
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.9",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.9",
|
|
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.9",
|
|
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.9",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|