@blamejs/pki 0.2.20 → 0.2.21

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,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.21 — 2026-07-15
8
+
9
+ ML-KEM public keys in X.509 certificates and PKCS#8 private keys (RFC 9935 / FIPS 203) become a first-class, fail-closed surface: certification-path validation enforces the keyEncipherment-only key usage, key import validates the private-key CHOICE and rejects an inconsistent key with a typed error, and pki.lint gains the RFC 9935 certificate rows.
10
+
11
+ ### Added
12
+
13
+ - pki.path.validate enforces the RFC 9935 section 5 key-usage rule for ML-KEM (id-ml-kem-512/768/1024) certificates: a present keyUsage MUST assert keyEncipherment as the only bit. A leaf with digitalSignature, an ML-KEM key with keyCertSign / cRLSign / keyAgreement / dataEncipherment / nonRepudiation, or keyEncipherment set alongside any reserved bit, fails closed with the frozen code path/kem-key-usage -- for the target and every intermediate whose own subject key is ML-KEM. An absent keyUsage places no restriction (RFC 5280 section 4.2.1.3).
14
+ - pki.lint gains an rfc9935 profile: lint/rfc9935/kem-key-usage (the section 5 keyEncipherment-only rule) and lint/rfc9935/kem-key-length (the SPKI encapsulation key must be exactly 800 / 1184 / 1568 octets for the id-ml-kem-512 / 768 / 1024 OID -- the OID is the sole authority for the parameter set, so an OCTET-STRING-wrapped or wrong-set key is flagged). Both run by default and are silent on a non-ML-KEM certificate.
15
+
16
+ ### Fixed
17
+
18
+ - pki.webcrypto.subtle.importKey no longer surfaces a raw engine exception when handed a malformed or inconsistent key: a bad SPKI, a bad JWK, or an ML-KEM private key whose seed and expanded halves are inconsistent (FIPS 203 section 7.3) now fails closed with a typed webcrypto/data error. For an ML-KEM PKCS#8, the RFC 9935 section 6 private-key CHOICE is validated by its DER tag and exact size for the algorithm OID before the engine imports it -- the OpenSSL-legacy bare-seed, bare-expanded-key, and concatenated layouts the engine would otherwise accept are rejected, so a non-conformant private key cannot be imported under an ML-KEM name.
19
+ - pki.webcrypto.subtle.importKey now fails closed on two further malformed-input classes: a JWK import whose key data is not an object (null, a primitive, or an array -- including the JSON null an unwrap over non-authenticating ciphertext can yield) returns a typed webcrypto/data error instead of a raw TypeError; and an SPKI or PKCS#8 import requested under a secret-key or key-derivation algorithm name (AES-GCM/CBC/CTR/KW, HMAC, HKDF, PBKDF2) is rejected as webcrypto/not-supported rather than importing a mislabeled key handle.
20
+
7
21
  ## v0.2.20 — 2026-07-15
8
22
 
9
23
  A WebAuthn attestation object whose attestation statement is not a CBOR map is now rejected with a typed webauthn/bad-attestation-object at parse instead of surfacing an untyped error from a format verifier, and the strict CBOR codec gains pki.cbor.read.mapGet -- a keyed map lookup that asserts the map's major type inside the accessor.
package/README.md CHANGED
@@ -201,7 +201,7 @@ is callable today; nothing below is a stub.
201
201
  | `pki.asn1` | Strict, bounded DER codec — `decode` (zero-copy node tree), `encode`, `build.*` canonical-DER value builders, `read.*` typed readers, `TAGS`, OID-content encode/decode |
