@blamejs/pki 0.4.13 → 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/key.js CHANGED
@@ -120,11 +120,23 @@ async function encrypt(privateKey, password, opts) {
120
120
  var iterations = pbes2.assertIterations(opts.iterations == null ? 600000 : opts.iterations, _err, "key");
121
121
  var salt = opts.salt != null ? pbes2.assertSalt(guard.bytes.view(opts.salt, KeyError, "key/bad-input", "salt"), _err, "key") : nodeCrypto.randomBytes(16);
122
122
  var iv = nodeCrypto.randomBytes(16);
123
- var dk = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(password, _err, "key"), salt, iterations, keyBits / 8, prfNode);
124
- var ciphertext = pbes2.cbcEncrypt(dk, iv, der, keyBits);
125
- var epki = b.sequence([pbes2.pbes2AlgId(salt, iterations, prf, cipherName, iv), b.octetString(ciphertext)]);
126
- pkcs8.parseEncrypted(epki); // self-check: the produced EncryptedPrivateKeyInfo re-parses
127
- return opts.pem ? pkcs8.pemEncode(epki, "ENCRYPTED PRIVATE KEY") : epki;
123
+ // The derived key protects a PRIVATE KEY, so it is cleared as soon as the encryption is done. The
124
+ // caller's password is untouched -- passwordBytes returns a supplied Buffer as-is.
125
+ // A string / Uint8Array password is encoded into a buffer THIS toolkit allocated -- a credential
126
+ // copy -- so it is cleared once the derivation has consumed it. A caller-supplied Buffer is
127
+ // borrowed and left intact.
128
+ var pwK = pbes2.passwordBytesOwned(password, _err, "key");
129
+ var dk;
130
+ try { dk = nodeCrypto.pbkdf2Sync(pwK.bytes, salt, iterations, keyBits / 8, prfNode); }
131
+ finally { if (pwK.owned) guard.secret.zeroize(pwK.bytes, KeyError, "key/bad-input", "the password encoding"); }
132
+ try {
133
+ var ciphertext = pbes2.cbcEncrypt(dk, iv, der, keyBits);
134
+ var epki = b.sequence([pbes2.pbes2AlgId(salt, iterations, prf, cipherName, iv), b.octetString(ciphertext)]);
135
+ pkcs8.parseEncrypted(epki); // self-check: the produced EncryptedPrivateKeyInfo re-parses
136
+ return opts.pem ? pkcs8.pemEncode(epki, "ENCRYPTED PRIVATE KEY") : epki;
137
+ } finally {
138
+ guard.secret.zeroize(dk, KeyError, "key/bad-input", "the password-derived encryption key");
139
+ }
128
140
  }
129
141
 
