diff --git a/doc/api/cli.md b/doc/api/cli.md index 09c9b5309ffa..b14ccc838c40 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` 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. +* `blocked` {boolean} Whether an indicator callback 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. When +[`--force-fips=strict`][] is used, Node.js rejects callback-indicated +non-approved operations whether or not indicator events are enabled or the +channel has subscribers. + +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 +2029,8 @@ 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 +[`--force-fips=strict`]: cli.md#--force-fips [`BoundedChannel`]: #class-boundedchannel [`DatabaseSync`]: sqlite.md#class-databasesync [`TracingChannel`]: #class-tracingchannel diff --git a/doc/node.1 b/doc/node.1 index 72680aa892ab..0a6f8c52c95e 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 @@ -844,6 +850,20 @@ Disable loading native addons that are not context-aware. .It Fl -force-fips Enable FIPS mode at startup and prevent it from being disabled from script code. The same OpenSSL requirements as \fB--enable-fips\fR apply. +An optional mode can be specified using \fB--force-fips=mode\fR: +.Bl -bullet +.It +\fBprovider\fR: Preserve the OpenSSL FIPS provider's configured handling of +non-approved operations. This is the current default when the mode is +omitted. +.It +\fBstrict\fR: Reject non-approved operations reported through the OpenSSL FIPS +indicator callback. This mode requires OpenSSL 3.4 or later. +.El +The \fBstrict\fR mode only covers operations reported through the callback for +OpenSSL's default library context. It does not cover native addons that use +another \fBOSSL_LIB_CTX\fR or another copy of \fBlibcrypto\fR, nor operation-specific +indicators that do not invoke the callback. . .It Fl -force-node-api-uncaught-exceptions-policy Enforces \fBuncaughtException\fR event on Node-API asynchronous callbacks. @@ -1978,6 +1998,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/per_thread.js b/lib/internal/process/per_thread.js index 86a5e8080974..6f43502a0c6c 100644 --- a/lib/internal/process/per_thread.js +++ b/lib/internal/process/per_thread.js @@ -392,6 +392,9 @@ function buildAllowedFlags() { const allowedNodeEnvironmentFlags = []; for (const { 0: name, 1: info } of options) { + // Bracketed options are internal parser targets. They can be allowed in + // NODE_OPTIONS so aliases expand to them, but are not public flags. + if (name[0] === '[') continue; if (info.envVarSettings === kAllowedInEnvvar) { ArrayPrototypePush(allowedNodeEnvironmentFlags, name); if (info.type === kBoolean) { 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..942e00accba9 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 @@ -41,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; @@ -206,6 +216,238 @@ 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; +}; + +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() { + static FipsIndicatorState state; + return state; + } + + void Install() { + reject_unapproved_.store( + per_process::cli_options->force_fips_crypto && + per_process::cli_options->force_fips_crypto_policy == "strict", + std::memory_order_release); + 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); + const int result = reject_unapproved_.load(std::memory_order_acquire) + ? 0 + : previous_result; + if (!active_.load(std::memory_order_acquire)) return result; + + const bool blocked = 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 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; + 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); + 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}; + std::atomic reject_unapproved_{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) + const auto& options = per_process::cli_options; + const bool strict = options->force_fips_crypto && + options->force_fips_crypto_policy == "strict"; + if (options->enable_fips_indicator_events || strict) { + 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 +526,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 +1249,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 +1271,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/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) \ diff --git a/src/node.cc b/src/node.cc index 5e00996c1ba3..30be9089d389 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1239,6 +1239,7 @@ InitializeOncePerProcessInternal(const std::vector& args, result->errors_.emplace_back(std::move(*fips_error)); return result; } + crypto::InstallFipsIndicatorCallback(); // Ensure CSPRNG is properly seeded. CHECK(ncrypto::CSPRNG(nullptr, 0)); diff --git a/src/node_options.cc b/src/node_options.cc index 78309d1ae5cd..4665f1faeda7 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -9,7 +9,7 @@ #include "node_sea.h" #include "uv.h" #if HAVE_OPENSSL -#include "openssl/opensslv.h" +#include "ncrypto.h" // Defines OPENSSL_VERSION_PREREQ for BoringSSL. #include "quic/guard.h" #endif @@ -84,6 +84,23 @@ void PerProcessOptions::CheckOptions(std::vector* errors, "used, not both"); } + if (force_fips_crypto_policy != "provider" && + force_fips_crypto_policy != "strict") { + errors->push_back( + "invalid value for --force-fips; expected 'provider' or 'strict'"); + } + +#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"); + } + + if (force_fips_crypto && force_fips_crypto_policy == "strict") { + errors->push_back("--force-fips=strict 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,10 +1499,20 @@ 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)", + "force FIPS crypto (optional mode: provider or strict)", BOOL_FIELD(force_fips_crypto), kAllowedInEnvvar); + AddOption("[force_fips_crypto_policy]", + "", + &PerProcessOptions::force_fips_crypto_policy, + kAllowedInEnvvar); + AddAlias("--force-fips=", {"[force_fips_crypto_policy]", "--force-fips"}); #ifndef V8_ENABLE_SANDBOX AddOption("--secure-heap", "total size of the OpenSSL secure heap", @@ -2085,6 +2112,12 @@ void GetOptionsAsFlags(const FunctionCallbackInfo& args) { switch (option_info.type) { case kBoolean: { bool current_value = field->GetBool(opts); +#if HAVE_OPENSSL + if (option_name == "--force-fips" && current_value) { + flags.push_back(option_name + "=" + opts->force_fips_crypto_policy); + break; + } +#endif // For boolean options with default_is_true, we want the opposite logic if (option_info.default_is_true) { if (!current_value) { diff --git a/src/node_options.h b/src/node_options.h index 907174fe6c57..322955cc38a9 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -409,7 +409,9 @@ 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; + std::string force_fips_crypto_policy = "provider"; #endif // HAVE_OPENSSL #if OPENSSL_VERSION_MAJOR >= 3 DEFINE_BOOL_FIELD(openssl_legacy_provider) = false; 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-crypto-fips-indicator-strict.js b/test/parallel/test-crypto-fips-indicator-strict.js new file mode 100644 index 000000000000..71da2286bd07 --- /dev/null +++ b/test/parallel/test-crypto-fips-indicator-strict.js @@ -0,0 +1,158 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (process.features.openssl_is_boringssl) { + common.skip('BoringSSL does not support FIPS'); +} + +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +const diagnosticsChannel = require('node:diagnostics_channel'); +const { once } = require('node:events'); +const { createHmac, subtle } = require('node:crypto'); +const { Worker } = require('node:worker_threads'); +const { hasFIPS, hasOpenSSL } = require('../common/crypto'); + +const channelName = 'crypto.fips.indicator'; +const mode = process.env.NODE_TEST_FIPS_FORCE_MODE; + +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 if (mode === 'provider') { + assertSerializedMode(mode); + assert.strictEqual( + createHmac('sha256', Buffer.alloc(13)).digest().byteLength, 32); +} else if (mode === 'strict') { + assertSerializedMode(mode); + const subscriber = common.mustNotCall(); + diagnosticsChannel.subscribe(channelName, subscriber); + assert.throws( + () => createHmac('sha256', Buffer.alloc(13)).digest(), + { code: /^ERR_OSSL_/ }); + setImmediate(common.mustCall(() => { + assert.strictEqual( + diagnosticsChannel.unsubscribe(channelName, subscriber), true); + })); +} else if (mode === 'strict-events') { + assertSerializedMode('strict'); + testStrictEvents().then(common.mustCall()); +} else { + runParent(); +} + +function assertSerializedMode(expected) { + const { getOptionsAsFlagsFromBinding } = require('internal/options'); + assert.ok(getOptionsAsFlagsFromBinding().includes(`--force-fips=${expected}`)); +} + +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 runParent() { + try { + createHmac('sha256', Buffer.alloc(13)).digest(); + } catch (error) { + assert.match(error.code, /^ERR_OSSL_/); + common.printSkipMessage( + 'the FIPS provider rejects unapproved operations before signaling'); + return; + } + + for (const [args, childMode] of [ + [['--force-fips'], 'provider'], + [['--force-fips=provider'], 'provider'], + [['--force-fips=strict'], 'strict'], + [[ + '--force-fips=strict', + '--enable-fips-indicator-events', + ], 'strict-events'], + ]) { + const child = spawnSync( + process.execPath, [...args, '--expose-internals', __filename], { + env: { ...process.env, NODE_TEST_FIPS_FORCE_MODE: childMode }, + }); + assert.strictEqual( + child.status, + 0, + `args: ${args.join(' ')}\nstdout: ${child.stdout}\nstderr: ${child.stderr}`); + } +} + +async function testStrictEvents() { + const key = Buffer.alloc(13); + + assert.throws( + () => createHmac('sha256', key).digest(), + { code: /^ERR_OSSL_/ }); + + let eventPromise = nextIndicator(); + assert.throws( + () => createHmac('sha256', key).digest(), + { code: /^ERR_OSSL_/ }); + assert.deepStrictEqual(await eventPromise, { + operation: 'HMAC', + reason: 'keysize', + blocked: true, + count: 1, + dropped: 0, + }); + + const hmacKey = await subtle.importKey( + 'raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + eventPromise = nextIndicator(); + await assert.rejects( + subtle.sign('HMAC', hmacKey, Buffer.alloc(0)), + { name: 'OperationError' }); + const webCryptoEvent = await eventPromise; + assert.strictEqual(webCryptoEvent.operation, 'HMAC'); + assert.strictEqual(webCryptoEvent.reason, 'keysize'); + assert.strictEqual(webCryptoEvent.blocked, true); + + eventPromise = nextIndicator(); + const worker = new Worker(` + 'use strict'; + const { createHmac } = require('node:crypto'); + const { workerData } = require('node:worker_threads'); + createHmac('sha256', workerData.key).digest(); + `, { + eval: true, + workerData: { key }, + }); + const errorPromise = once(worker, 'error'); + const exitPromise = new Promise((resolve) => worker.on('exit', resolve)); + const [[error], workerEvent] = await Promise.all([ + errorPromise, + eventPromise, + ]); + assert.match(error.code, /^ERR_OSSL_/); + assert.deepStrictEqual(workerEvent, { + operation: 'HMAC', + reason: 'keysize', + blocked: true, + count: 1, + dropped: 0, + }); + const exitCode = await exitPromise; + assert.strictEqual(exitCode, 1); +} diff --git a/test/parallel/test-crypto-fips.js b/test/parallel/test-crypto-fips.js index 8b2e1c9a3649..68deaa843c0b 100644 --- a/test/parallel/test-crypto-fips.js +++ b/test/parallel/test-crypto-fips.js @@ -13,7 +13,7 @@ const path = require('path'); const fixtures = require('../common/fixtures'); const { internalBinding } = require('internal/test/binding'); const { testFipsCrypto } = internalBinding('crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); +const { hasOpenSSL, hasOpenSSL3 } = require('../common/crypto'); const FIPS_ENABLED = 1; const FIPS_DISABLED = 0; @@ -94,6 +94,48 @@ testHelper( 'require("crypto").getFips()', process.env); +// Explicit provider mode should preserve the behavior of bare --force-fips. +testHelper( + testFipsCrypto() ? 'stdout' : 'stderr', + ['--force-fips=provider'], + testFipsCrypto() ? kNoFailure : kGenericUserError, + testFipsCrypto() ? FIPS_ENABLED : FIPS_FORCE_ERROR_STRING, + 'require("crypto").getFips()', + process.env); + +{ + const child = spawnSync( + process.execPath, ['--force-fips=invalid', '-e', '0']); + assert.strictEqual(child.status, 9); + assert.match( + child.stderr.toString(), + /invalid value for --force-fips; expected 'provider' or 'strict'/); +} + +if (hasOpenSSL(3, 4)) { + testHelper( + testFipsCrypto() ? 'stdout' : 'stderr', + ['--force-fips=strict'], + testFipsCrypto() ? kNoFailure : kGenericUserError, + testFipsCrypto() ? FIPS_ENABLED : FIPS_FORCE_ERROR_STRING, + 'require("crypto").getFips()', + process.env); +} else { + const indicatorChild = spawnSync( + process.execPath, ['--enable-fips-indicator-events', '-e', '0']); + assert.strictEqual(indicatorChild.status, 9); + assert.match( + indicatorChild.stderr.toString(), + /--enable-fips-indicator-events requires OpenSSL 3\.4 or later/); + + const strictChild = spawnSync( + process.execPath, ['--force-fips=strict', '-e', '0']); + assert.strictEqual(strictChild.status, 9); + assert.match( + strictChild.stderr.toString(), + /--force-fips=strict requires OpenSSL 3\.4 or later/); +} + // By default FIPS should be off in both FIPS and non-FIPS builds // unless Node.js was configured using --shared-openssl in // which case it may be enabled by the system. 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..1b4f54fabac8 --- /dev/null +++ b/test/parallel/test-diagnostics-channel-crypto-fips-indicator.js @@ -0,0 +1,195 @@ +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const assert = require('node:assert'); +const { spawnSync } = require('node:child_process'); +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 if (!process.execArgv.includes('--enable-fips-indicator-events')) { + const child = spawnSync( + process.execPath, + ['--enable-fips-indicator-events', __filename], + { encoding: 'utf8' }); + assert.strictEqual( + child.status, + 0, + `stdout: ${child.stdout}\nstderr: ${child.stderr}`); +} 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;