@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/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/lib/cms-decrypt.js +162 -65
- package/lib/cms-encrypt.js +223 -150
- package/lib/guard-all.js +2 -0
- package/lib/guard-secret.js +82 -0
- package/lib/hpke.js +94 -21
- package/lib/key.js +24 -6
- package/lib/lint.js +3 -1
- package/lib/oid.js +35 -0
- package/lib/pbes2.js +36 -7
- package/lib/pkcs12-build.js +69 -16
- package/lib/schema-cms.js +3 -3
- package/lib/webcrypto.js +220 -48
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
480
|
-
|
|
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
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
736
|
-
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
|
|
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
|
-
|
|
824
|
-
|
|
825
|
-
ML_KEM_INNER[oid.byName(
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
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:
|
|
5
|
+
"serialNumber": "urn:uuid:a5caa56c-cfae-4869-93f2-4e8984c7c32f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
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.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.4.14",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.4.
|
|
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.
|
|
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.
|
|
57
|
+
"ref": "@blamejs/pki@0.4.14",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|