Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 41 additions & 22 deletions src/runtime/node/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,45 @@ impl StringOrBuffer {
}
}

/// Decode an array-buffer-like `value` into `*out`.
///
/// Pin/protect prevents GC movement and detach, but not
/// `ArrayBuffer.prototype.resize()`. An async worker must not keep a live
/// pointer/length into a non-shared resizable backing store, so those
/// inputs are snapshotted into owned bytes before handoff; fixed buffers
/// (and growable SharedArrayBuffers) keep the pinned/protected fast path.
#[inline]
fn decode_array_buffer_into(
out: &mut StringOrBuffer,
global: &JSGlobalObject,
value: JSValue,
is_async: bool,
) {
let buffer = if is_async {
Buffer::from_js_pinned(global, value)
.unwrap_or_else(|| Buffer::from_array_buffer(global, value))
} else {
Buffer::from_array_buffer(global, value)
};

if is_async {
if buffer.buffer.resizable && !buffer.buffer.shared {
let owned = buffer.buffer.byte_slice().to_vec();
global.vm().report_extra_memory(owned.len());
let mut buffer = buffer;
if buffer.pinned {
buffer.pinned = false;
buffer.buffer.unpin();
}
*out = Self::EncodedSlice(ZigStringSlice::init_owned(owned));
return;
}
buffer.buffer.value.protect();
}

*out = Self::Buffer(buffer);
}

/// Out-param core of [`from_js_maybe_async`]. Writes the decoded payload
/// directly into `*out` (Zig result-location semantics) and returns
/// `Ok(true)` on success, `Ok(false)` if `value` is not a string/buffer
Expand Down Expand Up @@ -455,18 +494,7 @@ impl StringOrBuffer {
| JSType::BigInt64Array
| JSType::BigUint64Array
| JSType::DataView => {
let buffer = if is_async {
Buffer::from_js_pinned(global, value)
.unwrap_or_else(|| Buffer::from_array_buffer(global, value))
} else {
Buffer::from_array_buffer(global, value)
};

if is_async {
buffer.buffer.value.protect();
}

*out = Self::Buffer(buffer);
Self::decode_array_buffer_into(out, global, value, is_async);
Ok(true)
}
_ => Ok(false),
Expand Down Expand Up @@ -526,16 +554,7 @@ impl StringOrBuffer {
allow_string_object: bool,
) -> JsResult<bool> {
if value.is_cell() && value.js_type().is_array_buffer_like() {
let buffer = if is_async {
Buffer::from_js_pinned(global, value)
.unwrap_or_else(|| Buffer::from_array_buffer(global, value))
} else {
Buffer::from_array_buffer(global, value)
};
if is_async {
buffer.buffer.value.protect();
}
*out = Self::Buffer(buffer);
Self::decode_array_buffer_into(out, global, value, is_async);
return Ok(true);
}

Expand Down
61 changes: 61 additions & 0 deletions test/js/node/crypto/scrypt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import crypto from "node:crypto";

// When `crypto.scrypt` fails to allocate the output buffer (OOM for a huge
// `keylen`), `CryptoJob.init` takes the error path. Previously the `errdefer`
Expand Down Expand Up @@ -76,3 +77,63 @@ test("scrypt async does not leak callback/buffers when output allocation fails",

expect(exitCode).toBe(0);
});

// Async `crypto.scrypt` snapshots its password/salt at submission time. Safe JS
// can back either input with a resizable ArrayBuffer and resize it to zero
// before the worker runs; the derived key must still match the originally
// submitted bytes (matching Node) instead of reading a stale descriptor.
const SCRYPT_OPTS = { N: 16, r: 8, p: 1 };

test("scrypt async snapshots a resizable-ArrayBuffer-backed password", async () => {
const salt = Buffer.alloc(16, 0x42);
const rab = new ArrayBuffer(1024, { maxByteLength: 1024 });
const password = new Uint8Array(rab);
password.fill(0x41);
const original = Buffer.from(password);
const expected = crypto.scryptSync(original, salt, 64, SCRYPT_OPTS);

const { promise, resolve, reject } = Promise.withResolvers();
crypto.scrypt(password, salt, 64, SCRYPT_OPTS, (err, key) => (err ? reject(err) : resolve(key)));
rab.resize(0);

const key = await promise;
expect(rab.byteLength).toBe(0);
expect(key).toEqual(expected);
});

test("scrypt async snapshots the active region of a RAB-backed password view", async () => {
const salt = Buffer.alloc(16, 0x42);
const rab = new ArrayBuffer(256, { maxByteLength: 256 });
const full = new Uint8Array(rab);
for (let i = 0; i < full.length; i++) full[i] = i & 0xff;

// Non-zero byteOffset, length < backing: the snapshot must be the view's
// active region, not the whole ArrayBuffer.
const password = new Uint8Array(rab, 64, 100);
const original = Buffer.from(password);
const expected = crypto.scryptSync(original, salt, 64, SCRYPT_OPTS);

const { promise, resolve, reject } = Promise.withResolvers();
crypto.scrypt(password, salt, 64, SCRYPT_OPTS, (err, key) => (err ? reject(err) : resolve(key)));
rab.resize(0);

const key = await promise;
expect(key).toEqual(expected);
});

test("scrypt async snapshots a resizable-ArrayBuffer-backed salt", async () => {
const password = Buffer.alloc(64, 0x41);
const rab = new ArrayBuffer(16, { maxByteLength: 16 });
const salt = new Uint8Array(rab);
salt.fill(0x42);
const original = Buffer.from(salt);
const expected = crypto.scryptSync(password, original, 64, SCRYPT_OPTS);

const { promise, resolve, reject } = Promise.withResolvers();
crypto.scrypt(password, salt, 64, SCRYPT_OPTS, (err, key) => (err ? reject(err) : resolve(key)));
rab.resize(0);

const key = await promise;
expect(rab.byteLength).toBe(0);
expect(key).toEqual(expected);
});