Deno: Miller-Rabin Primality Test Allows Zero Rounds
Description
Node-compatible cryptographic APIs in Deno skipped all Miller-Rabin rounds when the checks parameter defaulted to 0, causing composites with large prime factors to be falsely reported as prime.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Node-compatible cryptographic APIs in Deno skipped all Miller-Rabin rounds when the `checks` parameter defaulted to 0, causing composites with large prime factors to be falsely reported as prime.
Vulnerability
The crypto.checkPrime() and crypto.checkPrimeSync() APIs in Deno's node:crypto module failed to run any Miller-Rabin probabilistic rounds when the caller left options.checks at its default value of 0. The only test applied was trial division by primes up to 17,863; any composite whose smallest prime factor exceeds that bound (e.g., the product of two primes just above it, such as 17,881 × 17,891) was incorrectly reported as true ("probably prime"). The same issue affected the lower-level op_node_check_prime / op_node_check_prime_bytes paths. Node.js itself avoids this problem because it forwards checks = 0 to OpenSSL's BN_check_prime, which substitutes a sensible default number of rounds per FIPS 186-4 Appendix C.3 Table C.1. Deno's Rust implementation had no equivalent fallback, so count = 0 skipped the loop entirely. The bug impacted callers using default options or explicitly passing { checks: 0 }, while callers passing checks >= 1 still ran fewer rounds than the FIPS minimum for the candidate's bit length. [1][2][3]
Exploitation
An attacker can craft a composite number whose smallest prime factor is larger than 17,863 and supply it as the candidate argument to crypto.checkPrime() or crypto.checkPrimeSync(). No authentication or special network position is required beyond the ability to call the affected APIs (e.g., from a script or a compromised process). The function immediately returns true because the complete absence of Miller-Rabin rounds means the composite is never detected. [1][2]
Impact
A successful exploit causes the primality test to report a composite as "probably prime." The realistic exposure depends on how applications use checkPrime: in cryptographic validation (e.g., accepting RSA key generation parameters, verifying DH parameters, or custom prime validation), a false positive could lead to acceptance of weak or maliciously crafted keys, potentially enabling impersonation, signature forgery, or decryption by an attacker who knows the factorization. The function's documentation and common usage indicate it is intended for security-critical checks, amplifying the severity of this false acceptance. [1]
Mitigation
The fix was merged in Deno pull request #34391 (May 26, 2026) and commits referenced on June 10, 2026. The patch clamps the round count to the FIPS 186-4 Appendix C.3 minimums — matching OpenSSL's defaults inside BN_check_prime — so that even when checks is 0 or omitted, the loop always runs a sufficient number of Miller-Rabin rounds for the candidate's bit length. Users should update to a patched version of Deno containing this change. No workaround exists for unpatched versions: applications must either avoid calling checkPrime with default options or explicitly pass a checks value that is at least the FIPS minimum for the bit length. Prime generation (crypto.generatePrime, crypto.generatePrimeSync, and DH parameter generation) was not affected. [3]
AI Insight generated on Jun 16, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
1b7e72335c0c5fix(ext/node): enforce minimum Miller-Rabin rounds in checkPrime (#34391)
1 file changed · +60 −1
ext/node_crypto/primes.rs+60 −1 modified@@ -270,6 +270,31 @@ fn witnessexp(b: &BigInt, e: &BigInt, m: &BigInt) -> Witness { tmp } +/// Minimum number of Miller-Rabin rounds to keep the false-positive +/// probability below 2^-80, per FIPS 186-4 Appendix C.3 Table C.1. +/// Matches the defaults OpenSSL applies inside `BN_check_prime`, which +/// is what Node's `crypto.checkPrime` reaches when the caller leaves +/// `checks` at its default of 0. +fn min_miller_rabin_rounds_for_bits(bits: u64) -> usize { + if bits >= 3747 { + 3 + } else if bits >= 1345 { + 4 + } else if bits >= 476 { + 5 + } else if bits >= 400 { + 6 + } else if bits >= 347 { + 7 + } else if bits >= 308 { + 8 + } else if bits >= 55 { + 27 + } else { + 34 + } +} + pub(crate) fn is_probably_prime(n: &BigInt, count: usize) -> bool { if *n == BigInt::from(1) { return false; @@ -294,8 +319,14 @@ pub(crate) fn is_probably_prime(n: &BigInt, count: usize) -> bool { } } + // Always run at least the FIPS-recommended number of Miller-Rabin + // rounds. Without this floor, `count == 0` would return true for any + // composite whose smallest prime factor exceeds the largest SMALL_PRIMES + // entry, since the loop below would be skipped entirely. + let rounds = count.max(min_miller_rabin_rounds_for_bits(n.bits())); + let mut rng = rand::thread_rng(); - for _ in 0..count { + for _ in 0..rounds { let a = rng.gen_range(BigInt::one()..n.clone()); let we = witnessexp(&a, &(n - BigInt::one()), n); if we.wit != BigInt::zero() || we.pow != BigInt::one() { @@ -501,6 +532,34 @@ mod tests { assert!(!is_probably_prime(&BigInt::parse_bytes(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890", 10).unwrap(), 16)); } + #[test] + fn detects_composite_with_factors_above_trial_division() { + // Two primes both strictly greater than the largest SMALL_PRIMES entry + // (17863); their product survives trial division, so Miller-Rabin must + // catch it even when the caller passes `count = 0`. + let p = BigInt::from(17881u32); + let q = BigInt::from(17891u32); + let composite = &p * &q; + assert!(!is_probably_prime(&composite, 0)); + + // A larger composite (two ~32-bit primes) for the same property. + let p = BigInt::parse_bytes(b"4294967291", 10).unwrap(); + let q = BigInt::parse_bytes(b"4294967279", 10).unwrap(); + let composite = &p * &q; + assert!(!is_probably_prime(&composite, 0)); + } + + #[test] + fn min_rounds_table_matches_fips_186_4() { + assert_eq!(min_miller_rabin_rounds_for_bits(4096), 3); + assert_eq!(min_miller_rabin_rounds_for_bits(2048), 4); + assert_eq!(min_miller_rabin_rounds_for_bits(1024), 5); + assert_eq!(min_miller_rabin_rounds_for_bits(512), 5); + assert_eq!(min_miller_rabin_rounds_for_bits(256), 27); + assert_eq!(min_miller_rabin_rounds_for_bits(64), 27); + assert_eq!(min_miller_rabin_rounds_for_bits(32), 34); + } + #[test] fn oeis_a014233() { // https://oeis.org/A014233
Vulnerability mechanics
Root cause
"Missing fallback for zero Miller-Rabin rounds: when `options.checks` defaulted to 0, the probabilistic loop was skipped entirely, leaving only trial division by primes up to 17,863."
Attack vector
An attacker crafts a composite number whose smallest prime factor exceeds 17,863 — for example, the product of two primes just above that bound, such as 17,881 × 17,891 [ref_id=1]. The victim application calls `crypto.checkPrime` or `crypto.checkPrimeSync` with default options (or explicitly `{ checks: 0 }`) on this attacker-supplied bignum. Because the Miller-Rabin loop is skipped entirely when `count == 0`, the only test applied is trial division by primes up to 17,863, which the composite passes. The function incorrectly returns `true` ("probably prime"), and the application then uses the composite in a security-sensitive operation such as key exchange or signature verification [ref_id=2].
Affected code
The bug resides in `ext/node_crypto/primes.rs` in the `is_probably_prime` function. The Miller-Rabin loop was bounded by a caller-supplied `count` parameter with no floor, so `count == 0` (the default from Node's `options.checks`) skipped the loop entirely. The same path is reached through the `op_node_check_prime` / `op_node_check_prime_bytes` ops that the JavaScript polyfill calls into [ref_id=1].
What the fix does
The patch introduces `min_miller_rabin_rounds_for_bits(bits)` in `ext/node_crypto/primes.rs`, which returns the FIPS 186-4 Appendix C.3 Table C.1 minimum round counts matching OpenSSL's `BN_check_prime` defaults [patch_id=6192606]. Inside `is_probably_prime`, the loop bound is changed from `count` to `count.max(min_miller_rabin_rounds_for_bits(n.bits()))`, ensuring the probabilistic loop always executes at least the FIPS-recommended number of rounds. Callers that pass a higher explicit `checks` still get exactly that many rounds; callers that leave the default (or pass any value below the table) now get a strong probabilistic check [ref_id=1].
Preconditions
- inputThe victim application must call crypto.checkPrime() or crypto.checkPrimeSync() with default options (or explicitly { checks: 0 })
- inputThe attacker must supply a composite number whose smallest prime factor exceeds 17,863
- configThe victim application must act on the incorrect 'true' result in a security-sensitive context
Reproduction
```ts import { checkPrimeSync } from "node:crypto";
// 17881 and 17891 are both prime and both above the trial-division // ceiling used by Deno's implementation. const composite = 17881n * 17891n;
// Affected versions print `true`; the patched version prints `false`. console.log(checkPrimeSync(composite)); ```
Generated on Jun 16, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
0No linked articles in our index yet.