diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 6c52ed5af934..4590f1cd35e5 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -66,11 +66,20 @@ const { getHashes, scrypt, scryptSync, + argon2: _argon2, + argon2Sync: _argon2Sync, } = $rust("node_crypto_binding.rs", "createNodeCryptoBindingZig"); const normalizeEncoding = $newRustFunction("node_util_binding.rs", "normalizeEncoding", 1); -const { validateString } = require("internal/validators"); +const { + validateFunction, + validateInteger, + validateObject, + validateOneOf, + validateString, + validateUint32, +} = require("internal/validators"); const { deprecate } = require("internal/util/deprecate"); const kHandle = Symbol("kHandle"); @@ -300,14 +309,97 @@ crypto_exports.randomBytes = randomBytes; crypto_exports.randomUUID = randomUUID; crypto_exports.randomUUIDv7 = randomUUIDv7; -// Node only provides Argon2 when built against OpenSSL >= 3.2; BoringSSL has no -// Argon2, so these throw the same error an unsupported Node build throws. -// The unused parameters are declared to keep `.length` equal to Node's (3 and 2). -crypto_exports.argon2 = function argon2(_algorithm, _parameters, _callback) { - throw $ERR_CRYPTO_ARGON2_NOT_SUPPORTED("Argon2 algorithm not supported"); +const kArgon2Types = { __proto__: null, argon2d: 0, argon2i: 1, argon2id: 2 }; + +// Node's argon2 path rejects KeyObject and throws node-formatted +// ERR_INVALID_ARG_TYPE, unlike the local `getArrayBufferOrView` above. +function getArgon2BufferSource(buffer, name) { + if (isAnyArrayBuffer(buffer)) return buffer; + if (typeof buffer === "string") return Buffer.from(buffer, "utf8"); + if (!isArrayBufferView(buffer)) { + throw $ERR_INVALID_ARG_TYPE(name, ["string", "ArrayBuffer", "Buffer", "TypedArray", "DataView"], buffer); + } + return buffer; +} + +// Mirrors `check()` in node's lib/internal/crypto/argon2.js, except a +// wrong-typed secret/associatedData names the property (node passes no name +// there and trips ERR_INTERNAL_ASSERTION on it). +function checkArgon2(algorithm, parameters) { + validateString(algorithm, "algorithm"); + validateOneOf(algorithm, "algorithm", ["argon2d", "argon2i", "argon2id"]); + const type = kArgon2Types[algorithm]; + + validateObject(parameters, "parameters"); + + const { parallelism, tagLength, memory, passes } = parameters; + const MAX_POSITIVE_UINT_32 = 2 ** 32 - 1; + + const message = getArgon2BufferSource(parameters.message, "parameters.message"); + validateInteger(message.byteLength, "parameters.message.byteLength", 0, MAX_POSITIVE_UINT_32); + + const nonce = getArgon2BufferSource(parameters.nonce, "parameters.nonce"); + validateInteger(nonce.byteLength, "parameters.nonce.byteLength", 8, MAX_POSITIVE_UINT_32); + + validateInteger(parallelism, "parameters.parallelism", 1, 2 ** 24 - 1); + validateInteger(tagLength, "parameters.tagLength", 4, MAX_POSITIVE_UINT_32); + validateInteger(memory, "parameters.memory", 8 * parallelism, MAX_POSITIVE_UINT_32); + validateUint32(passes, "parameters.passes", true); + + let secret = parameters.secret; + if (secret === undefined) { + secret = new Uint8Array(0); + } else { + secret = getArgon2BufferSource(secret, "parameters.secret"); + validateInteger(secret.byteLength, "parameters.secret.byteLength", 0, MAX_POSITIVE_UINT_32); + } + + let associatedData = parameters.associatedData; + if (associatedData === undefined) { + associatedData = new Uint8Array(0); + } else { + associatedData = getArgon2BufferSource(associatedData, "parameters.associatedData"); + validateInteger(associatedData.byteLength, "parameters.associatedData.byteLength", 0, MAX_POSITIVE_UINT_32); + } + + return { message, nonce, secret, associatedData, tagLength, passes, parallelism, memory, type }; +} + +crypto_exports.argon2 = function argon2(algorithm, parameters, callback) { + parameters = checkArgon2(algorithm, parameters); + + validateFunction(callback, "callback"); + + _argon2( + parameters.message, + parameters.nonce, + parameters.parallelism, + parameters.tagLength, + parameters.memory, + parameters.passes, + parameters.secret, + parameters.associatedData, + parameters.type, + (err, result) => { + if (err !== undefined) return callback(err); + callback(null, result); + }, + ); }; -crypto_exports.argon2Sync = function argon2Sync(_algorithm, _parameters) { - throw $ERR_CRYPTO_ARGON2_NOT_SUPPORTED("Argon2 algorithm not supported"); +crypto_exports.argon2Sync = function argon2Sync(algorithm, parameters) { + parameters = checkArgon2(algorithm, parameters); + + return _argon2Sync( + parameters.message, + parameters.nonce, + parameters.parallelism, + parameters.tagLength, + parameters.memory, + parameters.passes, + parameters.secret, + parameters.associatedData, + parameters.type, + ); }; crypto_exports.checkPrime = checkPrime; diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 1272afb83d3a..9e4d27e596f3 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -339,7 +339,6 @@ const errors: ErrorCodeMapping = [ ["ERR_TRACE_EVENTS_UNAVAILABLE", Error], ["ERR_TRAILING_JUNK_AFTER_STREAM_END", TypeError], ["ERR_SQLITE_ERROR", Error], - ["ERR_CRYPTO_ARGON2_NOT_SUPPORTED", Error], ["ERR_HTTP2_INVALID_CONNECTION_HEADERS", TypeError], ["ERR_QUIC_CONNECTION_FAILED", Error], ["ERR_QUIC_ENDPOINT_CLOSED", Error], diff --git a/src/runtime/node/node_crypto_binding.rs b/src/runtime/node/node_crypto_binding.rs index 06a87250fd56..59bee40fe765 100644 --- a/src/runtime/node/node_crypto_binding.rs +++ b/src/runtime/node/node_crypto_binding.rs @@ -3,6 +3,8 @@ use core::ffi::{c_char, c_void}; +// `rust-argon2` exports its lib as crate `argon2`; alias past the `argon2` host fn below. +use ::argon2 as rust_argon2; use bun_boringssl as boringssl; use bun_collections::CaseInsensitiveAsciiStringArrayHashMap; use bun_jsc::{ @@ -765,6 +767,28 @@ pub(crate) struct Scrypt { keylen: u32, } +// ─────────────────────────────────────────────────────────────────────────── +// Argon2 (crypto.argon2 / crypto.argon2Sync) +// ─────────────────────────────────────────────────────────────────────────── + +/// One argon2 derivation, routed to the pure-Rust `rust-argon2` crate that +/// `Bun.password` already uses (BoringSSL has no argon2). Inputs are copied +/// out of JS at call time so the work-pool half never touches JS memory; +/// node's async jobs copy the same way. +pub(crate) struct Argon2 { + message: Vec, + nonce: Vec, + secret: Vec, + associated_data: Vec, + parallelism: u32, + tag_length: u32, + memory: u32, + passes: u32, + variant: rust_argon2::Variant, + output: Vec, + failed: bool, +} + mod _impl { use super::*; use crate::node::util::validators; @@ -1320,8 +1344,220 @@ mod _impl { Ok(buf) } + impl Argon2 { + /// Arguments arrive pre-validated from `checkArgon2()` in `crypto.ts`; + /// the checks here only defend the internal binding itself. + fn from_js(global: &JSGlobalObject, call_frame: &CallFrame) -> JsResult<(Self, JSValue)> { + fn copy_buffer_arg( + global: &JSGlobalObject, + value: JSValue, + name: &'static [u8], + ) -> JsResult> { + let Some(buf) = value.as_array_buffer(global) else { + return Err(global.throw_invalid_argument_type_value( + name, + b"ArrayBuffer, Buffer, TypedArray, or DataView", + value, + )); + }; + let bytes = buf.byte_slice(); + let mut copy = Vec::new(); + if copy.try_reserve_exact(bytes.len()).is_err() { + return Err(global.throw_out_of_memory()); + } + copy.extend_from_slice(bytes); + Ok(copy) + } + + let [ + message_value, + nonce_value, + parallelism_value, + tag_length_value, + memory_value, + passes_value, + secret_value, + associated_data_value, + variant_value, + callback, + ] = call_frame.arguments_as_array::<10>(); + + let parallelism = validators::validate_uint32( + global, + parallelism_value, + format_args!("parameters.parallelism"), + true, + )?; + let tag_length = validators::validate_uint32( + global, + tag_length_value, + format_args!("parameters.tagLength"), + true, + )?; + let memory = validators::validate_uint32( + global, + memory_value, + format_args!("parameters.memory"), + true, + )?; + let passes = validators::validate_uint32( + global, + passes_value, + format_args!("parameters.passes"), + true, + )?; + let variant = match validators::validate_uint32( + global, + variant_value, + format_args!("type"), + false, + )? { + 0 => rust_argon2::Variant::Argon2d, + 1 => rust_argon2::Variant::Argon2i, + 2 => rust_argon2::Variant::Argon2id, + _ => { + return Err(global.throw_invalid_argument_type_value( + b"type", + b"a supported argon2 type", + variant_value, + )); + } + }; + + // The validators admit sizes rust-argon2 would abort on + // (`vec![Block::zero(); mem_cost]` and the output Vec allocate + // infallibly). Pre-fail the job instead, so both paths deliver + // the same catchable error node produces when OpenSSL's argon2 + // allocation fails. + let limit = jsc::virtual_machine::synthetic_allocation_limit(); + let failed = + (memory as usize).saturating_mul(1024) > limit || tag_length as usize > limit; + + let ctx = Argon2 { + message: copy_buffer_arg(global, message_value, b"message")?, + nonce: copy_buffer_arg(global, nonce_value, b"nonce")?, + secret: copy_buffer_arg(global, secret_value, b"secret")?, + associated_data: copy_buffer_arg(global, associated_data_value, b"associatedData")?, + parallelism, + tag_length, + memory, + passes, + variant, + output: Vec::new(), + failed, + }; + Ok((ctx, callback)) + } + + fn run(&mut self) { + if self.failed { + return; + } + let config = rust_argon2::Config { + ad: &self.associated_data, + hash_length: self.tag_length, + lanes: self.parallelism, + mem_cost: self.memory, + secret: &self.secret, + // Sequential like Bun.password (pwhash.rs): lanes determine + // the output, not the thread count, so results match node, + // which threads lanes via OpenSSL on its worker. + thread_mode: rust_argon2::ThreadMode::Sequential, + time_cost: self.passes, + variant: self.variant, + version: rust_argon2::Version::Version13, + }; + match rust_argon2::hash_raw(&self.message, &self.nonce, &config) { + Ok(hash) => self.output = hash, + // Unreachable via `node:crypto`: `checkArgon2()` bounds are a + // superset of rust-argon2's constraints. + Err(_) => self.failed = true, + } + } + } + + /// JS-thread state for the argon2 job: the user callback, invoked as + /// `(err)` or `(undefined, buffer)`. + #[derive(bun_jsc::JsAffine)] + pub(crate) struct Argon2Js { + callback: Strong, + } + + /// `crypto.argon2` off the JS thread: `from_js` copied every input out of + /// JS, so the pool half owns plain memory and needs no `JsPtr`. + impl JobContext for Argon2 { + type OffThread = Self; + type Js = Argon2Js; + + fn run( + this: &mut Self, + done: bun_jsc::Completion, + ) -> Option> { + this.run(); + Some(done) + } + + fn then(mut this: Self, js: Argon2Js, cx: &JsThread<'_>) -> JsResult<()> { + let global = cx.global(); + let event_loop = global.bun_vm().event_loop_mut(); + let callback = js.callback.get(); + if this.failed { + let exception = + global.create_error_instance(format_args!("Argon2 derivation failed")); + event_loop.run_callback(callback, global, JSValue::UNDEFINED, &[exception]); + return Ok(()); + } + let output = core::mem::take(&mut this.output); + // Ownership transfers to JSC (freed via MarkedArrayBuffer_deallocator). + match JSValue::create_buffer(global, output.leak()) { + Ok(buf) => event_loop.run_callback( + callback, + global, + JSValue::UNDEFINED, + &[JSValue::UNDEFINED, buf], + ), + // The result could not be built (allocation failure): that is + // this derivation's error. + Err(err) => event_loop.run_callback( + callback, + global, + JSValue::UNDEFINED, + &[global.take_error(err)], + ), + } + Ok(()) + } + } + + #[bun_jsc::host_fn] + fn argon2(global: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { + let (ctx, callback) = Argon2::from_js(global, call_frame)?; + let _ = validators::validate_function(global, "callback", callback)?; + let cx = global.js_thread(); + Job::::schedule( + &cx, + ctx, + Argon2Js { + callback: Strong::create(callback.with_async_context_if_needed(global), global), + }, + ); + Ok(JSValue::UNDEFINED) + } + + #[bun_jsc::host_fn] + fn argon2_sync(global: &JSGlobalObject, call_frame: &CallFrame) -> JsResult { + let (mut ctx, _) = Argon2::from_js(global, call_frame)?; + ctx.run(); + if ctx.failed { + let err = global.create_error_instance(format_args!("Argon2 derivation failed")); + return Err(global.throw_value(err)); + } + // Ownership transfers to JSC (freed via MarkedArrayBuffer_deallocator). + JSValue::create_buffer(global, ctx.output.leak()) + } + pub(crate) fn create_node_crypto_binding_zig(global: &JSGlobalObject) -> JSValue { - let crypto = JSValue::create_empty_object(global, 15); + let crypto = JSValue::create_empty_object(global, 17); // `#[bun_jsc::host_fn]` emits a `__jsc_host_{name}` shim with the raw `JSHostFn` ABI; // pass that (not the safe-Rust body) to `JSFunction::create`. @@ -1493,6 +1729,23 @@ mod _impl { ), ); + crypto.put( + global, + b"argon2", + JSFunction::create(global, "argon2", __jsc_host_argon2, 10, Default::default()), + ); + crypto.put( + global, + b"argon2Sync", + JSFunction::create( + global, + "argon2Sync", + __jsc_host_argon2_sync, + 9, + Default::default(), + ), + ); + crypto } } // mod _impl diff --git a/test/js/node/crypto/argon2.test.ts b/test/js/node/crypto/argon2.test.ts new file mode 100644 index 000000000000..0d923100a562 --- /dev/null +++ b/test/js/node/crypto/argon2.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import nodeCrypto from "node:crypto"; + +// Not yet in @types/node 25. +const crypto = nodeCrypto as typeof nodeCrypto & { + argon2: ( + algorithm: string, + parameters: Record, + callback: (err: Error | null, result?: Buffer) => void, + ) => void; + argon2Sync: (algorithm: string, parameters: Record) => Buffer; +}; + +const message = Buffer.alloc(32, 0x01); +const nonce = Buffer.alloc(16, 0x02); +const secret = Buffer.alloc(8, 0x03); +const associatedData = Buffer.alloc(12, 0x04); +const defaults = { message, nonce, parallelism: 1, tagLength: 64, memory: 8, passes: 3 }; + +function argon2Async(algorithm: string, parameters: Record): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + crypto.argon2(algorithm, parameters, (err, result) => (err ? reject(err) : resolve(result!))); + return promise; +} + +function expectNodeError(fn: () => unknown, ctor: ErrorConstructor, code: string, message: string) { + let error: any; + try { + fn(); + } catch (e) { + error = e; + } + expect(error).toBeInstanceOf(ctor); + expect(error.code).toBe(code); + expect(error.message).toBe(message); +} + +// Same parameter sets and expected outputs as the upstream +// test/js/node/test/parallel/test-crypto-argon2.js (RFC 9106 and OpenSSL 3.2 +// test vectors; that file skips under bun because it gates on OpenSSL >= 3.2), +// except the two memory:65536 entries are downsized to memory:4096 to fit +// debug/ASAN time budgets, plus two extras; every output below was generated +// by Node.js v26.3.0. +const vectors: [algorithm: string, overrides: Record, expectedHex: string][] = [ + [ + "argon2d", + { secret, associatedData, parallelism: 4, tagLength: 32, memory: 32 }, + "512b391b6f1162975371d30919734294f868e3be3984f3c1a13a4db9fabe4acb", + ], + [ + "argon2i", + { secret, associatedData, parallelism: 4, tagLength: 32, memory: 32 }, + "c814d9d1dc7f37aa13f0d77f2494bda1c8de6b016dd388d29952a4c4672b6ce8", + ], + [ + "argon2id", + { secret, associatedData, parallelism: 4, tagLength: 32, memory: 32 }, + "0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659", + ], + [ + "argon2d", + { message: "1234567890", nonce: "saltsalt" }, + "d16ad773b1c6400d3193bc3e66271603e9de72bace20af3f89c236f5434cdec9" + + "9072ddfc6b9c77ea9f386c0e8d7cb0c37cec6ec3277a22c92d5be58ef67c7eaa", + ], + [ + "argon2id", + { message: "", parallelism: 4, tagLength: 32, memory: 32 }, + "0a34f1abde67086c82e785eaf17c68382259a264f4e61b91cd2763cb75ac189a", + ], + [ + "argon2d", + { message: "1234567890", nonce: "saltsalt", parallelism: 2, memory: 4096 }, + "491760c694fe6a7c94ab4e6a6344b55115565a6dbb3e078567b3f75c92a6dc5d" + + "03e823078bfa9811e7be1cc94fa2d9d167ab316aada7d846845ac288aa7e07c7", + ], + [ + "argon2i", + { parallelism: 4, tagLength: 32, memory: 32 }, + "a9a7510e6db4d588ba3414cd0e094d480d683f97b9ccb612a544fe8ef65ba8e0", + ], + [ + "argon2id", + { parallelism: 4, tagLength: 32, memory: 32 }, + "03aab965c12001c9d7d0d2de33192c0494b684bb148196d73c1df1acaf6d0c2e", + ], + [ + "argon2d", + { message: "1234567890", nonce: "saltsalt", parallelism: 2, tagLength: 128, memory: 4096 }, + "4e644cec0ff484c60f220e807147bb9fa2d5085e1ffb4071a8b606446d97e3b5" + + "57c985d85fca2e6dc7f08b8a2398f79fbf48a642b810c5e2406fe5f5ed959864" + + "30c73c4ddfda92ea9b6d43dce62078ada1529c4217ae75968f0412140dc00204" + + "74360ba67e43bef4b790cac30a8fe7f3de8efdaaee5bc44617b39f18bb950c5c", + ], + [ + "argon2id", + {}, + "509fa5d06cdeb30aa3ae36410116bdbd98da46bbe034d50810ba8518de408678" + + "49ffdc2d57c5562abe837602ac0035c612fab842582e00009bd7733f4e6fd49e", + ], + ["argon2id", { passes: 1, tagLength: 4 }, "6e76a640"], +]; + +describe("crypto.argon2", () => { + test("exports match node's shape", () => { + expect(typeof crypto.argon2).toBe("function"); + expect(typeof crypto.argon2Sync).toBe("function"); + expect(crypto.argon2.length).toBe(3); + expect(crypto.argon2Sync.length).toBe(2); + }); + + describe("derives node's expected output", () => { + for (const [algorithm, overrides, expected] of vectors) { + const label = `${algorithm} ${JSON.stringify(overrides).slice(0, 70)}`; + test(label, async () => { + const parameters = { ...defaults, ...overrides }; + + const syncResult = crypto.argon2Sync(algorithm, parameters); + expect(Buffer.isBuffer(syncResult)).toBe(true); + expect(syncResult.toString("hex")).toBe(expected); + expect(syncResult.length).toBe((parameters.tagLength as number) ?? 64); + + const asyncResult = await argon2Async(algorithm, parameters); + expect(Buffer.isBuffer(asyncResult)).toBe(true); + expect(asyncResult.toString("hex")).toBe(expected); + }); + } + }); + + test("omitted secret/associatedData equals explicit empty", () => { + const omitted = crypto.argon2Sync("argon2id", defaults); + const explicitEmpty = crypto.argon2Sync("argon2id", { + ...defaults, + secret: Buffer.alloc(0), + associatedData: Buffer.alloc(0), + }); + expect(omitted).toEqual(explicitEmpty); + }); + + test("accepts ArrayBuffer and offset TypedArray views for message", () => { + const base = crypto.argon2Sync("argon2id", { ...defaults, tagLength: 32 }); + + const asArrayBuffer = message.buffer.slice(message.byteOffset, message.byteOffset + message.byteLength); + expect(crypto.argon2Sync("argon2id", { ...defaults, tagLength: 32, message: asArrayBuffer })).toEqual(base); + + // A view whose byteOffset is non-zero must hash only the view's range. + const padded = Buffer.concat([Buffer.alloc(5, 0xee), message]); + expect(crypto.argon2Sync("argon2id", { ...defaults, tagLength: 32, message: padded.subarray(5) })).toEqual(base); + }); + + test("async callback gets (null, Buffer) and inputs are copied at call time", async () => { + const parameters = { ...defaults, tagLength: 32 }; + const expected = crypto.argon2Sync("argon2id", parameters); + + const mutableMessage = Buffer.from(message); + const mutableNonce = Buffer.from(nonce); + const { promise, resolve, reject } = Promise.withResolvers<{ err: unknown; result: Buffer }>(); + crypto.argon2("argon2id", { ...parameters, message: mutableMessage, nonce: mutableNonce }, (err, result) => { + if (err) return reject(err); + resolve({ err, result: result! }); + }); + // Clobbering the inputs after the call must not affect the job. + mutableMessage.fill(0xff); + mutableNonce.fill(0xff); + + const { err, result } = await promise; + expect(err).toBeNull(); + expect(result).toEqual(expected); + }); + + test("concurrent async jobs all complete", async () => { + const parameters = { ...defaults, parallelism: 4, tagLength: 32, memory: 32 }; + const algorithms = ["argon2d", "argon2i", "argon2id"]; + const results = await Promise.all(algorithms.map(algorithm => argon2Async(algorithm, parameters))); + expect(results).toEqual(algorithms.map(algorithm => crypto.argon2Sync(algorithm, parameters))); + }); + + describe("rejects out-of-range parameters like node", () => { + const cases: [overrides: Record, message: string][] = [ + [ + { nonce: nonce.subarray(0, 7) }, + 'The value of "parameters.nonce.byteLength" is out of range. It must be >= 8 && <= 4294967295. Received 7', + ], + [ + { tagLength: 3 }, + 'The value of "parameters.tagLength" is out of range. It must be >= 4 && <= 4294967295. Received 3', + ], + [ + { tagLength: 2 ** 32 }, + 'The value of "parameters.tagLength" is out of range. It must be >= 4 && <= 4294967295. Received 4294967296', + ], + [{ passes: 0 }, 'The value of "parameters.passes" is out of range. It must be >= 1 && <= 4294967295. Received 0'], + [ + { passes: 2 ** 32 }, + 'The value of "parameters.passes" is out of range. It must be >= 1 && <= 4294967295. Received 4294967296', + ], + [ + { parallelism: 0 }, + 'The value of "parameters.parallelism" is out of range. It must be >= 1 && <= 16777215. Received 0', + ], + [ + { parallelism: 2 ** 24 }, + 'The value of "parameters.parallelism" is out of range. It must be >= 1 && <= 16777215. Received 16777216', + ], + [ + { parallelism: 4, memory: 16 }, + 'The value of "parameters.memory" is out of range. It must be >= 32 && <= 4294967295. Received 16', + ], + [ + { memory: 2 ** 32 }, + 'The value of "parameters.memory" is out of range. It must be >= 8 && <= 4294967295. Received 4294967296', + ], + ]; + + for (const [overrides, errorMessage] of cases) { + test(JSON.stringify(overrides), () => { + const parameters = { ...defaults, ...overrides }; + expectNodeError( + () => crypto.argon2("argon2id", parameters, () => {}), + RangeError, + "ERR_OUT_OF_RANGE", + errorMessage, + ); + expectNodeError(() => crypto.argon2Sync("argon2id", parameters), RangeError, "ERR_OUT_OF_RANGE", errorMessage); + }); + } + }); + + describe("rejects missing parameters like node", () => { + const bufferTypesMessage = + "must be of type string or an instance of ArrayBuffer, Buffer, TypedArray, or DataView. Received undefined"; + const cases: Record = { + message: `The "parameters.message" property ${bufferTypesMessage}`, + nonce: `The "parameters.nonce" property ${bufferTypesMessage}`, + parallelism: 'The "parameters.parallelism" property must be of type number. Received undefined', + tagLength: 'The "parameters.tagLength" property must be of type number. Received undefined', + memory: 'The "parameters.memory" property must be of type number. Received undefined', + passes: 'The "parameters.passes" property must be of type number. Received undefined', + }; + + for (const [key, errorMessage] of Object.entries(cases)) { + test(key, () => { + const parameters: Record = { ...defaults }; + delete parameters[key]; + expectNodeError( + () => crypto.argon2("argon2id", parameters, () => {}), + TypeError, + "ERR_INVALID_ARG_TYPE", + errorMessage, + ); + expectNodeError( + () => crypto.argon2Sync("argon2id", parameters), + TypeError, + "ERR_INVALID_ARG_TYPE", + errorMessage, + ); + }); + } + }); + + test("rejects invalid algorithm, parameters, and callback like node", () => { + expectNodeError( + () => crypto.argon2Sync("argon2x", defaults), + TypeError, + "ERR_INVALID_ARG_VALUE", + "The argument 'algorithm' must be one of: 'argon2d', 'argon2i', 'argon2id'. Received 'argon2x'", + ); + expectNodeError( + () => crypto.argon2Sync(5 as any, defaults), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "algorithm" argument must be of type string. Received type number (5)', + ); + expectNodeError( + () => (crypto.argon2 as any)(), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "algorithm" argument must be of type string. Received undefined', + ); + expectNodeError( + () => crypto.argon2Sync("argon2id", null as any), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "parameters" argument must be of type object. Received null', + ); + // Parameters are validated before the callback, like node. + expectNodeError( + () => (crypto.argon2 as any)("argon2id", null, null), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "parameters" argument must be of type object. Received null', + ); + expectNodeError( + () => (crypto.argon2 as any)("argon2id", defaults, null), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "callback" argument must be of type function. Received null', + ); + expectNodeError( + () => (crypto.argon2 as any)("argon2id", defaults, {}), + TypeError, + "ERR_INVALID_ARG_TYPE", + 'The "callback" argument must be of type function. Received an instance of Object', + ); + }); + + test("rejects wrong-typed secret/associatedData", () => { + // Node currently throws ERR_INTERNAL_ASSERTION here (its check() passes no + // name to getArrayBufferOrView); throw a proper error naming the property. + const expected = (name: string) => + `The "${name}" property must be of type string or an instance of ArrayBuffer, Buffer, TypedArray, or DataView. Received type number (42)`; + expectNodeError( + () => crypto.argon2Sync("argon2id", { ...defaults, secret: 42 }), + TypeError, + "ERR_INVALID_ARG_TYPE", + expected("parameters.secret"), + ); + expectNodeError( + () => crypto.argon2Sync("argon2id", { ...defaults, associatedData: 42 }), + TypeError, + "ERR_INVALID_ARG_TYPE", + expected("parameters.associatedData"), + ); + }); + + test("detached message hashes as empty, like node", () => { + // Matches the empty-string-message vector above. + const emptyMessageHash = "0a34f1abde67086c82e785eaf17c68382259a264f4e61b91cd2763cb75ac189a"; + const base = { ...defaults, parallelism: 4, tagLength: 32, memory: 32 }; + + const detached = new ArrayBuffer(32); + detached.transfer(); + expect(crypto.argon2Sync("argon2id", { ...base, message: detached }).toString("hex")).toBe(emptyMessageHash); + + const viewBuffer = new ArrayBuffer(32); + const detachedView = new Uint8Array(viewBuffer); + viewBuffer.transfer(); + expect(crypto.argon2Sync("argon2id", { ...base, message: detachedView }).toString("hex")).toBe(emptyMessageHash); + + // A detached nonce has byteLength 0 and fails the >= 8 check. + const detachedNonce = new ArrayBuffer(16); + detachedNonce.transfer(); + expectNodeError( + () => crypto.argon2Sync("argon2id", { ...base, nonce: detachedNonce }), + RangeError, + "ERR_OUT_OF_RANGE", + 'The value of "parameters.nonce.byteLength" is out of range. It must be >= 8 && <= 4294967295. Received 0', + ); + }); + + test("accepts SharedArrayBuffer inputs, like node", () => { + // Matches the plain-Buffer vector above with the same bytes. + const expected = "03aab965c12001c9d7d0d2de33192c0494b684bb148196d73c1df1acaf6d0c2e"; + const sabMessage = new SharedArrayBuffer(32); + new Uint8Array(sabMessage).fill(0x01); + const sabNonce = new SharedArrayBuffer(16); + new Uint8Array(sabNonce).fill(0x02); + + const parameters = { parallelism: 4, tagLength: 32, memory: 32, passes: 3 }; + expect(crypto.argon2Sync("argon2id", { ...parameters, message: sabMessage, nonce: sabNonce }).toString("hex")).toBe( + expected, + ); + expect( + crypto + .argon2Sync("argon2id", { + ...parameters, + message: new Uint8Array(sabMessage), + nonce: new Uint8Array(sabNonce), + }) + .toString("hex"), + ).toBe(expected); + }); + + test("allocation-limit failures are catchable errors, not aborts", async () => { + // In-range parameters can still exceed what the process can allocate + // (rust-argon2 allocates infallibly; node surfaces an OpenSSL error). + // Lower the synthetic limit so the guard fires at test-friendly sizes, + // and assert both paths deliver node's catchable error. + using dir = tempDir("argon2-alloc-limit", { + "check.js": ` + const crypto = require("node:crypto"); + const base = { message: "pw", nonce: "saltsalt", parallelism: 1, tagLength: 32, memory: 8, passes: 1 }; + const results = {}; + + try { + crypto.argon2Sync("argon2id", { ...base, memory: 32 * 1024 }); // 32 MiB > 16 MiB limit + results.syncMemory = "no error"; + } catch (e) { + results.syncMemory = e.message; + } + try { + crypto.argon2Sync("argon2id", { ...base, tagLength: 32 * 1024 * 1024 }); + results.syncTagLength = "no error"; + } catch (e) { + results.syncTagLength = e.message; + } + results.withinLimit = crypto.argon2Sync("argon2id", base).length; + + crypto.argon2("argon2id", { ...base, memory: 32 * 1024 }, (err) => { + results.asyncMemory = err === null ? "no error" : err.message; + console.log(JSON.stringify(results)); + process.exit(0); + }); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "check.js"], + env: { ...bunEnv, BUN_FEATURE_FLAG_SYNTHETIC_MEMORY_LIMIT: String(16 * 1024 * 1024) }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ + syncMemory: "Argon2 derivation failed", + syncTagLength: "Argon2 derivation failed", + withinLimit: 32, + asyncMemory: "Argon2 derivation failed", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/node/test/parallel/test-crypto-argon2-unsupported.js b/test/js/node/test/parallel/test-crypto-argon2-unsupported.js deleted file mode 100644 index e0764d3293bd..000000000000 --- a/test/js/node/test/parallel/test-crypto-argon2-unsupported.js +++ /dev/null @@ -1,14 +0,0 @@ -'use strict'; -const common = require('../common'); -if (!common.hasCrypto) - common.skip('missing crypto'); - -const { hasOpenSSL } = require('../common/crypto'); - -if (hasOpenSSL(3, 2)) - common.skip('requires OpenSSL < 3.2'); - -const assert = require('node:assert'); -const crypto = require('node:crypto'); - -assert.throws(() => crypto.argon2(), { code: 'ERR_CRYPTO_ARGON2_NOT_SUPPORTED' });