@blamejs/pki 0.6.52 → 0.6.54

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,34 @@ 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.6.54 — 2026-09-07
8
+
9
+ A holder of a composite ML-KEM key can enroll for it, proving possession by opening the challenge addressed to it.
10
+
11
+ ### Fixed
12
+
13
+ - pki.cmc.build completes an unsigned enrollment for a composite ML-KEM key (RFC 5272 App. C.1). The rule is unchanged: the challenge must be addressed to the key the request asks for, and a challenge opened by any other key licenses nothing. What changed is how the two are compared. A key the runtime engine cannot read has no key object to compare, so the composite SubjectPublicKeyInfo answers instead, which is sound because that encoding is canonical: fixed component widths under a single OID, with no compressed-point spelling for two forms of one key to differ over. That ambiguity is the reason the classical comparison uses key objects, and it does not arise here. Every other key type is compared exactly as before.
14
+
15
+ ## v0.6.53 — 2026-09-07
16
+
17
+ A composite ML-KEM private key states its public half, so a holder can name the recipient it is.
18
+
19
+ ### Added
20
+
21
+ - pki.key.publicFromPrivate derives the public key of a composite ML-KEM private key (draft-ietf-lamps-pq-composite-kem section 4.1). A composite private key is the ML-KEM seed followed by the traditional private key, and the public key is the ML-KEM encapsulation key followed by the traditional public key under the same OID. Each half states its own: the seed generates the encapsulation key, and the traditional half is read in the encoding its algorithm uses, an RSAPrivateKey, a SEC1 ECPrivateKey, or the raw scalar of an OKP curve. All twelve registered families are covered, and each derives byte-for-byte the public key the draft's own Appendix G test vectors publish for that key. opts.pem returns it armored, as it does for every other family.
22
+ - The derived key is the one the rest of the toolkit takes. It is the recipient pki.cms.encrypt accepts for a composite KEM recipient, and pki.kem.encapsulate establishes a shared secret for it that the private key it came from decapsulates to the same value.
23
+
24
+ ### Changed
25
+
26
+ - A composite ML-KEM private key reports why it cannot be read, rather than reporting that a public key could not be derived from it. An AlgorithmIdentifier carrying parameters is kem/bad-algorithm, since the draft requires them absent (section 5.3); a key no longer than the ML-KEM seed is kem/bad-key; and an OID no registered family claims is refused rather than guessed at. The private key material and the copy taken of it are wiped before the call returns, on the refusing paths as well as the deriving one.
27
+ - Each component is held to what the composite OID names it must be. The RSA component's modulus must be the size that family states, and the EC component must be on the curve it states: a SEC1 ECPrivateKey carries its own curve, and exporting the point of one curve under an OID naming another would state a public key nothing can use. Decapsulation reaches the same conclusion by failing the agreement, and a derivation performs no operation that would notice, so it is checked.
28
+ - Every copy of private key material the derivation allocates is cleared before it returns, on the refusing paths as well as the deriving one: the caller's snapshot, the key octets, and the ML-KEM seed re-encoded for the key engine to read. The EC component adds two more, the scalar taken out of the ECPrivateKey and the copy a width check rejects, and a decapsulation refused after its Diffie-Hellman has already run clears that agreement's secret before reporting the refusal.
29
+ - The EC component's point is generated from its scalar rather than read from the key, in the derivation and in pki.kem.decapsulate alike. RFC 5915 section 3 gives an ECPrivateKey an optional public point, which may be written compressed, and the composite public key carries the uncompressed point at a fixed width. The combiner hashes that public key, so the two sides read one function for it: a key storing a compressed point now establishes the same shared secret whichever spelling of it a sender encapsulated to. Generating the point also means the answer is the one the scalar produces, so a point stored beside a scalar that does not produce it does not reach the result.
30
+
31
+ ### Fixed
32
+
33
+ - The X25519 and X448 component of a composite ML-KEM key is imported through an encoding that is now cleared once the import has read it. That encoding holds the component's private scalar and is allocated inside the helper both pki.kem.decapsulate and the new derivation reach the curve through, so a decapsulation left a copy of it behind.
34
+
7
35
  ## v0.6.52 — 2026-09-07
