@blamejs/pki 0.4.12 → 0.4.14

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
@@ -467,25 +467,31 @@ SubtleCrypto.prototype.encrypt = async function encrypt(algorithm, key, data) {
467
467
  var iv = _toBuf(alg.iv, "AES-GCM iv");
468
468
  var aad = alg.additionalData ? _toBuf(alg.additionalData, "AES-GCM aad") : null;
469
469
  return _toArrayBuffer(_runCipher(function () {
470
- var cipher = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-gcm", _secretBytes(key), iv, { authTagLength: (alg.tagLength || 128) / 8 });
471
- if (aad) cipher.setAAD(aad);
472
- var ct = Buffer.concat([cipher.update(buf), cipher.final()]);
473
- return Buffer.concat([ct, cipher.getAuthTag()]);
470
+ return _withSecretBytes(key, function (kb) {
471
+ var cipher = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-gcm", kb, iv, { authTagLength: (alg.tagLength || 128) / 8 });
472
+ if (aad) cipher.setAAD(aad);
473
+ var ct = Buffer.concat([cipher.update(buf), cipher.final()]);
474
+ return Buffer.concat([ct, cipher.getAuthTag()]);
475
+ });
474
476
  }, "encrypt"));
475
477
  }
476
478
  if (name === "AES-CBC") {
477
479
  var cbcIv = _toBuf(alg.iv, "AES-CBC iv");
478
480
  return _toArrayBuffer(_runCipher(function () {
479
- var c2 = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-cbc", _secretBytes(key), cbcIv);
480
- return Buffer.concat([c2.update(buf), c2.final()]);
481
+ return _withSecretBytes(key, function (kb) {
482
+ var c2 = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-cbc", kb, cbcIv);
483
+ return Buffer.concat([c2.update(buf), c2.final()]);
484
+ });
481
485
  }, "encrypt"));
482
486
  }
483
487
  if (name === "AES-CTR") {
484
488
  _requireCtrLength128(alg);
485
489
  var ctrCounter = _toBuf(alg.counter, "AES-CTR counter");
486
490
  return _toArrayBuffer(_runCipher(function () {
487
- var c3 = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-ctr", _secretBytes(key), ctrCounter);
488
- return Buffer.concat([c3.update(buf), c3.final()]);
491
+ return _withSecretBytes(key, function (kb) {
492
+ var c3 = nodeCrypto.createCipheriv("aes-" + key.algorithm.length + "-ctr", kb, ctrCounter);
493
+ return Buffer.concat([c3.update(buf), c3.final()]);
494
+ });
489
495
  }, "encrypt"));
490
496
  }
491
497
  throw new WebCryptoError("webcrypto/not-supported", "encrypt: unsupported algorithm " + JSON.stringify(name));
