crypto.generatePrimeSync(1) panics and aborts the process instead of throwing
crypto.generatePrimeSync(1) takes the whole Deno process down, while Node just throws an ordinary error. It takes one line to trigger.
Summary
Passing 1 to crypto.generatePrimeSync() makes Deno panic and kill the process. try/catch cannot stop it. Node returns a normal catchable error for the same input.
Reproduction
That's the whole thing:
import crypto from "node:crypto";
try {
crypto.generatePrimeSync(1);
} catch (e) {
console.log("caught:", e.code); // never reached on Deno
}
console.log("SURVIVED");
What actually happens
| runtime | generatePrimeSync(1) |
process |
|---|---|---|
| Node v22.8.0 | throws Error (bits too small) |
survives |
| Node v24.14.0 | throws Error (bits too small) |
survives |
| Node v25.8.0 | throws Error (bits too small) |
survives |
| Node v26.7.0 | throws ERR_OSSL_BN_BITS_TOO_SMALL |
survives |
| Node v26.8.1 | throws ERR_OSSL_BN_BITS_TOO_SMALL |
survives |
| Deno 2.9.6 | panics, process dies | exit code 1 |
I tried every Node from v22 through v26.8 and the behaviour is identical, so this is not a version difference — it is specific to Deno.
Screenshots: how it gets there
Below is me stepping through it with VS Code + CodeLLDB on a Deno built from source (not the release binary).
Step 1: breakpoint in gen_prime, with size = 1
The argument arrives as size = 1, safe = false, and add/rem are both empty, so the else branch is next.
Step 2: step in, and land on the line with no check at all
This is the problem. The two branches of the same function are not symmetric:
- the branch on line
1643callsgenerate_with_options(...)?— it has a?that propagates the error - the branch on line
1646callsPrime::generate(size)— nothing, it just goes through
size = 1 takes the second one.
Step 3: step in again, and stop on the dependency's panic!
This one is the clearest: the debugger stops on panic!("prime size must be at least 2-bit") inside the third-party num-bigint-dig crate, with bit_size = 1 sitting in the Variables panel.
Reading the Call Stack top-down makes the path obvious:
bigrand.rs:310 <- the panic! itself
primes.rs:18 <- Deno's Prime::generate, passes it down unchecked
lib.rs:1646 <- Deno's gen_prime, else branch, unchecked
lib.rs:1683 <- the op entry point
In other words: Deno never stopped the 1, it travelled all the way into the library, and the library panicked.
Step 4: the process is killed
Same script, same machine:
deno exit=1— killednode exit=0— threw normally, process alive
And the ./ext/node_crypto/lib.rs:1646:8 in the backtrace is exactly the line the breakpoint was on in step 2.
Why this is worse than a normal crash
Deno's release profile sets panic = "abort" in Cargo.toml, so a Rust panic is not an exception — it kills the entire process. A caller's try/catch is useless and there is nothing to fall back on.
So any code that forwards an external value into generatePrimeSync() (for example passing a user-supplied primeLength straight through) can be taken down by a single 1.
Cause
gen_prime() in ext/node_crypto/lib.rs:
fn gen_prime(
size: usize,
safe: bool,
add: Option<&[u8]>,
rem: Option<&[u8]>,
) -> Result<Uint8Array, GeneratePrimeError> {
if safe || add.is_some() || rem.is_some() {
let prime = primes::Prime::generate_with_options(size, safe, add, rem)?; // has ?
Ok(prime.0.to_bytes_be().into())
} else {
Ok(primes::Prime::generate(size).0.to_bytes_be().into()) // no check
}
}
The JS side validates with validateInt32(size, "size", 1), which only guarantees size >= 1. But num-bigint-dig underneath requires >= 2:
// num-bigint-dig-0.8.6/src/bigrand.rs
fn gen_prime(&mut self, bit_size: usize) -> BigUint {
if bit_size < 2 {
panic!("prime size must be at least 2-bit");
}
The two ranges disagree, and size = 1 falls exactly in the gap: it passes the JS check (1 >= 1) and misses the lower-level requirement (1 < 2), so it panics.
For what it's worth, that else branch is what remained after PR #32618 added safe/add/rem support. That PR changed gen_prime to return a Result, but only added ? to the new branch; this pre-existing else branch was left untouched. That PR was itself about tightening boundary checks.
Why the tests did not catch it
parallel/test-crypto-prime.js is already listed in tests/node_compat/config.jsonc and is expected to pass. But its size cases are:
[-1, 0, 2 ** 31, 2 ** 31 + 1, 2 ** 32 - 1, 2 ** 32].forEach(...)
which skips 1. So this gap was never covered.
Suggested fix
Reject it before gen_prime() is reached, and return an error instead of letting it panic:
if size < 2 {
return Err(GeneratePrimeError::BitsTooSmall);
}
Match Node's message for the target version (26.3.0 behaves as error:01800076:bignum routines::bits too small), and add 1 to that size array in the tests.
Environment
- Deno: 2.9.6 (built from clone of
main@83bb8d5780505d0b4f30c089680ce98b0e7a4770, with debug symbols) - OS: macOS aarch64
- Node for comparison: v22.8.0 / v24.14.0 / v25.8.0 / v26.7.0 / v26.8.1
Aside: the same panic has a second path
While fixing this I ran the official crypto tests and found another place that hits the exact same panic, without going through generatePrimeSync:
crypto.generateKeyPairSync('dh', { primeLength: 0 }) // panics the same way
The backtrace goes through a different function:
bigrand.rs:310 panic!("prime size must be at least 2-bit")
primes.rs:18 Prime::generate
keys.rs:3465 dh_generate <- calls Prime::generate directly, also unchecked
keys.rs:3509 op_node_generate_dh_key
keys.rs:3465 is this line:
.unwrap_or_else(|| Prime::generate(prime_len))
which is the same shape as the else branch in gen_prime — straight into Prime::generate with no check that prime_len is large enough.
The corresponding official test test-crypto-negative-zero.js currently fails. That failure predates my change and is not caused by it.
That said, I want to be precise rather than overstate it:
primeLength |
Node v22.8 | v24.14 | v25.8 | v26.7 | v26.8.1 |
|---|---|---|---|---|---|
0 |
throws | throws | throws | ERR_OSSL_DH_MODULUS_TOO_SMALL |
same |
1 |
throws | throws | throws | same | same |
-0 |
crashes | crashes | crashes | throws | throws |
- For
0and1: Node has thrown a catchable error from v22 all the way to v26, while Deno panics — that one is Deno's own problem. - For
-0: Node used to crash too before v26.7 (Assertion failed: IsAnyBufferSource), and fixed it later. Deno should align with the post-26.7 behaviour.
This is the same root cause as generatePrime (no lower-bound check before calling Prime::generate), but the fix is in a different file, so I kept it out of that PR.
Source: denoland/deno