130
142
  /**
@@ -176,7 +188,13 @@ function _decryptPbes2(encAlg, ciphertext, password, opts) {
176
188
  // The shared PBES2 decrypt home (strict params + the uniform decrypt-failed on a wrong key / bad pad). The
177
189
  // plaintext re-parse below is the MAC-less PBES2-CBC integrity check (RFC 8018 sec. 8): a bad pad and a
178
190
  // valid pad whose plaintext is not a PrivateKeyInfo are indistinguishable, and it MUST run (never skipped).
179
- var plaintext = pbes2.pbes2Decrypt(pbes2.passwordBytes(password, _err, "key"), encAlg.parameters, ciphertext, opts, _err, "key");
191
+ // A string / Uint8Array password is encoded into a buffer THIS toolkit allocated -- a credential
192
+ // copy -- so it is cleared once the derivation has consumed it. A caller-supplied Buffer is
193
+ // borrowed and left intact.
194
+ var pwD = pbes2.passwordBytesOwned(password, _err, "key");
195
+ var plaintext;
196
+ try { plaintext = pbes2.pbes2Decrypt(pwD.bytes, encAlg.parameters, ciphertext, opts, _err, "key"); }
197
+ finally { if (pwD.owned) guard.secret.zeroize(pwD.bytes, KeyError, "key/bad-input", "the password encoding"); }
180
198
  try { pkcs8.parse(plaintext); }
181
199
  catch (_e) { throw _err("key/decrypt-failed", "decryption failed"); }
182
200
  return plaintext;
package/lib/pbes2.js CHANGED
@@ -38,10 +38,17 @@ var CONTENT_KEYBITS = {}, CONTENT_MODE = {};
38
38
 
39
39
  // A password is an octet string (RFC 8018 sec. 2): a string is UTF-8-encoded deterministically (correct for
40
40
  // non-ASCII, and byte-identical to OpenSSL), a Buffer/Uint8Array used verbatim.
41
- function passwordBytes(p, E, prefix) {
42
- if (Buffer.isBuffer(p)) return p;
43
- if (p instanceof Uint8Array) return Buffer.from(p);
44
- if (typeof p === "string") return Buffer.from(p, "utf8");
41
+ function passwordBytes(p, E, prefix) { return passwordBytesOwned(p, E, prefix).bytes; }
42
+
43
+ // The same conversion, reporting OWNERSHIP. A string or Uint8Array password is encoded into a fresh
44
+ // Buffer this toolkit allocated -- a credential copy that must be cleared once the derivation has
45
+ // consumed it -- while a caller-supplied Buffer is BORROWED and must be left intact. Without this
46
+ // distinction a call site can only be safe by leaving every password encoding readable, which is
47
+ // the common case, since a password arrives as a string far more often than as a Buffer.
48
+ function passwordBytesOwned(p, E, prefix) {
49
+ if (Buffer.isBuffer(p)) return { bytes: p, owned: false };
50
+ if (p instanceof Uint8Array) return { bytes: Buffer.from(p), owned: true };
51
+ if (typeof p === "string") return { bytes: Buffer.from(p, "utf8"), owned: true };
45
52
  throw E(prefix + "/bad-input", "a password must be a string, Buffer, or Uint8Array");
46
53
  }
47
54
 
@@ -148,8 +155,19 @@ function pbes2Encrypt(pwBytes, plaintext, opts, E, prefix) {
148
155
  var iterations = assertIterations(opts.iterations == null ? 2048 : opts.iterations, E, prefix);
149
156
  var salt = opts.salt != null ? assertSalt(opts.salt, E, prefix) : nodeCrypto.randomBytes(16);
150
157
  var iv = opts.iv != null ? opts.iv : nodeCrypto.randomBytes(16);
158
+ // The derived key is this module's allocation and protects the caller's plaintext -- a private key
159
+ // in the pkcs8 / pkcs12 callers -- so it is cleared once the encryption has consumed it. pwBytes is
160
+ // NOT: the caller formatted it and may still own the buffer.
151
161
  var key = nodeCrypto.pbkdf2Sync(pwBytes, salt, iterations, keyBits / 8, prfNode);
152
- return { algId: pbes2AlgId(salt, iterations, prf, cipherName, iv), ct: cbcEncrypt(key, iv, plaintext, keyBits) };
162
+ try {
163
+ return { algId: pbes2AlgId(salt, iterations, prf, cipherName, iv), ct: cbcEncrypt(key, iv, plaintext, keyBits) };
164
+ } finally {
165
+ // E is the caller's error FACTORY, not a class; guard-secret delegates its only throw to
166
+ // guard-bytes, which constructs. yields the error the factory returns, so the
167
+ // currency is compatible -- and the throw is unreachable here regardless, because a freshly
168
+ // allocated pbkdf2 buffer cannot be detached.
169
+ guard.secret.zeroize(key, E, prefix + "/bad-input", "the password-derived encryption key");
170
+ }
153
171
  }
154
172
 
155
173
  // PBES2 AES-CBC DECRYPT: parse the PBES2 params (PBKDF2 kdf + AES-CBC scheme), derive the key from
@@ -182,6 +200,7 @@ function pbes2Decrypt(pwBytes, params, ciphertext, opts, E, prefix) {
182
200
  var dk = nodeCrypto.pbkdf2Sync(pwBytes, pb.salt, pb.iterations, keyBits / 8, pb.prfNode);
183
201
  try { return cbcDecrypt(dk, iv, ciphertext, keyBits); }
184
202
  catch (_e) { throw E(prefix + "/decrypt-failed", "decryption failed"); }
203
+ finally { guard.secret.zeroize(dk, E, prefix + "/bad-input", "the password-derived decryption key"); }
185
204
  }
186
205
 
187
206
  // ---- PBMAC1 (RFC 9579 / RFC 8018 App. A.5) : PBKDF2 -> HMAC over a message --
@@ -207,14 +226,24 @@ function pbmac1(pwBytes, salt, iterationCount, keyLength, prfHash, macHash, mess
207
226
  return subtle.importKey("raw", pwBytes, { name: "PBKDF2" }, false, ["deriveBits"]).then(function (baseKey) {
208
227
  return subtle.deriveBits({ name: "PBKDF2", salt: salt, iterations: iterationCount, hash: prfHash }, baseKey, keyLength * 8);
209
228
  }).then(function (bits) {
210
- return subtle.importKey("raw", Buffer.from(bits), { name: "HMAC", hash: macHash }, false, ["sign"]).then(function (hmacKey) {
229
+ // The derived MAC key is this function's allocation, in two forms: the ArrayBuffer deriveBits
230
+ // returned and the Buffer view over it that is imported. Clearing the view clears both, and it
231
+ // runs whether the signing succeeds or throws -- the sibling PBES2 encrypt/decrypt above clear
232
+ // their derived keys, and every PBMAC1 consumer (PKCS#12 build and verification) inherits this.
233
+ var mk = Buffer.from(bits);
234
+ return subtle.importKey("raw", mk, { name: "HMAC", hash: macHash }, false, ["sign"]).then(function (hmacKey) {
211
235
  return subtle.sign({ name: "HMAC" }, hmacKey, message);
236
+ }).finally(function () {
237
+ // pbmac1 takes no caller error factory, and this key is engine-adjacent, so the engine's
238
+ // typed class is the right currency -- a raw TypeError must never escape a lib boundary.
239
+ // Unreachable in practice: a freshly allocated Buffer cannot be detached.
240
+ guard.secret.zeroize(mk, webcrypto.WebCryptoError, "webcrypto/operation", "the PBMAC1 key");
212
241
  });
213
242
  }).then(function (sig) { return Buffer.from(sig); });
214
243
  }
215
244
 
216
245
  module.exports = {
217
- passwordBytes: passwordBytes, assertIterations: assertIterations, assertSalt: assertSalt,
246
+ passwordBytes: passwordBytes, passwordBytesOwned: passwordBytesOwned, assertIterations: assertIterations, assertSalt: assertSalt,
218
247
  prfNodeByName: prfNodeByName, prfNodeByOid: prfNodeByOid,
219
248
  pbkdf2ParamsSeq: pbkdf2ParamsSeq, pbes2AlgId: pbes2AlgId, parsePbkdf2Params: parsePbkdf2Params,
220
249
  requireChildren: requireChildren, seqChildren: seqChildren,
@@ -100,7 +100,17 @@ var DIGEST_NAME = { sha1: "sha1", sha256: "sha256", sha384: "sha384", sha512: "s
100
100
  // RFC 7292 App. B.1: a PKCS#12 password is the BMPString (UTF-16BE) encoding of the string plus a trailing
101
101
  // 2-byte NULL terminator. "Beavis" -> 14 bytes. A non-BMP scalar (surrogate) is rejected. A Buffer/Uint8Array
102
102
  // is taken verbatim as already-formatted bytes (an escape hatch for a caller that pre-encodes).
103
- function _p12Password(pw) {
103
+ function _p12Password(pw) { return _p12PasswordOwned(pw).bytes; }
104
+
105
+ // The same encoding, reporting OWNERSHIP -- mirroring pbes2.passwordBytesOwned. A caller-supplied
106
+ // Buffer is returned AS-IS and is BORROWED: clearing it would destroy the caller's own credential,
107
+ // which is a worse defect than leaving a copy readable. Every other input is re-encoded into a
108
+ // buffer this module allocated, which it must clear once a derivation has consumed it.
109
+ function _p12PasswordOwned(pw) {
110
+ if (Buffer.isBuffer(pw)) return { bytes: pw, owned: false };
111
+ return { bytes: _p12Encode(pw), owned: true };
112
+ }
113
+ function _p12Encode(pw) {
104
114
  if (pw == null) pw = "";
105
115
  if (Buffer.isBuffer(pw)) return pw;
106
116
  if (pw instanceof Uint8Array) return Buffer.from(pw);
@@ -148,16 +158,43 @@ function _blockFill(src, blockSize) {
148
158
  function _p12Kdf(hashName, id, pwBytes, salt, iterations, nBytes) {
149
159
  var uv = P12_KDF_UV[hashName], u = uv.u, v = uv.v;
150
160
  var D = Buffer.alloc(v, id); // step 1: v copies of the ID diversifier
151
- var I = Buffer.concat([_blockFill(salt, v), _blockFill(pwBytes, v)]); // steps 2-4: I = S || P
161
+ // Each fill is a full block-repeated copy of the salt / password; only their concatenation is
162
+ // used, so the sources are cleared rather than abandoned.
163
+ var sFill = _blockFill(salt, v), pFill = _blockFill(pwBytes, v);
164
+ var I = Buffer.concat([sFill, pFill]); // steps 2-4: I = S || P
165
+ guard.secret.zeroizeAll([sFill, pFill], Pkcs12Error, "pkcs12/bad-input", "a key-derivation fill");
152
166
  var c = Math.ceil(nBytes / u); // step 5
153
- var out = Buffer.alloc(0);
167
+ // The accumulator is allocated ONCE at its final size. Growing it with Buffer.concat per round
168
+ // would abandon a password-derived copy of everything derived so far on every iteration, and
169
+ // those copies could not be reached to clear. Each intermediate digest is cleared as it is
170
+ // superseded, so only the value actually in use is ever readable.
171
+ var out = Buffer.alloc(c * u);
154
172
  for (var i = 0; i < c; i++) { // step 6
155
173
  var A = Buffer.concat([D, I]);
156
- for (var r = 0; r < iterations; r++) A = nodeCrypto.createHash(hashName).update(A).digest(); // 6A: A = H^r(D||I)
157
- out = Buffer.concat([out, A]); // step 7 (accumulate A_1..A_c)
158
- if (i < c - 1) _kdfStepC(I, _blockFill(A, v), v); // 6B+6C: modify I for the next block
174
+ for (var r = 0; r < iterations; r++) { // 6A: A = H^r(D||I)
175
+ var prev = A;
176
+ A = nodeCrypto.createHash(hashName).update(A).digest();
177
+ guard.secret.zeroize(prev, Pkcs12Error, "pkcs12/bad-input", "a key-derivation intermediate");
178
+ }
179
+ A.copy(out, i * u); // step 7 (accumulate A_1..A_c)
180
+ if (i < c - 1) { // 6B+6C: modify I for the next block
181
+ var B = _blockFill(A, v); // a full password-derived block
182
+ _kdfStepC(I, B, v);
183
+ guard.secret.zeroize(B, Pkcs12Error, "pkcs12/bad-input", "a key-derivation intermediate");
184
+ }
185
+ guard.secret.zeroize(A, Pkcs12Error, "pkcs12/bad-input", "a key-derivation block");
159
186
  }
160
- return out.subarray(0, nBytes); // step 8: first nBytes of A
187
+ // I is S || P -- it carries the password bytes -- and is finished with here.
188
+ guard.secret.zeroize(I, Pkcs12Error, "pkcs12/bad-input", "the key-derivation input block");
189
+ // Step 8 is "the first nBytes of A", but returning a SUBARRAY would hand back a view over a
190
+ // larger allocation: a caller clearing what it received would leave the rest of the final digest
191
+ // block -- password-derived material -- readable in the same backing buffer (15 unused bytes
192
+ // behind a 5-byte RC2 key, 16 behind a 24-byte 3DES key). Copy into an exact-sized buffer the
193
+ // caller wholly owns, and clear the oversized accumulator here, where it was allocated.
194
+ var exact = Buffer.alloc(nBytes);
195
+ out.copy(exact, 0, 0, nBytes);
196
+ guard.secret.zeroize(out, Pkcs12Error, "pkcs12/bad-input", "the key-derivation accumulator");
197
+ return exact;
161
198
  }
162
199
 
163
200
  // RFC 7292 App. B.2 step 6C: treat I as consecutive v-byte blocks and set each I_j = (I_j + B + 1) mod 2^(8v)
@@ -344,10 +381,18 @@ async function _buildMacData(macOpts, sharedPassword, authSafeDer) {
344
381
  var node = DIGEST_NAME[hash];
345
382
  if (!node || !P12_KDF_UV[node]) throw _err("pkcs12/unsupported-algorithm", "unsupported classic MAC hash " + JSON.stringify(hash) + " (sha1 / sha256 / sha384 / sha512)");
346
383
  var iter = _assertMacIter(macOpts.iterations == null ? DEFAULT_MAC_ITER : macOpts.iterations, CLASSIC_MAC_MAX_ITERATIONS);
347
- var macKey = _p12Kdf(node, 3, _p12Password(password), salt, iter, P12_KDF_UV[node].u); // classic KDF -> BMPString
348
- var digest = nodeCrypto.createHmac(node, macKey).update(authSafeDer).digest();
349
- var digestInfo = b.sequence([b.sequence([b.oid(O(node)), b.nullValue()]), b.octetString(digest)]);
350
- return b.sequence([digestInfo, b.octetString(salt), b.integer(BigInt(iter))]);
384
+ // _p12Password always ALLOCATES (it re-encodes to BMPString + NULL), so its result is this
385
+ // module's own credential copy and is cleared once the derivation has consumed it.
386
+ var macPw = _p12PasswordOwned(password);
387
+ var macKey = _p12Kdf(node, 3, macPw.bytes, salt, iter, P12_KDF_UV[node].u); // classic KDF -> BMPString
388
+ if (macPw.owned) guard.secret.zeroize(macPw.bytes, Pkcs12Error, "pkcs12/bad-input", "the password encoding");
389
+ try {
390
+ var digest = nodeCrypto.createHmac(node, macKey).update(authSafeDer).digest();
391
+ var digestInfo = b.sequence([b.sequence([b.oid(O(node)), b.nullValue()]), b.octetString(digest)]);
392
+ return b.sequence([digestInfo, b.octetString(salt), b.integer(BigInt(iter))]);
393
+ } finally {
394
+ guard.secret.zeroize(macKey, Pkcs12Error, "pkcs12/bad-input", "the password-derived MAC key");
395
+ }
351
396
  }
352
397
  if (algorithm === "pbmac1") {
353
398
  var prf = PBMAC1_PRF[hash];
@@ -495,8 +540,11 @@ async function verifyMac(pfx, password, opts) {
495
540
  if (!P12_KDF_UV[node]) throw _err("pkcs12/unsupported-algorithm", "unsupported classic MAC hash " + m.mac.hashName);
496
541
  // The iteration count and salt are attacker-controlled work factors: bound them BEFORE the KDF runs.
497
542
  _capWork(m.mac.iterations, m.mac.macSalt, opts, undefined, CLASSIC_MAC_MAX_ITERATIONS);
498
- var macKey = _p12Kdf(node, 3, _p12Password(password), m.mac.macSalt, m.mac.iterations, P12_KDF_UV[node].u); // classic KDF -> BMPString
499
- computed = nodeCrypto.createHmac(node, macKey).update(m.macedBytes).digest();
543
+ var vMacPw = _p12PasswordOwned(password);
544
+ var macKey = _p12Kdf(node, 3, vMacPw.bytes, m.mac.macSalt, m.mac.iterations, P12_KDF_UV[node].u); // classic KDF -> BMPString
545
+ if (vMacPw.owned) guard.secret.zeroize(vMacPw.bytes, Pkcs12Error, "pkcs12/bad-input", "the password encoding");
546
+ try { computed = nodeCrypto.createHmac(node, macKey).update(m.macedBytes).digest(); }
547
+ finally { guard.secret.zeroize(macKey, Pkcs12Error, "pkcs12/bad-input", "the password-derived MAC key"); }
500
548
  } else {
501
549
  var kdf = m.mac.pbmac1.kdf;
502
550
  var prfWc = PRF_WC[kdf.prfName];
@@ -693,17 +741,22 @@ function _decryptLegacyPbe(ea, ct, password, opts, budget) {
693
741
  var u = P12_KDF_UV[scheme.hash].u;
694
742
  budget.rounds -= (Math.ceil(scheme.keyLen / u) + Math.ceil(scheme.ivLen / u)) * iterations;
695
743
  if (budget.rounds < 0) throw _err("pkcs12/iteration-limit", "the store's aggregate legacy PBE key-derivation work exceeds the budget (a hostile many-bag store)");
696
- var p12pw = _p12Password(password); // App. B.1 BMPString + NULL (NOT the PBES2 UTF-8 encoding)
697
- var keyM = _p12Kdf(scheme.hash, 1, p12pw, salt, iterations, scheme.keyLen);
698
- var iv = _p12Kdf(scheme.hash, 2, p12pw, salt, iterations, scheme.ivLen);
744
+ var p12pw = _p12PasswordOwned(password); // App. B.1 BMPString + NULL (NOT the PBES2 UTF-8 encoding)
745
+ var keyM = _p12Kdf(scheme.hash, 1, p12pw.bytes, salt, iterations, scheme.keyLen);
746
+ var iv = _p12Kdf(scheme.hash, 2, p12pw.bytes, salt, iterations, scheme.ivLen);
747
+ if (p12pw.owned) guard.secret.zeroize(p12pw.bytes, Pkcs12Error, "pkcs12/bad-input", "the password encoding");
699
748
  // BOTH ciphers funnel through ONE uniform failure (same code AND message), so a bad-pad (secret-dependent)
700
749
  // and a valid-pad-wrong-content outcome are indistinguishable -- no CBC padding oracle (RFC 8018 sec. 8). RC2
701
750
  // is unavailable in node; the in-tree RFC 2268 primitive fills the gap and its typed reject is normalized here.
751
+ // keyM is a password-derived key this function allocated -- the PBES2 arm of the same dispatch
752
+ // clears its equivalent, so this arm owes it too, on the failing path an attacker drives. The
753
+ // derived IV is not secret and is left alone.
702
754
  try {
703
755
  if (scheme.rc2) return rc2.cbcDecrypt(keyM, scheme.rc2, iv, ct, _err, "pkcs12/decrypt-failed");
704
756
  var d = nodeCrypto.createDecipheriv(scheme.cipher, keyM, iv);
705
757
  return Buffer.concat([d.update(ct), d.final()]);
706
758
  } catch (_e) { throw _err("pkcs12/decrypt-failed", "decryption failed"); }
759
+ finally { guard.secret.zeroize(keyM, Pkcs12Error, "pkcs12/bad-input", "the password-derived decryption key"); }
707
760
  }
708
761
 
709
762
  // Decrypt a PBES2 (RFC 8018) or legacy-PBE (RFC 7292 App. C) bag/safe -- dispatch on the encryptionAlgorithm
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,41 +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
- // _secretBytes EXPORTS the key material into a fresh Buffer this module owns -- for a KEM flow
612
- // that Buffer is the shared secret itself. It is a controllable allocation, not one of the
613
- // runtime-internal copies the best-effort caveat covers, so it is wiped once HKDF has consumed it.
614
- var ikm = _secretBytes(key);
615
- try {
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) {
616
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);
617
675
  return derived instanceof ArrayBuffer ? derived : _toArrayBuffer(Buffer.from(derived));
618
- } finally {
619
- guard.secret.zeroize(ikm, WebCryptoError, "webcrypto/operation", "the HKDF input key material");
620
- }
676
+ });
621
677
  }
622
678
  if (name === "PBKDF2") {
623
679
  _requireDeriveLength(length, "PBKDF2");
624
- var out = await _pbkdf2Async(_secretBytes(key), _toBuf(alg.salt, "PBKDF2 salt"), alg.iterations, length / 8, _hashNode(alg.hash, "PBKDF2"));
625
- 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
+ });
626
691
  }
627
692
  if (name === "X963KDF") {
628
693
  // ANSI-X9.63 / SEC1 sec. 3.6.1 single-step KDF: K = H(Z || INT32(counter) || SharedInfo)
629
694
  // concatenated over counter = 1, 2, ... The RFC 5753 kari KEK derivation; the base key holds
630
695
  // the ECDH shared secret Z, alg.info holds the DER ECC-CMS-SharedInfo.
631
696
  _requireDeriveLength(length, "X963KDF");
632
- 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
+ });
633
705
  }
634
706
  throw new WebCryptoError("webcrypto/not-supported", "deriveBits: unsupported algorithm " + JSON.stringify(name));
635
707
  }
@@ -644,7 +716,16 @@ function _x963Kdf(hashNode, z, sharedInfo, lenBytes) {
644
716
  var h = nodeCrypto.createHash(hashNode).update(z).update(ctr).update(sharedInfo).digest();
645
717
  blocks.push(h); got += h.length; counter += 1;
646
718
  }
647
- 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;
648
729
  }
649
730
 
650
731
  SubtleCrypto.prototype.deriveBits = async function deriveBits(algorithm, key, length) {
@@ -686,8 +767,12 @@ SubtleCrypto.prototype.deriveKey = async function deriveKey(algorithm, baseKey,
686
767
  // material; a KDF base has no implicit output size and fails closed.
687
768
  bits = dk.length != null ? dk.length : null;
688
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).
689
773
  var raw = await _deriveBitsRaw(alg, baseKey, bits);
690
- 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"); }
691
776
  };
692
777
 
693
778
  // ML-KEM (FIPS 203) key encapsulation over Node's crypto.encapsulate/decapsulate.
@@ -766,8 +851,12 @@ SubtleCrypto.prototype.decapsulateBits = async function decapsulateBits(algorith
766
851
  };
767
852
 
768
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.
769
857
  var exported = await this.exportKey(format, key);
770
858
  var bytes = (format === "jwk") ? Buffer.from(JSON.stringify(exported)) : Buffer.from(exported);
859
+ try {
771
860
  var alg = _normalizeAlg(wrapAlgorithm, "wrapKey");
772
861
  _requireUsage(wrappingKey, "wrapKey");
773
862
  if (alg.name !== "AES-KW" && !ENCRYPT_DECRYPT_NAMES[alg.name]) throw new WebCryptoError("webcrypto/not-supported", "wrapKey: unsupported algorithm " + JSON.stringify(alg.name));
@@ -782,17 +871,24 @@ SubtleCrypto.prototype.wrapKey = async function wrapKey(format, key, wrappingKey
782
871
  // The mirror of unwrapKey below: in a KEM flow this export is the SENDER's copy of the same
783
872
  // key-encryption key, so leaving it unwiped would keep a full copy of the KEK alive for the
784
873
  // process lifetime and make the wipes the CMS layer performs pointless in the encrypt direction.
785
- var wkBytes = null;
786
874
  try {
787
- wkBytes = _secretBytes(wrappingKey);
788
- var c = nodeCrypto.createCipheriv("aes" + wrappingKey.algorithm.length + "-wrap", wkBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
789
- 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
+ });
790
879
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "wrapKey: AES-KW key wrap failed", e); }
791
- finally { guard.secret.zeroize(wkBytes, WebCryptoError, "webcrypto/operation", "the AES-KW wrapping key"); }
792
880
  }
793
881
  // Delegate to a content-encryption algorithm (RSA-OAEP / AES-GCM).
794
882
  var wrapKeyClone = _cloneWithUsage(wrappingKey, "encrypt");
795
- 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
+ }
796
892
  };
797
893
 
798
894
  SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey, unwrappingKey, unwrapAlgorithm, unwrappedKeyAlgorithm, extractable, keyUsages) {
@@ -817,16 +913,15 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
817
913
  // The exported wrapping key is a Buffer this module owns; in a KEM flow it is the KEK derived
818
914
  // from the shared secret, so it is wiped once the unwrap has consumed it -- on the failing path
819
915
  // too, which is the one an attacker induces by tampering with the wrapped key.
820
- // Declared outside so the finally can reach it, but EXPORTED INSIDE the try: a key whose handle
821
- // cannot be exported must still surface the typed verdict this branch promises, not a raw node
822
- // TypeError -- and a non-PkiError throw would also break the fuzz-harness contract.
823
- var kwBytes = null;
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.
824
919
  try {
825
- kwBytes = _secretBytes(unwrappingKey);
826
- var d = nodeCrypto.createDecipheriv("aes" + unwrappingKey.algorithm.length + "-wrap", kwBytes, Buffer.from("A6A6A6A6A6A6A6A6", "hex"));
827
- 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
+ });
828
924
  } catch (e) { throw new WebCryptoError("webcrypto/operation", "unwrapKey: AES-KW key unwrap failed (integrity or length)", e); }
829
- finally { guard.secret.zeroize(kwBytes, WebCryptoError, "webcrypto/operation", "the AES-KW unwrapping key"); }
830
925
  } else {
831
926
  var unwrapKeyClone = _cloneWithUsage(unwrappingKey, "decrypt");
832
927
  bytes = Buffer.from(await this.decrypt(unwrapAlgorithm, unwrapKeyClone, wrappedKey));
@@ -1101,16 +1196,13 @@ SubtleCrypto.prototype.exportKey = async function exportKey(format, key) {
1101
1196
  if (!key.extractable) throw new WebCryptoError("webcrypto/invalid-access", "key is not extractable");
1102
1197
  if (format === "jwk") return key._handle.export({ format: "jwk" });
1103
1198
  if (key.type === "secret") {
1104
- // export() allocates a fresh Buffer holding the secret key, and _toArrayBuffer copies it via
1105
- // ArrayBuffer.slice -- so the export Buffer is a controllable allocation nothing references once
1106
- // the copy exists. Wiped in a `finally` so the unsupported-format throw below clears it too.
1107
- var raw = key._handle.export();
1108
- try {
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) {
1109
1203
  if (format === "raw") return _toArrayBuffer(raw);
1110
1204
  throw new WebCryptoError("webcrypto/not-supported", "exportKey: secret keys support 'raw' / 'jwk' only");
1111
- } finally {
1112
- guard.secret.zeroize(raw, WebCryptoError, "webcrypto/operation", "the exported secret key");
1113
- }
1205
+ });
1114
1206
  }
1115
1207
  if (format === "spki") return _toArrayBuffer(key._handle.export({ format: "der", type: "spki" }));
1116
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.13",
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",