@@ -499,11 +505,16 @@ SubtleCrypto.prototype.decrypt = async function decrypt(algorithm, key, data) {
499
505
  if (!ENCRYPT_DECRYPT_NAMES[name]) throw new WebCryptoError("webcrypto/not-supported", "decrypt: unsupported algorithm " + JSON.stringify(name));
500
506
  _requireAlgMatch(alg, key, "decrypt");
501
507
  if (name === "RSA-OAEP") {
502
- return _toArrayBuffer(nodeCrypto.privateDecrypt({
508
+ // For a CMS key-transport recipient this plaintext IS the recovered content-encryption key, and
509
+ // _toArrayBuffer copies it -- so the node-allocated buffer is finished with the moment the copy
510
+ // exists, and nothing outside this function can reach it to clear it.
511
+ var oaepOut = nodeCrypto.privateDecrypt({
503
512
  key: key._handle, padding: nodeCrypto.constants.RSA_PKCS1_OAEP_PADDING,
504
513
  oaepHash: _hashNode(key.algorithm.hash, "decrypt"),
505
514
  oaepLabel: alg.label ? _toBuf(alg.label, "decrypt label") : undefined,
506
- }, buf));
515
+ }, buf);
516
+ try { return _toArrayBuffer(oaepOut); }
517
+ finally { guard.secret.zeroize(oaepOut, WebCryptoError, "webcrypto/operation", "the RSA-OAEP decryption output"); }
507
518
  }
508
519
  if (name === "AES-GCM") {
509
520
  var tagLen = (alg.tagLength || 128) / 8;
@@ -513,31 +524,71 @@ SubtleCrypto.prototype.decrypt = async function decrypt(algorithm, key, data) {
513
524
  var tag = buf.subarray(buf.length - tagLen);
514
525
  var gcmAad = alg.additionalData ? _toBuf(alg.additionalData, "AES-GCM aad") : null;
515
526
  return _toArrayBuffer(_runCipher(function () {
516
- var d = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-gcm", _secretBytes(key), iv, { authTagLength: tagLen });
517
- if (gcmAad) d.setAAD(gcmAad);
518
- d.setAuthTag(tag);
519
- return Buffer.concat([d.update(ct), d.final()]);
527
+ return _withSecretBytes(key, function (kb) {
528
+ var d = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-gcm", kb, iv, { authTagLength: tagLen });
529
+ if (gcmAad) d.setAAD(gcmAad);
530
+ d.setAuthTag(tag);
531
+ return Buffer.concat([d.update(ct), d.final()]);
532
+ });
520
533
  }, "decrypt"));
521
534
  }
522
535
  if (name === "AES-CBC") {
523
536
  var cbcIv2 = _toBuf(alg.iv, "AES-CBC iv");
524
537
  return _toArrayBuffer(_runCipher(function () {
525
- var d2 = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-cbc", _secretBytes(key), cbcIv2);
526
- return Buffer.concat([d2.update(buf), d2.final()]);
538
+ return _withSecretBytes(key, function (kb) {
539
+ var d2 = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-cbc", kb, cbcIv2);
540
+ return Buffer.concat([d2.update(buf), d2.final()]);
541
+ });
527
542
  }, "decrypt"));
528
543
  }
529
544
  if (name === "AES-CTR") {
530
545
  _requireCtrLength128(alg);
531
546
  var ctrCounter2 = _toBuf(alg.counter, "AES-CTR counter");
532
547
  return _toArrayBuffer(_runCipher(function () {
533
- var d3 = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-ctr", _secretBytes(key), ctrCounter2);
534
- return Buffer.concat([d3.update(buf), d3.final()]);
548
+ return _withSecretBytes(key, function (kb) {
549
+ var d3 = nodeCrypto.createDecipheriv("aes-" + key.algorithm.length + "-ctr", kb, ctrCounter2);
550
+ return Buffer.concat([d3.update(buf), d3.final()]);
551
+ });
535
552
  }, "decrypt"));
536
553
  }
537
554
  throw new WebCryptoError("webcrypto/not-supported", "decrypt: unsupported algorithm " + JSON.stringify(name));
538
555
  };
539
556
 
540
- function _secretBytes(key) { return key._handle.export(); }
557
+ // _withSecretBytes(key, fn) -- export a secret key's raw material, hand it to fn, and wipe
558
+ // the export the moment fn is done with it (NIST SP 800-227 RS5 / sec. 4.2, RFC 9629 sec. 7).
559
+ //
560
+ // export() allocates a FRESH Buffer this module owns; the CryptoKey keeps its own copy inside
561
+ // the node KeyObject, so wiping the export never destroys the caller's key or the key itself.
562
+ // Node's cipher and KDF constructors copy the key into their own context, so the export's
563
+ // useful life ends when the operation returns.
564
+ //
565
+ // This is the ONLY way to reach raw secret key material: there is deliberately no unwiped
566
+ // `_secretBytes(key)` primitive to call, because a new consumer that forgot the wipe was the
567
+ // live defect this replaced -- the AES content-encryption paths and two of the three KDF arms
568
+ // each exported the key and left it readable while their sibling arms did not.
569
+ //
570
+ // When fn returns a PROMISE the wipe rides the settlement rather than the callback's return.
571
+ // Node's async primitives copy their inputs when the job is queued, so an eager wipe would not
572
+ // corrupt today's derivations -- but that is an implementation detail of the provider, not a
573
+ // documented guarantee, and the export's useful life is the operation's, not the callback's.
574
+ // Deferring costs nothing and keeps the rule true for any future asynchronous consumer.
575
+ function _withSecretBytes(key, fn) {
576
+ var kb = key._handle.export();
577
+ // Set once the export's lifetime has been handed to the promise handlers below, so the
578
+ // `finally` does not clear a buffer the pending operation is still entitled to.
579
+ var deferred = false;
580
+ try {
581
+ var out = fn(kb);
582
+ if (out && typeof out.then === "function") {
583
+ deferred = true;
584
+ return out.then(function (v) { _wipeExport(kb); return v; }, function (e) { _wipeExport(kb); throw e; });
585
+ }
586
+ return out;
587
+ } finally {
588
+ if (!deferred) guard.secret.zeroize(kb, WebCryptoError, "webcrypto/operation", "the exported key material");
589
+ }
590
+ }
591
+ function _wipeExport(kb) { guard.secret.zeroize(kb, WebCryptoError, "webcrypto/operation", "the exported key material"); }
541
592
 
542
593
  // AES-CTR: node always treats the full 128-bit block as the counter and
543
594
  // never reads the spec's `length` (counter-width) parameter. A length < 128
@@ -595,33 +646,62 @@ async function _deriveBitsRaw(alg, key, length) {
595
646
  if (name === "ECDH" || name === "X25519" || name === "X448") {
596
647
  _requireAlgMatch(alg, alg.public, name + " public key");
597
648
  _requireOwnKey(alg.public, "deriveBits public key");
649
+ // The provider hands back z (ECDH) / mz (X25519, X448) in a Buffer this module owns. It is
650
+ // the raw key-agreement shared secret -- the same class of material as a KEM shared secret --
651
+ // so it is wiped once the caller's copy exists, on EVERY exit including the over-request
652
+ // throw. _toArrayBuffer copies via ArrayBuffer.slice, so the returned bits are unaffected.
598
653
  var secret = nodeCrypto.diffieHellman({ privateKey: key._handle, publicKey: alg.public._handle });
599
- if (length == null) return _toArrayBuffer(secret);
600
- _requireDeriveLength(length, name);
601
- // subarray clamps at the end of the secret, so an unchecked over-request
602
- // would silently return fewer bytes than asked. W3C deriveBits: throw an
603
- // OperationError when the requested length cannot be satisfied.
604
- if (length / 8 > secret.length) {
605
- throw new WebCryptoError("webcrypto/operation", name + ": requested " + length + " bits but the shared secret has " + (secret.length * 8));
654
+ try {
655
+ if (length == null) return _toArrayBuffer(secret);
656
+ _requireDeriveLength(length, name);
657
+ // subarray clamps at the end of the secret, so an unchecked over-request
658
+ // would silently return fewer bytes than asked. W3C deriveBits: throw an
659
+ // OperationError when the requested length cannot be satisfied.
660
+ if (length / 8 > secret.length) {
661
+ throw new WebCryptoError("webcrypto/operation", name + ": requested " + length + " bits but the shared secret has " + (secret.length * 8));
662
+ }
663
+ return _toArrayBuffer(secret.subarray(0, length / 8));
664
+ } finally {
665
+ guard.secret.zeroize(secret, WebCryptoError, "webcrypto/operation", "the raw key-agreement shared secret");
606
666
  }
607
- return _toArrayBuffer(secret.subarray(0, length / 8));
608
667
  }
609
668
  if (name === "HKDF") {
610
669
  _requireDeriveLength(length, "HKDF");
611
- var derived = nodeCrypto.hkdfSync(_hashNode(alg.hash, "HKDF"), _secretBytes(key), _toBuf(alg.salt, "HKDF salt"), _toBuf(alg.info || Buffer.alloc(0), "HKDF info"), length / 8);
612
- return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
670
+ // The export is a fresh Buffer this module owns -- for a KEM flow it is the shared secret
671
+ // itself. A controllable allocation, not one of the runtime-internal copies the best-effort
672
+ // caveat covers, so it is wiped once the derivation has consumed it.
673
+ return _withSecretBytes(key, function (ikm) {
674
+ var derived = nodeCrypto.hkdfSync(_hashNode(alg.hash, "HKDF"), ikm, _toBuf(alg.salt, "HKDF salt"), _toBuf(alg.info || Buffer.alloc(0), "HKDF info"), length / 8);
675
+ return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
676
+ });
613
677
  }
614
678
  if (name === "PBKDF2") {
615
679
  _requireDeriveLength(length, "PBKDF2");
616
- var out = await _pbkdf2Async(_secretBytes(key), _toBuf(alg.salt, "PBKDF2 salt"), alg.iterations, length / 8, _hashNode(alg.hash, "PBKDF2"));
617
- return _toArrayBuffer(out);
680
+ // The derivation is asynchronous; _withSecretBytes defers the wipe to the promise's
681
+ // settlement so the export outlives the operation rather than the callback.
682
+ return _withSecretBytes(key, function (pw) {
683
+ return _pbkdf2Async(pw, _toBuf(alg.salt, "PBKDF2 salt"), alg.iterations, length / 8, _hashNode(alg.hash, "PBKDF2"))
684
+ .then(function (out) {
685
+ // _toArrayBuffer COPIES, so the node-allocated derivation buffer is finished with the
686
+ // moment the copy exists -- and nothing else can reach it to clear it.
687
+ try { return _toArrayBuffer(out); }
688
+ finally { guard.secret.zeroize(out, WebCryptoError, "webcrypto/operation", "the PBKDF2 output"); }
689
+ });
690
+ });
618
691
  }
619
692
  if (name === "X963KDF") {
620
693
  // ANSI-X9.63 / SEC1 sec. 3.6.1 single-step KDF: K = H(Z || INT32(counter) || SharedInfo)
621
694
  // concatenated over counter = 1, 2, ... The RFC 5753 kari KEK derivation; the base key holds
622
695
  // the ECDH shared secret Z, alg.info holds the DER ECC-CMS-SharedInfo.
623
696
  _requireDeriveLength(length, "X963KDF");
624
- return _toArrayBuffer(_x963Kdf(_hashNode(alg.hash, "X963KDF"), _secretBytes(key), _toBuf(alg.info || Buffer.alloc(0), "X963KDF SharedInfo"), length / 8));
697
+ // The base key here HOLDS the ECDH shared secret Z of an RFC 5753 kari, so this export is
698
+ // exactly as sensitive as the raw agreement secret wiped above.
699
+ return _withSecretBytes(key, function (z) {
700
+ var derived = _x963Kdf(_hashNode(alg.hash, "X963KDF"), z, _toBuf(alg.info || Buffer.alloc(0), "X963KDF SharedInfo"), length / 8);
701
+ // Same as PBKDF2 above: the copy is what the caller receives, so the derived buffer is cleared.
702
+ try { return _toArrayBuffer(derived); }
703
+ finally { guard.secret.zeroize(derived, WebCryptoError, "webcrypto/operation", "the X9.63 KDF output"); }
704
+ });
625
705
  }
626
706
  throw new WebCryptoError("webcrypto/not-supported", "deriveBits: unsupported algorithm " + JSON.stringify(name));
627
707
  }
@@ -636,7 +716,16 @@ function _x963Kdf(hashNode, z, sharedInfo, lenBytes) {
636
716
  var h = nodeCrypto.createHash(hashNode).update(z).update(ctr).update(sharedInfo).digest();
637
717
  blocks.push(h); got += h.length; counter += 1;
638
718
  }
639
- return Buffer.concat(blocks).subarray(0, lenBytes);
719
+ // Return an exact-sized buffer the caller wholly owns, not a VIEW over the joined blocks: a
720
+ // caller clearing what it received would otherwise leave the unused tail of the final digest
721
+ // block -- derived from the shared secret -- readable behind it. The accumulator and the
722
+ // per-block digests are cleared here, where they were allocated.
723
+ var joined = Buffer.concat(blocks);
724
+ var exact = Buffer.alloc(lenBytes);
725
+ joined.copy(exact, 0, 0, lenBytes);
726
+ guard.secret.zeroize(joined, WebCryptoError, "webcrypto/operation", "the KDF accumulator");
727
+ for (var bi = 0; bi < blocks.length; bi++) guard.secret.zeroize(blocks[bi], WebCryptoError, "webcrypto/operation", "a KDF block");
728
+ return exact;
640
729
  }
641
730
 
642
731
  SubtleCrypto.prototype.deriveBits = async function deriveBits(algorithm, key, length) {
@@ -678,8 +767,12 @@ SubtleCrypto.prototype.deriveKey = async function deriveKey(algorithm, baseKey,
678
767
  // material; a KDF base has no implicit output size and fails closed.
679
768
  bits = dk.length != null ? dk.length : null;
680
769
  }
770
+ // The derived bits are the KEY -- importKey copies them into a KeyObject, so this buffer is a
771
+ // transient copy nothing else can reach once the CryptoKey exists, and it is cleared on the
772
+ // failing import too (a bad derivedKeyType is caller-controlled).
681
773
  var raw = await _deriveBitsRaw(alg, baseKey, bits);
682
- return this.importKey("raw", raw, dk, extractable, keyUsages);
774
+ try { return await this.importKey("raw", raw, dk, extractable, keyUsages); }
775
+ finally { guard.secret.zeroize(new Uint8Array(raw), WebCryptoError, "webcrypto/operation", "the derived key material"); }
683
776
  };
684
777
 
685
778
  // ML-KEM (FIPS 203) key encapsulation over Node's crypto.encapsulate/decapsulate.
@@ -701,7 +794,18 @@ SubtleCrypto.prototype.encapsulateBits = async function encapsulateBits(algorith
701
794
  var r;
702
795
  try { r = nodeCrypto.encapsulate(encapsulationKey._handle); }
703
796
  catch (e) { throw new WebCryptoError("webcrypto/operation", "encapsulateBits: ML-KEM encapsulation failed", e); }
704
- return { sharedKey: _toArrayBuffer(Buffer.from(r.sharedKey)), ciphertext: _toArrayBuffer(Buffer.from(r.ciphertext)) };
797
+ try {
798
+ // _toArrayBuffer copies via ArrayBuffer.slice, so the shared key is passed straight through --
799
+ // an intermediate Buffer.from would be another copy of the secret that nothing wipes. The
800
+ // ciphertext is public and needs no such care, but it is copied the same way for symmetry.
801
+ return { sharedKey: _toArrayBuffer(r.sharedKey), ciphertext: _toArrayBuffer(r.ciphertext) };
802
+ } finally {
803
+ // Encapsulation produces a shared secret exactly as decapsulation does, so it owes the same
804
+ // duty: the provider's buffer is wiped once the caller's copy exists (NIST SP 800-227 RS5 /
805
+ // sec. 4.2, RFC 9629 sec. 7). Wiping only the decapsulation side would make the guarantee a
806
+ // half-truth -- the sender holds the same secret the recipient does.
807
+ guard.secret.zeroize(r.sharedKey, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
808
+ }
705
809
  };
706
810
 
707
811
  SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorithm, decapsulationKey, ciphertext) {
@@ -711,15 +815,48 @@ SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorith
711
815
  _requireAlgMatch(alg, decapsulationKey, "decapsulateBits");
712
816
  if (decapsulationKey.type !== "private") throw new WebCryptoError("webcrypto/invalid-access", "decapsulateBits requires a private (decapsulation) key, got " + JSON.stringify(decapsulationKey.type));
713
817
  var ct = _toBuf(ciphertext, "decapsulateBits ciphertext");
818
+ // FIPS 203 sec. 7.3 makes the ciphertext-length check the ONE per-execution input check a
819
+ // decapsulating party owes, so it belongs here, at the engine boundary, rather than only in the
820
+ // format module that happens to call this today: a direct caller -- or a future composite-KEM or
821
+ // HPKE consumer -- inherits nothing from a check that lives in cms-decrypt. A distinct code names
822
+ // the real reason, which "the operation failed" cannot.
823
+ //
824
+ // Length ONLY. A correct-length ciphertext that has been tampered with must still resolve to a
825
+ // pseudo-random shared secret (the Fujisaki-Okamoto implicit rejection of FIPS 203 sec. 6.3);
826
+ // turning that into a throw would hand an attacker a decryption oracle, and it is the property
827
+ // the CMS uniform verdict is built on.
828
+ // The registry is keyed by the registered OID name ("id-ml-kem-768"), which is the node
829
+ // algorithm name this module already maps ("ml-kem-768") under its id- prefix.
830
+ var kemRow = oid.kemParams("id-" + ML_KEM_NODE[alg.name]);
831
+ if (kemRow && ct.length !== kemRow.ct) {
832
+ throw new WebCryptoError("webcrypto/bad-kem-ciphertext",
833
+ "decapsulateBits: " + alg.name + " expects a " + kemRow.ct + "-octet ciphertext, got " + ct.length + " (FIPS 203 sec. 7.3)");
834
+ }
714
835
  var ss;
715
836
  try { ss = nodeCrypto.decapsulate(decapsulationKey._handle, ct); }
716
837
  catch (e) { throw new WebCryptoError("webcrypto/operation", "decapsulateBits: ML-KEM decapsulation failed (malformed or wrong-length ciphertext)", e); }
717
- return _toArrayBuffer(Buffer.from(ss));
838
+ try {
839
+ // ss is already a Buffer, and _toArrayBuffer copies via ArrayBuffer.slice -- so it is passed
840
+ // straight through. An intermediate Buffer.from(ss) would be a THIRD copy of the secret that
841
+ // nothing wipes, which would give back most of what the wipe below is for.
842
+ return _toArrayBuffer(ss);
843
+ } finally {
844
+ // The shared secret is returned as a COPY, so the buffer the provider handed back would stay
845
+ // readable until collection -- and a caller wiping only what it receives would leave the
846
+ // original behind, which is the whole secret. Wiping here means the engine owns the lifetime of
847
+ // the buffer it allocated, and every caller (CMS today, a composite KEM or HPKE later) inherits
848
+ // it rather than each having to remember (NIST SP 800-227 RS5 / sec. 4.2).
849
+ guard.secret.zeroize(ss, WebCryptoError, "webcrypto/operation", "the KEM shared secret");
850
+ }
718
851
  };
719
852
 
720
853
  SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey, wrapAlgorithm) {
854
+ // is the PLAINTEXT serialization of the key being wrapped -- the very material the wrap
855
+ // exists to protect. It is this function's allocation and is cleared once the wrap has consumed
856
+ // it, on the delegated branch as well as the AES-KW one.
721
857
  var exported = await this.exportKey(format, key);
722
858
  var bytes = (format === "jwk") ? Buffer.from(JSON.stringify(exported)) : Buffer.from(exported);
859
+ try {
723
860
  var alg = _normalizeAlg(wrapAlgorithm, "wrapKey");
724
861
  _requireUsage(wrappingKey, "wrapKey");
725
862
  if (alg.name !== "AES-KW" && !ENCRYPT_DECRYPT_NAMES[alg.name]) throw new WebCryptoError("webcrypto/not-supported", "wrapKey: unsupported algorithm " + JSON.stringify(alg.name));
@@ -731,14 +868,27 @@ SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey
731
868
  if (bytes.length < 16 || bytes.length % 8 !== 0) {
732
869
  throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW requires the serialized key be a multiple of 8 bytes (>= 16); got " + bytes.length + " -- format " + JSON.stringify(format) + " is not AES-KW-wrappable");
733
870
  }
871
+ // The mirror of unwrapKey below: in a KEM flow this export is the SENDER's copy of the same
872
+ // key-encryption key, so leaving it unwiped would keep a full copy of the KEK alive for the
873
+ // process lifetime and make the wipes the CMS layer performs pointless in the encrypt direction.
734
874
  try {
735
- var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", _secretBytes(wrappingKey), Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
736
- return _toArrayBuffer(Buffer.concat([c.update(bytes), c.final()]));
875
+ return _withSecretBytes(wrappingKey, function (wkBytes) {
876
+ var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", wkBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
877
+ return _toArrayBuffer(Buffer.concat([c.update(bytes), c.final()]));
878
+ });
737
879
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW key wrap failed", e); }
738
880
  }
739
881
  // Delegate to a content-encryption algorithm (RSA-OAEP / AES-GCM).
740
882
  var wrapKeyClone = _cloneWithUsage(wrappingKey, "encrypt");
741
- return this.encrypt(wrapAlgorithm, wrapKeyClone, bytes);
883
+ return await this.encrypt(wrapAlgorithm, wrapKeyClone, bytes);
884
+ } finally {
885
+ // Clearing also clears the export it came from: for every non-jwk format
886
+ // Buffer.from(<ArrayBuffer>) is a VIEW over that buffer, not a copy, so one wipe covers both.
887
+ // A "jwk" export is the residual -- its key material lives in immutable JavaScript strings that
888
+ // no code can overwrite -- so wrapping a jwk serialization cannot offer this guarantee, and the
889
+ // documentation does not claim it does.
890
+ guard.secret.zeroize(bytes, WebCryptoError, "webcrypto/operation", "the exported key being wrapped");
891
+ }
742
892
  };