202
202
  | `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers incl. the keyed map lookup `read.mapGet` (text or COSE-label integer key, the map's major type asserted in the accessor), fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
203
203
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
204
- | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation (KEM encapsulation on the roadmap). Zero-dependency, OpenSSL-interoperable |
204
+ | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation and certificate/PKCS#8 import — the RFC 9935 seed / expandedKey / both private-key CHOICE is validated fail-closed, so an OpenSSL-legacy bare-seed or an internally inconsistent key is rejected with a typed error (KEM encapsulation lands with CMS KEM-decrypt). Zero-dependency, OpenSSL-interoperable |
205
205
  | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
206
206
  | `pki.schema.x509` | Parse DER / PEM certificates into structured, validated fields — `parse`, `pemDecode`, `pemEncode` |
207
207
  | `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode`, `pemEncode` |
package/lib/lint.js CHANGED
@@ -25,6 +25,7 @@
25
25
 
26
26
  var frameworkError = require("./framework-error");
27
27
  var asn1 = require("./asn1-der");
28
+ var guard = require("./guard-all");
28
29
  var oid = require("./oid");
29
30
  var x509 = require("./schema-x509");
30
31
  var pkix = require("./schema-pkix");
@@ -458,11 +459,51 @@ var CABF_TLS_RULES = [
458
459
  },
459
460
  ];
460
461
 
462
+ // RFC 9935 -- ML-KEM public keys in X.509 certificates. The OID is the sole authority for the
463
+ // parameter set; the SPKI BIT STRING is the raw ek, exactly 384k+32 octets for that OID.
464
+ var ML_KEM_EK_LEN = {};
465
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n, i) { ML_KEM_EK_LEN[n] = [800, 1184, 1568][i]; });
466
+ function _isMlKem(cert) {
467
+ var spki = cert.subjectPublicKeyInfo;
468
+ return !!(spki && spki.algorithm && ML_KEM_EK_LEN[spki.algorithm.name] !== undefined);
469
+ }
470
+ var RFC9935_RULES = [
471
+ {
472
+ id: "lint/rfc9935/kem-key-usage", severity: "error", source: "rfc9935", citation: "RFC 9935 5",
473
+ message: "an ML-KEM certificate's keyUsage, if present, must assert keyEncipherment as the only key usage set",
474
+ appliesTo: _isMlKem,
475
+ check: function (cert, ctx) {
476
+ var ku = ctx.decode("keyUsage");
477
+ if (!ku || !ku.value) return null; // absent keyUsage is unconstrained (RFC 5280 4.2.1.3)
478
+ var v = ku.value;
479
+ var others = v.digitalSignature || v.nonRepudiation || v.dataEncipherment || v.keyAgreement ||
480
+ v.keyCertSign || v.cRLSign || v.encipherOnly || v.decipherOnly || v.reservedBitsSet === true;
481
+ return (v.keyEncipherment && !others) ? null : true;
482
+ },
483
+ },
484
+ {
485
+ id: "lint/rfc9935/kem-key-length", severity: "error", source: "rfc9935", citation: "RFC 9935 4 / FIPS 203",
486
+ message: "an ML-KEM subjectPublicKey must be the raw encapsulation key at its exact FIPS 203 size for the OID, in a byte-aligned BIT STRING",
487
+ appliesTo: _isMlKem,
488
+ check: function (cert) {
489
+ var spki = cert.subjectPublicKeyInfo;
490
+ var want = ML_KEM_EK_LEN[spki.algorithm.name];
491
+ var pub = spki.publicKey; // a parsed SPKI always surfaces the BIT STRING as { bytes, unusedBits }
492
+ // The ek is a raw byte string carried in the BIT STRING: it must be the exact size AND
493
+ // byte-aligned. A right-length but non-octet-aligned BIT STRING (unusedBits != 0) is not the
494
+ // raw ek (RFC 9935 sec. 4); route the alignment test through the shared guard primitive.
495
+ return (guard.crypto.isOctetAligned(pub) && pub.bytes.length === want) ? null
496
+ : { context: { expected: want, got: pub.bytes.length, unusedBits: pub.unusedBits } };
497
+ },
498
+ },
499
+ ];
500
+
461
501
  var PROFILES = {
462
502
  "rfc5280": RFC5280_RULES,
503
+ "rfc9935": RFC9935_RULES,
463
504
  "cabf-tls": CABF_TLS_RULES,
464
505
  };
465
- var ALL_RULES = RFC5280_RULES.concat(CABF_TLS_RULES);
506
+ var ALL_RULES = RFC5280_RULES.concat(RFC9935_RULES).concat(CABF_TLS_RULES);
466
507
 
467
508
  function _selectRules(profile) {
468
509
  if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
@@ -557,7 +598,7 @@ function rules(profile) {
557
598
  *
558
599
  * List the known lint-profile names.
559
600
  * @example
560
- * pki.lint.profiles(); // -> ["rfc5280", "cabf-tls"]
601
+ * pki.lint.profiles(); // -> ["rfc5280", "rfc9935", "cabf-tls"]
561
602
  */
562
603
  function profiles() { return Object.keys(PROFILES); }
563
604
 
package/lib/oid.js CHANGED
@@ -553,7 +553,7 @@ var _PARAMS_ABSENT = new Set();
553
553
  "id-hash-slh-dsa-shake-192s-with-shake256", "id-hash-slh-dsa-shake-192f-with-shake256",
554
554
  "id-hash-slh-dsa-shake-256s-with-shake256", "id-hash-slh-dsa-shake-256f-with-shake256",
555
555
  "Ed25519", "Ed448", "X25519", "X448",
556
- // FIPS 203 ML-KEM (RFC 9936 sec. 3: PARAMS ARE absent).
556
+ // FIPS 203 ML-KEM (RFC 9935 sec. 3 certificates + RFC 9936 sec. 3 CMS: PARAMS ARE absent).
557
557
  "id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024",
558
558
  // HKDF key-derivation identifiers (RFC 8619 sec. 2: when any of these appear
559
559
  // within AlgorithmIdentifier, the parameters component SHALL be absent).
@@ -331,6 +331,32 @@ function compositeKeyUsageCheck(cert) {
331
331
  return { ok: true };
332
332
  }
333
333
 
334
+ // The ML-KEM SubjectPublicKeyInfo OIDs (RFC 9935 / FIPS 203). A certificate carrying one of
335
+ // these keys is a KEM key-establishment certificate: it can neither sign nor agree.
336
+ var ML_KEM_OIDS = {};
337
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) { ML_KEM_OIDS[oid.byName(n)] = true; });
338
+
339
+ // RFC 9935 sec. 5: a certificate whose SubjectPublicKeyInfo carries an id-ml-kem-* OID, IF it
340
+ // has a keyUsage extension, MUST assert keyEncipherment as the ONLY key usage set -- an ML-KEM
341
+ // key is a key-establishment-only key (it cannot sign or agree, so no other bit is legitimate,
342
+ // and an unnamed/reserved bit set alongside keyEncipherment is equally forbidden). The caller
343
+ // invokes this only for an ML-KEM-keyed certificate; an absent keyUsage places no restriction
344
+ // (RFC 5280 sec. 4.2.1.3). This also makes an ML-KEM "CA" (keyCertSign) an explicit reject.
345
+ function kemKeyUsageCheck(cert) {
346
+ var ku;
347
+ try { ku = decodeExt(cert, OID.keyUsage); }
348
+ catch (e) { return { ok: false, code: "path/kem-key-usage", error: e }; }
349
+ if (!ku) return { ok: true };
350
+ var v = ku.value;
351
+ var others = v.digitalSignature || v.nonRepudiation || v.dataEncipherment || v.keyAgreement ||
352
+ v.keyCertSign || v.cRLSign || v.encipherOnly || v.decipherOnly || (v.reservedBitsSet === true);
353
+ if (!v.keyEncipherment || others) {
354
+ return { ok: false, code: "path/kem-key-usage",
355
+ error: E("path/kem-key-usage", "an ML-KEM key's keyUsage must assert keyEncipherment as the only bit (RFC 9935 sec. 5)") };
356
+ }
357
+ return { ok: true };
358
+ }
359
+
334
360
  // Import a descriptor's verification key, validating an EdDSA point FIRST: node/OpenSSL import a
335
361
  // low-order (e.g. identity or all-zeroes) Ed25519/Ed448 SPKI without complaint and such a key
336
362
  // verifies a forged signature. This is the ONE seam both the certificate path and the revocation
@@ -1215,6 +1241,14 @@ async function validate(path, opts) {
1215
1241
  if (!cku.ok) failed = true;
1216
1242
  }
1217
1243
 
1244
+ // RFC 9935 sec. 5: an ML-KEM-keyed certificate's keyUsage must be keyEncipherment-only.
1245
+ // Runs for the target AND every intermediate whose own subject key is ML-KEM.
1246
+ if (ML_KEM_OIDS[cert.subjectPublicKeyInfo.algorithm.oid]) {
1247
+ var kku = kemKeyUsageCheck(cert);
1248
+ checks.push({ name: "kemKeyUsage", ok: kku.ok, code: kku.ok ? undefined : kku.code });
1249
+ if (!kku.ok) failed = true;
1250
+ }
1251
+
1218
1252
  // 6.1.3(a)(2) validity window.
1219
1253
  var t = opts.time;
1220
1254
  var vOk = true, vCode;
@@ -660,6 +660,14 @@ function certExtensionDecoders(ns) {
660
660
  var byte = bit >> 3, mask = 0x80 >> (bit & 7);
661
661
  out[nm] = byte < bs.bytes.length && (bs.bytes[byte] & mask) !== 0;
662
662
  });
663
+ // Any bit beyond the 9 named positions is a reserved/unknown usage. RFC 5280 tolerates it,
664
+ // but a "MUST be the ONLY bit" rule (e.g. RFC 9935 sec. 5 ML-KEM keyEncipherment-only) needs
665
+ // to see it -- so surface it rather than silently drop bits >= 9.
666
+ var reserved = false;
667
+ for (var bit2 = KU_BITS.length; bit2 < bs.bytes.length * 8 && !reserved; bit2++) {
668
+ if ((bs.bytes[bit2 >> 3] & (0x80 >> (bit2 & 7))) !== 0) reserved = true;
669
+ }
670
+ out.reservedBitsSet = reserved;
663
671
  return out;
664
672
  }
665
673
 
package/lib/webcrypto.js CHANGED
@@ -22,10 +22,11 @@
22
22
  * still runs on -- RSASSA-PKCS1-v1_5, RSA-PSS, RSA-OAEP, ECDSA, ECDH,
23
23
  * Ed25519 / Ed448, AES-GCM / CBC / KW, HMAC, HKDF, PBKDF2, and the SHA
24
24
  * family (including legacy SHA-1 for old certificates and signatures).
25
- * FIPS 203 ML-KEM key generation and encoding are available; KEM
26
- * encapsulation follows once Node exposes it. Because it is
27
- * OpenSSL-backed, every key and signature it emits is interoperable
28
- * with OpenSSL, NSS, and other PKI implementations.
25
+ * FIPS 203 ML-KEM key generation, encoding, and certificate/PKCS#8
26
+ * import are available; KEM encapsulation/decapsulation is deferred to
27
+ * the CMS KEM-decrypt surface by decision (the Node primitive exists).
28
+ * Because it is OpenSSL-backed, every key and signature it emits is
29
+ * interoperable with OpenSSL, NSS, and other PKI implementations.
29
30
  *
30
31
  * @card
31
32
  * A zero-dep, PQC-first W3C WebCrypto (`SubtleCrypto`) engine over
@@ -37,6 +38,8 @@ var nodeCrypto = require("node:crypto");
37
38
  var frameworkError = require("./framework-error");
38
39
  var guard = require("./guard-all");
39
40
  var constants = require("./constants");
41
+ var asn1 = require("./asn1-der");
42
+ var oid = require("./oid");
40
43
 
41
44
  // Single-owner error class -- co-located with its module (framework-error
42
45
  // stays the cross-module home; this is webcrypto-private). withCause: a
@@ -127,8 +130,9 @@ var ML_KEM_NODE = { "ML-KEM-512": "ml-kem-512", "ML-KEM-768": "ml-kem-768", "ML-
127
130
 
128
131
  // FIPS 205 SLH-DSA -- stateless hash-based signatures. All twelve
129
132
  // parameter sets Node exposes; signing is one-shot (null algorithm), the
130
- // same shape as ML-DSA / EdDSA. KEM encapsulation for ML-KEM is not yet
131
- // in Node's API, so ML-KEM here is key-generation / encoding only.
133
+ // same shape as ML-DSA / EdDSA. ML-KEM here is key-generation / encoding /
134
+ // import; encapsulation/decapsulation (which Node now exposes) is deferred
135
+ // to the CMS KEM-decrypt surface, not blocked by the engine.
132
136
  var SLH_DSA_NODE = {};
133
137
  ["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
134
138
  "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
@@ -144,6 +148,12 @@ var SIGN_VERIFY_NAMES = {};
144
148
  .forEach(function (n) { SIGN_VERIFY_NAMES[n] = true; });
145
149
  var ENCRYPT_DECRYPT_NAMES = { "RSA-OAEP": true, "AES-GCM": true, "AES-CBC": true, "AES-CTR": true };
146
150
  var DERIVE_NAMES = { "ECDH": true, "X25519": true, "X448": true, "HKDF": true, "PBKDF2": true };
151
+ // The secret-key / KDF algorithms whose key material is raw octets (imported via "raw" or a
152
+ // JWK "oct"), NEVER an SPKI / PKCS#8 asymmetric-key structure. importKey("spki"|"pkcs8", ...)
153
+ // under one of these names is unsupported (W3C: NotSupportedError) -- and, without this gate, it
154
+ // would mint a mislabeled CryptoKey wrapping an asymmetric handle AND dodge the algorithm-keyed
155
+ // pkcs8 pre-validation (e.g. the RFC 9935 ML-KEM CHOICE guard).
156
+ var SECRET_KEY_NAMES = { "AES-GCM": true, "AES-CBC": true, "AES-CTR": true, "AES-KW": true, "HMAC": true, "HKDF": true, "PBKDF2": true };
147
157
 
148
158
  // ---- CryptoKey -------------------------------------------------------
149
159
 
@@ -654,14 +664,97 @@ function _assertAesImportLen(name, byteLen) {
654
664
  }
655
665
  }
656
666
 
667
+ // Run a node:crypto key-import call, mapping a raw engine error (a malformed DER, an
668
+ // inconsistent key) to a typed webcrypto/data DataError -- importKey over hostile bytes
669
+ // must never leak a bare OpenSSL/Node exception through the public API.
670
+ function _nodeKey(fn, who) {
671
+ try { return fn(); }
672
+ catch (e) {
673
+ // Coverage residual -- every current thunk calls node:crypto (which throws a node error),
674
+ // never a WebCryptoError, so this already-typed passthrough is defensive depth for a future
675
+ // thunk that pre-validates and throws typed; it prevents double-wrapping a typed error.
676
+ if (e instanceof WebCryptoError) throw e;
677
+ throw new WebCryptoError("webcrypto/data", who + ": the key material is malformed or internally inconsistent", e);
678
+ }
679
+ }
680
+
681
+ // RFC 9935 sec. 6 ML-KEM-*-PrivateKey CHOICE: the inner sizes, keyed by the OID -- the OID is
682
+ // the SOLE authority for the parameter set (never a length heuristic). ek = 384k+32, dk = the
683
+ // FIPS 203 decapsulation key length.
684
+ var ML_KEM_INNER = {};
685
+ ML_KEM_INNER[oid.byName("id-ml-kem-512")] = { ek: 800, dk: 1632 };
686
+ ML_KEM_INNER[oid.byName("id-ml-kem-768")] = { ek: 1184, dk: 2400 };
687
+ ML_KEM_INNER[oid.byName("id-ml-kem-1024")] = { ek: 1568, dk: 3168 };
688
+
689
+ function _isOctet(node, size) {
690
+ return node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING &&
691
+ !node.constructed && node.content && node.content.length === size;
692
+ }
693
+ // Validate the RFC 9935 sec. 6 inner CHOICE structurally, dispatching on the DER tag (0x80 seed
694
+ // [0] / 0x04 expandedKey / 0x30 both) -- NEVER on the length of the enclosing OCTET STRING. A
695
+ // bare seed, a bare dk, an oqskeypair dk||ek, a constructed [0], or a wrong-size arm is rejected
696
+ // fail-closed (guards never guess a plausible layout). The seed/expandedKey CRYPTOGRAPHIC
697
+ // consistency (sec. 8 / FIPS 203 sec. 7.3) is enforced by the engine on import (a mismatch is a
698
+ // raw node error _nodeKey maps to webcrypto/data).
699
+ function _validateMlKemInner(innerBytes, sizes) {
700
+ var node;
701
+ try { node = asn1.decode(innerBytes); }
702
+ catch (e) { throw new WebCryptoError("webcrypto/data", "an ML-KEM private key must be the RFC 9935 sec. 6 seed/expandedKey/both CHOICE, not a bare key", e); }
703
+ if (node.tagClass === "context" && node.tagNumber === 0 && !node.constructed) {
704
+ if (!node.content || node.content.length !== 64) throw new WebCryptoError("webcrypto/data", "an ML-KEM seed must be exactly 64 octets (RFC 9935 sec. 6)");
705
+ return;
706
+ }
707
+ if (_isOctet(node, sizes.dk)) return;
708
+ if (node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING && !node.constructed) {
709
+ throw new WebCryptoError("webcrypto/data", "an ML-KEM expandedKey must be exactly " + sizes.dk + " octets for this parameter set (RFC 9935 sec. 6)");
710
+ }
711
+ if (node.tagClass === "universal" && node.tagNumber === asn1.TAGS.SEQUENCE) {
712
+ // Coverage residual -- a decoded universal SEQUENCE always carries a children array, so the
713
+ // `|| []` fallback is defensive (asn1.decode never yields a childless constructed node).
714
+ var kids = node.children || [];
715
+ if (kids.length !== 2 || !_isOctet(kids[0], 64) || !_isOctet(kids[1], sizes.dk)) {
716
+ throw new WebCryptoError("webcrypto/data", "an ML-KEM both-arm must be SEQUENCE { seed OCTET STRING(64), expandedKey OCTET STRING(" + sizes.dk + ") } (RFC 9935 sec. 6)");
717
+ }
718
+ return;
719
+ }
720
+ throw new WebCryptoError("webcrypto/data", "an ML-KEM private key must be the seed, expandedKey, or both CHOICE (RFC 9935 sec. 6)");
721
+ }
722
+
723
+ // PKCS#8 inner-key pre-validation, keyed by the privateKeyAlgorithm OID (a registry row, not a
724
+ // switch). The parse surfaces the inner key as opaque octets by design; the fail-closed
725
+ // per-algorithm structural check lives here, at the one import boundary. Only ML-KEM needs it
726
+ // today: the engine ACCEPTS the OpenSSL-legacy bare-seed layout RFC 9935 sec. 6 forbids.
727
+ var PKCS8_INNER_VALIDATORS = {};
728
+ Object.keys(ML_KEM_INNER).forEach(function (o) { PKCS8_INNER_VALIDATORS[o] = function (inner) { _validateMlKemInner(inner, ML_KEM_INNER[o]); }; });
729
+
730
+ function _preValidatePkcs8(p8, name) {
731
+ if (!ML_KEM_NODE[name]) return; // only the ML-KEM CHOICE carries the bare-seed hazard
732
+ var algOid, innerBytes;
733
+ try {
734
+ var root = asn1.decode(p8);
735
+ algOid = asn1.read.oid(root.children[1].children[0]);
736
+ innerBytes = asn1.read.octetString(root.children[2]);
737
+ } catch (e) { throw new WebCryptoError("webcrypto/data", "importKey pkcs8: the ML-KEM PKCS#8 envelope is not well-formed", e); }
738
+ var validate = PKCS8_INNER_VALIDATORS[algOid];
739
+ if (!validate) throw new WebCryptoError("webcrypto/data", "importKey pkcs8: " + name + " does not match the key's algorithm OID");
740
+ validate(innerBytes);
741
+ }
742
+
657
743
  SubtleCrypto.prototype.importKey = async function importKey(format, keyData, algorithm, extractable, keyUsages) {
658
744
  var alg = _normalizeAlg(algorithm, "importKey");
659
745
  var usages = keyUsages || [];
660
746
  var name = alg.name;
661
747
 
748
+ // spki / pkcs8 carry an asymmetric key; a secret-key / KDF name has no such structure. Reject
749
+ // it up front (W3C NotSupportedError) so it can neither mint a mislabeled key nor bypass the
750
+ // per-algorithm pkcs8 pre-validation below.
751
+ if ((format === "spki" || format === "pkcs8") && SECRET_KEY_NAMES[name]) {
752
+ throw new WebCryptoError("webcrypto/not-supported", "importKey: " + name + " does not support the " + format + " key format");
753
+ }
754
+
662
755
  if (format === "raw") {
663
756
  // Symmetric raw material, or a raw public key for EC/OKP.
664
- if (name === "AES-GCM" || name === "AES-CBC" || name === "AES-CTR" || name === "AES-KW" || name === "HMAC" || name === "HKDF" || name === "PBKDF2") {
757
+ if (SECRET_KEY_NAMES[name]) {
665
758
  var raw = _toBuf(keyData, "importKey raw");
666
759
  _assertAesImportLen(name, raw.length);
667
760
  var secret = nodeCrypto.createSecretKey(raw);
@@ -673,15 +766,24 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
673
766
  }
674
767
 
675
768
  if (format === "spki") {
676
- var pub = nodeCrypto.createPublicKey({ key: _toBuf(keyData, "importKey spki"), format: "der", type: "spki" });
769
+ var spkiBuf = _toBuf(keyData, "importKey spki");
770
+ var pub = _nodeKey(function () { return nodeCrypto.createPublicKey({ key: spkiBuf, format: "der", type: "spki" }); }, "importKey spki");
677
771
  return new CryptoKey("public", true, _algFromImport(name, alg, pub), usages, pub);
678
772
  }
679
773
  if (format === "pkcs8") {
680
- var priv = nodeCrypto.createPrivateKey({ key: _toBuf(keyData, "importKey pkcs8"), format: "der", type: "pkcs8" });
774
+ var p8Buf = _toBuf(keyData, "importKey pkcs8");
775
+ _preValidatePkcs8(p8Buf, name);
776
+ var priv = _nodeKey(function () { return nodeCrypto.createPrivateKey({ key: p8Buf, format: "der", type: "pkcs8" }); }, "importKey pkcs8");
681
777
  return new CryptoKey("private", extractable, _algFromImport(name, alg, priv), usages, priv);
682
778
  }
683
779
  if (format === "jwk") {
684
780
  var jwk = keyData;
781
+ // A JWK is a JSON object; null / undefined / an array / a primitive (incl. the JSON token
782
+ // `null` from an unwrapKey over non-authenticating ciphertext) fails closed as a typed
783
+ // DataError, never a raw property-access TypeError.
784
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) {
785
+ throw new WebCryptoError("webcrypto/data", "importKey jwk: keyData must be a JsonWebKey object");
786
+ }
685
787
  if (jwk.kty === "oct") {
686
788
  var kbuf = _b64urlToBuf(jwk.k, "JWK oct key material");
687
789
  _assertAesImportLen(name, kbuf.length);
@@ -690,7 +792,7 @@ SubtleCrypto.prototype.importKey = async function importKey(format, keyData, alg
690
792
  return new CryptoKey("secret", extractable, a2, usages, s2);
691
793
  }
692
794
  var isPrivate = Object.prototype.hasOwnProperty.call(jwk, "d");
693
- var ko = isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" });
795
+ var ko = _nodeKey(function () { return isPrivate ? nodeCrypto.createPrivateKey({ key: jwk, format: "jwk" }) : nodeCrypto.createPublicKey({ key: jwk, format: "jwk" }); }, "importKey jwk");
694
796
  return new CryptoKey(isPrivate ? "private" : "public", isPrivate ? extractable : true, _algFromImport(name, alg, ko), usages, ko);
695
797
  }
696
798
  throw new WebCryptoError("webcrypto/not-supported", "importKey: unsupported format " + JSON.stringify(format));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
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:2c42308d-4c79-437b-8bf2-36089abf8898",
5
+ "serialNumber": "urn:uuid:77add14d-d236-4f91-aec8-607349cb6aa8",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-15T22:55:37.673Z",
8
+ "timestamp": "2026-07-16T01:35:31.295Z",
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.20",
22
+ "bom-ref": "@blamejs/pki@0.2.21",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.20",
25
+ "version": "0.2.21",
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.20",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.21",
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.20",
57
+ "ref": "@blamejs/pki@0.2.21",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]