node:crypto: copy password and salt for async pbkdf2 and scrypt
Closed
RO
19d ago+253+253+012345678901234567890123456789-54-54-01234567890123456789
Problem
- Async
crypto.pbkdf2andcrypto.scrypthand 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 resizableArrayBufferinput callsresize(0)after the call. - Cause:
StringOrBuffer::from_js_maybe_asyncwithFlavor::Async(src/runtime/node/types.rs) pins and GC-roots the buffer but keeps a pointer into it.PBKDF2::runandScrypt::run_task_implread that pointer on the pool thread.
Fix
- Add
StringOrBuffer::make_thread_isolated_copy. It copies a buffer's current bytes into an ownedVec(no pin, no root) and thread-isolates a string. PBKDF2::from_jsandScrypt::from_jscopypasswordandsaltafter the option getters and the callback check ran, where the sync flavor already re-reads them.pbkdf2SyncandscryptSyncare unchanged.- Correct because the job owns every byte it reads. Node's async KDF jobs and bun's
hkdfandargon2already do this. - Verified:
test/js/node/crypto/pbkdf2.test.tsandtest/js/node/crypto/scrypt.test.ts, four new tests each. All eight fail on the unfixed build. Other suites: see Notes.
Background
- A
StringOrBufferis a parsed string-or-buffer JS argument. ItsBufferarm is a pointer into the JSArrayBuffer, not a copy.Flavor::Asyncmarks 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 theSendwrapper that carries parsed arguments to the pool. Itsunsafeconstructor 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 ortoString. 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'stest-crypto-pbkdf2.jsandtest-crypto-scrypt.js, and thefs.test.tsBuffer-path test from #40511. - The
SAFETYnotes onThreadIsolated::newin bothfrom_js_asyncconstructors 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 theScryptJobconstructor afterwards.scryptSyncin 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, likefrom_js_to_owned_sliceand the transcoded-string path in the same file. - Rebased onto #40511, which replaced the
protect()/Unprotectbookkeeping withPinnedArrayBufferand theunsafeThreadIsolated::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
ArrayBufferinputs) 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.zstdDecompresswith a resizable input that shrinks after the call (#34751, now fixed by #40641), andBun.markdown.htmlwith 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 failstest still passes: it measures the delta of protectedUint8Arraycounts, 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)
[1m[92m Compiling[0m bun_analytics v0.0.0 (/workspace/bun/src/analytics)
[1m[92m Compiling[0m bun_exe_format v0.0.0 (/workspace/bun/src/exe_format)
[1m[92m Compiling[0m bun_options_types v0.0.0 (/workspace/bun/src/options_types)
[1m[92m Compiling[0m bun_api v0.0.0 (/workspace/bun/src/api)
[1m[92m Compiling[0m 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