8
36
 
9
37
  A CMP client can accept a key pair the CA generated for it, and refuses one from an authority that never claimed to be one.
package/lib/cmc-build.js CHANGED
@@ -17,6 +17,7 @@ var oid = require("./oid");
17
17
  var cmsSign = require("./cms-sign");
18
18
  var cmsEncrypt = require("./cms-encrypt");
19
19
  var cmsDecrypt = require("./cms-decrypt");
20
+ var compositeKem = require("./composite-kem");
20
21
  var key = require("./key");
21
22
  var _createPublicKey = nodeCrypto.createPublicKey;
22
23
  var guard = require("./guard-all");
@@ -518,19 +519,40 @@ function _requestIdentity(parsed) {
518
519
 
519
520
  /** @internal Whether the key that opened the challenge is the key the request asks to have certified.
520
521
  * Only a tcr is answered this way: a crm request carries its own proof-of-possession field. */
521
- function _provesRequestKey(openingKey, outgoing) {
522
- if (!openingKey || !outgoing || !outgoing.certificationRequestBytes) return false;
523
- var requested;
522
+ function _provesRequestKey(opening, outgoing) {
523
+ if (!opening || !outgoing || !outgoing.certificationRequestBytes) return false;
524
+ var requestedSpki;
525
+ try {
526
+ requestedSpki = csr.parse(outgoing.certificationRequestBytes).subjectPublicKeyInfo.bytes;
527
+ } catch (_e) { return false; } // allow:swallow-unverified a request this runtime cannot read proves nothing here
524
528
  /** @internal Compared as keys rather than as encodings: an elliptic-curve point written in compressed
525
529
  * form and the same point written uncompressed are different bytes and the same key, and it is the key
526
530
  * the section is about. */
531
+ if (opening.key !== null) {
532
+ var requested;
533
+ try {
534
+ requested = _createPublicKey({ key: requestedSpki, format: "der", type: "spki" });
535
+ } catch (_e) { return false; } // allow:swallow-unverified a key this runtime cannot read proves nothing here
536
+ return intrinsic.keyEquals(requested, opening.key);
537
+ }
538
+ /** @internal A key the runtime engine cannot read has no KeyObject to compare, and a composite
539
+ * ML-KEM key is one. Its encoding answers instead, which is sound because that encoding is
540
+ * canonical: fixed component widths under a single OID, with no compressed-point spelling for two
541
+ * forms of one key to differ over, which is the ambiguity the comparison above exists to absorb. */
542
+ if (!_isCompositeSpki(requestedSpki)) return false;
543
+ return intrinsic.bufferEquals(requestedSpki, opening.spki);
544
+ }
545
+
546
+ /** @internal Whether a SubjectPublicKeyInfo names a composite ML-KEM algorithm, read from its
547
+ * AlgorithmIdentifier alone. Anything this cannot read is not one. */
548
+ function _isCompositeSpki(spki) {
527
549
  try {
528
- requested = _createPublicKey({
529
- key: csr.parse(outgoing.certificationRequestBytes).subjectPublicKeyInfo.bytes,
530
- format: "der", type: "spki",
531
- });
532
- } catch (_e) { return false; } // allow:swallow-unverified a key this runtime cannot read proves nothing here
533
- return intrinsic.keyEquals(requested, openingKey);
550
+ var node = asn1.decode(spki);
551
+ if (!node.children || node.children.length !== 2) return false;
552
+ var alg = node.children[0];
553
+ if (!alg.children || alg.children.length < 1) return false;
554
+ return compositeKem.isCompositeKem(asn1.read.oid(alg.children[0]));
555
+ } catch (_e) { return false; } // allow:swallow-unverified an unreadable algorithm is not a composite one
534
556
  }
535
557
 
536
558
  /** @internal The public half of the key that OPENED the challenge. The recipient kind comes from the
@@ -539,11 +561,18 @@ function _provesRequestKey(openingKey, outgoing) {
539
561
  * yields null, and the request is then refused rather than accepted on an unproven key. */
540
562
  async function _openingPublicKey(recipient, recipientType) {
541
563
  if (!ASYMMETRIC_RECIPIENT[recipientType]) return null;
564
+ var spki;
542
565
  try {
543
- return _createPublicKey({
544
- key: await key.publicFromPrivate(recipient.key), format: "der", type: "spki",
545
- });
566
+ spki = await key.publicFromPrivate(recipient.key);
546
567
  } catch (_e) { return null; } // allow:swallow-unverified a key whose public half cannot be derived proves nothing here
568
+ /** @internal Both spellings of the answer: the KeyObject the comparison prefers, and the encoding a
569
+ * key the runtime engine cannot read has instead of one. A null `key` is not a failure; it says the
570
+ * engine does not know this algorithm, and the comparison below decides what that permits. */
571
+ var asKey;
572
+ try {
573
+ asKey = _createPublicKey({ key: spki, format: "der", type: "spki" });
574
+ } catch (_e) { asKey = null; } // allow:swallow-unverified a composite key the runtime cannot read is compared by its canonical bytes
575
+ return { spki: spki, key: asKey };
547
576
  }
548
577
 
549
578
  async function _recoverProofValue(decoded, recipient, witnessHash) {
@@ -102,9 +102,16 @@ function _okpPubFromRaw(d, raw) {
102
102
  var spki = _b.sequence([_b.sequence([_b.oid(oid.byName(d.trad.curve))]), _b.bitString(raw, 0)]);
103
103
  return nodeCrypto.createPublicKey({ key: spki, format: "der", type: "spki" });
104
104
  }
105
+ /** @internal The encoding is this helper's own allocation and holds the traditional private scalar, so
106
+ * it is cleared here. A caller's own cleanup cannot reach a buffer it never sees, and both the
107
+ * derivation and the decapsulation reach the curve through this one function. */
105
108
  function _okpPrivFromRaw(d, raw) {
106
109
  var p8 = _b.sequence([_b.integer(0n), _b.sequence([_b.oid(oid.byName(d.trad.curve))]), _b.octetString(_b.octetString(raw))]);
107
- return nodeCrypto.createPrivateKey({ key: p8, format: "der", type: "pkcs8" });
110
+ try {
111
+ return nodeCrypto.createPrivateKey({ key: p8, format: "der", type: "pkcs8" });
112
+ } finally {
113
+ guard.secret.zeroize(p8, KemError, "kem/bad-input", "the " + d.trad.curve + " component key encoding");
114
+ }
108
115
  }
109
116
  function _rawPubOf(keyObject) {
110
117
  var spki = keyObject.export({ format: "der", type: "spki" });
@@ -170,7 +177,20 @@ function _tradDecaps(d, tradSK, tradCT) {
170
177
  var dh;
171
178
  try { dh = Buffer.from(nodeCrypto.diffieHellman({ privateKey: priv2, publicKey: eph })); }
172
179
  catch (e) { return Promise.reject(_err("kem/decapsulation-failed", "the " + trad.curve + " Diffie-Hellman failed", e)); }
173
- return Promise.resolve({ ss: dh, tradPK: _rawPubOf(nodeCrypto.createPublicKey(priv2)) });
180
+ /** @internal The same canonical point the derivation states, read from the one function that spells
181
+ * it. The combiner hashes these bytes, so a stored spelling here and a generated one there would
182
+ * reach different secrets from the two sides of one key. */
183
+ var tradPK;
184
+ try {
185
+ tradPK = trad.kind === "ec" ? _ecPointOf(d, tradSK) : _rawPubOf(nodeCrypto.createPublicKey(priv2));
186
+ } catch (e) {
187
+ /** @internal The agreement already produced a secret, and this rejection happens after it, so the
188
+ * secret is wiped here: the caller's settle-or-wipe covers a value it was handed, and a rejected
189
+ * promise hands it nothing. */
190
+ guard.secret.zeroize(dh, KemError, "kem/bad-input", "the " + trad.curve + " component secret");
191
+ return Promise.reject(e);
192
+ }
193
+ return Promise.resolve({ ss: dh, tradPK: tradPK });
174
194
  }
175
195
 
176
196
  function _combine(d, mlkemSS, tradSS, tradCT, tradPK) {
@@ -309,8 +329,152 @@ async function _encapsulate(publicKey) {
309
329
 
310
330
  function _isCompositeKem(algOid) { return COMPOSITE_KEM[algOid] != null; }
311
331
 
332
+ /** @internal The EC component's public point, generated from its scalar. RFC 5915 sec. 3 gives an
333
+ * ECPrivateKey an optional public point which may be written compressed, and the key engine keeps
334
+ * whatever encoding it was handed. The composite concatenation carries the uncompressed point at a
335
+ * fixed width (draft sec. 4.1), and the combiner hashes those bytes, so BOTH the derivation and the
336
+ * decapsulation read this one function: a point spelled two ways would agree on the secret nowhere. */
337
+ function _ecPointOf(d, tradSK) {
338
+ /** @internal The scalar is a fresh copy of private key material, so it is wiped here rather than
339
+ * left for the caller's own copies to cover: those wipe the PKCS#8 and the key octets, and this
340
+ * buffer is neither. */
341
+ var scalar = _ecScalarOf(d, tradSK);
342
+ var point;
343
+ try {
344
+ var ecdh = nodeCrypto.createECDH(d.trad.node);
345
+ ecdh.setPrivateKey(scalar);
346
+ point = ecdh.getPublicKey(null, "uncompressed");
347
+ } catch (e) {
348
+ if (e instanceof KemError) throw e;
349
+ throw _err("kem/bad-key", "the EC component scalar does not generate a point on " + d.trad.node, e);
350
+ } finally {
351
+ guard.secret.zeroize(scalar, KemError, "kem/bad-input", "the EC component scalar");
352
+ }
353
+ if (point.length !== d.trad.ctLen) throw _err("kem/bad-key", "the EC component point is not the expected " + d.trad.ctLen + " bytes for " + d.name);
354
+ return point;
355
+ }
356
+
357
+ /** @internal The private scalar of a SEC1 ECPrivateKey (RFC 5915 sec. 3), left-padded to the field
358
+ * width the curve uses. The structure states the scalar as an OCTET STRING in its second field, and
359
+ * the point generation below is positional, so a short one is padded rather than shifted. */
360
+ function _ecScalarOf(d, tradSK) {
361
+ var width = (d.trad.ctLen - 1) / 2;
362
+ var raw;
363
+ try {
364
+ var node = asn1.decode(tradSK);
365
+ raw = Buffer.from(asn1.read.octetString(node.children[1]));
366
+ } catch (e) { throw _err("kem/bad-key", "the EC component private key is not a well-formed ECPrivateKey", e); }
367
+ /** @internal `raw` holds private key material. It is handed on only when it IS the returned buffer,
368
+ * which the caller then wipes; on every other exit, the padded one and the refusal alike, this is
369
+ * the last reference and the copy is cleared here. */
370
+ var handedOn = false;
371
+ try {
372
+ if (raw.length === 0 || raw.length > width) throw _err("kem/bad-key", "the EC component scalar is not a " + d.trad.node + " field element");
373
+ if (raw.length === width) { handedOn = true; return raw; }
374
+ var out = Buffer.alloc(width);
375
+ raw.copy(out, width - raw.length);
376
+ return out;
377
+ } finally {
378
+ if (!handedOn) guard.secret.zeroize(raw, KemError, "kem/bad-input", "the EC component scalar copy");
379
+ }
380
+ }
381
+
382
+ /** @internal The traditional component's public half, derived from its private half by the same route
383
+ * `_tradDecaps` takes to report `tradPK`. Each kind states its private key in its own encoding: an
384
+ * RSAPrivateKey, a SEC1 ECPrivateKey, or the raw scalar an OKP curve carries. */
385
+ function _tradPubOf(d, tradSK) {
386
+ var trad = d.trad;
387
+ if (trad.kind === "rsa") {
388
+ var rp;
389
+ try {
390
+ asn1.decode(tradSK);
391
+ rp = nodeCrypto.createPrivateKey({ key: tradSK, format: "der", type: "pkcs1" });
392
+ } catch (e) { throw _err("kem/bad-key", "the RSA component private key is not a valid RSAPrivateKey", e); }
393
+ if (rp.asymmetricKeyDetails.modulusLength !== trad.bits) {
394
+ throw _err("kem/bad-key", "the RSA component modulus is not " + trad.bits + " bits (draft sec. 6)");
395
+ }
396
+ return Buffer.from(nodeCrypto.createPublicKey(rp).export({ format: "der", type: "pkcs1" }));
397
+ }
398
+ var priv;
399
+ try {
400
+ if (trad.kind === "ec") {
401
+ asn1.decode(tradSK);
402
+ priv = nodeCrypto.createPrivateKey({ key: tradSK, format: "der", type: "sec1" });
403
+ } else {
404
+ priv = _okpPrivFromRaw(d, tradSK);
405
+ }
406
+ } catch (e) { throw _err("kem/bad-key", "the " + trad.curve + " key material is invalid", e); }
407
+ /** @internal A SEC1 ECPrivateKey names its own curve, and the composite OID names the curve the
408
+ * component must be on. Exporting the point of one curve under an OID that names another states a
409
+ * public key nothing can use, so the two are held to each other here. Decapsulation reaches the same
410
+ * conclusion by failing the agreement; a derivation performs no operation that would notice. */
411
+ if (trad.kind === "ec") {
412
+ var named = priv.asymmetricKeyDetails && priv.asymmetricKeyDetails.namedCurve;
413
+ if (named !== trad.node) {
414
+ throw _err("kem/bad-key", "the EC component is on " + guard.text.showValue(named) + ", and " + d.name + " names " + trad.node);
415
+ }
416
+ /** @internal Recomputed from the scalar rather than exported. RFC 5915 gives an ECPrivateKey an
417
+ * optional public point, the key engine keeps whatever encoding it was handed, and a compressed
418
+ * point is neither the width nor the form the composite concatenation carries (draft sec. 4.1).
419
+ * Generating the point from the scalar answers with the key this component actually holds, so a
420
+ * point stored beside it cannot stand in for one it does not generate. */
421
+ return _ecPointOf(d, tradSK);
422
+ }
423
+ return _rawPubOf(nodeCrypto.createPublicKey(priv));
424
+ }
425
+
426
+ /** @internal The composite `SubjectPublicKeyInfo` for a composite ML-KEM private key. The runtime key
427
+ * decoder does not know a composite OID, so a caller deriving one cannot go through it; the key is
428
+ * split the way `_decapsulate` splits it (draft sec. 4.1: `seed || tradSK`) and each half states its
429
+ * own public key, concatenated under the same OID as `mlkemEK || tradPK` (sec. 5.1). The ML-KEM half
430
+ * is recovered by importing the seed and reading the public key it generates, so a stored public copy
431
+ * could not answer in its place. The private material is wiped on every path. */
432
+ async function _publicFromPrivate(privateKey) {
433
+ var pkcs8 = _toDer(privateKey, "PRIVATE KEY", "the composite ML-KEM private key");
434
+ /** @internal Every buffer this derivation allocates that holds private key material is registered
435
+ * here and cleared once, on every exit. A copy added later is covered by registering it rather than
436
+ * by a further nested block, which is how the ML-KEM seed encoding came to outlive two of them. */
437
+ var secrets = [];
438
+ guard.list.append(secrets, pkcs8);
439
+ var d;
440
+ try {
441
+ var sk;
442
+ try {
443
+ var info = schemaPkcs8.parse(pkcs8);
444
+ var alg = info.privateKeyAlgorithm;
445
+ d = _descriptorForAlg(alg.oid, alg.parameters !== null);
446
+ sk = Buffer.from(info.privateKey);
447
+ guard.list.append(secrets, sk);
448
+ } catch (e) {
449
+ if (e instanceof KemError) throw e;
450
+ if (e && e.code === "pkcs8/bad-algorithm-parameters") throw _err("kem/bad-algorithm", "a composite ML-KEM AlgorithmIdentifier MUST have absent parameters (draft sec. 5.3)", e);
451
+ throw _err("kem/bad-key", "the composite ML-KEM private key is not a well-formed OneAsymmetricKey", e);
452
+ }
453
+ if (sk.length <= MLKEM_SEED) throw _err("kem/bad-key", "the composite private key is shorter than the ML-KEM seed");
454
+ var seed = sk.subarray(0, MLKEM_SEED), tradSK = sk.subarray(MLKEM_SEED);
455
+ var ek;
456
+ try {
457
+ /** @internal The seed re-encoded as a OneAsymmetricKey the key engine reads, which is a second
458
+ * copy of the seed and is registered as one. */
459
+ var seedPkcs8 = _mlkemSeedPkcs8(d, seed);
460
+ guard.list.append(secrets, seedPkcs8);
461
+ var mlPriv = nodeCrypto.createPrivateKey({ key: seedPkcs8, format: "der", type: "pkcs8" });
462
+ ek = _rawPubOf(nodeCrypto.createPublicKey(mlPriv));
463
+ } catch (e) {
464
+ if (e instanceof KemError) throw e;
465
+ throw _err("kem/bad-key", "the ML-KEM component private key is invalid", e);
466
+ }
467
+ if (ek.length !== d.mlkemEk) throw _err("kem/bad-key", "the ML-KEM component public key is not the expected " + d.mlkemEk + " bytes for " + d.name);
468
+ var composite = Buffer.concat([ek, _tradPubOf(d, tradSK)]);
469
+ return _b.sequence([_b.sequence([_b.oid(oid.byName(d.name))]), _b.bitString(composite, 0)]);
470
+ } finally {
471
+ guard.secret.zeroizeAll(secrets, KemError, "kem/bad-input", "the composite private key material");
472
+ }
473
+ }
474
+
312
475
  module.exports = {
313
476
  encapsulate: _encapsulate,
314
477
  decapsulate: _decapsulate,
315
478
  isCompositeKem: _isCompositeKem,
479
+ publicFromPrivate: _publicFromPrivate,
316
480
  };
package/lib/key.js CHANGED
@@ -26,6 +26,7 @@ var pbes2 = require("./pbes2");
26
26
  var pkcs8 = require("./schema-pkcs8");
27
27
  var pkix = require("./schema-pkix");
28
28
  var webcrypto = require("./webcrypto");
29
+ var compositeKem = require("./composite-kem");
29
30
  var frameworkError = require("./framework-error");
30
31
  var guard = require("./guard-all");
31
32
 
@@ -311,7 +312,7 @@ async function generate(algorithm, opts) {
311
312
  * @signature pki.key.publicFromPrivate(privateKey, opts?) -> Promise<Buffer|string>
312
313
  * @since 0.3.10
313
314
  * @status stable
314
- * @spec RFC 5280 sec. 4.1.2.7, RFC 8410 sec. 3
315
+ * @spec RFC 5280 sec. 4.1.2.7, RFC 8410 sec. 3, draft-ietf-lamps-pq-composite-kem sec. 4.1
315
316
  * @related pki.key.export, pki.key.import
316
317
  *
317
318
  * Derive the SubjectPublicKeyInfo (SPKI) public key from a PKCS#8 private key (DER `Buffer`, `PRIVATE KEY`
@@ -319,6 +320,13 @@ async function generate(algorithm, opts) {
319
320
  * the algorithm from the key structure, so no `AlgorithmIdentifier` is re-encoded: Ed25519 stays
320
321
  * parameters-absent, RSA keeps its NULL, EC keeps its namedCurve.
321
322
  *
323
+ * A composite ML-KEM key is derived from its two components instead, since the node key engine does not
324
+ * read a composite algorithm. The private key is `seed || tradSK` and the public key is
325
+ * `mlkemEK || tradPK` under the same OID: the seed states the ML-KEM encapsulation key it generates, the
326
+ * traditional half states its own public key, and the two are concatenated. An AlgorithmIdentifier
327
+ * carrying parameters, a key no longer than the ML-KEM seed, and an unregistered composite OID are each
328
+ * refused with a typed `KemError`.
329
+ *
322
330
  * @opts
323
331
  * - `pem` (boolean) -- return a `PUBLIC KEY` PEM string instead of DER.
324
332
  * @example
@@ -327,12 +335,40 @@ async function generate(algorithm, opts) {
327
335
  */
328
336
  var _PUBLIC_FROM_PRIVATE_OPTS = Object.assign(Object.create(null), { pem: 1 });
329
337
 
338
+ /** @internal The composite ML-KEM OID a private key names, or null when it names anything else. Read
339
+ * from the AlgorithmIdentifier alone: a key that is not well formed enough to state one is left to the
340
+ * decoder below, which reports it. */
341
+ function _compositeKemOidOf(der) {
342
+ var algOid;
343
+ try {
344
+ /** @internal The AlgorithmIdentifier OID only, read without the strict OneAsymmetricKey rules. A
345
+ * composite key that breaks one of those rules is still a composite key, and the module that owns
346
+ * the algorithm is the one that should say so: parsing strictly here would send a composite key
347
+ * carrying parameters to the runtime decoder, which reports that it cannot read the key rather
348
+ * than that the parameters are not allowed (draft sec. 5.3). */
349
+ var node = asn1.decode(der);
350
+ if (!node.children || node.children.length < 3) return null;
351
+ var alg = node.children[1];
352
+ if (!alg.children || alg.children.length < 1) return null;
353
+ algOid = asn1.read.oid(alg.children[0]);
354
+ } catch (_e) { return null; }
355
+ return typeof algOid === "string" && compositeKem.isCompositeKem(algOid) ? algOid : null;
356
+ }
357
+
330
358
  async function publicFromPrivate(privateKey, opts) {
331
359
  opts = guard.identifier.optionsObject(opts, _err, "key/bad-input", "pki.key.publicFromPrivate options");
332
360
  guard.identifier.assertKnownKeys(opts, _PUBLIC_FROM_PRIVATE_OPTS, _err, "key/bad-input",
333
361
  "pki.key.publicFromPrivate has an unknown option: ");
334
362
  var der = await _toPrivateKeyDer(privateKey);
335
363
  var spki;
364
+ /** @internal A composite ML-KEM key is a toolkit-defined algorithm the runtime decoder cannot read,
365
+ * so it is routed before the decoder rather than after it fails. Its own module owns the split and
366
+ * the wipe, and reports its own typed reasons. */
367
+ var compositeOid = _compositeKemOidOf(der);
368
+ if (compositeOid !== null) {
369
+ spki = await compositeKem.publicFromPrivate(der);
370
+ return opts.pem ? pkix.pemEncode(spki, "PUBLIC KEY", PemError) : spki;
371
+ }
336
372
  try {
337
373
  var priv = nodeCrypto.createPrivateKey({ key: der, format: "der", type: "pkcs8" });
338
374
  spki = nodeCrypto.createPublicKey(priv).export({ format: "der", type: "spki" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.6.52",
3
+ "version": "0.6.54",
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:5de9cc32-97b6-4f34-a868-7ad628091d11",
5
+ "serialNumber": "urn:uuid:3fcb367d-0079-41f4-bd25-0141050006d7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-09-07T15:01:14.383Z",
8
+ "timestamp": "2026-09-07T18:01:28.536Z",
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.6.52",
22
+ "bom-ref": "@blamejs/pki@0.6.54",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.6.52",
25
+ "version": "0.6.54",
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.6.52",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.6.54",
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.6.52",
57
+ "ref": "@blamejs/pki@0.6.54",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]