From 650888d5bf8237a33786684e80ec096be624497d Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:01:37 -0700 Subject: [PATCH] src: seed V8 from the OS CSPRNG instead of OpenSSL's DRBG InitializeOncePerProcessInternal() calls CSPRNG(nullptr, 0) to confirm OpenSSL's random source is seeded and installs a V8 entropy source that goes through CSPRNG() as well. The first RAND_status() of the process therefore runs before V8 starts, instantiates the DRBG, and with it constructs the default provider's algorithm and name tables (ossl_method_construct, ossl_namemap_stored): 3.7% of the samples of `node -e 0` on Linux x64, all of it before v8Start. V8 uses the entropy for hash seeds, address space layout randomization and Math.random(), none of which are cryptographic, so read the OS CSPRNG directly through uv_random(). AIX is the exception: uv_random() reads the blocking /dev/random there, so it stays on OpenSSL's DRBG, which seeds from /dev/urandom. Keep activating the default provider at startup, which the eager check did as a side effect and --openssl-legacy-provider depends on. Its explicit OSSL_PROVIDER_load() disables OpenSSL's provider fallback, so without a prior activation the default provider never loads. Run the seeding check itself only when that provider is unavailable or FIPS is in effect, the cases where an OpenSSL configuration from any source can leave the process without a DRBG and an early abort beats a hang at the first crypto call. Every crypto consumer stays on OpenSSL, and a system without a usable CSPRNG still aborts at startup, now from uv_random() failing. Two other behaviors change. A configuration whose [random] section names a DRBG that cannot be fetched used to abort at startup; it now starts and the first crypto call fails on the fetch. With --secure-heap the process DRBGs are instantiated after the secure heap exists, so they are allocated from it, and a Worker whose per-thread DRBG cannot be allocated no longer aborts the process from the entropy callback. Tests cover both, and the default provider staying active under --openssl-legacy-provider. Measured on Linux x64 against an unpatched build of the same tree, both binaries interleaved, min of 300 runs: `node -e 0` 29.18 -> 27.82 ms, nodeStart to v8Start 2.91 -> 2.11 ms. RAND_status and the provider's table construction leave the startup profile (2.8% of samples before); the provider activation that remains is 0.05%. The first crypto.randomBytes() instantiates the DRBG in 0.19 ms. The `parallel`, `sequential`, `message`, `es-module` and `addons` suites show no failure the unpatched build does not have. Refs: https://github.com/nodejs/node/commit/5cc36c39d2 Refs: https://github.com/nodejs/node/pull/44493 Refs: https://github.com/nodejs/node/pull/46237 Signed-off-by: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> --- src/node.cc | 36 +++++++++++++++---- .../test-legacy-provider-option.js | 3 ++ .../openssl3-conf/random_unavailable.cnf | 7 ++++ test/parallel/test-crypto-no-algorithm.js | 16 +++++++++ test/parallel/test-crypto-secure-heap.js | 22 ++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/openssl3-conf/random_unavailable.cnf diff --git a/src/node.cc b/src/node.cc index a43eb28b779d..2ae48af4e355 100644 --- a/src/node.cc +++ b/src/node.cc @@ -49,6 +49,9 @@ #if HAVE_OPENSSL #include "ncrypto.h" +#if OPENSSL_VERSION_MAJOR >= 3 +#include +#endif #include "node_crypto.h" #if OPENSSL_VERSION_MAJOR >= 3 && !defined(CONF_MFLAGS_IGNORE_MISSING_FILE) // OpenSSL hides this deprecated macro under OPENSSL_NO_DEPRECATED, but the @@ -1259,15 +1262,36 @@ InitializeOncePerProcessInternal(const std::vector& args, } crypto::InstallFipsIndicatorCallback(); - // Ensure CSPRNG is properly seeded. - CHECK(ncrypto::CSPRNG(nullptr, 0)); + // Activating the default provider here keeps --openssl-legacy-provider + // working. Its explicit load disables OpenSSL's fallback, and the eager + // CSPRNG check used to activate the provider as a side effect. Only + // check the seeding when that provider is missing or FIPS is on, so a + // configuration without a DRBG still aborts at startup instead of + // hanging at the first crypto call. Otherwise the DRBG is instantiated + // on first use. +#if OPENSSL_VERSION_MAJOR >= 3 + const bool check_csprng = ncrypto::isFipsEnabled() || + !OSSL_PROVIDER_available(nullptr, "default"); +#else + const bool check_csprng = true; +#endif + if (check_csprng) { + CHECK(ncrypto::CSPRNG(nullptr, 0)); + } + // V8 uses the entropy for hash seeds, ASLR and Math.random(), none of + // it cryptographic. Going through OpenSSL would instantiate the DRBG + // and build the default provider's algorithm tables on every startup. + // V8 falls back to very weak entropy when the source fails, so abort + // instead. V8::SetEntropySource([](unsigned char* buffer, size_t length) { - // V8 falls back to very weak entropy when this function fails - // and /dev/urandom isn't available. That wouldn't be so bad if - // the entropy was only used for Math.random() but it's also used for - // hash table and address space layout randomization. Better to abort. +#ifdef _AIX + // uv_random() reads /dev/random on AIX, which blocks. OpenSSL seeds + // from /dev/urandom there. CHECK(ncrypto::CSPRNG(buffer, length)); +#else + CHECK_EQ(uv_random(nullptr, nullptr, buffer, length, 0, nullptr), 0); +#endif return true; }); #endif // !defined(OPENSSL_IS_BORINGSSL) diff --git a/test/addons/openssl-providers/test-legacy-provider-option.js b/test/addons/openssl-providers/test-legacy-provider-option.js index 5ad60dac9b86..1f01ce55a8f2 100644 --- a/test/addons/openssl-providers/test-legacy-provider-option.js +++ b/test/addons/openssl-providers/test-legacy-provider-option.js @@ -22,3 +22,6 @@ if (getFips()) { common.skip('this test cannot be run in FIPS mode'); } providers.testProviderPresent('legacy'); +// The explicit legacy load disables OpenSSL's provider fallback, so the +// default provider has to be active before it runs. +providers.testProviderPresent('default'); diff --git a/test/fixtures/openssl3-conf/random_unavailable.cnf b/test/fixtures/openssl3-conf/random_unavailable.cnf new file mode 100644 index 000000000000..a2dc8d2c9ffa --- /dev/null +++ b/test/fixtures/openssl3-conf/random_unavailable.cnf @@ -0,0 +1,7 @@ +nodejs_conf = nodejs_init + +[nodejs_init] +random = random_sect + +[random_sect] +random = NO-SUCH-DRBG diff --git a/test/parallel/test-crypto-no-algorithm.js b/test/parallel/test-crypto-no-algorithm.js index 90d19ff97fcb..174d707874ab 100644 --- a/test/parallel/test-crypto-no-algorithm.js +++ b/test/parallel/test-crypto-no-algorithm.js @@ -57,3 +57,19 @@ if (isMainThread) { assert(common.nodeProcessAborted(cp.status, cp.signal), `process did not abort, code:${cp.status} signal:${cp.signal}`); } + +{ + // A configuration whose random section names a DRBG that cannot be + // fetched starts normally; the first crypto call fails, without a hang. + const fixtures = require('../common/fixtures'); + const { spawnSync } = require('node:child_process'); + const randomConf = fixtures.path('openssl3-conf', 'random_unavailable.cnf'); + const cp = spawnSync(process.execPath, + [ `--openssl-config=${randomConf}`, '-e', + 'require("node:crypto").randomBytes(8)' ], + { encoding: 'utf8' }); + assert(!common.nodeProcessAborted(cp.status, cp.signal), + `process aborted, code:${cp.status} signal:${cp.signal}`); + assert.strictEqual(cp.status, 1); + assert.match(cp.stderr, /unable to fetch drbg/); +} diff --git a/test/parallel/test-crypto-secure-heap.js b/test/parallel/test-crypto-secure-heap.js index 8bd93c5281da..d7be1d5b37ef 100644 --- a/test/parallel/test-crypto-secure-heap.js +++ b/test/parallel/test-crypto-secure-heap.js @@ -61,6 +61,17 @@ if (process.argv[2] === 'child') { return; } +if (process.argv[2] === 'workers') { + // A Worker's crypto calls may run out of secure heap; that surfaces as an + // error in the Worker, never as an abort of the process. + const { Worker } = require('worker_threads'); + for (let i = 0; i < 8; i++) { + new Worker('try { require("crypto").randomBytes(4); } catch {}', + { eval: true }); + } + return; +} + const child = fork( process.argv[1], ['child'], @@ -70,6 +81,17 @@ child.on('exit', common.mustCall((code) => { assert.strictEqual(code, 0); })); +{ + const child = fork( + process.argv[1], + ['workers'], + { execArgv: ['--secure-heap=1024', '--secure-heap-min=4'] }); + child.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(signal, null); + assert.strictEqual(code, 0); + })); +} + { const child = fork(fixtures.path('a.js'), { execArgv: ['--secure-heap=3', '--secure-heap-min=3'],