@blamejs/pki 0.2.7 → 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 CHANGED
@@ -4,6 +4,24 @@ 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
+
16
+ ## v0.2.8 — 2026-07-13
17
+
18
+ pki.webcrypto rejects an AES key of invalid length at import instead of deferring the failure to first use.
19
+
20
+ ### Fixed
21
+
22
+ - pki.webcrypto.subtle.importKey now rejects a raw or JWK AES key whose length is not 128, 192, or 256 bits as a webcrypto/data DataError at import, rather than returning a CryptoKey that only fails at first use. Covers AES-GCM, AES-CBC, AES-CTR, and AES-KW; HMAC, HKDF, and PBKDF2 keys are unaffected.
23
+ - The README capability table and the documentation site render the certificate-inspection entry correctly: an inline code span containing pipe characters no longer breaks the surrounding table into misaligned columns.
24
+
7
25
  ## v0.2.7 — 2026-07-13
8
26
 
9
27
  pki.webcrypto rejects an imported key whose type disagrees with its algorithm, and reports every cipher fault as a typed error.
package/README.md CHANGED
@@ -220,14 +220,14 @@ 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` |
227
227
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
228
228
  | `pki.hpke` | Hybrid Public Key Encryption (RFC 9180) — the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS`/`setupR` establish a sender/recipient context (KEM encapsulation + HKDF key schedule); the context's `seal`/`open` AEAD-encrypt with a sequence-counter nonce and `export` derives further secrets; `seal`/`open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) × HKDF-SHA256/SHA512 × AES-GCM/ChaCha20Poly1305/export-only × all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM / X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
229
229
  | `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
230
- | `pki.inspect` | Human-readable certificate inspection — the pure-JS equivalent of `openssl x509 -text`. `certificate(pem | der | parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. Built over the strict parser and the two-way OID registry, so it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed extension falls back to a hex dump rather than throwing — `certificate` |
230
+ | `pki.inspect` | Human-readable certificate inspection — the pure-JS equivalent of `openssl x509 -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. Built over the strict parser and the two-way OID registry, so it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed extension falls back to a hex dump rather than throwing — `certificate` |
231
231
  | `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers **ESP256/384/512, Ed25519, and Ed448** — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally-encoded DER ECDSA signature. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. Chaining the returned trust path to a pinned root (and aaguid→root via FIDO MDS) is the caller's step through `pki.path.validate` — `parseAttestationObject`, `verify` |
232
232
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
233
233
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError` / `WebauthnError`, each carrying a stable `code` in `domain/reason` form |
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
@@ -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 (name === "ED25519" || name === "ED448" || ML_DSA_NODE[name] || SLH_DSA_NODE[name]) {
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 (name === "ED25519" || name === "ED448" || ML_DSA_NODE[name] || SLH_DSA_NODE[name]) {
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") {
@@ -621,6 +643,17 @@ function _cloneWithUsage(key, usage) {
621
643
  return k;
622
644
  }
623
645
 
646
+ // A raw or JWK-oct AES key MUST be 128/192/256 bits: W3C WebCrypto importKey rejects a
647
+ // non-conforming length as a DataError AT IMPORT, never a CryptoKey that only faults at
648
+ // first use (where node would build an "aes-160-gcm" cipher that fails only then). HMAC /
649
+ // HKDF / PBKDF2 keys carry no length restriction, so the check is scoped to the AES names.
650
+ function _assertAesImportLen(name, byteLen) {
651
+ if ((name === "AES-GCM" || name === "AES-CBC" || name === "AES-CTR" || name === "AES-KW") &&
652
+ byteLen !== 16 && byteLen !== 24 && byteLen !== 32) {
653
+ throw new WebCryptoError("webcrypto/data", name + ": an imported AES key must be 128, 192, or 256 bits");
654
+ }
655
+ }
656
+
624
657
  SubtleCrypto.prototype.importKey = async function importKey(format, keyData, algorithm, extractable, keyUsages) {
625
658
  var alg = _normalizeAlg(algorithm, "importKey");
626
659
  var usages = keyUsages || [];
@@ -630,6 +663,7 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
630
663
  // Symmetric raw material, or a raw public key for EC/OKP.
631
664
  if (name === "AES-GCM" || name === "AES-CBC" || name === "AES-CTR" || name === "AES-KW" || name === "HMAC" || name === "HKDF" || name === "PBKDF2") {
632
665
  var raw = _toBuf(keyData, "importKey raw");
666
+ _assertAesImportLen(name, raw.length);
633
667
  var secret = nodeCrypto.createSecretKey(raw);
634
668
  var symAlg = (name === "HMAC") ? { name: name, hash: _hashObj(alg.hash), length: raw.length * 8 } : { name: name, length: raw.length * 8 };
635
669
  return new CryptoKey("secret", extractable, symAlg, usages, secret);
@@ -650,6 +684,7 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
650
684
  var jwk = keyData;
651
685
  if (jwk.kty === "oct") {
652
686
  var kbuf = _b64urlToBuf(jwk.k, "JWK oct key material");
687
+ _assertAesImportLen(name, kbuf.length);
653
688
  var s2 = nodeCrypto.createSecretKey(kbuf);
654
689
  var a2 = (name === "HMAC") ? { name: name, hash: _hashObj(alg.hash), length: kbuf.length * 8 } : { name: name, length: kbuf.length * 8 };
655
690
  return new CryptoKey("secret", extractable, a2, usages, s2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:ea60e88f-9354-49c4-aa35-ad6712102de1",
5
+ "serialNumber": "urn:uuid:a09496c1-2301-420c-a786-1cb883b3f77c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-13T07:29:25.087Z",
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.7",
22
+ "bom-ref": "@blamejs/pki@0.2.9",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.7",
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.7",
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.7",
57
+ "ref": "@blamejs/pki@0.2.9",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]