743
893
 
744
894
  SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages) {
@@ -760,9 +910,17 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
760
910
  if (wrapped.length < 24 || wrapped.length % 8 !== 0) {
761
911
  throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW wrapped key must be a multiple of 8 bytes (>= 24); got " + wrapped.length);
762
912
  }
913
+ // The exported wrapping key is a Buffer this module owns; in a KEM flow it is the KEK derived
914
+ // from the shared secret, so it is wiped once the unwrap has consumed it -- on the failing path
915
+ // too, which is the one an attacker induces by tampering with the wrapped key.
916
+ // The export happens INSIDE the try: a key whose handle cannot be exported must still surface
917
+ // the typed verdict this branch promises, not a raw node TypeError -- and a non-PkiError throw
918
+ // would also break the fuzz-harness contract.
763
919
  try {
764
- var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", _secretBytes(unwrappingKey), Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
765
- bytes = Buffer.concat([d.update(wrapped), d.final()]);
920
+ bytes = _withSecretBytes(unwrappingKey, function (kwBytes) {
921
+ var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", kwBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
922
+ return Buffer.concat([d.update(wrapped), d.final()]);
923
+ });
766
924
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW key unwrap failed (integrity or length)", e); }
767
925
  } else {
768
926
  var unwrapKeyClone = _cloneWithUsage(unwrappingKey, "decrypt");
@@ -783,7 +941,14 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
783
941
  } else {
784
942
  keyData = bytes;
785
943
  }
786
- return this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages);
944
+ try {
945
+ return await this.importKey(format, keyData, unwrappedKeyAlgorithm, extractable, keyUsages);
946
+ } finally {
947
+ // `bytes` is the UNWRAPPED key in plaintext -- a module-owned buffer, and the last plaintext
948
+ // copy this layer controls once importKey has taken its own. Clearing the caller-visible copy
949
+ // downstream while leaving this one live would make that wipe ceremonial.
950
+ guard.secret.zeroize(bytes, WebCryptoError, "webcrypto/operation", "the unwrapped key material");
951
+ }
787
952
  };
