Repository navigation
crypto: createDiffieHellman(prime, generator) blocks for seconds with standard MODP primes (regression since 24.20.0 and 26.6.0) #67067
Description
Version
v24.22.1 (also v24.20.0, v24.21.0 and v26.12.1)
Platform
Linux vm 6.18.44-fc-v165 #1 SMP PREEMPT_DYNAMIC @0 x86_64 x86_64 x86_64 GNU/Linux (4 cores)
Subsystem
crypto
What steps will reproduce the bug?
No server is needed. Run this on Node.js 24.22.1 and on 24.19.0:
'use strict';
const crypto = require('node:crypto');
for (const name of ['modp14', 'modp15', 'modp16', 'modp17', 'modp18']) {
const prime = crypto.getDiffieHellman(name).getPrime();
const start = process.hrtime.bigint();
const dh = crypto.createDiffieHellman(prime, Buffer.from([2]));
const seconds = Number(process.hrtime.bigint() - start) / 1e9;
console.log(name, `${prime.length * 8} bits`, `${seconds.toFixed(2)} s`,
'verifyError', dh.verifyError);
}To show that the main thread is blocked, add this to the same file before the loop:
setInterval(() => {}, 100).unref();
let last = Date.now();
setInterval(() => {
const now = Date.now();
if (now - last > 500) console.log('event loop blocked for', now - last, 'ms');
last = now;
}, 100).unref();Real-world case: the ssh2 library calls createDiffieHellman(serverPrime, serverGenerator) for the diffie-hellman-group-exchange-* key exchange. With ssh2 1.17.0, diffie-hellman-group-exchange-sha256 and the aes256-ctr cipher, the client asks an OpenSSH server for an 8192-bit group. If the server offers the RFC 3526 groups, the handshake takes 0.3 s on 24.14.1 and 24.19.0. On 24.20.0, 24.22.1 and 26.12.1 the process freezes for 29 to 33 s, and the handshake fails with "Timed out while waiting for handshake".
How often does it reproduce? Is there a required condition?
Always. Every call with a standard RFC 3526 prime (modp14 to modp18) and generator 2 is slow on 24.20.0 and later, and on 26.6.0 and later. The time grows with the size of the prime. crypto.getDiffieHellman(name) and crypto.createDiffieHellmanGroup(name) are fast on every version.
What is the expected behavior? Why is that the expected behavior?
createDiffieHellman(prime, generator) with a standard group returns in well under 1 ms, and verifyError is 0. This is what Node.js 24.19.0 and earlier, and 22.x, do.
Why: a standard group needs no primality check. Before commit 0290e0a ("crypto: split OpenSSL 3, BoringSSL, and legacy backends", PR #64211), DHPointer::check() called OpenSSL DH_check(). OpenSSL recognizes the named groups and skips the check. The commit message says "No public crypto or TLS API behavior is intentionally changed."
What do you see instead?
The call blocks the main thread. Times for createDiffieHellman(prime, 2) on a 4-core Linux x64 machine:
| Node.js version | modp14 (2048) | modp15 (3072) | modp16 (4096) | modp17 (6144) | modp18 (8192) |
|---|---|---|---|---|---|
| 22.23.4 | 0 ms | 0 ms | 0 ms | not measured | not measured |
| 24.14.1 to 24.19.0 | 0 ms | 0 ms | 0 ms | 0 ms (24.14.1) | 0 ms (24.14.1) |
| 24.20.0 | 0.28 s | 1.6 s | 3.9 s | not measured | not measured |
| 24.21.0 | 0.34 s | 2.0 s | 4.0 s | not measured | not measured |
| 24.22.1 | 0.3 s | 1.9 s | 3.9 s | 12 s | 30 s |
| 26.12.1 | 0.28 s | 1.7 s | 4.0 s | not measured | not measured |
On 24.22.1 the time is almost the same as the sum of two crypto.checkPrimeSync calls, one for p and one for (p - 1) / 2:
| Group | createDiffieHellman | checkPrimeSync(p) + checkPrimeSync((p-1)/2) |
|---|---|---|
| modp14 | 0.26 s | 0.27 s |
| modp15 | 1.63 s | 1.62 s |
| modp16 | 3.66 s | 3.88 s |
On 24.19.0 the same createDiffieHellman calls take 0.00 s.
Affected versions from the source (not all measured): 24.20.0 and later, 26.6.0 and later, and 27.x (CheckDhParams is in the tags and branches). Not affected: 25.x, 23.x, 22.x and 20.x. The v24.22.2-proposal and v26.13.0-proposal branches have the same code.
Additional information
Root cause
The JS constructor reads verifyError at once (lib/internal/crypto/diffiehellman.js). The getter runs DHPointer::check() (src/crypto/crypto_dh.cc, function Check).
After 0290e0a, the OpenSSL 3 path is built with OPENSSL_NO_DEPRECATED and no longer uses DH. DHPointer::check() calls the new CheckDhParams() in deps/ncrypto/ncrypto.cc. It has no shortcut for known groups. It calls BN_check_prime(p) and, when q is not set, BN_check_prime((p - 1) / 2).
The only shortcut is if (group_name_ != nullptr). group_name_ is set only by DHPointer::FromGroup(), which is why getDiffieHellman(name) and createDiffieHellmanGroup(name) stay fast. DHPointer::New(p, g) never sets it.
Locations in the v24.22.1 tag: deps/ncrypto/ncrypto.cc:2047 (CheckDhParams), :2121 and :2127 (the two BN_check_prime calls), :2308 (DHPointer::check()), :2314 (the shortcut), and src/crypto/crypto_dh.cc:373 and :379.
Related commits: b72c0b9 "crypto: retain legacy DH validation" (#64547), be82372 "crypto: handle DH operation failures" (#64851), e392e1f "crypto: fix large DH generator validation" (#64092).
Suggested fix
- Add a known-group shortcut in
DHPointer::check(). Whenpequals one of the RFC 3526 primes andgis 2 andqis not set, returnCheckResult::NONE. - Better: in
DHPointer::New(p, g), detect a known group and setgroup_name_. Check the effect ongenerateKeys()first. - Add a regression test:
createDiffieHellman(modp18Prime, 2)returns fast andverifyErroris 0.
Non-standard primes (for example generator 5, or primes from the OpenSSH moduli file) were already slow on all versions. A shortcut for known groups does not change that.
Workaround
Preload this with node -r ./dh-fast-path.js app.js:
'use strict';
const crypto = require('node:crypto');
const known = new Map();
for (const name of ['modp14', 'modp15', 'modp16', 'modp17', 'modp18']) {
known.set(crypto.getDiffieHellman(name).getPrime().toString('hex'), name);
}
const original = crypto.createDiffieHellman;
const strip = (b) => {
let i = 0;
while (i < b.length - 1 && b[i] === 0) i++;
return b.subarray(i);
};
crypto.createDiffieHellman = function createDiffieHellman(prime, ...rest) {
const g = rest[0];
const isTwo = g === 2 || (Buffer.isBuffer(g) && strip(g).equals(Buffer.from([2])));
if (rest.length === 1 && Buffer.isBuffer(prime) && isTwo) {
const name = known.get(strip(prime).toString('hex'));
if (name) return crypto.getDiffieHellman(name);
}
return original.call(this, prime, ...rest);
};Other workarounds: stay on 24.19.0 or earlier (or 22.x), or remove diffie-hellman-group-exchange-* from the SSH algorithms.kex list.
Activity
paulrutter commentedon Oct 27, 2026
Confirmation from production: we hit this with a real SFTP server, and a preload shim fixed it.
What happened
ssh2(throughssh2-sftp-client) on Node.js 24. After the image moved to 24.20 or later, SFTP connections to one server started to time out.client-timeout. A test connection was cancelled after about 30 s.diffie-hellman-group-exchange-*. The whole process is frozen whilecreateDiffieHellmanruns, so the 20 sreadyTimeoutfires as soon as the event loop runs again.What fixed it
We loaded a small shim at process start. It wraps
crypto.createDiffieHellman. When the prime is one of the standard MODP groups (modp1, 2, 5, 14 to 18) and the generator is 2, it returnscrypto.getDiffieHellman(name). All other calls go to the original function. The SSH wire format can add a leading zero byte to the prime and the generator, so the shim removes leading zeros before it compares.With the shim, the same test connection on the same server succeeds in about 5 s (before: cancelled at about 30 s). We checked this on a real tenant, not only in a local test.
Notes
modulifile, were already slow on every version. A known-group shortcut does not change that, so the fix here is only for the standard groups.DHPointer::check()shortcut and a regression test if a maintainer confirms that this is the preferred direction.panva commentedon Oct 27, 2026
#67071