From debb49a8999a97c1ff6b027bb680b870c1a4bd00 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 29 Aug 2026 14:05:45 +0200 Subject: [PATCH 1/7] crypto: add FIPS indicator diagnostics channel OpenSSL 3.4 added an indicator callback for operations that its FIPS provider allows after an approved-mode check has been relaxed. Publish these indicators on the crypto.fips.indicator diagnostics channel when Node.js is started with --enable-fips-indicator-events. Leave the OpenSSL callback unset otherwise so ordinary cryptographic operations have no indicator callback overhead. Keep the channel observation-only so enforcement remains the responsibility of the provider configuration, and independent diagnostic subscribers can coexist. Install the native callback during process initialization so it is not changed while Workers or thread-pool jobs can be using OpenSSL. Preserve callbacks installed by embedders, and queue bounded, coalesced messages for asynchronous delivery on the main environment. Document the channel as best-effort telemetry that cannot identify the originating call or establish FIPS validation. Signed-off-by: Filip Skokan --- doc/api/cli.md | 13 + doc/api/crypto.md | 6 + doc/api/diagnostics_channel.md | 65 +++++ doc/node.1 | 8 + lib/internal/process/pre_execution.js | 9 +- src/crypto/crypto_util.cc | 233 ++++++++++++++++++ src/crypto/crypto_util.h | 1 + src/node.cc | 1 + src/node_options.cc | 12 + src/node_options.h | 1 + test/parallel/test-cli-node-print-help.js | 3 +- ...agnostics-channel-crypto-fips-indicator.js | 187 ++++++++++++++ ...rocess-env-allowed-flags-are-documented.js | 1 + typings/internalBinding/crypto.d.ts | 1 + 14 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 test/parallel/test-diagnostics-channel-crypto-fips-indicator.js diff --git a/doc/api/cli.md b/doc/api/cli.md index 09c9b5309ffa..de7cefab4461 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -888,6 +888,17 @@ Enable [FIPS mode][] at startup. With OpenSSL 3, a configured provider named `fips` must be available and initialize successfully. With OpenSSL 1.1.1, Node.js must be built against a FIPS-capable OpenSSL. +### `--enable-fips-indicator-events` + + + +Publish OpenSSL FIPS indicator results to the +[`'crypto.fips.indicator'`][] diagnostics channel. This option requires OpenSSL +3.4 or later. It does not enable [FIPS mode][] or change whether an operation +is permitted. + ### `--enable-source-maps` + +* `operation` {string} The provider-defined operation type. +* `reason` {string} The provider-defined description of why the operation is + not approved. +* `blocked` {boolean} Whether a native indicator callback installed before + Node.js blocked the operation. +* `count` {number} The number of matching pending indicator invocations + represented by this message. +* `dropped` {number} The number of additional indicator invocations dropped + before this message was delivered. + +Emitted when the OpenSSL FIPS provider used by Node.js detects an operation that +is not FIPS approved after the corresponding provider check was relaxed. Such +operations are possible when the provider is configured for backwards +compatibility. The `operation` and `reason` values come from the provider and +should be treated as opaque strings rather than stable enumerations. + +Start Node.js with [`--enable-fips-indicator-events`][] to enable this channel. +Without the option, subscribing does not install the OpenSSL callback and no +messages are published. + +Subscribing to the channel is observation-only and never changes the result of +an operation. Node.js preserves the result from any native indicator callback +installed before Node.js initializes its crypto support. + +Messages are published asynchronously on the main thread because OpenSSL +indicators can originate from Workers or other threads. Only subscriptions on +the main thread receive messages. Delivery order relative to the originating +operation is not defined, and a message cannot be correlated with a particular +call or Worker. + +One cryptographic operation can invoke the OpenSSL indicator more than once. +Matching pending invocations are coalesced and reflected in `count`, which does +not necessarily represent a number of cryptographic operations. At most 256 +distinct messages are queued. Additional invocations are reported in `dropped` +on the first queued message. Queued messages do not keep the event loop active, +so this channel is best-effort diagnostics rather than an authoritative audit +log. + +This channel observes the default OpenSSL library context used by Node.js. It +does not observe native addons or other code that uses another `OSSL_LIB_CTX` +or another copy of `libcrypto`. It is active with OpenSSL 3.4 and later and is +not available with BoringSSL. A provider configured to reject a check directly, +including a pedantic OpenSSL FIPS provider, can reject an operation without +emitting an indicator. Receiving or not receiving a message does not establish +that Node.js or a cryptographic operation is FIPS validated. + +```mjs +import diagnosticsChannel from 'node:diagnostics_channel'; + +diagnosticsChannel.subscribe('crypto.fips.indicator', (message) => { + console.error('Non-approved cryptographic operation', message); +}); +``` + #### HTTP > Stability: 1 - Experimental @@ -1963,6 +2027,7 @@ statement, since both are still in use while the event is being delivered; see [BoundedChannel Channels]: #boundedchannel-channels [TracingChannel Channels]: #tracingchannel-channels [`'uncaughtException'`]: process.md#event-uncaughtexception +[`--enable-fips-indicator-events`]: cli.md#--enable-fips-indicator-events [`BoundedChannel`]: #class-boundedchannel [`DatabaseSync`]: sqlite.md#class-databasesync [`TracingChannel`]: #class-tracingchannel diff --git a/doc/node.1 b/doc/node.1 index 72680aa892ab..d52eba3f21f4 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -517,6 +517,12 @@ Enable FIPS mode at startup. With OpenSSL 3, a configured provider named \fBfips\fR must be available and initialize successfully. With OpenSSL 1.1.1, Node.js must be built against a FIPS-capable OpenSSL. . +.It Fl -enable-fips-indicator-events +Publish OpenSSL FIPS indicator results to the +\fB'crypto.fips.indicator'\fR diagnostics channel. This option requires OpenSSL +3.4 or later. It does not enable FIPS mode or change whether an operation +is permitted. +. .It Fl -enable-source-maps Enable Source Map support for stack traces. When using a transpiler, such as TypeScript, stack traces thrown by an @@ -1978,6 +1984,8 @@ one is included in the list below. .It \fB--dns-result-order\fR .It +\fB--enable-fips-indicator-events\fR +.It \fB--enable-fips\fR .It \fB--enable-network-family-autoselection\fR diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 56544e7fef62..1c9d903f0c9c 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -128,7 +128,7 @@ function prepareExecution(options) { // Process initial diagnostic reporting configuration, if present. initializeReport(); - setupDiagnosticsChannel(); + setupDiagnosticsChannel(isMainThread); // Load permission system API initializePermission(); @@ -668,7 +668,7 @@ function initializeClusterIPC() { } } -function setupDiagnosticsChannel() { +function setupDiagnosticsChannel(isMainThread) { // Re-link native channels after snapshot deserialization since // JS references are cleared during serialization. // Keep this callback in sync with the initial registration in @@ -683,6 +683,11 @@ function setupDiagnosticsChannel() { (channel._stores?.size || 0); return channel; }); + if (isMainThread && + process.versions.openssl !== undefined && + getOptionValue('--enable-fips-indicator-events')) { + internalBinding('crypto').setupFipsIndicatorChannel(); + } } function initializePermission() { diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index edaf4bdbe2fe..e1dc2094ccc9 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -6,18 +6,27 @@ #include "memory_tracker-inl.h" #include "ncrypto.h" #include "node_buffer.h" +#include "node_diagnostics_channel.h" #include "node_options-inl.h" +#include "node_realm-inl.h" #include "string_bytes.h" #include "threadpoolwork-inl.h" #include "util-inl.h" #include "v8.h" +#include +#include +#include #include "math.h" #if OPENSSL_VERSION_MAJOR >= 3 #include "openssl/provider.h" #endif +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 4) +#include +#endif + #if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 0) #include #include @@ -206,6 +215,226 @@ int NoPasswordCallback(char* buf, int size, int rwflag, void* u) { return 0; } +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 4) +namespace { + +constexpr size_t kMaxPendingFipsIndicatorEvents = 256; +constexpr std::string_view kFipsIndicatorChannel = "crypto.fips.indicator"; + +struct FipsIndicatorEvent { + std::string operation; + std::string reason; + bool blocked; + uint32_t count = 1; + uint32_t dropped = 0; +}; + +class FipsIndicatorState final { + public: + static FipsIndicatorState& Get() { + static FipsIndicatorState state; + return state; + } + + void Install() { + std::call_once(install_once_, [this]() { + OSSL_INDICATOR_get_callback(nullptr, &previous_callback_); + OSSL_INDICATOR_set_callback(nullptr, OnOpenSSLIndicator); + }); + } + + void Setup(Environment* env) { + CHECK(env->owns_process_state()); + auto channel = + diagnostics_channel::Channel::Get(env, kFipsIndicatorChannel); + if (!channel) return; + + Realm* realm = env->principal_realm(); + auto* binding = realm->GetBindingData(); + CHECK_NOT_NULL(binding); + const uint32_t index = + binding->GetOrCreateChannelIndex(std::string(kFipsIndicatorChannel)); + + { + Mutex::ScopedLock lock(mutex_); + CHECK_NULL(env_); + env_ = env; + channel_ = channel; + } + env->AddCleanupHook(Cleanup, this); + binding->SetChannelStatusCallback( + index, [this](bool active) { SetActive(active); }); + SetActive(channel->HasSubscribers()); + } + + private: + void SetActive(bool active) { + subscription_generation_++; + active_.store(active, std::memory_order_release); + if (active) return; + + { + Mutex::ScopedLock lock(mutex_); + events_.clear(); + dropped_events_ = 0; + } + } + + static int OnOpenSSLIndicator(const char* operation, + const char* reason, + const OSSL_PARAM* params) { + return Get().OnIndicator(operation, reason, params); + } + + static void Cleanup(void* data) { + static_cast(data)->CleanupEnvironment(); + } + + int OnIndicator(const char* operation, + const char* reason, + const OSSL_PARAM* params) { + const int previous_result = + previous_callback_ == nullptr + ? 1 + : previous_callback_(operation, reason, params); + if (!active_.load(std::memory_order_acquire)) return previous_result; + + const bool blocked = previous_result == 0; + { + Mutex::ScopedLock lock(mutex_); + if (env_ != nullptr && active_.load(std::memory_order_relaxed)) { + const std::string operation_string = + operation == nullptr ? "" : operation; + const std::string reason_string = reason == nullptr ? "" : reason; + const auto existing = std::find_if( + events_.begin(), + events_.end(), + [&](const FipsIndicatorEvent& event) { + return event.operation == operation_string && + event.reason == reason_string && event.blocked == blocked; + }); + if (existing == events_.end()) { + if (events_.size() < kMaxPendingFipsIndicatorEvents) { + events_.push_back({operation_string, reason_string, blocked}); + } else if (dropped_events_ != UINT32_MAX) { + dropped_events_++; + } + } else if (existing->count != UINT32_MAX) { + existing->count++; + } else if (dropped_events_ != UINT32_MAX) { + dropped_events_++; + } + if (!dispatch_scheduled_) { + dispatch_scheduled_ = true; + env_->SetImmediateThreadsafe( + [](Environment* env) { Get().Drain(env); }, + CallbackFlags::kUnrefed); + } + } + } + return previous_result; + } + + void Drain(Environment* env) { + CHECK(env->owns_process_state()); + std::deque events; + { + Mutex::ScopedLock lock(mutex_); + if (env_ != env) return; + events.swap(events_); + if (!events.empty()) events.front().dropped = dropped_events_; + dropped_events_ = 0; + dispatch_scheduled_ = false; + } + if (events.empty() || !channel_ || !channel_->HasSubscribers()) return; + + Isolate* isolate = env->isolate(); + HandleScope handle_scope(isolate); + Local context = env->context(); + const uint64_t subscription_generation = subscription_generation_; + for (const auto& event : events) { + if (subscription_generation_ != subscription_generation) return; + Local value = Object::New(isolate); + if (value + ->Set(context, + OneByteString(isolate, "operation"), + OneByteString(isolate, event.operation)) + .IsNothing() || + value + ->Set(context, + OneByteString(isolate, "reason"), + OneByteString(isolate, event.reason)) + .IsNothing() || + value + ->Set(context, + OneByteString(isolate, "blocked"), + v8::Boolean::New(isolate, event.blocked)) + .IsNothing() || + value + ->Set(context, + OneByteString(isolate, "count"), + Uint32::New(isolate, event.count)) + .IsNothing() || + value + ->Set(context, + OneByteString(isolate, "dropped"), + Uint32::New(isolate, event.dropped)) + .IsNothing()) { + return; + } + channel_->Publish(env, value); + if (subscription_generation_ != subscription_generation) return; + } + } + + void CleanupEnvironment() { + subscription_generation_++; + active_.store(false, std::memory_order_release); + { + Mutex::ScopedLock lock(mutex_); + env_ = nullptr; + channel_.reset(); + events_.clear(); + dropped_events_ = 0; + dispatch_scheduled_ = false; + } + } + + std::once_flag install_once_; + std::atomic active_{false}; + OSSL_INDICATOR_CALLBACK* previous_callback_ = nullptr; + Mutex mutex_; + Environment* env_ = nullptr; + BaseObjectPtr channel_; + std::deque events_; + uint32_t dropped_events_ = 0; + bool dispatch_scheduled_ = false; + uint64_t subscription_generation_ = 0; +}; + +} // namespace +#endif + +void InstallFipsIndicatorCallback() { +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 4) + if (per_process::cli_options->enable_fips_indicator_events) { + FipsIndicatorState::Get().Install(); + } +#endif +} + +void SetupFipsIndicatorChannel(const FunctionCallbackInfo& args) { +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(3, 4) + Environment* env = Environment::GetCurrent(args); + if (env->owns_process_state() && + per_process::cli_options->enable_fips_indicator_events) { + FipsIndicatorState::Get().Setup(env); + } +#else + USE(args); +#endif +} + std::optional ProcessFipsOptions() { const bool enable_fips = per_process::cli_options->enable_fips_crypto; const bool force_fips = per_process::cli_options->force_fips_crypto; @@ -284,6 +513,7 @@ void InitCryptoOnce() { #endif OPENSSL_init_ssl(0, settings); + InstallFipsIndicatorCallback(); #if OPENSSL_WITH_OPENSSL_PQC // Configure all loaded providers to prefer seed-only format for ML-KEM and @@ -1006,6 +1236,8 @@ void Initialize(Environment* env, Local target) { SetMethodNoSideEffect(context, target, "getFipsCrypto", GetFipsCrypto); SetMethodNoSideEffect( context, target, "getFipsCryptoGeneration", GetFipsCryptoGeneration); + SetMethod( + context, target, "setupFipsIndicatorChannel", SetupFipsIndicatorChannel); SetMethod(context, target, "setFipsCrypto", SetFipsCrypto); SetMethodNoSideEffect(context, target, "testFipsCrypto", TestFipsCrypto); @@ -1026,6 +1258,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(GetFipsCrypto); registry->Register(GetFipsCryptoGeneration); + registry->Register(SetupFipsIndicatorChannel); registry->Register(SetFipsCrypto); registry->Register(TestFipsCrypto); registry->Register(SecureBuffer); diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index 5344743dab27..aafdd3bf273b 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -68,6 +68,7 @@ constexpr T NumBitsToBytes(T bits) { // options were applied successfully. std::optional ProcessFipsOptions(); bool IsFipsEnabled(); +void InstallFipsIndicatorCallback(); bool InitCryptoOnce(v8::Isolate* isolate); void InitCryptoOnce(); diff --git a/src/node.cc b/src/node.cc index 5e00996c1ba3..6449c098c413 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1233,6 +1233,7 @@ InitializeOncePerProcessInternal(const std::vector& args, OPENSSL_init(); } #endif + crypto::InstallFipsIndicatorCallback(); if (auto fips_error = crypto::ProcessFipsOptions()) { result->exit_code_ = ExitCode::kGenericUserError; result->early_return_ = true; diff --git a/src/node_options.cc b/src/node_options.cc index 78309d1ae5cd..baac82683156 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -84,6 +84,13 @@ void PerProcessOptions::CheckOptions(std::vector* errors, "used, not both"); } +#if defined(OPENSSL_IS_BORINGSSL) || !OPENSSL_VERSION_PREREQ(3, 4) + if (enable_fips_indicator_events) { + errors->push_back( + "--enable-fips-indicator-events requires OpenSSL 3.4 or later"); + } +#endif + // Any value less than 2 disables use of the secure heap. #ifndef V8_ENABLE_SANDBOX // The secure heap is not supported when V8_ENABLE_SANDBOX is enabled. @@ -1482,6 +1489,11 @@ PerProcessOptionsParser::PerProcessOptionsParser( "enable FIPS crypto at startup", BOOL_FIELD(enable_fips_crypto), kAllowedInEnvvar); + AddOption("--enable-fips-indicator-events", + "publish FIPS indicator results to the " + "crypto.fips.indicator diagnostics channel", + BOOL_FIELD(enable_fips_indicator_events), + kAllowedInEnvvar); AddOption("--force-fips", "force FIPS crypto (cannot be disabled)", BOOL_FIELD(force_fips_crypto), diff --git a/src/node_options.h b/src/node_options.h index 907174fe6c57..74baa2d618c3 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -409,6 +409,7 @@ class PerProcessOptions : public Options { DEFINE_BOOL_FIELD(use_openssl_ca) = false; DEFINE_BOOL_FIELD(use_bundled_ca) = false; DEFINE_BOOL_FIELD(enable_fips_crypto) = false; + DEFINE_BOOL_FIELD(enable_fips_indicator_events) = false; DEFINE_BOOL_FIELD(force_fips_crypto) = false; #endif // HAVE_OPENSSL #if OPENSSL_VERSION_MAJOR >= 3 diff --git a/test/parallel/test-cli-node-print-help.js b/test/parallel/test-cli-node-print-help.js index f42129eb1330..84704be2104a 100644 --- a/test/parallel/test-cli-node-print-help.js +++ b/test/parallel/test-cli-node-print-help.js @@ -27,7 +27,8 @@ function validateNodePrintHelp() { { compileConstant: HAVE_OPENSSL, flags: [ '--openssl-config=...', '--tls-cipher-list=...', '--use-bundled-ca', '--use-openssl-ca', '--use-system-ca', - '--enable-fips', '--force-fips' ] }, + '--enable-fips', '--enable-fips-indicator-events', + '--force-fips' ] }, { compileConstant: NODE_HAVE_I18N_SUPPORT, flags: [ '--icu-data-dir=...', 'NODE_ICU_DATA' ] }, { compileConstant: HAVE_INSPECTOR, diff --git a/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js b/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js new file mode 100644 index 000000000000..2eada5e54d5b --- /dev/null +++ b/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js @@ -0,0 +1,187 @@ +'use strict'; + +// Flags: --enable-fips-indicator-events + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const assert = require('node:assert'); +const diagnosticsChannel = require('node:diagnostics_channel'); +const { once } = require('node:events'); +const { Worker } = require('node:worker_threads'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); +const { + createHmac, + generateKeyPairSync, + sign, + subtle, +} = require('node:crypto'); + +const channelName = 'crypto.fips.indicator'; + +if (!hasOpenSSL(3, 4)) { + common.skip('OpenSSL 3.4 or later is required'); +} else if (!hasFIPS(3, 4)) { + common.skip('an active OpenSSL 3.4+ FIPS provider is required'); +} else { + run().then(common.mustCall()); +} + +function nextIndicator() { + let resolve; + const keepAlive = setInterval(common.mustNotCall(), 10_000); + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + const subscriber = common.mustCall((event, name) => { + assert.strictEqual(name, channelName); + clearInterval(keepAlive); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, subscriber), true); + resolve(event); + }); + diagnosticsChannel.subscribe(channelName, subscriber); + return promise; +} + +function testUnsubscribeDuringDrain(key, privateKey) { + let resolve; + const keepAlive = setInterval(common.mustNotCall(), 10_000); + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + const subscriber = common.mustCall(() => { + clearInterval(keepAlive); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, subscriber), true); + setImmediate(common.mustCall(resolve)); + }); + diagnosticsChannel.subscribe(channelName, subscriber); + createHmac('sha256', key).digest(); + sign('sha1', Buffer.alloc(0), privateKey); + return promise; +} + +async function testNoStaleIndicator(key) { + createHmac('sha256', key).digest(); + const subscriber = common.mustNotCall(); + diagnosticsChannel.subscribe(channelName, subscriber); + await new Promise((resolve) => setImmediate(resolve)); + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, subscriber), true); +} + +async function run() { + const key = Buffer.alloc(13); + + let resolveProbe; + const keepAlive = setInterval(common.mustNotCall(), 10_000); + const probeEvent = new Promise((resolve) => { + resolveProbe = resolve; + }); + const probeSubscriber = (event) => { + clearInterval(keepAlive); + diagnosticsChannel.unsubscribe(channelName, probeSubscriber); + resolveProbe(event); + }; + diagnosticsChannel.subscribe(channelName, probeSubscriber); + + let output; + try { + output = createHmac('sha256', key).digest(); + } catch (error) { + clearInterval(keepAlive); + diagnosticsChannel.unsubscribe(channelName, probeSubscriber); + assert.match(error.code, /^ERR_OSSL_/); + common.printSkipMessage( + 'the FIPS provider rejects unapproved operations before signaling'); + return; + } + + assert.strictEqual(output.byteLength, 32); + assert.deepStrictEqual(await probeEvent, { + operation: 'HMAC', + reason: 'keysize', + blocked: false, + count: 1, + dropped: 0, + }); + + await testNoStaleIndicator(key); + + let eventPromise = nextIndicator(); + createHmac('sha256', key).digest(); + createHmac('sha256', key).digest(); + assert.deepStrictEqual(await eventPromise, { + operation: 'HMAC', + reason: 'keysize', + blocked: false, + count: 2, + dropped: 0, + }); + + const secondSubscriber = common.mustCall(); + diagnosticsChannel.subscribe(channelName, secondSubscriber); + eventPromise = nextIndicator(); + createHmac('sha256', key).digest(); + await eventPromise; + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, secondSubscriber), true); + + const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + await testUnsubscribeDuringDrain(key, privateKey); + + const hmacKey = await subtle.importKey( + 'raw', Buffer.alloc(13), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + eventPromise = nextIndicator(); + const [signature, hmacEvent] = await Promise.all([ + subtle.sign('HMAC', hmacKey, Buffer.alloc(0)), + eventPromise, + ]); + assert.strictEqual(signature.byteLength, 32); + assert.strictEqual(hmacEvent.operation, 'HMAC'); + assert.strictEqual(hmacEvent.reason, 'keysize'); + assert.strictEqual(hmacEvent.blocked, false); + + eventPromise = nextIndicator(); + const worker = new Worker(` + 'use strict'; + const diagnosticsChannel = require('node:diagnostics_channel'); + const { parentPort, workerData } = require('node:worker_threads'); + const { createHmac } = require('node:crypto'); + + let indicatorCount = 0; + diagnosticsChannel.subscribe('crypto.fips.indicator', () => { + indicatorCount++; + }); + const result = createHmac('sha256', workerData.key).digest(); + setImmediate(() => { + parentPort.postMessage({ + indicatorCount, + length: result.byteLength, + }); + }); + `, { + eval: true, + workerData: { key }, + }); + worker.on('error', common.mustNotCall()); + const exitPromise = once(worker, 'exit'); + const [[message], workerEvent] = await Promise.all([ + once(worker, 'message'), + eventPromise, + ]); + assert.deepStrictEqual(message, { indicatorCount: 0, length: 32 }); + assert.deepStrictEqual(workerEvent, { + operation: 'HMAC', + reason: 'keysize', + blocked: false, + count: 1, + dropped: 0, + }); + const [exitCode] = await exitPromise; + assert.strictEqual(exitCode, 0); +} diff --git a/test/parallel/test-process-env-allowed-flags-are-documented.js b/test/parallel/test-process-env-allowed-flags-are-documented.js index 6028bbab787e..8349d4c3af6f 100644 --- a/test/parallel/test-process-env-allowed-flags-are-documented.js +++ b/test/parallel/test-process-env-allowed-flags-are-documented.js @@ -72,6 +72,7 @@ const conditionalOpts = [ '--secure-heap', '--secure-heap-min', '--enable-fips', + '--enable-fips-indicator-events', '--force-fips', ].includes(opt); } diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index 6fba90eeb614..eb40d33c513c 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -1023,6 +1023,7 @@ export interface CryptoBinding { secureBuffer(length: number): Uint8Array | undefined; secureHeapUsed(): bigint | undefined; setEngine?(engine: string, flags: number): void; + setupFipsIndicatorChannel(): void; setFipsCrypto(fips: boolean | number): void; startLoadingCertificatesOffThread(): void; testFipsCrypto(): 0 | 1; From 38263930851d9776593c555fd95a1e71913dabab Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 29 Aug 2026 14:05:53 +0200 Subject: [PATCH 2/7] crypto: add strict mode to --force-fips Allow --force-fips to take an optional provider or strict mode. Keep provider as the current default and explicit compatibility mode. In strict mode, return failure from the OpenSSL 3.4+ FIPS indicator callback when it reports a non-approved operation. Install the callback for enforcement without implicitly enabling diagnostics; --enable-fips-indicator-events remains the independent observation opt-in. Keep the bare form mapped to provider for compatibility, allowing the default to change to strict in a future major release. Preserve the mode when forwarding flags to child test processes, and keep the parser's internal mode storage out of process.allowedNodeEnvironmentFlags. Document the callback scope limitations and cover provider compatibility, validation, synchronous crypto, WebCrypto, Workers, and opt-in event publication. Signed-off-by: Filip Skokan --- doc/api/cli.md | 17 ++ doc/api/crypto.md | 4 +- doc/api/diagnostics_channel.md | 9 +- doc/node.1 | 14 ++ lib/internal/process/per_thread.js | 3 + src/crypto/crypto_util.cc | 19 ++- src/node_options.cc | 23 ++- src/node_options.h | 1 + .../test-crypto-fips-indicator-strict.js | 158 ++++++++++++++++++ test/parallel/test-crypto-fips.js | 44 ++++- 10 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 test/parallel/test-crypto-fips-indicator-strict.js diff --git a/doc/api/cli.md b/doc/api/cli.md index de7cefab4461..7500b6ac56e8 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1601,11 +1601,28 @@ Disable loading native addons that are not [context-aware][]. Enable [FIPS mode][] at startup and prevent it from being disabled from script code. The same OpenSSL requirements as [`--enable-fips`][] apply. +An optional mode can be specified using `--force-fips=mode`: + +* `provider`: Preserve the OpenSSL FIPS provider's configured handling of + non-approved operations. This is the current default when the mode is + omitted. +* `strict`: Reject non-approved operations reported through the OpenSSL FIPS + indicator callback. This mode requires OpenSSL 3.4 or later. + +The `strict` mode only covers operations reported through the callback for +OpenSSL's default library context. It does not cover native addons that use +another `OSSL_LIB_CTX` or another copy of `libcrypto`, nor operation-specific +indicators that do not invoke the callback. + ### `--force-node-api-uncaught-exceptions-policy` +> Stability: 1 - Experimental + +##### Event: `'crypto.fips.indicator'` + * `operation` {string} The provider-defined operation type. * `reason` {string} The provider-defined description of why the operation is not approved. From 8a05fbc53c1e62b6f0790eb3015df667ac2ebbca Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 29 Aug 2026 19:58:45 +0200 Subject: [PATCH 6/7] fixup! crypto: add FIPS indicator diagnostics channel --- src/crypto/crypto_util.cc | 54 ++++++++++++++++++++------------------- src/env_properties.h | 1 + 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 356397aa41d3..942e00accba9 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -50,6 +50,7 @@ using v8::BackingStoreInitializationMode; using v8::BackingStoreOnFailureMode; using v8::BigInt; using v8::Context; +using v8::DictionaryTemplate; using v8::EscapableHandleScope; using v8::Exception; using v8::Function; @@ -229,6 +230,22 @@ struct FipsIndicatorEvent { uint32_t dropped = 0; }; +Local GetFipsIndicatorEventTemplate(Environment* env) { + auto tmpl = env->fips_indicator_event_template(); + if (tmpl.IsEmpty()) { + static constexpr std::string_view names[] = { + "operation", + "reason", + "blocked", + "count", + "dropped", + }; + tmpl = DictionaryTemplate::New(env->isolate(), names); + env->set_fips_indicator_event_template(tmpl); + } + return tmpl; +} + class FipsIndicatorState final { public: static FipsIndicatorState& Get() { @@ -361,32 +378,17 @@ class FipsIndicatorState final { const uint64_t subscription_generation = subscription_generation_; for (const auto& event : events) { if (subscription_generation_ != subscription_generation) return; - Local value = Object::New(isolate); - if (value - ->Set(context, - OneByteString(isolate, "operation"), - OneByteString(isolate, event.operation)) - .IsNothing() || - value - ->Set(context, - OneByteString(isolate, "reason"), - OneByteString(isolate, event.reason)) - .IsNothing() || - value - ->Set(context, - OneByteString(isolate, "blocked"), - v8::Boolean::New(isolate, event.blocked)) - .IsNothing() || - value - ->Set(context, - OneByteString(isolate, "count"), - Uint32::New(isolate, event.count)) - .IsNothing() || - value - ->Set(context, - OneByteString(isolate, "dropped"), - Uint32::New(isolate, event.dropped)) - .IsNothing()) { + MaybeLocal values[] = { + OneByteString(isolate, event.operation), + OneByteString(isolate, event.reason), + v8::Boolean::New(isolate, event.blocked), + Uint32::New(isolate, event.count), + Uint32::New(isolate, event.dropped), + }; + Local value; + if (!NewDictionaryInstance( + context, GetFipsIndicatorEventTemplate(env), values) + .ToLocal(&value)) { return; } channel_->Publish(env, value); diff --git a/src/env_properties.h b/src/env_properties.h index 886d4adba9fc..3eb1db96940b 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -441,6 +441,7 @@ V(ffi_dynamic_library_constructor_template, v8::FunctionTemplate) \ V(ffi_function_constructor_template, v8::FunctionTemplate) \ V(filehandlereadwrap_template, v8::ObjectTemplate) \ + V(fips_indicator_event_template, v8::DictionaryTemplate) \ V(free_list_statistics_template, v8::DictionaryTemplate) \ V(fsreqpromise_constructor_template, v8::ObjectTemplate) \ V(handle_wrap_ctor_template, v8::FunctionTemplate) \ From b83f3bf8e16032a82cc3d2b2cd632e9507c26a04 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sat, 29 Aug 2026 19:59:01 +0200 Subject: [PATCH 7/7] fixup! crypto: add strict mode to --force-fips --- src/node.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node.cc b/src/node.cc index 6449c098c413..30be9089d389 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1233,13 +1233,13 @@ InitializeOncePerProcessInternal(const std::vector& args, OPENSSL_init(); } #endif - crypto::InstallFipsIndicatorCallback(); if (auto fips_error = crypto::ProcessFipsOptions()) { result->exit_code_ = ExitCode::kGenericUserError; result->early_return_ = true; result->errors_.emplace_back(std::move(*fips_error)); return result; } + crypto::InstallFipsIndicatorCallback(); // Ensure CSPRNG is properly seeded. CHECK(ncrypto::CSPRNG(nullptr, 0));