@blamejs/pki 0.7.40 → 0.7.41

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,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.7.41 — 2026-09-12
8
+
9
+ A delivered or archived private key is proven the private half of its certificate with the key itself.
10
+
11
+ ### Fixed
12
+
13
+ - pki.est.serverkeygen proves a cleartext server-generated private key is the private half of the returned certificate it binds it to (a signature, an agreement, or an encapsulation the key completes), and refuses with est/key-cert-mismatch a key whose stated public half matches the certificate while its private material cannot use it. The selection of the certificate by public-key match and the ambiguity refusal are unchanged, and a finite-field Diffie-Hellman key, whose private structure carries the exponent alone, is still bound by the value it derives, at any width the runtime reads.
14
+ - pki.crmf.build proves the key enclosed in an encryptedKey proof of possession is the private half of certTemplate.publicKey with the key itself, and refuses with crmf/bad-popo an RSA, EC, EdDSA, ML-DSA, ML-KEM or composite key that only states the template's public components; a key the toolkit cannot read stays crmf/bad-input. A finite-field Diffie-Hellman key, whose private structure carries the exponent alone, keeps the derived comparison at any width, so the archival path still accepts a key wider than any group the toolkit agrees over.
15
+
7
16
  ## v0.7.40 — 2026-09-12
8
17
 
9
18
  A PKCS#12 store links a certificate only to its own key.
package/lib/crmf-sign.js CHANGED
@@ -836,7 +836,38 @@ function _buildEncryptedKeyPop(pop, outerTag, template) {
836
836
  }
837
837
  }
838
838
 
