node:crypto: copy password and salt for async pbkdf2 and scrypt

Closed
RO
19d ago
+253-54

Problem

  • Async crypto.pbkdf2 and crypto.scrypt hand the pool thread a borrow of the caller's buffers. A caller that zeroizes the secret right after the call gets a key from wiped bytes. Node copies.
  • The same borrow crashes the pool thread (Segmentation fault) when a resizable ArrayBuffer input calls resize(0) after the call.
  • Cause: StringOrBuffer::from_js_maybe_async with Flavor::Async (src/runtime/node/types.rs) pins and GC-roots the buffer but keeps a pointer into it. PBKDF2::run and Scrypt::run_task_impl read that pointer on the pool thread.

Fix

  • Add StringOrBuffer::make_thread_isolated_copy. It copies a buffer's current bytes into an owned Vec (no pin, no root) and thread-isolates a string.
  • PBKDF2::from_js and Scrypt::from_js copy password and salt after the option getters and the callback check ran, where the sync flavor already re-reads them. pbkdf2Sync and scryptSync are unchanged.
  • Correct because the job owns every byte it reads. Node's async KDF jobs and bun's hkdf and argon2 already do this.
  • Verified: test/js/node/crypto/pbkdf2.test.ts and test/js/node/crypto/scrypt.test.ts, four new tests each. All eight fail on the unfixed build. Other suites: see Notes.

Background

  • A StringOrBuffer is a parsed string-or-buffer JS argument. Its Buffer arm is a pointer into the JS ArrayBuffer, not a copy. Flavor::Async marks a value that outlives the call.
  • A pin stops a detach (a transfer() copies instead). A GC root stops collection. Neither stops JS from writing to, or resizing, the buffer while the pool thread reads it.
  • ThreadIsolated<T> (#40511) is the Send wrapper that carries parsed arguments to the pool. Its unsafe constructor asks why the value is sendable.
Notes

Repro (from the report):

import crypto from "node:crypto";
const pw = Buffer.alloc(4096, "p"), salt = Buffer.alloc(16, "s");
const want  = crypto.pbkdf2Sync(pw, salt, 1, 32, "sha256").toString("hex");
const wantS = crypto.scryptSync(pw, salt, 32, { N: 1024 }).toString("hex");
let r = { pbkdf2: { ok: 0, wrong: 0 }, scrypt: { ok: 0, wrong: 0 } };
for (let i = 0; i < 50; i++) {
  pw.fill("p"); salt.fill("s");
  const a = new Promise(res => crypto.pbkdf2(pw, salt, 1, 32, "sha256", (e, d) => res(d.toString("hex"))));
  const b = new Promise(res => crypto.scrypt(pw, salt, 32, { N: 1024 }, (e, d) => res(d.toString("hex"))));
  pw.fill(0); salt.fill(0);
  const [da, db] = await Promise.all([a, b]);
  r.pbkdf2[da === want ? "ok" : "wrong"]++; r.scrypt[db === wantS ? "ok" : "wrong"]++;
}
console.log(JSON.stringify(r));
  • On bun 1.4.1 the repro above gives pbkdf2 2 ok / 48 wrong and scrypt 0 ok / 50 wrong. Node: 50 / 50 ok.
  • The four new tests per file: zeroize after the call, copy only after every argument was coerced, resize(0) after the call, resize(0) from a later argument's getter or toString. The last one is the case #35821 raised for the sync path, here for the async path.
  • Other suites run: node-crypto.test.js, crypto.test.ts, crypto-extra-memory.test.ts, Node's test-crypto-pbkdf2.js and test-crypto-scrypt.js, and the fs.test.ts Buffer-path test from #40511.
  • The SAFETY notes on ThreadIsolated::new in both from_js_async constructors now name the copy as the reason the value is sendable.
  • With the wipe placed right after each call (the shape the tests use), every round is wrong on the unfixed release build for password sizes from 16 bytes to 1 MiB, and on the unfixed debug ASAN build in 3 of 3 runs.
  • A partial overwrite gives a key that matches neither the old nor the new input: the pool thread hashes the buffer while the JS thread writes it.
  • The other async crypto jobs (hkdf, sign/verify, checkPrime, subtle.*, Bun.password, argon2) already copy at call time.
  • Why the copy is taken at the end of parsing and not at parse time: Node reads the buffers in check() (lib/internal/crypto/scrypt.js), runs the option getters there, and copies in the ScryptJob constructor afterwards. scryptSync in bun already re-reads its buffers after option coercion (scryptSync reads its buffers only after every argument has been coerced). A first version of this PR copied at parse time, so an option getter that wrote to the password was invisible to the job. The review caught it.
  • The copy is reported to the GC with report_extra_memory, like from_js_to_owned_slice and the transcoded-string path in the same file.
  • Rebased onto #40511, which replaced the protect()/Unprotect bookkeeping with PinnedArrayBuffer and the unsafe ThreadIsolated::new. That PR fixed the leak of the root; this one removes the borrow. The earlier commits of this PR (the removed unprotect scope guards) are folded into one commit because #40511 removed the same guards.
  • #34751 and #35821 took a narrower cut at the same borrow (copy only resizable ArrayBuffer inputs) on the plumbing that #40511 replaced. Both are closed in favor of this PR for the KDF slice. This PR copies every buffer input of the two KDF jobs, which is what makes wipe-after-use correct. The cases from those PRs that still crash on main are recorded in their closing comments: Bun.zstdCompress/Bun.zstdDecompress with a resizable input that shrinks after the call (#34751, now fixed by #40641), and Bun.markdown.html with an option getter that shrinks the input (#35821, proposed in #31730). Neither is in this PR's scope.
  • The scrypt async does not leak callback/buffers when output allocation fails test still passes: it measures the delta of protected Uint8Array counts, and that delta stays 0. Its comments are updated: the inputs are no longer rooted, so that half of the assertion now guards that no root comes back.

[review] gate passed · iteration 3 · 5 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/crypto/pbkdf2.test.ts" test/js/node/crypto/scrypt.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/165] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/165] gen cpp.rs (cppbind)
[3/165] gen JS modules (bundle-modules)
Preprocess modules (7337ms)
Bundle modules (31ms)
Postprocesss modules (30ms)
Bundle Functions (385ms)
Generate Code (34ms)

[7.82s] Bundled "src/js" for development
  2787 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/165] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[4/165] cc obj/codegen/InternalModuleRegistryConstants.S.o
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_D
... (truncated)

