@blamejs/pki 0.2.20 → 0.2.22

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/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.22",
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:54db6dce-3101-401d-be5e-baa7f068783f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-15T22:55:37.673Z",
8
+ "timestamp": "2026-07-16T03:51:39.360Z",
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.22",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.20",
25
+ "version": "0.2.22",
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.22",
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.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]