839
+ async function _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki) {
840
+ var pair;
841
+ try {
842
+ pair = await key.correspondsTo(privateKeyDer, templateSpki);
843
+ } catch (e) {
844
+ if (e instanceof CrmfError) throw e;
845
+ /** @internal A key the key module cannot read at all is bad input; one it reads and cannot
846
+ * exercise together with the template's key proves possession of nothing, which is the proof
847
+ * failing, not the input. */
848
+ if (e && e.isPkiError === true && e.code === "key/bad-input") {
849
+ throw _err("crmf/bad-input", "pop.privateKey could not be read as a private key to check against " +
850
+ "certTemplate.publicKey: " + (e.message || e), e);
851
+ }
852
+ throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession could not be " +
853
+ "exercised with certTemplate.publicKey, so it cannot be shown to be that key's private half (RFC 4211 sec. 4.2)", e);
854
+ }
855
+ if (pair !== true) {
856
+ throw _err("crmf/bad-popo", "the private key enclosed in the encryptedKey proof of possession is not the " +
857
+ "private half of certTemplate.publicKey, so it proves possession of a key this request does not ask to " +
858
+ "have certified (RFC 4211 sec. 4.2)");
859
+ }
860
+ }
861
+
839
862
  function _assertEnclosedKeyMatches(privateKeyDer, templateSpki) {
863
+ /** @internal Which proof the enclosed key takes depends on what its structure can carry. A
864
+ * finite-field PrivateKeyInfo carries the exponent alone, so the public value derived from it IS
865
+ * the proof, and this arm keeps that derivation at any width, with the cheap tests below. Every
866
+ * other family's private structure carries public components of its own (RFC 5958 sec. 2,
867
+ * RFC 5915, PKCS #1), which a caller could set to the template's beside private material that
868
+ * cannot use it, so those are proven by USING the key against the template. */
869
+ var enclosedAlg = key.algorithmOidAt(privateKeyDer, 1);
870
+ if (enclosedAlg !== null && !key.isDhOid(enclosedAlg)) return _assertEnclosedKeyCorresponds(privateKeyDer, templateSpki);
840
871
  return Promise.resolve()
841
872
  .then(function () { return key.publicFromPrivate(privateKeyDer); })
842
873
  .then(function (derivedSpki) {
@@ -991,7 +1022,9 @@ function _buildProofOfPossession(pop, certReqDer, template, signingKey, opts) {
991
1022
  * intended, so it takes `pop.archive: true`; `pop.privateKey` is the PKCS#8 DER, `pop.recipients` the CMS
992
1023
  * recipients to envelope it to, and `pop.identifier` (a string or GeneralName) is required, because
993
1024
  * sec. 4.2.1 makes it mandatory whenever the purpose is proving possession: without it a decrypting
994
- * agent cannot tell whose key it holds. `pop.contentEncryptionAlgorithm` defaults to `aes-256-cbc`; an
1025
+ * agent cannot tell whose key it holds. The enclosed key is proven the private half of
1026
+ * `certTemplate.publicKey` with the key itself (`crmf/bad-popo` when it is not), not by the public
1027
+ * components its structure states. `pop.contentEncryptionAlgorithm` defaults to `aes-256-cbc`; an
995
1028
  * AEAD algorithm is refused, since it would produce an `AuthEnvelopedData` this field cannot carry.
996
1029
  * - `agreeMAC`, which agrees a secret with the authority and MACs the request under a key derived from it,
997
1030
  * so nothing is signed (RFC 4211 sec. 4.3, RFC 2875 sec. 3). `pop.key` is the requester's finite-field
package/lib/est.js CHANGED
@@ -1742,6 +1742,18 @@ async function _serverkeygenResult(res, opts, derived) {
1742
1742
  var bound = findIssuedCert(out.certificates, spki);
1743
1743
  if (!bound) throw E("est/key-cert-mismatch", "the cleartext server-generated private key matches no returned certificate's public key (RFC 7030 sec. 4.4.2)");
1744
1744
  if (findIssuedCert(out.certificates.filter(function (c) { return c !== bound; }), spki)) throw E("est/ambiguous-issued-cert", "more than one returned certificate carries the server-generated key; the issued certificate is ambiguous (RFC 7030 sec. 4.4.2)");
1745
+ /** @internal The public half the key states selected the certificate; the pair is then proven
1746
+ * with the PRIVATE half, since a private structure states public components of its own that a
1747
+ * server could set to the certificate's beside private material that cannot use it. A
1748
+ * finite-field Diffie-Hellman PrivateKeyInfo carries the exponent alone, so the value derived
1749
+ * from it above IS the proof, at any width the runtime reads. */
1750
+ var keyAlg = key.algorithmOidAt(out.privateKeyDer, 1);
1751
+ if (keyAlg === null || !key.isDhOid(keyAlg)) {
1752
+ var pair;
1753
+ try { pair = await key.correspondsTo(out.privateKeyDer, spki); }
1754
+ catch (e2) { throw E("est/key-cert-mismatch", "the cleartext server-generated private key could not be exercised with the returned certificate's public key, so the two cannot be shown to be a pair (RFC 7030 sec. 4.4.2)", e2); }
1755
+ if (pair !== true) throw E("est/key-cert-mismatch", "the cleartext server-generated private key is not the private half of the returned certificate's public key (RFC 7030 sec. 4.4.2)");
1756
+ }
1745
1757
  delete out.privateKeyDer;
1746
1758
  }
1747
1759
  return out;
@@ -1765,7 +1777,9 @@ async function _serverkeygenResult(res, opts, derived) {
1765
1777
  * the key so the issued certificate's public key is the generated one, not the throwaway CSR key. A cleartext
1766
1778
  * key is bound to its certificate before it resolves: the delivered private key's public half MUST match
1767
1779
  * exactly one returned certificate (`est/key-cert-mismatch` on none, `est/ambiguous-issued-cert` on more than
1768
- * one), so a mis-associated key is refused, never handed back unusable.
1780
+ * one), and the key is then proven the private half of that certificate's public key with the key itself
1781
+ * (`est/key-cert-mismatch` when it is not; a finite-field DH key, whose structure carries the exponent alone,
1782
+ * is proven by the value it derives), so a mis-associated or unusable key is refused, never handed back.
1769
1783
  * The encryption requirement + expected recipient are derived from the CSR's own DecryptKeyIdentifier /
1770
1784
  * AsymmetricDecryptKeyIdentifier attribute; an `opts` value that contradicts the CSR is `est/bad-input`
1771
1785
  * (a cleartext-key downgrade cannot slip past). The delivered key's channel is asserted confidentiality-
package/lib/key.js CHANGED
@@ -680,7 +680,22 @@ function _dhAsPkcs3(der, at, what) {
680
680
  if (!algNode.children || algNode.children.length !== 2) {
681
681
  throw _err("key/bad-input", what + " X9.42 AlgorithmIdentifier does not carry DomainParameters");
682
682
  }
683
- var dom = x942Domain(algNode.children[1], KeyError, "key/bad-input", what);
683
+ /** @internal A field the codec refuses to read as the type the structure states is a malformed
684
+ * key, reported as one rather than as the codec's own complaint. */
685
+ var dom, y = null;
686
+ try {
687
+ dom = x942Domain(algNode.children[1], KeyError, "key/bad-input", what);
688
+ if (at === 0) {
689
+ var pub = asn1.read.bitString(node.children[1]);
690
+ if (!guard.crypto.isOctetAligned(pub)) {
691
+ throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
692
+ }
693
+ y = asn1.read.integer(asn1.decode(pub.bytes));
694
+ }
695
+ } catch (e) {
696
+ if (e instanceof KeyError) throw e;
697
+ throw _err("key/bad-input", what + " X9.42 Diffie-Hellman key could not be read", e);
698
+ }
684
699
  assertDhOperandsWithin([dom.p, dom.g, dom.q, dom.j], KeyError, "key/bad-input", what);
685
700
  assertDhDomainCoherent(dom.p, dom.g, dom.q, dom.j, KeyError, "key/bad-input", what);
686
701
  assertDhGeneratorOrder(dom.p, dom.g, dom.q, KeyError, "key/bad-input", what);
@@ -689,11 +704,6 @@ function _dhAsPkcs3(der, at, what) {
689
704
  /** @internal The one field the X9.42 form states that the PKCS #3 form cannot be held to is the
690
705
  * subgroup, so a public value is held to it here, where q is still known. */
691
706
  if (at === 0) {
692
- var pub = asn1.read.bitString(node.children[1]);
693
- if (!guard.crypto.isOctetAligned(pub)) {
694
- throw _err("key/bad-input", what + " Diffie-Hellman public key BIT STRING must be octet-aligned (0 unused bits)");
695
- }
696
- var y = asn1.read.integer(asn1.decode(pub.bytes));
697
707
  if (y <= 1n || y >= dom.p - 1n) {
698
708
  throw _err("key/bad-input", what + " Diffie-Hellman public value is outside the group (RFC 2875 sec. 3)");
699
709
  }
@@ -711,19 +721,45 @@ function _dhAsPkcs3(der, at, what) {
711
721
  * algorithm; every secret the probe makes is wiped on every way out. A public key of another
712
722
  * algorithm, composite or not, is another family and not a pair. */
713
723
  async function _compositeCorrespondsTo(privDer, spkiDer, privateOid) {
724
+ /** @internal The private half is read first, as on the classical route: a composite key the KEM
725
+ * cannot read is bad input whatever the public half names, decided before the family shortcut
726
+ * answers that the two are not a pair. The derivation's result is not what is compared; the
727
+ * read is what it answers for. */
728
+ try { await compositeKem.publicFromPrivate(privDer); }
729
+ catch (e0) {
730
+ if (_isKemStructureFault(e0)) throw _err("key/bad-input", "the private key could not be read", e0);
731
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e0);
732
+ }
714
733
  if (_algorithmOidAt(spkiDer, 0) !== privateOid) return false;
715
734
  var sealed = null, opened = null;
716
735
  try {
717
- sealed = await compositeKem.encapsulate(spkiDer);
718
- opened = await compositeKem.decapsulate(privDer, sealed.ciphertext);
736
+ /** @internal A structure the KEM cannot read is bad input, the verdict an unreadable classical
737
+ * key gets from the runtime import above; a decapsulation that runs and fails is the pair not
738
+ * deciding. The KEM tells the two apart by its own codes, and so does this. */
739
+ try { sealed = await compositeKem.encapsulate(spkiDer); }
740
+ catch (e) {
741
+ if (_isKemStructureFault(e)) throw _err("key/bad-input", "the public key could not be read", e);
742
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
743
+ }
744
+ try { opened = await compositeKem.decapsulate(privDer, sealed.ciphertext); }
745
+ catch (e2) {
746
+ if (_isKemStructureFault(e2)) throw _err("key/bad-input", "the private key could not be read", e2);
747
+ throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e2);
748
+ }
719
749
  return guard.crypto.constantTimeEqual(opened, sealed.sharedSecret);
720
- } catch (e) {
721
- throw _err("key/unsupported-algorithm", "the key pair could not be exercised to decide whether its two halves correspond", e);
722
750
  } finally {
723
751
  guard.secret.zeroizeAll([sealed && sealed.sharedSecret, opened], KeyError, "key/bad-input", "the correspondence probe secrets");
724
752
  }
725
753
  }
726
754
 
755
+ var _KEM_STRUCTURE_FAULTS = _assign(_create(null), { "kem/bad-key": 1, "kem/bad-input": 1, "kem/bad-algorithm": 1, "kem/bad-ciphertext": 1 });
756
+
757
+ /** @internal Whether a KEM error says the key or ciphertext could not be READ, as opposed to a
758
+ * decapsulation that ran and failed. */
759
+ function _isKemStructureFault(e) {
760
+ return !!(e && e.isPkiError === true && _KEM_STRUCTURE_FAULTS[guard.text.keyOf(e.code)] === 1);
761
+ }
762
+
727
763
  async function publicFromPrivate(privateKey, opts) {
728
764
  opts = guard.identifier.optionsObject(opts, _err, "key/bad-input", "pki.key.publicFromPrivate options");
729
765
  guard.identifier.assertKnownKeys(opts, _PUBLIC_FROM_PRIVATE_OPTS, _err, "key/bad-input",
@@ -860,13 +896,15 @@ function _pssSalt(a, b) {
860
896
 
861
897
  async function correspondsTo(privateKey, spkiDer) {
862
898
  var privDer = await _toPrivateKeyDer(privateKey);
899
+ var spki = _bufferFrom(spkiDer);
863
900
  var compositeOid = _compositeKemOidOf(privDer);
864
- if (compositeOid !== null) return _compositeCorrespondsTo(privDer, _bufferFrom(spkiDer), compositeOid);
865
- /** @internal Whether the two halves are finite-field Diffie-Hellman is answered from their
866
- * algorithm identifiers before either structure is validated: a DH structure's validation proves
867
- * its modulus prime, and a half of another family is not a pair with it whatever it states, so
868
- * that proof is never run to say so. */
869
- if (_isDhOid(_algorithmOidAt(privDer, 1)) !== _isDhOid(_algorithmOidAt(_bufferFrom(spkiDer), 0))) return false;
901
+ if (compositeOid !== null) return _compositeCorrespondsTo(privDer, spki, compositeOid);
902
+ /** @internal The private half is read FIRST, whatever the public half names: a private key that
903
+ * cannot be read is bad input, and a caller tells that apart from a pair that is not one. Only
904
+ * then is the family question asked, from the two identifiers, before the public structure is
905
+ * validated: a composite or a finite-field half against a half of another family is not a pair
906
+ * whatever it states, so the runtime is never asked to read a structure it cannot, and a DH
907
+ * structure's validation, which proves its modulus prime, is never run to say so. */
870
908
  var priv, pub;
871
909
  /** @internal The PKCS #3 rewrite of an X9.42 private key is this function's own copy of the
872
910
  * exponent, wiped once the runtime holds the key. */
@@ -876,7 +914,10 @@ async function correspondsTo(privateKey, spkiDer) {
876
914
  try { priv = nodeCrypto.createPrivateKey({ key: privAsPkcs3, format: "der", type: "pkcs8" }); }
877
915
  catch (e) { throw _err("key/bad-input", "the private key could not be read", e); }
878
916
  } finally { if (privAsPkcs3 !== privDer) guard.secret.zeroize(privAsPkcs3, KeyError, "key/bad-input", "the rewritten private key"); }
879
- var pubAsPkcs3 = _dhAsPkcs3(_bufferFrom(spkiDer), 0, "the public key's");
917
+ var publicOid = _algorithmOidAt(spki, 0);
918
+ if (publicOid !== null && compositeKem.isCompositeKem(publicOid)) return false;
919
+ if (_isDhOid(_algorithmOidAt(privDer, 1)) !== _isDhOid(publicOid)) return false;
920
+ var pubAsPkcs3 = _dhAsPkcs3(spki, 0, "the public key's");
880
921
  _assertDhWithin(pubAsPkcs3, 0, "the public key's");
881
922
  try { pub = nodeCrypto.createPublicKey({ key: pubAsPkcs3, format: "der", type: "spki" }); }
882
923
  catch (e) { throw _err("key/bad-input", "the public key could not be read", e); }
@@ -1000,6 +1041,7 @@ module.exports = {
1000
1041
  x942Domain: x942Domain, // @internal
1001
1042
  pkcs3Params: pkcs3Params, // @internal
1002
1043
  isDhOid: _isDhOid, // @internal
1044
+ algorithmOidAt: _algorithmOidAt, // @internal
1003
1045
  assertDhOperandsWithin: assertDhOperandsWithin, // @internal
1004
1046
  assertDhDomainCoherent: assertDhDomainCoherent, // @internal
1005
1047
  assertDhGeneratorOrder: assertDhGeneratorOrder, // @internal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.7.40",
3
+ "version": "0.7.41",
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:19ec3e74-60d4-4102-bfdf-50fadfa1aeaa",
5
+ "serialNumber": "urn:uuid:2f774cf6-ab61-4b1c-8dab-ee0085cfb2a5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-12T19:13:54.735Z",
8
+ "timestamp": "2026-09-12T20:24:38.981Z",
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.7.40",
22
+ "bom-ref": "@blamejs/pki@0.7.41",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.7.40",
25
+ "version": "0.7.41",
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.7.40",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.7.41",
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.7.40",
57
+ "ref": "@blamejs/pki@0.7.41",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]