release without fix: 8 FAILED
bun test v1.4.1-canary.1 (731aa92da)

test/js/node/crypto/pbkdf2.test.ts:
(pass) 120fb6cffcf8b32c43e7225256c4f837a86548c9 [0.32ms]
(pass) ae4d0c95af6b46d32d0adff928f06dd02a303f8e [0.04ms]
(pass) c5e478d59288c841aa530db6845c4c8d962893a0 [1.36ms]
(pass) 348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c [1.53ms]
(pass) 89b69d0516f829893c696226650a8687 [1.56ms]
(pass) 64c486c55d30d4c5a079b8823b7d7cb37ff0556f537da8410233bcec330ed956 [0.08ms]
(pass) f7ce0b653d2d72a4108cf5abe912ffdd777616dbbb27a70e8204f3ae2d0f6fad [0.02ms]
(pass) keylen is the length of the derived key > keylen=1 [0.22ms]
(pass) keylen is the length of the derived key > keylen=31 [0.04ms]
(pass) keylen is the length of the derived key > keylen=32 [0.03ms]
(pass) keylen is the length of the derived key > keylen=33 [0.03ms]
(pass) keylen is the length of the derived key > keylen=63 [0.04ms]
(pass) keylen is the length of the derived key > keylen=64 [0.02ms]
(pass) invalid inputs > test is invalid [0.17ms]
(pass) invalid inputs >  is invalid [0.01ms]
(pass) invalid inputs > true is invalid [0.33ms]
(pass) invalid inputs > undefined is invalid
(pass) invalid inputs > null is invalid
(pass) invalid inputs > {}
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/js/node/crypto/pbkdf2.test.ts" test/js/node/crypto/scrypt.test.ts
bun test v1.4.1 (731aa92da)

test/js/node/crypto/pbkdf2.test.ts:
(pass) 120fb6cffcf8b32c43e7225256c4f837a86548c9 [44.98ms]
(pass) ae4d0c95af6b46d32d0adff928f06dd02a303f8e [2.84ms]
(pass) c5e478d59288c841aa530db6845c4c8d962893a0 [9.78ms]
(pass) 348c89dbcbd32b2f32d814b8116e84cf2b17347ebc1800181c [11.72ms]
(pass) 89b69d0516f829893c696226650a8687 [8.86ms]
(pass) 64c486c55d30d4c5a079b8823b7d7cb37ff0556f537da8410233bcec330ed956 [1.21ms]
(pass) f7ce0b653d2d72a4108cf5abe912ffdd777616dbbb27a70e8204f3ae2d0f6fad [1.29ms]
(pass) keylen is the length of the derived key > keylen=1 [12.00ms]
(pass) keylen is the length of the derived key > keylen=31 [2.80ms]
(pass) keylen is the length of the derived key > keylen=32 [1.57ms]
(pass) keylen is the length of the derived key > keylen=33 [1.60ms]
(pass) keylen is the length of the derived key > keylen=63 [1.55ms]
(pass) keylen is the length of the derived key > keylen=64 [1.43ms]
(pass) invalid inputs > test is invalid [9.65ms]
(pass)
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 629ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/125] gen cpp.rs (cppbind)
[3/125] gen JS modules (bundle-modules)
Preprocess modules (7318ms)
Bundle modules (35ms)
Postprocesss modules (25ms)
Bundle Functions (470ms)
Generate Code (30ms)

[7.89s] Bundled "src/js" for production
  2594 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/124] cargo bun_runtime → libbun_runtime.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

   Compiling bun_analytics v0.0.0 (/workspace/bun/src/analytics)
   Compiling bun_exe_format v0.0.0 (/workspace/bun/src/exe_format)
   Compiling bun_options_types v0.0.0 (/workspace/bun/src/options_types)
   Compiling bun_api v0.0.0 (/workspace/bun/src/api)
   Compiling bun_spawn_sys v0.0.0 (/wor
... (truncated)
diff hotspot
src/runtime/crypto/PBKDF2.rs            |  37 +++++-----
 src/runtime/node/node_crypto_binding.rs |  37 +++-------
 src/runtime/node/types.rs               |  27 ++++++++
 test/js/node/crypto/pbkdf2.test.ts      |  90 +++++++++++++++++++++++++
 test/js/node/crypto/scrypt.test.ts      | 116 +++++++++++++++++++++++++++++---
 5 files changed, 253 insertions(+), 54 deletions(-)

gate history · 7 passed · 0 rejected · iteration 3

evidence per changed file
file                                     reads  edits  tests
src/runtime/crypto/PBKDF2.rs                10     16      0
src/runtime/node/node_crypto_binding.rs     13     19      0
src/runtime/node/types.rs                   10     12      0
test/js/node/crypto/pbkdf2.test.ts           1      2      0
test/js/node/crypto/scrypt.test.ts           2      5      0