788
953
 
789
954
  function _cloneWithUsage(key, usage) {
@@ -819,10 +984,13 @@ function _nodeKey(fn, who) {
819
984
  // RFC 9935 sec. 6 ML-KEM-*-PrivateKey CHOICE: the inner sizes, keyed by the OID -- the OID is
820
985
  // the SOLE authority for the parameter set (never a length heuristic). ek = 384k+32, dk = the
821
986
  // FIPS 203 decapsulation key length.
987
+ // {ek, dk} come from the shared ML-KEM parameter registry (FIPS 203 Table 3) -- the same rows the
988
+ // CMS codec and the linter read, so a parameter set cannot mean one size here and another there.
822
989
  var ML_KEM_INNER = {};
823
- ML_KEM_INNER[oid.byName("id-ml-kem-512")] = { ek: 800, dk: 1632 };
824
- ML_KEM_INNER[oid.byName("id-ml-kem-768")] = { ek: 1184, dk: 2400 };
825
- ML_KEM_INNER[oid.byName("id-ml-kem-1024")] = { ek: 1568, dk: 3168 };
990
+ ["id-ml-kem-512", "id-ml-kem-768", "id-ml-kem-1024"].forEach(function (n) {
991
+ var row = oid.kemParams(n);
992
+ ML_KEM_INNER[oid.byName(n)] = { ek: row.ek, dk: row.dk };
993
+ });
826
994
 
827
995
  function _isOctet(node, size) {
828
996
  return node && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.OCTET_STRING &&
@@ -1028,9 +1196,13 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
1028
1196
  if (!key.extractable) throw new WebCryptoError("webcrypto/invalid-access", "key is not extractable");
1029
1197
  if (format === "jwk") return key._handle.export({ format: "jwk" });
1030
1198
  if (key.type === "secret") {
1031
- var raw = key._handle.export();
1032
- if (format === "raw") return _toArrayBuffer(raw);
1033
- throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
1199
+ // The export is a fresh Buffer holding the secret key, and _toArrayBuffer copies it via
1200
+ // ArrayBuffer.slice -- so the export is a controllable allocation nothing references once the
1201
+ // copy exists. The unsupported-format throw below clears it too.
1202
+ return _withSecretBytes(key, function (raw) {
1203
+ if (format === "raw") return _toArrayBuffer(raw);
1204
+ throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
1205
+ });
1034
1206
  }
1035
1207
  if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
1036
1208
  if (format === "pkcs8") return _toArrayBuffer(key._handle.export({ format: "der", type: "pkcs8" }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
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:14a71e45-54e7-49bd-8fe1-ac576a769036",
5
+ "serialNumber": "urn:uuid:a5caa56c-cfae-4869-93f2-4e8984c7c32f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-10T02:28:13.520Z",
8
+ "timestamp": "2026-08-10T23:24:00.079Z",
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.4.12",
22
+ "bom-ref": "@blamejs/pki@0.4.14",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.12",
25
+ "version": "0.4.14",
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.4.12",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.14",
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.4.12",
57
+ "ref": "@blamejs/pki@0.4.14",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]