From 616bd3fa26b4d1b5c2d7adfb7474ae8e69b22bc4 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 29 Aug 2026 12:49:44 +0200 Subject: [PATCH 001/280] doc: deprecate `Server.prototype._listen2` in `node:net` Signed-off-by: Antoine du Hamel PR-URL: https://github.com/nodejs/node/pull/65593 Refs: https://github.com/nodejs/node/pull/63249 Refs: https://github.com/nodejs/node/pull/64794 Reviewed-By: Filip Skokan Reviewed-By: Tim Perry --- doc/api/deprecations.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/api/deprecations.md b/doc/api/deprecations.md index 9b18c4ed486f..653155c239c3 100644 --- a/doc/api/deprecations.md +++ b/doc/api/deprecations.md @@ -4612,6 +4612,25 @@ throwing an error. This behavior is inconsistent with `hash.digest()` and may lead to subtle bugs. Calling `hmac.digest()` on a finalized `Hmac` instance will throw an error in a future version. + + +### DEP0208: `Server.prototype._listen2` + + + +Type: Documentation-only + +`net.Server.prototype._listen2` is an undocumented alias for an internal +function that sets up the listening handle. It is kept only so that code +replacing it keeps being called by [`server.listen()`][], and it will be +removed in a future version of Node.js. Use [`server.listen()`][] instead of +calling or overriding `_listen2`. + [DEP0142]: #dep0142-repl_builtinlibs [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf [RFC 6066]: https://tools.ietf.org/html/rfc6066#section-3 @@ -4740,6 +4759,7 @@ will throw an error in a future version. [`response.writableEnded`]: http.md#responsewritableended [`response.writableFinished`]: http.md#responsewritablefinished [`script.createCachedData()`]: vm.md#scriptcreatecacheddata +[`server.listen()`]: net.md#serverlisten [`setInterval()`]: timers.md#setintervalcallback-delay-args [`setTimeout()`]: timers.md#settimeoutcallback-delay-args [`socket.bufferSize`]: net.md#socketbuffersize From d0d300f01fc9955cdd68db4ac6c31f992287cf1b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 23 Aug 2026 14:07:41 +0200 Subject: [PATCH 002/280] crypto: discover hashes from OpenSSL providers Enumerate usable digests and aliases from activated OpenSSL 3 providers rather than relying only on the legacy digest registry. Normalize provider aliases, omit numeric OIDs and NULL, and validate them against the active default property query. Preserve legacy names and the OpenSSL 1.1.1 and BoringSSL paths. Expose KECCAK-KMAC-128, KECCAK-256, SHA256-192, and other provider digests. Add `functionName` and `customization` options for cSHAKE digests in `createHash()` and `crypto.hash()` with OpenSSL 4.0 or later. Resolve provider-only digest names across hashing, HMAC, KDF, signing, verification, and RSA digest options. Keep ordinary hash construction and one-shot hashing on the original binding arities and direct initialization paths. Use parameterized setup only when cSHAKE options are supplied. Lazily cache successful provider fetches per Environment. Index entries by case-insensitive query, canonical, and alias names. Deduplicate owners by provider and canonical identity. Return borrowed pointers on warm hits. Introduce a process-wide FIPS-state generation that advances only after successful, state-changing `setFips()` calls. Use it to invalidate per-Environment digest caches and refresh `getHashes()` snapshots in the main thread and workers. Keep cache IDs monotonic across invalidation because JavaScript Realms can retain them. Existing hash contexts can finish across a transition. Release provider owners before unloading worker addon DSOs. Document provider-dependent availability and operation-specific restrictions. Add known-answer vectors, option validation, provider resolution, property-query, FIPS transition, worker, snapshot, and cross-API coverage. Refs: https://github.com/nodejs/node/issues/62982 Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65484 Backport-PR-URL: https://github.com/nodejs/node/pull/65596 Fixes: https://github.com/nodejs/node/issues/43040 Fixes: https://github.com/nodejs/node/issues/64866 Reviewed-By: Antoine du Hamel --- deps/ncrypto/ncrypto.cc | 229 +++++++- deps/ncrypto/ncrypto.h | 84 ++- doc/api/crypto.md | 142 ++++- lib/internal/crypto/hash.js | 66 ++- lib/internal/crypto/util.js | 49 +- src/crypto/crypto_hash.cc | 509 +++++++++++++----- src/crypto/crypto_hash.h | 8 +- src/crypto/crypto_rsa.cc | 21 +- src/crypto/crypto_util.cc | 115 ++++ src/crypto/crypto_util.h | 29 + src/env.cc | 12 + src/env.h | 16 +- test/addons/addons.status | 1 + test/addons/openssl-providers/providers.cjs | 17 +- .../test-default-properties-config.js | 158 ++++++ .../openssl3-conf/default_properties.cnf | 19 + .../test-crypto-provider-hash-options.js | 387 +++++++++++++ test/parallel/test-crypto-provider-hashes.js | 258 +++++++++ typings/internalBinding/crypto.d.ts | 11 +- 19 files changed, 1952 insertions(+), 179 deletions(-) create mode 100644 test/addons/openssl-providers/test-default-properties-config.js create mode 100644 test/fixtures/openssl3-conf/default_properties.cnf create mode 100644 test/parallel/test-crypto-provider-hash-options.js create mode 100644 test/parallel/test-crypto-provider-hashes.js diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index fb7446578f57..18dac63be728 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -14,6 +14,7 @@ #endif #include #include +#include #include #include #include @@ -507,23 +508,43 @@ DataPointer DataPointer::resize(size_t len) { } // ============================================================================ -bool isFipsEnabled() { - ClearErrorOnReturn clear_error_on_return; +namespace { +// This generation only coordinates cache invalidation. It does not make +// OpenSSL default property changes safe to race with crypto operations. +std::atomic fips_state_generation{0}; + +bool isFipsEnabledRaw() { #if OPENSSL_VERSION_MAJOR >= 3 return EVP_default_properties_is_fips_enabled(nullptr) == 1; #else return FIPS_mode() == 1; #endif } +} // namespace + +bool isFipsEnabled() { + ClearErrorOnReturn clear_error_on_return; + return isFipsEnabledRaw(); +} bool setFipsEnabled(bool enable, CryptoErrorList* errors) { - if (isFipsEnabled() == enable) return true; + const bool was_enabled = isFipsEnabled(); + if (was_enabled == enable) return true; ClearErrorOnReturn clearErrorOnReturn(errors); #if OPENSSL_VERSION_MAJOR >= 3 - return EVP_default_properties_enable_fips(nullptr, enable ? 1 : 0) == 1; + const bool success = + EVP_default_properties_enable_fips(nullptr, enable ? 1 : 0) == 1; #else - return FIPS_mode_set(enable ? 1 : 0) == 1; + const bool success = FIPS_mode_set(enable ? 1 : 0) == 1; #endif + if (isFipsEnabledRaw() != was_enabled) { + fips_state_generation.fetch_add(1, std::memory_order_release); + } + return success; +} + +uint64_t getFipsStateGeneration() { + return fips_state_generation.load(std::memory_order_acquire); } bool testFipsEnabled() { @@ -4406,11 +4427,120 @@ bool SSLCtxPointer::setCipherSuites(const char* ciphers) { // ============================================================================ +namespace { +constexpr char AsciiToLower(char c) { + return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; +} + +#if NCRYPTO_USE_OPENSSL3_PROVIDER +void PushAlgorithmAlias(const char* name, void* arg) { + if (name == nullptr) return; + static_cast*>(arg)->emplace_back(name); +} +#endif +} // namespace + #if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV Cipher::Cipher(DeleteFnPtr cipher) : cipher_(cipher.get()), fetched_cipher_(std::move(cipher)) {} #endif +size_t CaseInsensitiveNameHash::operator()( + std::string_view name) const noexcept { + size_t hash = 5381; + for (char c : name) hash = ((hash << 5) + hash) ^ AsciiToLower(c); + return hash; +} + +bool CaseInsensitiveNameEqual::operator()(std::string_view lhs, + std::string_view rhs) const noexcept { + if (lhs.size() != rhs.size()) return false; + for (size_t n = 0; n < lhs.size(); n++) { + if (AsciiToLower(lhs[n]) != AsciiToLower(rhs[n])) return false; + } + return true; +} + +DigestCache::Result DigestCache::lookup(const char* name, + uint64_t generation) const { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (generation_ != generation) return {}; + const auto it = aliases_.find(name); + if (it == aliases_.end()) return {}; + return lookup(it->second, generation); +#else + static_cast(name); + static_cast(generation); + return {}; +#endif +} + +DigestCache::Result DigestCache::insert(const char* name, + const EVP_MD* digest, + uint64_t generation) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (generation_ != generation || name == nullptr || digest == nullptr) { + return {}; + } + + const char* canonical_name = EVP_MD_get0_name(digest); + const OSSL_PROVIDER* provider = EVP_MD_get0_provider(digest); + if (canonical_name == nullptr || provider == nullptr) return {}; + + for (size_t index = 0; index < digests_.size(); index++) { + const EVP_MD* cached = digests_[index].get(); + if (cached == nullptr) continue; + const char* cached_name = EVP_MD_get0_name(cached); + if (EVP_MD_get0_provider(cached) == provider && cached_name != nullptr && + CaseInsensitiveNameEqual()(cached_name, canonical_name)) { + const int32_t id = static_cast(first_id_ + index); + aliases_.insert_or_assign(name, id); + return {cached, id}; + } + } + + if (next_id_ == UINT32_MAX || + EVP_MD_up_ref(const_cast(digest)) != 1) { + return {}; + } + + digests_.emplace_back(const_cast(digest)); + const int32_t id = static_cast(next_id_++); + const size_t index = digests_.size() - 1; + + std::vector aliases; + EVP_MD_names_do_all(digests_[index].get(), PushAlgorithmAlias, &aliases); + for (const std::string& alias : aliases) aliases_.emplace(alias, id); + aliases_.insert_or_assign(name, id); + + return {digests_[index].get(), id}; +#else + static_cast(name); + static_cast(digest); + static_cast(generation); + return {}; +#endif +} + +void DigestCache::reset(uint64_t generation) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (generation_ == generation) return; + aliases_.clear(); + digests_.clear(); + first_id_ = next_id_; +#endif + generation_ = generation; +} + +const DigestCache::AliasMap& DigestCache::aliases() const { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + return aliases_; +#else + static const AliasMap empty; + return empty; +#endif +} + Cipher::Cipher(const Cipher& other) : cipher_(other.cipher_) { #if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV if (other.fetched_cipher_ != nullptr) { @@ -6479,11 +6609,19 @@ EVP_MD_CTX* EVPMDCtxPointer::release() { return ctx_.release(); } -bool EVPMDCtxPointer::digestInit(const Digest& digest) { +bool EVPMDCtxPointer::digestInit(const EVP_MD* digest) { if (!ctx_) return false; return EVP_DigestInit_ex(ctx_.get(), digest, nullptr) > 0; } +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 0) +bool EVPMDCtxPointer::digestInit(const EVP_MD* digest, + const OSSL_PARAM* params) { + if (!ctx_) return false; + return EVP_DigestInit_ex2(ctx_.get(), digest, params) > 0; +} +#endif + bool EVPMDCtxPointer::digestUpdate(const Buffer& in) { if (!ctx_) return false; return EVP_DigestUpdate(ctx_.get(), in.data, in.len) > 0; @@ -7009,7 +7147,10 @@ DataPointer xofHashDigest(const Buffer& buf, if (ctx.digestInit(md) != 1) { return {}; } - if (ctx.digestUpdate(reinterpret_cast&>(buf)) != 1) { + if (ctx.digestUpdate(Buffer{ + .data = buf.data, + .len = buf.len, + }) != 1) { return {}; } return ctx.digestFinal(output_length); @@ -7145,14 +7286,86 @@ size_t Digest::size() const { return EVP_MD_size(md_); } +#if NCRYPTO_USE_OPENSSL3_PROVIDER +Digest::Digest(DeleteFnPtr md) + : md_(md.get()), fetched_md_(std::move(md)) {} +#endif + +Digest::Digest(const Digest& other) : md_(other.md_) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (other.fetched_md_ != nullptr) { + if (EVP_MD_up_ref(other.fetched_md_.get()) == 1) { + fetched_md_.reset(other.fetched_md_.get()); + } else { + md_ = nullptr; + } + } +#endif +} + +Digest& Digest::operator=(const Digest& other) { + if (this == &other) return *this; +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (other.fetched_md_ != nullptr) { + if (EVP_MD_up_ref(other.fetched_md_.get()) == 1) { + fetched_md_.reset(other.fetched_md_.get()); + } else { + fetched_md_.reset(); + md_ = nullptr; + return *this; + } + } else { + fetched_md_.reset(); + } +#endif + md_ = other.md_; + return *this; +} + const Digest Digest::MD5 = Digest(EVP_md5()); const Digest Digest::SHA1 = Digest(EVP_sha1()); const Digest Digest::SHA256 = Digest(EVP_sha256()); const Digest Digest::SHA384 = Digest(EVP_sha384()); const Digest Digest::SHA512 = Digest(EVP_sha512()); +#if NCRYPTO_USE_OPENSSL3_PROVIDER +namespace { +bool IsSupportedDigest(const EVP_MD* md) { + if (md == nullptr || EVP_MD_is_a(md, "NULL")) return false; + + // OpenSSL currently crashes when ML-DSA-MU finalizes an empty input. Keep it + // unavailable until the provider implementation is fixed. + // https://github.com/openssl/openssl/issues/32445 + if (EVP_MD_is_a(md, "ML-DSA-MU")) return false; + + return true; +} +} // namespace +#endif + const Digest Digest::FromName(const char* name) { - return ncrypto::getDigestByName(name); + const EVP_MD* md = ncrypto::getDigestByName(name); + if (md != nullptr) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (md == EVP_md_null()) return Digest(); +#endif + return Digest(md); + } + + return Fetch(name); +} + +const Digest Digest::Fetch(const char* name) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + MarkPopErrorOnReturn mark_pop_error_on_return; + DeleteFnPtr fetched( + EVP_MD_fetch(nullptr, name, nullptr)); + if (IsSupportedDigest(fetched.get())) { + return Digest(std::move(fetched)); + } +#endif + + return Digest(); } // ============================================================================ diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 58e32cc18fc7..8d4091c75a98 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -397,9 +398,12 @@ class Digest final { static constexpr size_t MAX_SIZE = EVP_MAX_MD_SIZE; Digest() = default; Digest(const EVP_MD* md) : md_(md) {} - Digest(const Digest&) = default; - Digest& operator=(const Digest&) = default; + Digest(const Digest& other); + Digest& operator=(const Digest& other); inline Digest& operator=(const EVP_MD* md) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + fetched_md_.reset(); +#endif md_ = md; return *this; } @@ -418,9 +422,72 @@ class Digest final { static const Digest SHA512; static const Digest FromName(const char* name); + static const Digest Fetch(const char* name); private: const EVP_MD* md_ = nullptr; +#if NCRYPTO_USE_OPENSSL3_PROVIDER + explicit Digest(DeleteFnPtr md); + DeleteFnPtr fetched_md_; +#endif +}; + +struct CaseInsensitiveNameHash { + using is_transparent = void; + size_t operator()(std::string_view name) const noexcept; +}; + +struct CaseInsensitiveNameEqual { + using is_transparent = void; + bool operator()(std::string_view lhs, std::string_view rhs) const noexcept; +}; + +class DigestCache final { + public: + struct Result { + const EVP_MD* digest = nullptr; + int32_t id = -1; + }; + + using AliasMap = std::unordered_map; + + DigestCache() = default; + NCRYPTO_DISALLOW_COPY_AND_MOVE(DigestCache) + + Result lookup(const char* name, uint64_t generation) const; + inline Result lookup(int32_t id, uint64_t generation) const { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (generation_ != generation || id == -1) return {}; + const uint32_t unsigned_id = static_cast(id); + if (unsigned_id < first_id_) return {}; + const size_t index = unsigned_id - first_id_; + if (index >= digests_.size()) return {}; + return {digests_[index].get(), id}; +#else + static_cast(id); + static_cast(generation); + return {}; +#endif + } + Result insert(const char* name, const EVP_MD* digest, uint64_t generation); + void reset(uint64_t generation); + const AliasMap& aliases() const; + + private: + uint64_t generation_ = 0; +#if NCRYPTO_USE_OPENSSL3_PROVIDER + using EVPMDPointer = DeleteFnPtr; + + // IDs are not reused across generations because JavaScript caches them + // independently in each Realm. + uint32_t first_id_ = 0; + uint32_t next_id_ = 0; + std::vector digests_; + AliasMap aliases_; +#endif }; // Computes a fixed-length digest. @@ -1690,7 +1757,16 @@ class EVPMDCtxPointer final { void reset(EVP_MD_CTX* ctx = nullptr); EVP_MD_CTX* release(); - bool digestInit(const Digest& digest); + bool digestInit(const EVP_MD* digest); + inline bool digestInit(const Digest& digest) { + return digestInit(digest.get()); + } +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 0) + bool digestInit(const EVP_MD* digest, const OSSL_PARAM* params); + inline bool digestInit(const Digest& digest, const OSSL_PARAM* params) { + return digestInit(digest.get(), params); + } +#endif bool digestUpdate(const Buffer& in); DataPointer digestFinal(size_t length); bool digestFinalInto(Buffer* buf); @@ -1880,6 +1956,8 @@ bool isFipsEnabled(); bool setFipsEnabled(bool enabled, CryptoErrorList* errors); +uint64_t getFipsStateGeneration(); + bool testFipsEnabled(); // ============================================================================ diff --git a/doc/api/crypto.md b/doc/api/crypto.md index 66f596cd212a..c11ed29a5c35 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -3789,6 +3789,11 @@ and description of each available elliptic curve. * Returns: {string\[]} An array of the names of the supported hash algorithms, such as `'RSA-SHA256'`. Hash algorithms are also called "digest" algorithms. +This is the authoritative Node.js list of hash algorithms available to +[`crypto.createHash()`][] and [`crypto.hash()`][] in the current process. With +OpenSSL 3 or later, the list depends on the loaded providers and the default +property query in effect when the list is first generated. Some listed +algorithms can require API-specific options, such as `outputLength` for XOF +hash functions. + +A listed hash algorithm is not necessarily supported by APIs that combine a +digest with another cryptographic operation, such as HMAC, key derivation, or +signing. Those operations can apply additional restrictions. + ```mjs const { getHashes, @@ -4960,6 +5012,11 @@ added: - v21.7.0 - v20.12.0 changes: + - version: REPLACEME + pr-url: https://github.com/nodejs/node/pull/65484 + description: Hash algorithms exposed by OpenSSL providers are now + supported. The `functionName` and `customization` options + were added for cSHAKE hash functions. - version: - v25.5.0 - v24.13.1 @@ -4977,6 +5034,12 @@ changes: into a `TypedArray` using either `TextEncoder` or `Buffer.from()` and passing the encoded `TypedArray` into this API instead. * `options` {Object|string} + * `customization` {string|ArrayBuffer|Buffer|TypedArray|DataView} For cSHAKE + hash functions, specifies the customization byte string. **Default:** an + empty byte string. + * `functionName` {string|ArrayBuffer|Buffer|TypedArray|DataView} For cSHAKE + hash functions, specifies the NIST function-name byte string. **Default:** + an empty byte string. * `outputEncoding` {string} [Encoding][encoding] used to encode the returned digest. **Default:** `'hex'`. * `outputLength` {number} For XOF hash functions such as 'shake256', @@ -4988,10 +5051,21 @@ the object-based `crypto.createHash()` when hashing a smaller amount of data (<= 5MB) that's readily available. If the data can be big or if it is streamed, it's still recommended to use `crypto.createHash()` instead. -The `algorithm` is dependent on the available algorithms supported by the -version of OpenSSL on the platform. Examples are `'sha256'`, `'sha512'`, etc. -On recent releases of OpenSSL, `openssl list -digest-algorithms` will -display the available digest algorithms. +The available algorithms depend on the version and configuration of OpenSSL on +the platform. Examples are `'sha256'` and `'sha512'`. Use +[`crypto.getHashes()`][] to obtain the list of hash algorithms available to the +Node.js process. + +The `functionName` and `customization` options apply only to cSHAKE-128 and +cSHAKE-256. They are supported only when Node.js is built with OpenSSL 4.0 or +later and the selected provider supports the corresponding digest parameters. +Strings are encoded as UTF-8, and neither strings nor byte values may contain +NUL bytes. Both options default to an empty byte string. For OpenSSL's built-in +providers, `functionName` is case-sensitive and must be `''`, `'TupleHash'`, +`'ParallelHash'`, or `'KMAC'`. Other providers can impose different +restrictions. With both options empty, cSHAKE produces the same output as the +corresponding SHAKE function for the same output length. `cshake-128` and +`cshake-256` default to output lengths of 32 and 64 bytes, respectively. If `options` is a string, then it specifies the `outputEncoding`. @@ -5063,6 +5137,10 @@ changes: HKDF is a simple key derivation function defined in RFC 5869. The given `ikm`, `salt` and `info` are used with the `digest` to derive a key of `keylen` bytes. +The available digest algorithms depend on the version and configuration of +OpenSSL. HKDF uses HMAC internally. [`crypto.getHashes()`][] lists algorithms +available to the hashing APIs, but not every listed algorithm is necessarily +suitable for HMAC or HKDF. The supplied `callback` function is called with two arguments: `err` and `derivedKey`. If an error occurs while deriving the key, `err` will be set; @@ -5122,6 +5200,10 @@ changes: Provides a synchronous HKDF key derivation function as defined in RFC 5869. The given `ikm`, `salt` and `info` are used with the `digest` to derive a key of `keylen` bytes. +The available digest algorithms depend on the version and configuration of +OpenSSL. HKDF uses HMAC internally. [`crypto.getHashes()`][] lists algorithms +available to the hashing APIs, but not every listed algorithm is necessarily +suitable for HMAC or HKDF. The successfully generated `derivedKey` will be returned as an {ArrayBuffer}. @@ -5231,8 +5313,10 @@ pbkdf2('secret', 'salt', 100000, 64, 'sha512', (err, derivedKey) => { }); ``` -An array of supported digest functions can be retrieved using -[`crypto.getHashes()`][]. +The available digest algorithms depend on the version and configuration of +OpenSSL. PBKDF2 uses HMAC internally. [`crypto.getHashes()`][] lists algorithms +available to the hashing APIs, but not every listed algorithm is necessarily +suitable for HMAC or PBKDF2. This API uses libuv's threadpool, which can have surprising and negative performance implications for some applications; see the @@ -5304,8 +5388,10 @@ const key = pbkdf2Sync('secret', 'salt', 100000, 64, 'sha512'); console.log(key.toString('hex')); // '3745e48...08d59ae' ``` -An array of supported digest functions can be retrieved using -[`crypto.getHashes()`][]. +The available digest algorithms depend on the version and configuration of +OpenSSL. PBKDF2 uses HMAC internally. [`crypto.getHashes()`][] lists algorithms +available to the hashing APIs, but not every listed algorithm is necessarily +suitable for HMAC or PBKDF2. ### `crypto.privateDecrypt(privateKey, buffer)` @@ -5361,6 +5447,10 @@ changes: Decrypts `buffer` with `privateKey`. `buffer` was previously encrypted using the corresponding public key, for example using [`crypto.publicEncrypt()`][]. +[`crypto.getHashes()`][] lists algorithms available to the hashing APIs, but +the active RSA implementation can impose additional restrictions on digests +used for OAEP or MGF1. + If `privateKey` is not a [`KeyObject`][], this function behaves as if `privateKey` had been passed to [`crypto.createPrivateKey()`][]. If it is an object, the `padding` property can be passed. Otherwise, this function uses @@ -5509,6 +5599,10 @@ Encrypts the content of `buffer` with `key` and returns a new [`Buffer`][] with encrypted content. The returned data can be decrypted using the corresponding private key, for example using [`crypto.privateDecrypt()`][]. +[`crypto.getHashes()`][] lists algorithms available to the hashing APIs, but +the active RSA implementation can impose additional restrictions on digests +used for OAEP or MGF1. + If `key` is not a [`KeyObject`][], this function behaves as if `key` had been passed to [`crypto.createPublicKey()`][]. If it is an object, the `padding` property can be passed. Otherwise, this function uses @@ -6282,6 +6376,10 @@ dependent upon the key type. `algorithm` is required to be `null` or `undefined` for Ed25519, Ed448, and ML-DSA. +[`crypto.getHashes()`][] lists algorithms available to the hashing APIs, but +the key type and signature scheme determine whether a listed digest can be +used for signing. + If `key` is not a [`KeyObject`][], this function behaves as if `key` had been passed to [`crypto.createPrivateKey()`][]. When `key` is a string, `ArrayBuffer`, [`Buffer`][], `TypedArray`, or `DataView`, it must contain PEM-encoded key @@ -6419,6 +6517,10 @@ key type. `algorithm` is required to be `null` or `undefined` for Ed25519, Ed448, and ML-DSA. +[`crypto.getHashes()`][] lists algorithms available to the hashing APIs, but +the key type and signature scheme determine whether a listed digest can be +used for verification. + If `key` is not a [`KeyObject`][], this function behaves as if `key` had been passed to [`crypto.createPublicKey()`][]. When `key` is a string, `ArrayBuffer`, [`Buffer`][], `TypedArray`, or `DataView`, it must contain PEM-encoded key diff --git a/lib/internal/crypto/hash.js b/lib/internal/crypto/hash.js index a32d74f65cb4..3187531660ab 100644 --- a/lib/internal/crypto/hash.js +++ b/lib/internal/crypto/hash.js @@ -7,6 +7,7 @@ const { StringPrototypeToLowerCase, Symbol, TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeIncludes, } = primordials; const { @@ -31,6 +32,8 @@ const { kHandle, getCachedHashId, getHashCache, + getArrayBufferOrView, + getBufferSourceBytes, getOptionalByteLength, } = require('internal/crypto/util'); @@ -93,6 +96,17 @@ const maybeEmitDeprecationWarning = getDeprecationWarningEmitter( }, ); +function normalizeCShakeParameter(value, name) { + if (value === undefined) return undefined; + + value = getArrayBufferOrView(value, name); + if (TypedArrayPrototypeIncludes(getBufferSourceBytes(value), 0)) { + throw new ERR_INVALID_ARG_VALUE( + name, value, 'must not contain NUL bytes'); + } + return value; +} + function Hash(algorithm, options) { if (!new.target) return new Hash(algorithm, options); @@ -106,10 +120,36 @@ function Hash(algorithm, options) { // Coerce -0 to +0. xofLen += 0; } + let functionName; + let customization; + if (!isCopy && options !== undefined && options !== null) { + const functionNameOption = options.functionName; + if (functionNameOption !== undefined) { + functionName = normalizeCShakeParameter( + functionNameOption, 'options.functionName'); + } + const customizationOption = options.customization; + if (customizationOption !== undefined) { + customization = normalizeCShakeParameter( + customizationOption, 'options.customization'); + } + } // Lookup the cached ID from JS land because it's faster than decoding // the string in C++ land. const algorithmId = isCopy ? -1 : getCachedHashId(algorithm); - this[kHandle] = new _Hash(algorithm, xofLen, algorithmId, getHashCache()); + if (functionName === undefined && customization === undefined) { + this[kHandle] = new _Hash( + algorithm, xofLen, algorithmId, getHashCache()); + } else { + this[kHandle] = new _Hash( + algorithm, + xofLen, + algorithmId, + getHashCache(), + functionName, + customization, + ); + } this[kState] = { [kFinalized]: false, }; @@ -294,8 +334,15 @@ function hash(algorithm, input, options) { if (typeof input !== 'string' && !isArrayBufferView(input)) { throw new ERR_INVALID_ARG_TYPE('input', ['Buffer', 'TypedArray', 'DataView', 'string'], input); } + if (options === undefined) { + maybeEmitDeprecationWarning(algorithm); + return oneShotDigest(algorithm, getCachedHashId(algorithm), getHashCache(), + input, 'hex', encodingsMap.hex, undefined); + } let outputEncoding; let outputLength; + let functionName; + let customization; if (typeof options === 'string') { outputEncoding = options; @@ -303,6 +350,16 @@ function hash(algorithm, input, options) { validateObject(options, 'options'); outputLength = options.outputLength; outputEncoding = options.outputEncoding; + const functionNameOption = options.functionName; + if (functionNameOption !== undefined) { + functionName = normalizeCShakeParameter( + functionNameOption, 'options.functionName'); + } + const customizationOption = options.customization; + if (customizationOption !== undefined) { + customization = normalizeCShakeParameter( + customizationOption, 'options.customization'); + } } outputEncoding ??= 'hex'; @@ -333,8 +390,13 @@ function hash(algorithm, input, options) { maybeEmitDeprecationWarning(algorithm); } + if (functionName === undefined && customization === undefined) { + return oneShotDigest(algorithm, getCachedHashId(algorithm), getHashCache(), + input, normalized, encodingsMap[normalized], outputLength); + } return oneShotDigest(algorithm, getCachedHashId(algorithm), getHashCache(), - input, normalized, encodingsMap[normalized], outputLength); + input, normalized, encodingsMap[normalized], outputLength, + functionName, customization); } module.exports = { diff --git a/lib/internal/crypto/util.js b/lib/internal/crypto/util.js index 08088069f9f6..c6a829fc2874 100644 --- a/lib/internal/crypto/util.js +++ b/lib/internal/crypto/util.js @@ -5,6 +5,7 @@ const { ArrayBufferPrototypeGetByteLength, ArrayPrototypeIncludes, ArrayPrototypePush, + ArrayPrototypeSlice, BigInt, DataViewPrototypeGetBuffer, DataViewPrototypeGetByteLength, @@ -48,6 +49,7 @@ const { kKeyVariantAES_OCB_128: hasAesOcbMode, Argon2Job, getFipsCrypto, + getFipsCryptoGeneration, KmacJob, } = internalBinding('crypto'); @@ -117,24 +119,57 @@ function toBuf(val, encoding) { } let _hashCache; +if (isBuildingSnapshot()) { + addSerializeCallback(() => { _hashCache = undefined; }); +} + function getHashCache() { - if (_hashCache === undefined) { - _hashCache = getCachedAliases(); - if (isBuildingSnapshot()) { - // For dynamic linking, clear the map. - addSerializeCallback(() => { _hashCache = undefined; }); - } + while (_hashCache === undefined) { + const generation = getFipsCryptoGeneration(); + const cache = getCachedAliases(); + if (generation !== getFipsCryptoGeneration()) continue; + _hashCache = cache; } return _hashCache; } +function cachedArrayByFipsGeneration(fn, onRefresh) { + let result; + let generation; + if (isBuildingSnapshot()) { + addSerializeCallback(() => { + result = undefined; + generation = undefined; + }); + } + + return () => { + while (true) { + const current = getFipsCryptoGeneration(); + if (result === undefined || generation !== current) { + const next = fn(); + if (current !== getFipsCryptoGeneration()) continue; + result = next; + generation = current; + if (onRefresh !== undefined) onRefresh(); + } + return ArrayPrototypeSlice(result); + } + }; +} + function getCachedHashId(algorithm) { const result = getHashCache()[algorithm]; return result === undefined ? -1 : result; } const getCiphers = cachedResult(() => filterDuplicateStrings(_getCiphers())); -const getHashes = cachedResult(() => filterDuplicateStrings(_getHashes())); +const getHashes = cachedArrayByFipsGeneration( + () => filterDuplicateStrings(_getHashes()), + () => { + _hashCache = undefined; + }); + const getCurves = cachedResult(() => filterDuplicateStrings(_getCurves())); function setEngine(id, flags) { diff --git a/src/crypto/crypto_hash.cc b/src/crypto/crypto_hash.cc index a932b0755a0a..185c354db711 100644 --- a/src/crypto/crypto_hash.cc +++ b/src/crypto/crypto_hash.cc @@ -79,59 +79,100 @@ constexpr BoringSSLDigest kBoringSSLDigests[] = { }; #endif -#if OPENSSL_VERSION_MAJOR >= 3 -void PushAliases(const char* name, void* data) { - static_cast*>(data)->push_back(name); +void ResetHashCache(Environment* env, + uint64_t generation, + Local algorithm_cache = Local()) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + ncrypto::DigestCache* cache = env->provider_digest_cache.get(); + CHECK_NOT_NULL(cache); + if (!algorithm_cache.IsEmpty()) { + Isolate* isolate = env->isolate(); + Local context = isolate->GetCurrentContext(); + for (const auto& entry : cache->aliases()) { + if (algorithm_cache + ->Set(context, + OneByteString(isolate, entry.first), + Int32::New(isolate, -1)) + .IsNothing()) { + return; + } + } + } + cache->reset(generation); +#endif + env->supported_hash_algorithms.clear(); + env->hash_cache_generation = generation; } -EVP_MD* GetCachedMDByID(Environment* env, size_t id) { - CHECK_LT(id, env->evp_md_cache.size()); - EVP_MD* result = env->evp_md_cache[id].get(); - CHECK_NOT_NULL(result); - return result; +bool SynchronizeHashCache(Environment* env, + Local algorithm_cache = Local()) { + const uint64_t generation = ncrypto::getFipsStateGeneration(); + if (env->hash_cache_generation == generation) return false; + + ResetHashCache(env, generation, algorithm_cache); + return true; +} + +#if NCRYPTO_USE_OPENSSL3_PROVIDER +const EVP_MD* GetCachedMDByID(Environment* env, + int32_t id, + Local algorithm_cache = Local()) { + if (SynchronizeHashCache(env, algorithm_cache) || + env->provider_digest_cache == nullptr) { + return nullptr; + } + return env->provider_digest_cache->lookup(id, env->hash_cache_generation) + .digest; } struct MaybeCachedMD { - EVP_MD* explicit_md = nullptr; - const EVP_MD* implicit_md = nullptr; + const EVP_MD* cached_md = nullptr; + ncrypto::Digest digest; int32_t cache_id = -1; }; -MaybeCachedMD FetchAndMaybeCacheMD(Environment* env, const char* search_name) { - const EVP_MD* implicit_md = ncrypto::getDigestByName(search_name); - if (!implicit_md) return {nullptr, nullptr, -1}; - - const char* real_name = EVP_MD_get0_name(implicit_md); - if (!real_name) return {nullptr, implicit_md, -1}; - - auto it = env->alias_to_md_id_map.find(real_name); - if (it != env->alias_to_md_id_map.end()) { - size_t id = it->second; - return {GetCachedMDByID(env, id), implicit_md, static_cast(id)}; +MaybeCachedMD FetchAndMaybeCacheMD( + Environment* env, + const char* search_name, + Local algorithm_cache = Local(), + const char* fetch_name = nullptr) { + SynchronizeHashCache(env, algorithm_cache); + if (env->isolate()->HasPendingException()) return {}; + ncrypto::DigestCache* cache = env->provider_digest_cache.get(); + CHECK_NOT_NULL(cache); + const uint64_t generation = env->hash_cache_generation; + const EVP_MD* legacy = nullptr; + + if (auto cached = cache->lookup(search_name, generation); + cached.digest != nullptr) { + return {cached.digest, cached.digest, cached.id}; } - // EVP_*_fetch() does not support alias names, so we need to pass it the - // real/original algorithm name. - // We use EVP_*_fetch() as a filter here because it will only return an - // instance if the algorithm is supported by the public OpenSSL APIs (some - // algorithms are used internally by OpenSSL and are also passed to this - // callback). - EVP_MD* explicit_md = EVP_MD_fetch(nullptr, real_name, nullptr); - if (!explicit_md) return {nullptr, implicit_md, -1}; + if (fetch_name == nullptr) { + legacy = ncrypto::getDigestByName(search_name); + if (legacy != nullptr) { + if (legacy == EVP_md_null()) return {}; + fetch_name = EVP_MD_get0_name(legacy); + if (fetch_name == nullptr) return {nullptr, legacy, -1}; + } else { + fetch_name = search_name; + } + } - // Cache the EVP_MD* fetched. - env->evp_md_cache.emplace_back(explicit_md); - size_t id = env->evp_md_cache.size() - 1; + const ncrypto::Digest digest = ncrypto::Digest::Fetch(fetch_name); + if (!digest) { + return legacy == nullptr ? MaybeCachedMD{} + : MaybeCachedMD{nullptr, legacy, -1}; + } - // Add all the aliases to the map to speed up next lookup. - std::vector aliases; - EVP_MD_names_do_all(explicit_md, PushAliases, &aliases); - for (const auto& alias : aliases) { - env->alias_to_md_id_map.emplace(alias, id); + if (generation == ncrypto::getFipsStateGeneration()) { + auto cached = cache->insert(search_name, digest.get(), generation); + if (cached.digest != nullptr) { + return {cached.digest, cached.digest, cached.id}; + } } - env->alias_to_md_id_map.emplace(search_name, id); - return {explicit_md, implicit_md, static_cast(id)}; + return {nullptr, digest, -1}; } void SaveSupportedHashAlgorithmsAndCacheMD(const EVP_MD* md, @@ -140,12 +181,57 @@ void SaveSupportedHashAlgorithmsAndCacheMD(const EVP_MD* md, void* arg) { if (!from) return; Environment* env = static_cast(arg); - auto result = FetchAndMaybeCacheMD(env, from); - if (result.explicit_md) { + const ncrypto::Digest legacy = ncrypto::Digest::FromName(from); + const char* canonical_name = legacy ? EVP_MD_get0_name(legacy) : nullptr; + if (canonical_name == nullptr) return; + + auto result = FetchAndMaybeCacheMD(env, from, {}, canonical_name); + if (result.cached_md || result.digest) { env->supported_hash_algorithms.push_back(from); } } +struct ProviderHashNameContext { + Environment* env; +}; + +void SaveSupportedProviderHashName(const char* name, void* arg) { + if (name == nullptr) return; + + const std::string_view name_view(name); + const bool is_dotted_decimal = + name_view.find('.') != std::string_view::npos && + std::all_of(name_view.begin(), name_view.end(), [](unsigned char c) { + return (c >= '0' && c <= '9') || c == '.'; + }); + if (is_dotted_decimal) return; + + std::string normalized_name(name_view); + std::transform(normalized_name.begin(), + normalized_name.end(), + normalized_name.begin(), + [](unsigned char c) { + if (c >= 'A' && c <= 'Z') { + return static_cast(c + ('a' - 'A')); + } + return static_cast(c); + }); + + auto* context = static_cast(arg); + auto result = FetchAndMaybeCacheMD( + context->env, normalized_name.c_str(), {}, normalized_name.c_str()); + if (result.cached_md || result.digest) { + context->env->supported_hash_algorithms.push_back(normalized_name); + } +} + +void SaveSupportedProviderHashAlgorithms(EVP_MD* md, void* arg) { + ProviderHashNameContext context = { + .env = static_cast(arg), + }; + EVP_MD_names_do_all(md, SaveSupportedProviderHashName, &context); +} + #else void SaveSupportedHashAlgorithms(const EVP_MD* md, const char* from, @@ -155,25 +241,34 @@ void SaveSupportedHashAlgorithms(const EVP_MD* md, Environment* env = static_cast(arg); env->supported_hash_algorithms.push_back(from); } -#endif // OPENSSL_VERSION_MAJOR >= 3 +#endif // NCRYPTO_USE_OPENSSL3_PROVIDER const std::vector& GetSupportedHashAlgorithms(Environment* env) { - if (env->supported_hash_algorithms.empty()) { - MarkPopErrorOnReturn mark_pop_error_on_return; + while (true) { + SynchronizeHashCache(env); + const uint64_t generation = env->hash_cache_generation; + if (env->supported_hash_algorithms.empty()) { + MarkPopErrorOnReturn mark_pop_error_on_return; #if NCRYPTO_USE_BORINGSSL_EVP_DO_ALL_FALLBACK - for (const auto& digest : kBoringSSLDigests) { - static_cast(digest.get); - env->supported_hash_algorithms.emplace_back(digest.name); - } -#elif OPENSSL_VERSION_MAJOR >= 3 - // Since we'll fetch the EVP_MD*, cache them along the way to speed up - // later lookups instead of throwing them away immediately. - EVP_MD_do_all_sorted(SaveSupportedHashAlgorithmsAndCacheMD, env); + for (const auto& digest : kBoringSSLDigests) { + static_cast(digest.get); + env->supported_hash_algorithms.emplace_back(digest.name); + } +#elif NCRYPTO_USE_OPENSSL3_PROVIDER + // Since we'll fetch the EVP_MD*, cache them along the way to speed up + // later lookups instead of throwing them away immediately. + EVP_MD_do_all_sorted(SaveSupportedHashAlgorithmsAndCacheMD, env); + EVP_MD_do_all_provided(nullptr, SaveSupportedProviderHashAlgorithms, env); #else - EVP_MD_do_all_sorted(SaveSupportedHashAlgorithms, env); + EVP_MD_do_all_sorted(SaveSupportedHashAlgorithms, env); #endif + } + const uint64_t current_generation = ncrypto::getFipsStateGeneration(); + if (generation == current_generation) { + return env->supported_hash_algorithms; + } + ResetHashCache(env, current_generation); } - return env->supported_hash_algorithms; } void Hash::GetHashes(const FunctionCallbackInfo& args) { @@ -191,18 +286,19 @@ void Hash::GetCachedAliases(const FunctionCallbackInfo& args) { Isolate* isolate = args.GetIsolate(); Local context = args.GetIsolate()->GetCurrentContext(); Environment* env = Environment::GetCurrent(context); - size_t size = env->alias_to_md_id_map.size(); + SynchronizeHashCache(env); + size_t size = 0; LocalVector names(isolate); LocalVector values(isolate); -#if OPENSSL_VERSION_MAJOR >= 3 +#if NCRYPTO_USE_OPENSSL3_PROVIDER + const auto& aliases = env->provider_digest_cache->aliases(); + size = aliases.size(); names.reserve(size); values.reserve(size); - for (auto& [alias, id] : env->alias_to_md_id_map) { + for (const auto& [alias, id] : aliases) { names.push_back(OneByteString(isolate, alias)); - values.push_back(Uint32::New(isolate, id)); + values.push_back(Int32::New(isolate, id)); } -#else - CHECK(env->alias_to_md_id_map.empty()); #endif Local prototype = Null(isolate); Local result = @@ -210,18 +306,24 @@ void Hash::GetCachedAliases(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(result); } -const EVP_MD* GetDigestImplementation(Environment* env, - Local algorithm, - Local cache_id_val, - Local algorithm_cache) { +const EVP_MD* GetDigestImplementation( + Environment* env, + Local algorithm, + Local cache_id_val, + Local algorithm_cache, + std::optional& digest_owner) { CHECK(algorithm->IsString()); CHECK(cache_id_val->IsInt32()); CHECK(algorithm_cache->IsObject()); + DCHECK(!digest_owner.has_value()); -#if OPENSSL_VERSION_MAJOR >= 3 +#if NCRYPTO_USE_OPENSSL3_PROVIDER + Local cache = algorithm_cache.As(); int32_t cache_id = cache_id_val.As()->Value(); - if (cache_id != -1) { // Alias already cached, return the cached EVP_MD*. - return GetCachedMDByID(env, cache_id); + if (cache_id != -1) { + // Alias already cached, return the cached EVP_MD*. + if (const EVP_MD* md = GetCachedMDByID(env, cache_id, cache)) return md; + if (env->isolate()->HasPendingException()) return nullptr; } // Only decode the algorithm when we don't have it cached to avoid @@ -229,11 +331,11 @@ const EVP_MD* GetDigestImplementation(Environment* env, Isolate* isolate = env->isolate(); Utf8Value utf8(isolate, algorithm); - auto result = FetchAndMaybeCacheMD(env, *utf8); + auto result = FetchAndMaybeCacheMD(env, *utf8, cache); + if (env->isolate()->HasPendingException()) return nullptr; if (result.cache_id != -1) { - // Add the alias to both C++ side and JS side to speedup the lookup - // next time. - env->alias_to_md_id_map.emplace(*utf8, result.cache_id); + // Add the alias to the JavaScript side to speed up the next lookup. The + // native cache added it while inserting the implementation. if (algorithm_cache.As() ->Set(isolate->GetCurrentContext(), algorithm, @@ -243,7 +345,12 @@ const EVP_MD* GetDigestImplementation(Environment* env, } } - return result.explicit_md ? result.explicit_md : result.implicit_md; + if (result.cached_md != nullptr) return result.cached_md; + if (result.digest) { + digest_owner.emplace(result.digest); + return digest_owner->get(); + } + return nullptr; #else Utf8Value utf8(env->isolate(), algorithm); return ncrypto::getDigestByName(*utf8); @@ -257,21 +364,12 @@ void MarkInvalidXofLength() { EVPerr(EVP_F_EVP_DIGESTFINALXOF, EVP_R_NOT_XOF_OR_INVALID_LENGTH); #endif } -// crypto.digest(algorithm, algorithmId, algorithmCache, -// input, outputEncoding, outputEncodingId, outputLength) -void Hash::OneShotDigest(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); - CHECK_EQ(args.Length(), 7); - CHECK(args[0]->IsString()); // algorithm - CHECK(args[1]->IsInt32()); // algorithmId - CHECK(args[2]->IsObject()); // algorithmCache - CHECK(args[3]->IsString() || args[3]->IsArrayBufferView()); // input - CHECK(args[4]->IsString()); // outputEncoding - CHECK(args[5]->IsUint32() || args[5]->IsUndefined()); // outputEncodingId - CHECK(args[6]->IsUint32() || args[6]->IsUndefined()); // outputLength - const EVP_MD* md = GetDigestImplementation(env, args[0], args[1], args[2]); +void OneShotDigestWithMD(Environment* env, + const FunctionCallbackInfo& args, + const EVP_MD* md, + const CShakeOptions* options) { + Isolate* isolate = env->isolate(); if (md == nullptr) [[unlikely]] { Utf8Value method(isolate, args[0]); std::string message = @@ -313,7 +411,7 @@ void Hash::OneShotDigest(const FunctionCallbackInfo& args) { } } - if (output_length == 0) { + auto return_empty_output = [&]() { if (output_enc == BUFFER) { Local u8; if (Buffer::New(isolate, ArrayBuffer::New(isolate, 0), 0, 0) @@ -323,28 +421,72 @@ void Hash::OneShotDigest(const FunctionCallbackInfo& args) { } else { args.GetReturnValue().Set(String::Empty(isolate)); } + }; + + const bool has_digest_options = options != nullptr && !options->empty(); + if (output_length == 0 && !has_digest_options) { + return return_empty_output(); + } + + if (!has_digest_options) { + DataPointer output = ([&]() -> DataPointer { + if (args[3]->IsString()) { + Utf8Value utf8(isolate, args[3]); + ncrypto::Buffer input = { + .data = reinterpret_cast(utf8.out()), + .len = static_cast(utf8.length()), + }; + return is_xof ? ncrypto::xofHashDigest(input, md, output_length) + : ncrypto::hashDigest(input, md); + } + + ArrayBufferViewContents input(args[3]); + ncrypto::Buffer buffer = { + .data = input.data(), + .len = input.length(), + }; + return is_xof ? ncrypto::xofHashDigest(buffer, md, output_length) + : ncrypto::hashDigest(buffer, md); + })(); + if (!output) [[unlikely]] { + return ThrowCryptoError(env, ERR_get_error()); + } + + Local ret; + if (StringBytes::Encode(env->isolate(), + static_cast(output.get()), + output.size(), + output_enc) + .ToLocal(&ret)) { + args.GetReturnValue().Set(ret); + } return; } - DataPointer output = ([&]() -> DataPointer { + EVPMDCtxPointer ctx = EVPMDCtxPointer::New(); + if (!options->Initialize(&ctx, md)) { + return ThrowCryptoError( + env, ERR_get_error(), "Digest options are not supported"); + } + + const bool updated = [&]() { if (args[3]->IsString()) { - Utf8Value utf8(isolate, args[3]); - ncrypto::Buffer buf = { - .data = reinterpret_cast(utf8.out()), - .len = utf8.length(), - }; - return is_xof ? ncrypto::xofHashDigest(buf, md, output_length) - : ncrypto::hashDigest(buf, md); + Utf8Value input(isolate, args[3]); + return ctx.digestUpdate(ncrypto::Buffer{ + .data = input.out(), + .len = static_cast(input.length()), + }); } ArrayBufferViewContents input(args[3]); - ncrypto::Buffer buf = { - .data = reinterpret_cast(input.data()), + return ctx.digestUpdate(ncrypto::Buffer{ + .data = input.data(), .len = input.length(), - }; - return is_xof ? ncrypto::xofHashDigest(buf, md, output_length) - : ncrypto::hashDigest(buf, md); - })(); + }); + }(); + if (!updated) return ThrowCryptoError(env, ERR_get_error()); + if (output_length == 0) return return_empty_output(); + DataPointer output = ctx.digestFinal(output_length); if (!output) [[unlikely]] { return ThrowCryptoError(env, ERR_get_error()); @@ -360,6 +502,54 @@ void Hash::OneShotDigest(const FunctionCallbackInfo& args) { } } +// crypto.digest(algorithm, algorithmId, algorithmCache, input, outputEncoding, +// outputEncodingId, outputLength[, functionName, customization]) +void Hash::OneShotDigest(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK(args.Length() == 7 || args.Length() == 9); + CHECK(args[0]->IsString()); // algorithm + CHECK(args[1]->IsInt32()); // algorithmId + CHECK(args[2]->IsObject()); // algorithmCache + CHECK(args[3]->IsString() || args[3]->IsArrayBufferView()); // input + CHECK(args[4]->IsString()); // outputEncoding + CHECK(args[5]->IsUint32() || args[5]->IsUndefined()); // outputEncodingId + CHECK(args[6]->IsUint32() || args[6]->IsUndefined()); // outputLength + + if (args.Length() == 7) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + const int32_t cache_id = args[1].As()->Value(); + if (cache_id != -1) { + if (const EVP_MD* md = + GetCachedMDByID(env, cache_id, args[2].As())) { + return OneShotDigestWithMD(env, args, md, nullptr); + } + if (env->isolate()->HasPendingException()) return; + } +#else + Utf8Value utf8(env->isolate(), args[0]); + return OneShotDigestWithMD( + env, args, ncrypto::getDigestByName(*utf8), nullptr); +#endif + } + + if (args.Length() == 9) { + CShakeOptions options; + if (GetCShakeOptions(args, 7, &options).IsNothing()) return; + + std::optional digest_owner; + const EVP_MD* md = + GetDigestImplementation(env, args[0], args[1], args[2], digest_owner); + if (env->isolate()->HasPendingException()) return; + return OneShotDigestWithMD(env, args, md, &options); + } + + std::optional digest_owner; + const EVP_MD* md = + GetDigestImplementation(env, args[0], args[1], args[2], digest_owner); + if (env->isolate()->HasPendingException()) return; + OneShotDigestWithMD(env, args, md, nullptr); +} + void Hash::Initialize(Environment* env, Local target) { Isolate* isolate = env->isolate(); Local context = env->context(); @@ -396,30 +586,64 @@ void Hash::RegisterExternalReferences(ExternalReferenceRegistry* registry) { #endif } -// new Hash(algorithm, algorithmId, xofLen, algorithmCache) +// new Hash(algorithm, xofLen, algorithmId, algorithmCache[, functionName, +// customization]) void Hash::New(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); + CHECK(args.Length() == 4 || args.Length() == 6); + + Maybe xof_md_len = Nothing(); + if (!args[1]->IsUndefined()) { + CHECK(args[1]->IsUint32()); + xof_md_len = Just(args[1].As()->Value()); + } + +#if NCRYPTO_USE_OPENSSL3_PROVIDER + // This is the common path after the first lookup. Avoid constructing a + // digest owner when the Environment already owns the cached implementation. + if (args.Length() == 4 && args[0]->IsString()) { + CHECK(args[2]->IsInt32()); + const int32_t cache_id = args[2].As()->Value(); + if (cache_id != -1) { + if (const EVP_MD* md = + GetCachedMDByID(env, cache_id, args[3].As())) { + Hash* hash = new Hash(env, args.This()); + if (!hash->HashInit(md, xof_md_len)) { + return ThrowCryptoError( + env, ERR_get_error(), "Digest method not supported"); + } + return; + } + if (env->isolate()->HasPendingException()) return; + } + } +#endif const Hash* orig = nullptr; + std::optional digest_owner; const EVP_MD* md = nullptr; if (args[0]->IsObject()) { ASSIGN_OR_RETURN_UNWRAP(&orig, args[0].As()); CHECK_NOT_NULL(orig); md = orig->mdctx_.getDigest(); } else { - md = GetDigestImplementation(env, args[0], args[2], args[3]); - } - - Maybe xof_md_len = Nothing(); - if (!args[1]->IsUndefined()) { - CHECK(args[1]->IsUint32()); - xof_md_len = Just(args[1].As()->Value()); + md = GetDigestImplementation(env, args[0], args[2], args[3], digest_owner); + if (env->isolate()->HasPendingException()) return; } Hash* hash = new Hash(env, args.This()); - if (md == nullptr || !hash->HashInit(md, xof_md_len)) { - return ThrowCryptoError(env, ERR_get_error(), - "Digest method not supported"); + if (args.Length() == 4) { + if (md == nullptr || !hash->HashInit(md, xof_md_len)) { + return ThrowCryptoError( + env, ERR_get_error(), "Digest method not supported"); + } + } else { + CShakeOptions options; + if (GetCShakeOptions(args, 4, &options).IsNothing()) return; + if (md == nullptr || !hash->HashInit(md, options, xof_md_len)) { + return ThrowCryptoError( + env, ERR_get_error(), "Digest method not supported"); + } } if (orig != nullptr && !orig->mdctx_.copyTo(hash->mdctx_)) { @@ -427,9 +651,9 @@ void Hash::New(const FunctionCallbackInfo& args) { } } -bool Hash::HashInit(const EVP_MD* md, Maybe xof_md_len) { +bool Hash::HashInit(const EVP_MD* digest, Maybe xof_md_len) { mdctx_ = EVPMDCtxPointer::New(); - if (!mdctx_.digestInit(md)) [[unlikely]] { + if (!mdctx_.digestInit(digest)) [[unlikely]] { mdctx_.reset(); return false; } @@ -440,7 +664,7 @@ bool Hash::HashInit(const EVP_MD* md, Maybe xof_md_len) { // default lengths // TODO(@panva): remove this behaviour when DEP0198 is End-Of-Life if (mdctx_.hasXofFlag() && !xof_md_len.IsJust() && md_len_ == 0) { - const char* name = OBJ_nid2sn(EVP_MD_type(md)); + const char* name = OBJ_nid2sn(EVP_MD_type(digest)); if (name != nullptr) { if (strcmp(name, "SHAKE128") == 0) { md_len_ = 16; @@ -464,6 +688,30 @@ bool Hash::HashInit(const EVP_MD* md, Maybe xof_md_len) { return true; } +bool Hash::HashInit(const EVP_MD* digest, + const CShakeOptions& options, + Maybe xof_md_len) { + mdctx_ = EVPMDCtxPointer::New(); + if (!options.Initialize(&mdctx_, digest)) [[unlikely]] { + mdctx_.reset(); + return false; + } + + md_len_ = mdctx_.getDigestSize(); + if (xof_md_len.IsJust() && xof_md_len.FromJust() != md_len_) { + // This is a little hack to cause createHash to fail when an incorrect + // hashSize option was passed for a non-XOF hash function. + if (!mdctx_.hasXofFlag()) [[unlikely]] { + MarkInvalidXofLength(); + mdctx_.reset(); + return false; + } + md_len_ = xof_md_len.FromJust(); + } + + return true; +} + bool Hash::HashUpdate(const char* data, size_t len) { if (!mdctx_) return false; return mdctx_.digestUpdate(ncrypto::Buffer{ @@ -528,7 +776,10 @@ void Hash::HashDigest(const FunctionCallbackInfo& args) { } HashConfig::HashConfig(HashConfig&& other) noexcept - : in(std::move(other.in)), digest(other.digest), length(other.length) {} + : in(std::move(other.in)), + digest(other.digest), + options(std::move(other.options)), + length(other.length) {} HashConfig& HashConfig::operator=(HashConfig&& other) noexcept { if (&other == this) return *this; @@ -538,6 +789,9 @@ HashConfig& HashConfig::operator=(HashConfig&& other) noexcept { void HashConfig::MemoryInfo(MemoryTracker* tracker) const { tracker->TraitTrackInline(in, "in"); + if (options.has_value()) { + tracker->TrackField("options", *options); + } } MaybeLocal HashTraits::EncodeOutput(Environment* env, @@ -555,8 +809,8 @@ Maybe HashTraits::AdditionalConfig( CHECK(args[offset]->IsString()); // Hash algorithm Utf8Value digest(env->isolate(), args[offset]); - params->digest = ncrypto::getDigestByName(*digest); - if (params->digest == nullptr) [[unlikely]] { + params->digest = ncrypto::Digest::FromName(*digest); + if (!params->digest) [[unlikely]] { THROW_ERR_CRYPTO_INVALID_DIGEST(env, "Invalid digest: %s", digest); return Nothing(); } @@ -568,7 +822,14 @@ Maybe HashTraits::AdditionalConfig( } params->in = IsCryptoJobAsync(mode) ? data.ToCopy() : data.ToByteSource(); - unsigned int expected = EVP_MD_size(params->digest); + if (static_cast(args.Length()) > offset + 3) { + params->options.emplace(); + if (GetCShakeOptions(args, offset + 3, &*params->options).IsNothing()) { + return Nothing(); + } + } + + unsigned int expected = EVP_MD_size(params->digest.get()); params->length = expected; if (args[offset + 2]->IsUint32()) [[unlikely]] { // length is expressed in terms of bits @@ -576,7 +837,8 @@ Maybe HashTraits::AdditionalConfig( static_cast(args[offset + 2].As()->Value()) / CHAR_BIT; if (params->length != expected) { - if ((EVP_MD_flags(params->digest) & EVP_MD_FLAG_XOF) == 0) [[unlikely]] { + if ((EVP_MD_flags(params->digest.get()) & EVP_MD_FLAG_XOF) == 0) + [[unlikely]] { THROW_ERR_CRYPTO_INVALID_DIGEST(env, "Digest method not supported"); return Nothing(); } @@ -593,8 +855,11 @@ bool HashTraits::DeriveBits(Environment* env, CryptoErrorStore* errors) { auto ctx = EVPMDCtxPointer::New(); - if (!ctx.digestInit(params.digest) || !ctx.digestUpdate(params.in)) - [[unlikely]] { + const bool initialized = + params.options.has_value() + ? params.options->Initialize(&ctx, params.digest.get()) + : ctx.digestInit(params.digest.get()); + if (!initialized || !ctx.digestUpdate(params.in)) [[unlikely]] { return false; } diff --git a/src/crypto/crypto_hash.h b/src/crypto/crypto_hash.h index 3ae6a16c2579..146e9cf55b52 100644 --- a/src/crypto/crypto_hash.h +++ b/src/crypto/crypto_hash.h @@ -21,7 +21,10 @@ class Hash final : public BaseObject { SET_MEMORY_INFO_NAME(Hash) SET_SELF_SIZE(Hash) - bool HashInit(const EVP_MD* md, v8::Maybe xof_md_len); + bool HashInit(const EVP_MD* digest, v8::Maybe xof_md_len); + bool HashInit(const EVP_MD* digest, + const CShakeOptions& options, + v8::Maybe xof_md_len); bool HashUpdate(const char* data, size_t len); static void GetHashes(const v8::FunctionCallbackInfo& args); @@ -43,7 +46,8 @@ class Hash final : public BaseObject { struct HashConfig final : public MemoryRetainer { ByteSource in; - const EVP_MD* digest; + ncrypto::Digest digest; + std::optional options; unsigned int length; HashConfig() = default; diff --git a/src/crypto/crypto_rsa.cc b/src/crypto/crypto_rsa.cc index 15479284933d..e80c70c961df 100644 --- a/src/crypto/crypto_rsa.cc +++ b/src/crypto/crypto_rsa.cc @@ -38,6 +38,21 @@ using v8::Uint32; using v8::Value; namespace crypto { +namespace { +bool IsRsaPssDigestEncodable(const Digest& digest) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + const int nid = EVP_MD_type(digest.get()); + if (nid == NID_undef) return false; + + const ASN1_OBJECT* object = OBJ_nid2obj(nid); + return object != nullptr && OBJ_length(object) > 0; +#else + static_cast(digest); + return true; +#endif +} +} // namespace + EVPKeyCtxPointer RsaKeyGenTraits::Setup(RsaKeyPairGenConfig* params) { auto ctx = EVPKeyCtxPointer::NewFromID( params->params.variant == kKeyVariantRSA_PSS ? EVP_PKEY_RSA_PSS @@ -58,7 +73,8 @@ EVPKeyCtxPointer RsaKeyGenTraits::Setup(RsaKeyPairGenConfig* params) { } if (params->params.variant == kKeyVariantRSA_PSS) { - if (params->params.md && !ctx.setRsaPssKeygenMd(params->params.md)) { + if (params->params.md && (!IsRsaPssDigestEncodable(params->params.md) || + !ctx.setRsaPssKeygenMd(params->params.md))) { return {}; } @@ -71,7 +87,8 @@ EVPKeyCtxPointer RsaKeyGenTraits::Setup(RsaKeyPairGenConfig* params) { mgf1_md = params->params.md; } - if (mgf1_md && !ctx.setRsaPssKeygenMgf1Md(mgf1_md)) { + if (mgf1_md && (!IsRsaPssDigestEncodable(mgf1_md) || + !ctx.setRsaPssKeygenMgf1Md(mgf1_md))) { return {}; } diff --git a/src/crypto/crypto_util.cc b/src/crypto/crypto_util.cc index 133a5c7f7f1d..ff0026ab7bcf 100644 --- a/src/crypto/crypto_util.cc +++ b/src/crypto/crypto_util.cc @@ -18,6 +18,11 @@ #include "openssl/provider.h" #endif +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 0) +#include +#include +#endif + namespace node { using ncrypto::BignumPointer; @@ -76,6 +81,108 @@ size_t MemoryRetainerTraits::SelfSize( namespace crypto { +CShakeOptions::CShakeOptions(CShakeOptions&& other) noexcept + : function_name(std::move(other.function_name)), + customization(std::move(other.customization)), + flags(other.flags) {} + +CShakeOptions& CShakeOptions::operator=(CShakeOptions&& other) noexcept { + if (&other == this) return *this; + this->~CShakeOptions(); + return *new (this) CShakeOptions(std::move(other)); +} + +void CShakeOptions::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackFieldWithSize("function_name", function_name.size()); + tracker->TrackFieldWithSize("customization", customization.size()); +} + +bool CShakeOptions::Initialize(ncrypto::EVPMDCtxPointer* ctx, + const EVP_MD* digest) const { + if (!ctx || !*ctx || digest == nullptr) return false; + if (empty()) return ctx->digestInit(digest); + +#if !defined(OPENSSL_IS_BORINGSSL) && OPENSSL_VERSION_PREREQ(4, 0) + const bool is_cshake = + EVP_MD_is_a(digest, "CSHAKE-128") || EVP_MD_is_a(digest, "CSHAKE-256"); + if (!is_cshake) return false; + + OSSL_PARAM params[3]; + size_t count = 0; + if (has(kFunctionName)) { + params[count++] = OSSL_PARAM_construct_utf8_string( + OSSL_DIGEST_PARAM_FUNCTION_NAME, + const_cast(function_name.c_str()), + function_name.size()); + } + if (has(kCustomization)) { + params[count++] = OSSL_PARAM_construct_utf8_string( + OSSL_DIGEST_PARAM_CUSTOMIZATION, + const_cast(customization.c_str()), + customization.size()); + } + params[count] = OSSL_PARAM_construct_end(); + return ctx->digestInit(digest, params); +#else + return false; +#endif +} + +namespace { +bool ContainsNullByte(std::string_view value) { + return value.find('\0') != std::string_view::npos; +} + +v8::Maybe GetDigestStringOption( + Environment* env, + const v8::FunctionCallbackInfo& args, + unsigned int offset, + CShakeOptions::Flag flag, + std::string* target, + CShakeOptions* options) { + if (args[offset]->IsUndefined()) return v8::JustVoid(); + CHECK(IsAnyBufferSource(args[offset])); + ArrayBufferOrViewContents value(args[offset]); + if (!value.CheckSizeInt32()) { + THROW_ERR_OUT_OF_RANGE(env, "digest option is too big"); + return v8::Nothing(); + } + target->assign(value.data(), value.size()); + if (ContainsNullByte(*target)) { + THROW_ERR_INVALID_ARG_VALUE(env, + "Digest options must not contain null bytes"); + return v8::Nothing(); + } + options->flags |= flag; + return v8::JustVoid(); +} +} // namespace + +v8::Maybe GetCShakeOptions( + const v8::FunctionCallbackInfo& args, + unsigned int offset, + CShakeOptions* options) { + Environment* env = Environment::GetCurrent(args); + if (GetDigestStringOption(env, + args, + offset, + CShakeOptions::kFunctionName, + &options->function_name, + options) + .IsNothing() || + GetDigestStringOption(env, + args, + offset + 1, + CShakeOptions::kCustomization, + &options->customization, + options) + .IsNothing()) { + return v8::Nothing(); + } + + return v8::JustVoid(); +} + int PasswordCallback(char* buf, int size, int rwflag, void* u) { const ByteSource* passphrase = *static_cast(u); if (passphrase != nullptr) { @@ -231,6 +338,11 @@ void GetFipsCrypto(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(IsFipsEnabled() ? 1 : 0); } +void GetFipsCryptoGeneration(const FunctionCallbackInfo& args) { + args.GetReturnValue().Set(BigInt::NewFromUnsigned( + args.GetIsolate(), ncrypto::getFipsStateGeneration())); +} + void SetFipsCrypto(const FunctionCallbackInfo& args) { Mutex::ScopedLock lock(per_process::cli_options_mutex); Mutex::ScopedLock fips_lock(fips_mutex); @@ -891,6 +1003,8 @@ void Initialize(Environment* env, Local target) { #endif // !OPENSSL_NO_ENGINE SetMethodNoSideEffect(context, target, "getFipsCrypto", GetFipsCrypto); + SetMethodNoSideEffect( + context, target, "getFipsCryptoGeneration", GetFipsCryptoGeneration); SetMethod(context, target, "setFipsCrypto", SetFipsCrypto); SetMethodNoSideEffect(context, target, "testFipsCrypto", TestFipsCrypto); @@ -910,6 +1024,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { #endif // !OPENSSL_NO_ENGINE registry->Register(GetFipsCrypto); + registry->Register(GetFipsCryptoGeneration); registry->Register(SetFipsCrypto); registry->Register(TestFipsCrypto); registry->Register(SecureBuffer); diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h index c74a6e7fd507..62ae32d277d9 100644 --- a/src/crypto/crypto_util.h +++ b/src/crypto/crypto_util.h @@ -286,6 +286,35 @@ enum CryptoJobMode { kCryptoJobAsync, kCryptoJobSync, kCryptoJobWebCrypto }; CryptoJobMode GetCryptoJobMode(v8::Local args); bool IsCryptoJobAsync(CryptoJobMode mode); +struct CShakeOptions final : public MemoryRetainer { + enum Flag : uint8_t { + kFunctionName = 1 << 0, + kCustomization = 1 << 1, + }; + + std::string function_name; + std::string customization; + uint8_t flags = 0; + + CShakeOptions() = default; + CShakeOptions(CShakeOptions&& other) noexcept; + CShakeOptions& operator=(CShakeOptions&& other) noexcept; + + bool empty() const { return flags == 0; } + bool has(Flag flag) const { return (flags & flag) != 0; } + + bool Initialize(ncrypto::EVPMDCtxPointer* ctx, const EVP_MD* digest) const; + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(CShakeOptions) + SET_SELF_SIZE(CShakeOptions) +}; + +v8::Maybe GetCShakeOptions( + const v8::FunctionCallbackInfo& args, + unsigned int offset, + CShakeOptions* options); + v8::MaybeLocal CreateWebCryptoJobError(Environment* env, v8::Local cause); diff --git a/src/env.cc b/src/env.cc index b381cc341734..66e72d6eaeb5 100644 --- a/src/env.cc +++ b/src/env.cc @@ -16,6 +16,9 @@ #include "node_snapshotable.h" #include "node_v8_platform-inl.h" #include "node_worker.h" +#if HAVE_OPENSSL +#include "ncrypto.h" +#endif #include "req_wrap-inl.h" #include "stream_base.h" #include "tracing/agent.h" @@ -878,6 +881,10 @@ Environment::Environment(IsolateData* isolate_data, ? AllocateEnvironmentThreadId().id : thread_id.id), thread_name_(thread_name) { +#if HAVE_OPENSSL && NCRYPTO_USE_OPENSSL3_PROVIDER + provider_digest_cache = std::make_unique(); +#endif + if (!is_main_thread()) { // If this is a Worker thread, we can always safely use the parent's // Isolate's code cache because of the shared read-only heap. @@ -1129,6 +1136,11 @@ Environment::~Environment() { // Also, since the main thread usually stops just before the process exits, // this is far less relevant here. if (!is_main_thread()) { +#if HAVE_OPENSSL + // Provider methods can contain callbacks into native addons. Release the + // environment-owned methods before unloading any addon DSOs. + provider_digest_cache.reset(); +#endif // Dereference all addons that were loaded into this environment. for (binding::DLib& addon : loaded_addons_) { addon.Close(); diff --git a/src/env.h b/src/env.h index 84fad81e7b34..ca0d2866900f 100644 --- a/src/env.h +++ b/src/env.h @@ -53,10 +53,6 @@ #include "v8-profiler.h" #include "v8.h" -#if HAVE_OPENSSL -#include -#endif - #include #include #include @@ -72,6 +68,10 @@ #include #include +namespace ncrypto { +class DigestCache; +} // namespace ncrypto + namespace node { namespace shadow_realm { @@ -1093,12 +1093,8 @@ class Environment final : public MemoryRetainer { }; #if HAVE_OPENSSL -#if OPENSSL_VERSION_MAJOR >= 3 - // We declare another alias here to avoid having to include crypto_util.h - using EVPMDPointer = DeleteFnPtr; - std::vector evp_md_cache; -#endif // OPENSSL_VERSION_MAJOR >= 3 - std::unordered_map alias_to_md_id_map; + uint64_t hash_cache_generation = 0; + std::unique_ptr provider_digest_cache; std::vector supported_hash_algorithms; #endif // HAVE_OPENSSL diff --git a/test/addons/addons.status b/test/addons/addons.status index 18b1c2b2157d..60a33ec9acc6 100644 --- a/test/addons/addons.status +++ b/test/addons/addons.status @@ -14,6 +14,7 @@ openssl-binding/test: PASS,FLAKY openssl-binding/test: SKIP openssl-get-ssl-ctx/test: SKIP openssl-providers/test-default-only-config: SKIP +openssl-providers/test-default-properties-config: SKIP openssl-providers/test-legacy-provider-config: SKIP openssl-providers/test-legacy-provider-inactive-config: SKIP openssl-providers/test-legacy-provider-option: SKIP diff --git a/test/addons/openssl-providers/providers.cjs b/test/addons/openssl-providers/providers.cjs index efa1019c62d9..861439c66923 100644 --- a/test/addons/openssl-providers/providers.cjs +++ b/test/addons/openssl-providers/providers.cjs @@ -21,7 +21,11 @@ const { getProviders } = require(`./build/${common.buildType}/binding`); const providers = { 'default': { ciphers: ['des3-wrap'], - hashes: ['sha512-256'], + hashes: [ + 'sha512-256', + ...['keccak-kmac-128', 'keccak-kmac128'] + .filter((name) => getHashes().includes(name)), + ], }, 'legacy': { ciphers: ['blowfish', 'idea'], @@ -47,6 +51,15 @@ function assertArrayIncludes(array, item, desc) { `${desc} [${array}] does not include "${item}"`); } +function createSupportedHash(hash) { + try { + return createHash(hash); + } catch (err) { + if (err?.code !== 'ERR_OSSL_EVP_NOT_XOF_OR_INVALID_LENGTH') throw err; + return createHash(hash, { outputLength: 32 }); + } +} + function testProviderPresent(provider) { debug(`Checking '${provider}' is present`); assertArrayIncludes(getProviders(), provider, 'Loaded providers'); @@ -57,7 +70,7 @@ function testProviderPresent(provider) { for (const hash of providers[provider].hashes || []) { debug(`Checking '${hash}' hash is available`); assertArrayIncludes(getHashes(), hash, 'Available hashes'); - createHash(hash); + createSupportedHash(hash); } } diff --git a/test/addons/openssl-providers/test-default-properties-config.js b/test/addons/openssl-providers/test-default-properties-config.js new file mode 100644 index 000000000000..43f42864def4 --- /dev/null +++ b/test/addons/openssl-providers/test-default-properties-config.js @@ -0,0 +1,158 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../../common'); +const fixtures = require('../../common/fixtures'); +const providers = require('./providers.cjs'); + +const assert = require('node:assert'); +const { fork } = require('node:child_process'); +const { + createHash, + getHashes, + hash: oneShotHash, + setFips, +} = require('node:crypto'); +const { Worker } = require('node:worker_threads'); +const { getHashCache } = require('internal/crypto/util'); +const option = `--openssl-config=${fixtures.path( + 'openssl3-conf', + 'default_properties.cnf', +)}`; + +if (!process.execArgv.includes(option)) { + const cp = fork(__filename, { execArgv: [...process.execArgv, option] }); + cp.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + })); + return; +} + +assert(providers.getCurrentProviders().includes('default')); +assert(providers.getCurrentProviders().includes('legacy')); +providers.testProviderPresent('default'); + +const hashes = getHashes(); +const input = Buffer.alloc(0); +const md5 = 'd41d8cd98f00b204e9800998ecf8427e'; +assert.strictEqual(createHash('md5').update(input).digest('hex'), md5); +assert.strictEqual(oneShotHash('md5', input), md5); + +const hashName = 'SHA256'; +const hashCache = getHashCache(); +const descriptor = Object.getOwnPropertyDescriptor(hashCache, hashName); +assert(descriptor); +const cacheId = descriptor.value; +const sentinel = new Error('hash cache setter'); +const throwsSentinel = (err) => err === sentinel; + +function installThrowingHashCacheEntry(id) { + Object.defineProperty(hashCache, hashName, { + __proto__: null, + configurable: true, + enumerable: descriptor.enumerable, + get() { return id; }, + set() { throw sentinel; }, + }); +} + +installThrowingHashCacheEntry(-1); +assert.throws(() => createHash(hashName), throwsSentinel); +assert.throws(() => oneShotHash(hashName, input), throwsSentinel); +Object.defineProperty(hashCache, hashName, descriptor); + +installThrowingHashCacheEntry(cacheId); +setFips(true); +assert.throws(() => createHash(hashName), throwsSentinel); +assert.throws(() => oneShotHash(hashName, input), throwsSentinel); +Object.defineProperty(hashCache, hashName, descriptor); +assert.deepStrictEqual(getHashes(), []); +assert.throws( + () => createHash('md5'), + { code: 'ERR_OSSL_EVP_UNSUPPORTED' }, +); +assert.throws( + () => oneShotHash('md5', input), + { code: 'ERR_OSSL_EVP_UNSUPPORTED' }, +); + +setFips(false); +assert.deepStrictEqual(getHashes(), hashes); +assert.strictEqual(createHash('md5').update(input).digest('hex'), md5); +assert.strictEqual(oneShotHash('md5', input), md5); + +for (const hash of ['md4', 'whirlpool']) { + assert(!hashes.includes(hash)); + assert.throws(() => createHash(hash), { code: 'ERR_OSSL_EVP_UNSUPPORTED' }); +} + +const worker = new Worker(` + 'use strict'; + const { + createHash, + getHashes, + hash, + } = require('node:crypto'); + const { parentPort } = require('node:worker_threads'); + + const input = Buffer.alloc(0); + const hashes = getHashes(); + const liveHash = createHash('md5').update(input); + hash('md5', input); + parentPort.postMessage({ phase: 'warm' }); + + function getErrorCode(fn) { + try { + fn(); + } catch (err) { + return err.code; + } + } + + parentPort.on('message', (phase) => { + if (phase === 'fips-on') { + parentPort.postMessage({ + phase, + createHashError: getErrorCode(() => createHash('md5')), + oneShotHashError: getErrorCode(() => hash('md5', input)), + hashes: getHashes(), + liveDigest: liveHash.digest('hex'), + }); + } else { + parentPort.postMessage({ + phase, + createHashDigest: createHash('md5').update(input).digest('hex'), + oneShotHashDigest: hash('md5', input), + hashes: getHashes(), + }); + parentPort.close(); + } + }); +`, { eval: true }); + +worker.once('message', common.mustCall((message) => { + assert.strictEqual(message.phase, 'warm'); + setFips(true); + + worker.once('message', common.mustCall((message) => { + assert.strictEqual(message.phase, 'fips-on'); + assert.strictEqual(message.createHashError, 'ERR_OSSL_EVP_UNSUPPORTED'); + assert.strictEqual(message.oneShotHashError, 'ERR_OSSL_EVP_UNSUPPORTED'); + assert.deepStrictEqual(message.hashes, []); + assert.strictEqual(message.liveDigest, md5); + + setFips(false); + + worker.once('message', common.mustCall((message) => { + assert.strictEqual(message.phase, 'fips-off'); + assert.strictEqual(message.createHashDigest, md5); + assert.strictEqual(message.oneShotHashDigest, md5); + assert.deepStrictEqual(message.hashes, hashes); + })); + worker.postMessage('fips-off'); + })); + worker.postMessage('fips-on'); +})); +worker.on('error', common.mustNotCall()); +worker.on('exit', common.mustCall((code) => assert.strictEqual(code, 0))); diff --git a/test/fixtures/openssl3-conf/default_properties.cnf b/test/fixtures/openssl3-conf/default_properties.cnf new file mode 100644 index 000000000000..bb26de636b2c --- /dev/null +++ b/test/fixtures/openssl3-conf/default_properties.cnf @@ -0,0 +1,19 @@ +nodejs_conf = nodejs_init + +[nodejs_init] +providers = provider_sect +alg_section = algorithm_sect + +# Load both providers but select only implementations from the default provider. +[provider_sect] +default = default_sect +legacy = legacy_sect + +[default_sect] +activate = 1 + +[legacy_sect] +activate = 1 + +[algorithm_sect] +default_properties = provider=default diff --git a/test/parallel/test-crypto-provider-hash-options.js b/test/parallel/test-crypto-provider-hash-options.js new file mode 100644 index 000000000000..609d00d7f7b0 --- /dev/null +++ b/test/parallel/test-crypto-provider-hash-options.js @@ -0,0 +1,387 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +if (Number(process.versions.openssl.split('.')[0]) < 4 || + process.features.openssl_is_boringssl) { + common.skip('OpenSSL 4 provider support is required'); +} + +const assert = require('node:assert'); +const { + createHash, + getHashes, + hash, +} = require('node:crypto'); +const { internalBinding } = require('internal/test/binding'); +const { + HashJob, + kCryptoJobSync, + kCryptoJobWebCrypto, +} = internalBinding('crypto'); + +const hashes = getHashes(); +const hashNames = new Map( + hashes.map((name) => [name.toLowerCase(), name]), +); + +let exercised = false; + +function findHash(...names) { + for (const name of names) { + const result = hashNames.get(name); + if (result !== undefined) return result; + } + return undefined; +} + +function testHashJob(args, expected) { + const { 0: err, 1: result } = new HashJob( + kCryptoJobSync, + ...args, + ).run(); + assert.strictEqual(err, undefined); + assert.deepStrictEqual(Buffer.from(result), expected); + + (async () => { + const asyncResult = await new HashJob( + kCryptoJobWebCrypto, + ...args, + ).run(); + assert.deepStrictEqual(Buffer.from(asyncResult), expected); + })().then(common.mustCall()); +} + +const cshakeVectors = [ + { + names: ['cshake-128', 'cshake128'], + shakeNames: ['shake128', 'shake-128'], + outputLength: 32, + input: Buffer.from('00010203', 'hex'), + expected: 'c1c36925b6409a04f1b504fcbca9d82b' + + '4017277cb5ed2b2065fc1d3814d5aaf5', + }, + { + names: ['cshake-256', 'cshake256'], + shakeNames: ['shake256', 'shake-256'], + outputLength: 64, + input: Buffer.from('00010203', 'hex'), + expected: 'd008828e2b80ac9d2218ffee1d070c48' + + 'b8e4c87bff32c9699d5b6896eee0edd1' + + '64020e2be0560858d9c00c037e34a96' + + '937c561a74c412bb4c746469527281c8c', + }, +]; + +for (const vector of cshakeVectors) { + const algorithm = findHash(...vector.names); + if (algorithm === undefined) { + common.printSkipMessage(`${vector.names[0]} is not available`); + continue; + } + + exercised = true; + + const options = { + outputLength: vector.outputLength, + customization: 'Email Signature', + }; + const streaming = createHash(algorithm, options) + .update(vector.input.subarray(0, 2)) + .update(vector.input.subarray(2)) + .digest('hex'); + const partial = createHash(algorithm, options) + .update(vector.input.subarray(0, 2)); + const copyOptionReads = []; + const copied = partial.copy({ + get outputLength() { + copyOptionReads.push('outputLength'); + return vector.outputLength; + }, + get functionName() { + copyOptionReads.push('functionName'); + return undefined; + }, + get customization() { + copyOptionReads.push('customization'); + return undefined; + }, + }) + .update(vector.input.subarray(2)) + .digest('hex'); + + assert.strictEqual(streaming, vector.expected); + assert.strictEqual(copied, vector.expected); + assert.deepStrictEqual(copyOptionReads, ['outputLength']); + assert.strictEqual(hash(algorithm, vector.input, options), vector.expected); + + // BufferSource parameters have the same semantics as their string form. + const bufferOptions = { + ...options, + customization: Buffer.from(options.customization), + }; + assert.strictEqual( + createHash(algorithm, bufferOptions).update(vector.input).digest('hex'), + vector.expected, + ); + assert.strictEqual( + hash(algorithm, vector.input, bufferOptions), + vector.expected, + ); + + // Without function-name and customization parameters, cSHAKE is SHAKE. + const withoutParameters = createHash(algorithm) + .update(vector.input) + .digest('hex'); + assert.strictEqual(hash(algorithm, vector.input), withoutParameters); + + // Explicit undefined parameters have the same semantics as omitted ones. + const undefinedOptions = { + outputLength: vector.outputLength, + functionName: undefined, + customization: undefined, + }; + assert.strictEqual( + createHash(algorithm, undefinedOptions).update(vector.input).digest('hex'), + withoutParameters, + ); + assert.strictEqual( + hash(algorithm, vector.input, undefinedOptions), + withoutParameters, + ); + + // Empty BufferSource parameters are still supplied to OpenSSL, but cSHAKE + // with two empty parameters is equivalent to SHAKE. + const emptyOptions = { + outputLength: vector.outputLength, + functionName: Buffer.alloc(0), + customization: new Uint8Array(0), + }; + assert.strictEqual( + createHash(algorithm, emptyOptions).update(vector.input).digest('hex'), + withoutParameters, + ); + assert.strictEqual( + hash(algorithm, vector.input, emptyOptions), + withoutParameters, + ); + const emptyFunctionNameOptions = { + outputLength: vector.outputLength, + functionName: Buffer.alloc(0), + }; + assert.strictEqual( + createHash(algorithm, emptyFunctionNameOptions) + .update(vector.input) + .digest('hex'), + withoutParameters, + ); + assert.strictEqual( + hash(algorithm, vector.input, emptyFunctionNameOptions), + withoutParameters, + ); + + const createHashOptionReads = []; + assert.strictEqual( + createHash(algorithm, { + get outputLength() { + createHashOptionReads.push('outputLength'); + return vector.outputLength; + }, + get functionName() { + createHashOptionReads.push('functionName'); + return undefined; + }, + get customization() { + createHashOptionReads.push('customization'); + return undefined; + }, + }).update(vector.input).digest('hex'), + withoutParameters, + ); + assert.deepStrictEqual( + createHashOptionReads, + ['outputLength', 'functionName', 'customization'], + ); + + const hashOptionReads = []; + assert.strictEqual( + hash(algorithm, vector.input, { + get outputLength() { + hashOptionReads.push('outputLength'); + return vector.outputLength; + }, + get outputEncoding() { + hashOptionReads.push('outputEncoding'); + return 'hex'; + }, + get functionName() { + hashOptionReads.push('functionName'); + return undefined; + }, + get customization() { + hashOptionReads.push('customization'); + return undefined; + }, + }), + withoutParameters, + ); + assert.deepStrictEqual( + hashOptionReads, + ['outputLength', 'outputEncoding', 'functionName', 'customization'], + ); + + for (const zeroLengthOptions of [ + { outputLength: 0 }, + { + outputLength: 0, + functionName: Buffer.alloc(0), + customization: new Uint8Array(0), + }, + ]) { + assert.deepStrictEqual( + createHash(algorithm, zeroLengthOptions).update(vector.input).digest(), + Buffer.alloc(0), + ); + assert.strictEqual( + hash(algorithm, vector.input, zeroLengthOptions), + '', + ); + } + + const shake = findHash(...vector.shakeNames); + if (shake !== undefined) { + assert.strictEqual( + withoutParameters, + createHash(shake, { outputLength: vector.outputLength }) + .update(vector.input) + .digest('hex'), + ); + } + + const namedOptions = { + outputLength: vector.outputLength, + functionName: 'KMAC', + customization: 'Node.js', + }; + let namedResult; + try { + namedResult = createHash(algorithm, namedOptions) + .update(vector.input) + .digest(); + } catch { + common.printSkipMessage( + `${algorithm} does not support the KMAC function name`, + ); + } + if (namedResult !== undefined) { + assert.deepStrictEqual( + hash(algorithm, vector.input, { + ...namedOptions, + outputEncoding: 'buffer', + }), + namedResult, + ); + assert.deepStrictEqual( + createHash(algorithm, { + ...namedOptions, + functionName: Buffer.from(namedOptions.functionName), + customization: new Uint8Array(Buffer.from(namedOptions.customization)), + }).update(vector.input).digest(), + namedResult, + ); + assert.deepStrictEqual( + hash(algorithm, vector.input, { + ...namedOptions, + functionName: Buffer.from(namedOptions.functionName), + customization: new Uint8Array(Buffer.from(namedOptions.customization)), + outputEncoding: 'buffer', + }), + namedResult, + ); + } + + for (const functionName of ['', 'TupleHash', 'ParallelHash', 'KMAC']) { + const functionOptions = { + outputLength: vector.outputLength, + functionName, + }; + let functionResult; + try { + functionResult = createHash(algorithm, functionOptions) + .update(vector.input) + .digest(); + } catch { + common.printSkipMessage( + `${algorithm} does not support the ${functionName} function name`, + ); + continue; + } + assert.deepStrictEqual( + functionResult, + hash(algorithm, vector.input, { + ...functionOptions, + outputEncoding: 'buffer', + }), + ); + } + + testHashJob([ + algorithm, + vector.input, + vector.outputLength * 8, + undefined, + Buffer.from(options.customization), + ], Buffer.from(vector.expected, 'hex')); + + for (const invalidOptions of [ + { functionName: 1 }, + { customization: {} }, + ]) { + const expected = { code: 'ERR_INVALID_ARG_TYPE' }; + assert.throws(() => createHash(algorithm, invalidOptions), expected); + assert.throws( + () => hash(algorithm, vector.input, invalidOptions), + expected, + ); + } + + for (const invalidOptions of [ + { functionName: 'KMAC\0' }, + { customization: 'Node\0js' }, + { customization: Buffer.from([0x61, 0x00, 0x62]) }, + ]) { + const expected = { code: 'ERR_INVALID_ARG_VALUE' }; + assert.throws(() => createHash(algorithm, invalidOptions), expected); + assert.throws( + () => hash(algorithm, vector.input, invalidOptions), + expected, + ); + } +} + +if (cshakeVectors.some(({ names }) => findHash(...names) !== undefined)) { + for (const mismatchedOptions of [ + { functionName: 'KMAC' }, + { customization: 'Node.js' }, + { functionName: Buffer.alloc(0) }, + { customization: new Uint8Array(0) }, + ]) { + assert.throws( + () => createHash('sha256', mismatchedOptions), + { message: 'Digest method not supported' }, + ); + assert.throws( + () => hash('sha256', Buffer.from('abc'), mismatchedOptions), + { message: 'Digest options are not supported' }, + ); + } +} + +if (!exercised) { + common.printSkipMessage('cSHAKE is not available'); +} diff --git a/test/parallel/test-crypto-provider-hashes.js b/test/parallel/test-crypto-provider-hashes.js new file mode 100644 index 000000000000..166efaa0d7fd --- /dev/null +++ b/test/parallel/test-crypto-provider-hashes.js @@ -0,0 +1,258 @@ +// Flags: --expose-internals +'use strict'; + +const common = require('../common'); + +if (!common.hasCrypto) { + common.skip('missing crypto'); +} + +const assert = require('node:assert'); +const { + createHash, + createHmac, + createSign, + createVerify, + generateKeyPair, + generateKeyPairSync, + getHashes, + hash, + hkdf, + hkdfSync, + pbkdf2, + pbkdf2Sync, + privateDecrypt, + publicEncrypt, + sign, + verify, +} = require('node:crypto'); +const { hasOpenSSL3 } = require('../common/crypto'); + +if (!hasOpenSSL3 || process.features.openssl_is_boringssl) { + common.skip('OpenSSL 3 provider support is required'); +} + +const { internalBinding } = require('internal/test/binding'); +const { + HashJob, + kCryptoJobSync, + kCryptoJobWebCrypto, +} = internalBinding('crypto'); + +const hashes = getHashes(); +const lowercaseHashes = hashes.map((name) => name.toLowerCase()); +const modifiedHashes = getHashes(); +modifiedHashes.length = 0; + +assert.deepStrictEqual(hashes, [...hashes].sort()); +assert.deepStrictEqual(getHashes(), hashes); +assert.strictEqual(new Set(lowercaseHashes).size, hashes.length); +if (lowercaseHashes.includes('sha1')) { + assert(hashes.includes('RSA-SHA1')); +} +assert(!lowercaseHashes.includes('null')); +assert(!lowercaseHashes.includes('ml-dsa-mu')); +assert(!hashes.some((name) => /^\d+(?:\.\d+)+$/.test(name))); + +for (const name of hashes) { + try { + createHash(name); + } catch (err) { + assert.strictEqual(err.code, 'ERR_OSSL_EVP_NOT_XOF_OR_INVALID_LENGTH'); + createHash(name, { outputLength: 32 }); + } +} + +const input = Buffer.alloc(0); +assert.throws( + () => createHash('ml-dsa-mu'), + /Digest method not supported/, +); +assert.throws( + () => hash('ml-dsa-mu', input), + { message: 'Digest method ml-dsa-mu is not supported' }, +); + +const providerVectors = { + 'keccak-kmac-128': { + aliases: ['keccak-kmac-128', 'keccak-kmac128'], + expected: '83aa04c211dc19d16912571ed0a75130' + + 'd36aebd58562dd080c1ea84a8c7d73f7', + options: { outputLength: 32 }, + }, + 'keccak-256': { + aliases: ['keccak-256'], + expected: 'c5d2460186f7233c927e7db2dcc703c0' + + 'e500b653ca82273b7bfad8045d85a470', + }, + 'sha256-192': { + aliases: ['sha2-256/192', 'sha-256/192', 'sha256-192'], + expected: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934c', + }, +}; + +function testHashVector({ aliases, expected, options }) { + for (const alias of aliases) { + if (!hashes.includes(alias)) continue; + assert(!hashes.includes(alias.toUpperCase())); + + for (const name of [alias, alias.toUpperCase()]) { + const streaming = createHash(name, options).update(input).digest('hex'); + assert.strictEqual(streaming, expected); + assert.strictEqual(hash(name, input, options), expected); + } + } +} + +// These digests are tested when the active provider advertises them. +for (const name of ['keccak-kmac-128', 'keccak-256', 'sha256-192']) { + const vector = providerVectors[name]; + if (vector.aliases.some((alias) => hashes.includes(alias))) { + testHashVector(vector); + } else { + common.printSkipMessage(`${name} is not available from the active provider`); + } +} + +const keccakKmacName = providerVectors['keccak-kmac-128'].aliases + .find((alias) => hashes.includes(alias)); +if (keccakKmacName !== undefined) { + (async () => { + const { expected } = providerVectors['keccak-kmac-128']; + const { 0: err, 1: syncResult } = new HashJob( + kCryptoJobSync, + keccakKmacName, + input, + 256, + ).run(); + assert.strictEqual(err, undefined); + assert.strictEqual(Buffer.from(syncResult).toString('hex'), expected); + + const result = await new HashJob( + kCryptoJobWebCrypto, + keccakKmacName, + input, + 256, + ).run(); + assert.strictEqual(Buffer.from(result).toString('hex'), expected); + })().then(common.mustCall()); +} + +if (hashes.includes('sha256-192')) { + const operationInput = Buffer.from('abc'); + + assert.strictEqual( + createHmac('sha256-192', 'key').update(operationInput).digest('hex'), + 'd7774e586190fa2d2f4d4be4bc86ccd459a9170d52c38809', + ); + + const hkdfExpected = 'ef23757b94b5e1e46c3f981d87828d7aeb0207733ab5c78' + + 'c60df321c9e8c88e0ad54b4eecfef8c258ccd'; + assert.strictEqual( + Buffer.from(hkdfSync('sha256-192', 'key', 'salt', 'info', 42)) + .toString('hex'), + hkdfExpected, + ); + hkdf( + 'sha256-192', + 'key', + 'salt', + 'info', + 42, + common.mustSucceed((result) => { + assert.strictEqual(Buffer.from(result).toString('hex'), hkdfExpected); + }), + ); + + const pbkdf2Expected = '1fee3dd5ea13d5b563d3cc88fbc6dcf7' + + '3497aeffc3b3e6358ab3d3d1aa2aa0ee'; + assert.strictEqual( + pbkdf2Sync('password', 'salt', 2, 32, 'sha256-192').toString('hex'), + pbkdf2Expected, + ); + pbkdf2( + 'password', + 'salt', + 2, + 32, + 'sha256-192', + common.mustSucceed((result) => { + assert.strictEqual(result.toString('hex'), pbkdf2Expected); + }), + ); + + const { privateKey: ecPrivateKey, publicKey: ecPublicKey } = + generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const signature = sign('sha256-192', operationInput, ecPrivateKey); + assert(verify('sha256-192', operationInput, ecPublicKey, signature)); + + const streamingSignature = createSign('sha256-192') + .update(operationInput) + .sign(ecPrivateKey); + const verifier = createVerify('sha256-192'); + verifier.update(operationInput); + assert(verifier.verify(ecPublicKey, streamingSignature)); + + sign( + 'sha256-192', + operationInput, + ecPrivateKey, + common.mustSucceed((asyncSignature) => { + verify( + 'sha256-192', + operationInput, + ecPublicKey, + asyncSignature, + common.mustSucceed((result) => assert(result)), + ); + }), + ); + + const { privateKey: rsaPrivateKey, publicKey: rsaPublicKey } = + generateKeyPairSync('rsa', { modulusLength: 2048 }); + const plaintext = Buffer.from('provider digest'); + + assert.throws( + () => sign('sha256-192', plaintext, rsaPrivateKey), + { code: 'ERR_OSSL_DIGEST_NOT_ALLOWED' }, + ); + assert.deepStrictEqual( + privateDecrypt( + { key: rsaPrivateKey, oaepHash: 'sha256-192' }, + publicEncrypt( + { key: rsaPublicKey, oaepHash: 'sha256-192' }, + plaintext, + ), + ), + plaintext, + ); + + const pssOptions = [ + { + hashAlgorithm: 'sha256-192', + modulusLength: 2048, + }, + { + hashAlgorithm: 'sha256', + mgf1HashAlgorithm: 'sha256-192', + modulusLength: 2048, + }, + ]; + const keyGenerationFailed = { message: 'Key generation job failed' }; + + for (const options of pssOptions) { + assert.throws( + () => generateKeyPairSync('rsa-pss', options), + keyGenerationFailed, + ); + generateKeyPair( + 'rsa-pss', + options, + common.mustCall((err, publicKey, privateKey) => { + assert.strictEqual(err?.message, keyGenerationFailed.message); + assert.strictEqual(publicKey, undefined); + assert.strictEqual(privateKey, undefined); + }), + ); + } +} diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index f532cf6a0c75..3e98a60517ba 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -1,6 +1,8 @@ declare namespace InternalCryptoBinding { type Buffer = Uint8Array; - type ByteSource = string | ArrayBuffer | SharedArrayBuffer | ArrayBufferView; + type BufferSource = ArrayBuffer | SharedArrayBuffer | ArrayBufferView; + type OptionalBufferSource = BufferSource | undefined; + type ByteSource = string | BufferSource; type OptionalByteSource = ByteSource | undefined; type JwkKey = Record; type KeyFormatDER = 0; @@ -300,6 +302,8 @@ declare namespace InternalCryptoBinding { algorithm: string, data: ByteSource, outputLength?: number, + functionName?: OptionalBufferSource, + customization?: OptionalBufferSource, ): CryptoJobForMode; } @@ -818,6 +822,8 @@ export interface CryptoBinding { xofLen?: number, algorithmId?: number, algorithmCache?: Record, + functionName?: InternalCryptoBinding.OptionalBufferSource, + customization?: InternalCryptoBinding.OptionalBufferSource, ) => InternalCryptoBinding.HashHandle; Hmac: new () => InternalCryptoBinding.HmacHandle; KeyObjectHandle: new () => InternalCryptoBinding.KeyObjectHandle; @@ -939,6 +945,7 @@ export interface CryptoBinding { getCurves(): string[]; getExtraCACertificates(): string[]; getFipsCrypto(): 0 | 1; + getFipsCryptoGeneration(): bigint; getHashes(): string[]; getKeyObjectSlots(key: object): InternalCryptoBinding.KeyObjectSlots; getOpenSSLSecLevelCrypto(): number | undefined; @@ -953,6 +960,8 @@ export interface CryptoBinding { outputEncoding: string, outputEncodingId?: number, outputLength?: number, + functionName?: InternalCryptoBinding.OptionalBufferSource, + customization?: InternalCryptoBinding.OptionalBufferSource, ): string | InternalCryptoBinding.Buffer; parseX509(data: InternalCryptoBinding.ByteSource): InternalCryptoBinding.X509CertificateHandle; privateDecrypt: InternalCryptoBinding.PublicKeyCipher; From 9b52db81ebf3d15ce0dd6397f8c8b27daee6109c Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 23 Aug 2026 14:07:56 +0200 Subject: [PATCH 003/280] crypto: discover ciphers from OpenSSL providers Enumerate usable ciphers and aliases from activated OpenSSL 3 providers instead of maintaining lists of provider-only algorithms. Skip numeric OID aliases and filter NULL, TLS composite, multiblock, and encrypt-then-MAC implementations that the Cipher APIs cannot use. Preserve the OpenSSL 1.1.1 and BoringSSL paths. Expose CBC-CTS, SM4-GCM, SM4-CCM, SM4-XTS, and additional AES key wrap implementations. Add `ctsMode` (CS1/CS2/CS3) and `xtsStandard` (GB/IEEE) options for selecting provider CTS and SM4-XTS variants. Keep ordinary cipher construction on the original binding and legacy lookup paths. Lazily cache successful provider fetches per Environment for string initialization and `getCipherInfo()`. Index entries by case-insensitive query, canonical, and alias names. Deduplicate owners by provider and canonical identity. Return borrowed pointers on warm hits. Use the shared process-wide FIPS-state generation to invalidate per-Environment cipher caches and refresh `getCiphers()` snapshots in the main thread and workers. Existing cipher contexts retain their implementation and can finish across a transition. Release provider owners before unloading worker addon DSOs. Enforce one-shot updates for CBC-CTS, AES key wrap, SIV/GCM-SIV, and CCM decryption. Reject finalization without required input or CCM tags, and defer authentication failures to `final()`. Document streaming and XTS data-unit constraints. Add known-answer vectors, option validation, provider round trips, cache, worker, snapshot, FIPS transition, and construction benchmark coverage. Fixes: https://github.com/nodejs/node/issues/43040 Fixes: https://github.com/nodejs/node/issues/64866 Refs: https://github.com/nodejs/node/issues/62982 Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65484 Backport-PR-URL: https://github.com/nodejs/node/pull/65596 Reviewed-By: Antoine du Hamel --- benchmark/crypto/create-cipheriv.js | 62 +++ deps/ncrypto/ncrypto.cc | 388 ++++++++++++++---- deps/ncrypto/ncrypto.h | 81 ++-- doc/api/crypto.md | 269 +++++++++--- lib/internal/crypto/cipher.js | 32 +- lib/internal/crypto/util.js | 3 +- src/crypto/crypto_aes.h | 30 +- src/crypto/crypto_chacha20_poly1305.cc | 2 +- src/crypto/crypto_cipher.cc | 96 +++-- src/crypto/crypto_cipher.h | 10 +- src/crypto/crypto_context.cc | 11 +- src/env.cc | 2 + src/env.h | 2 + test/addons/openssl-providers/providers.cjs | 80 +++- test/fixtures/aead-vectors.js | 32 ++ .../snapshot/crypto-provider-cipher-cache.js | 55 +++ test/parallel/test-crypto-aes-wrap.js | 143 +++++++ test/parallel/test-crypto-authenticated.js | 44 +- ...est-crypto-cipherbase-options-fast-path.js | 130 ++++++ test/parallel/test-crypto-cipheriv-cbc-cts.js | 123 ++++++ .../test-crypto-cipheriv-decipheriv.js | 79 ++++ test/parallel/test-crypto-cipheriv-xts.js | 71 ++++ test/parallel/test-crypto-getcipherinfo.js | 69 +++- ...t-crypto-provider-cipher-cache-snapshot.js | 28 ++ .../test-crypto-provider-cipher-cache.js | 183 +++++++++ typings/internalBinding/crypto.d.ts | 2 + 26 files changed, 1802 insertions(+), 225 deletions(-) create mode 100644 benchmark/crypto/create-cipheriv.js create mode 100644 test/fixtures/snapshot/crypto-provider-cipher-cache.js create mode 100644 test/parallel/test-crypto-cipherbase-options-fast-path.js create mode 100644 test/parallel/test-crypto-cipheriv-cbc-cts.js create mode 100644 test/parallel/test-crypto-cipheriv-xts.js create mode 100644 test/parallel/test-crypto-provider-cipher-cache-snapshot.js create mode 100644 test/parallel/test-crypto-provider-cipher-cache.js diff --git a/benchmark/crypto/create-cipheriv.js b/benchmark/crypto/create-cipheriv.js new file mode 100644 index 000000000000..7774e393b403 --- /dev/null +++ b/benchmark/crypto/create-cipheriv.js @@ -0,0 +1,62 @@ +'use strict'; + +const common = require('../common.js'); +const assert = require('node:assert'); +const { + createCipheriv, + createDecipheriv, + getCiphers, +} = require('node:crypto'); + +const configurations = { + 'aes-128-cbc': { keyLength: 16, ivLength: 16 }, + 'aes-128-gcm': { keyLength: 16, ivLength: 12 }, + 'aes-128-cbc-cts': { keyLength: 16, ivLength: 16 }, + 'aes-128-wrap-inv': { keyLength: 16, ivLength: 8 }, + 'aes128-wrap-inv': { + keyLength: 16, + ivLength: 8, + warmupCipher: 'aes-128-wrap-inv', + }, +}; + +const ciphers = ['aes-128-cbc', 'aes-128-gcm']; +const availableCiphers = new Set(getCiphers()); +for (const cipher of [ + 'aes-128-cbc-cts', + 'aes-128-wrap-inv', + 'aes128-wrap-inv', +]) { + if (availableCiphers.has(cipher)) { + ciphers.push(cipher); + } +} + +const bench = common.createBenchmark(main, { + n: [1e5], + cipher: ciphers, + operation: ['encrypt', 'decrypt'], +}); + +function main({ n, cipher, operation }) { + const { + keyLength, + ivLength, + warmupCipher = cipher, + } = configurations[cipher]; + const key = Buffer.alloc(keyLength); + const iv = Buffer.alloc(ivLength); + const results = new Array(n); + const method = operation === 'encrypt' ? createCipheriv : createDecipheriv; + + const warmup = method(warmupCipher, key, iv); + assert.strictEqual(typeof warmup, 'object'); + + bench.start(); + for (let i = 0; i < n; ++i) { + results[i] = method(cipher, key, iv); + } + bench.end(n); + + assert.strictEqual(typeof results[n - 1], 'object'); +} diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 18dac63be728..d334d17c300b 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -537,7 +537,7 @@ bool setFipsEnabled(bool enable, CryptoErrorList* errors) { #else const bool success = FIPS_mode_set(enable ? 1 : 0) == 1; #endif - if (isFipsEnabledRaw() != was_enabled) { + if (success && isFipsEnabledRaw() != was_enabled) { fips_state_generation.fetch_add(1, std::memory_order_release); } return success; @@ -4433,6 +4433,40 @@ constexpr char AsciiToLower(char c) { } #if NCRYPTO_USE_OPENSSL3_PROVIDER +constexpr auto kUnsupportedCipherFlags = + EVP_CIPH_FLAG_CIPHER_WITH_MAC | EVP_CIPH_FLAG_TLS1_1_MULTIBLOCK; + +bool HasUnsupportedCipherFlags(const EVP_CIPHER* cipher) { + return (EVP_CIPHER_get_flags(cipher) & kUnsupportedCipherFlags) != 0; +} + +bool IsSupportedLegacyCipher(const EVP_CIPHER* cipher) { + return cipher != nullptr && cipher != EVP_enc_null() && + !HasUnsupportedCipherFlags(cipher); +} + +bool IsSupportedFetchedCipher(const EVP_CIPHER* cipher) { + if (cipher == nullptr || EVP_CIPHER_is_a(cipher, "NULL") || + HasUnsupportedCipherFlags(cipher)) { + return false; + } + +#ifdef OSSL_CIPHER_PARAM_ENCRYPT_THEN_MAC + int encrypt_then_mac = 0; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_int(OSSL_CIPHER_PARAM_ENCRYPT_THEN_MAC, + &encrypt_then_mac), + OSSL_PARAM_construct_end(), + }; + if (EVP_CIPHER_get_params(const_cast(cipher), params) == 1 && + encrypt_then_mac != 0) { + return false; + } +#endif + + return true; +} + void PushAlgorithmAlias(const char* name, void* arg) { if (name == nullptr) return; static_cast*>(arg)->emplace_back(name); @@ -4440,7 +4474,7 @@ void PushAlgorithmAlias(const char* name, void* arg) { #endif } // namespace -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER Cipher::Cipher(DeleteFnPtr cipher) : cipher_(cipher.get()), fetched_cipher_(std::move(cipher)) {} #endif @@ -4541,8 +4575,63 @@ const DigestCache::AliasMap& DigestCache::aliases() const { #endif } +const EVP_CIPHER* CipherCache::lookup(const char* name, uint64_t generation) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (generation_ != generation) { + aliases_.clear(); + ciphers_.clear(); + generation_ = generation; + } + + const auto it = aliases_.find(name); + if (it == aliases_.end()) return nullptr; + if (it->second >= ciphers_.size()) return nullptr; + return ciphers_[it->second].get(); +#else + static_cast(name); + static_cast(generation); + return nullptr; +#endif +} + +#if NCRYPTO_USE_OPENSSL3_PROVIDER +const EVP_CIPHER* CipherCache::insert( + const char* name, + DeleteFnPtr&& cipher, + uint64_t generation) { + if (generation_ != generation || cipher == nullptr) return nullptr; + + const char* canonical_name = EVP_CIPHER_get0_name(cipher.get()); + const OSSL_PROVIDER* provider = EVP_CIPHER_get0_provider(cipher.get()); + if (canonical_name != nullptr && provider != nullptr) { + for (size_t id = 0; id < ciphers_.size(); id++) { + const EVP_CIPHER* cached = ciphers_[id].get(); + const char* cached_name = EVP_CIPHER_get0_name(cached); + if (EVP_CIPHER_get0_provider(cached) == provider && + cached_name != nullptr && + CaseInsensitiveNameEqual()(cached_name, canonical_name)) { + aliases_.insert_or_assign(name, id); + return cached; + } + } + } + + ciphers_.emplace_back(std::move(cipher)); + const size_t id = ciphers_.size() - 1; + + std::vector aliases; + EVP_CIPHER_names_do_all(ciphers_[id].get(), PushAlgorithmAlias, &aliases); + for (const std::string& alias : aliases) { + aliases_.emplace(alias, id); + } + aliases_.insert_or_assign(name, id); + + return ciphers_[id].get(); +} +#endif + Cipher::Cipher(const Cipher& other) : cipher_(other.cipher_) { -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER if (other.fetched_cipher_ != nullptr) { if (EVP_CIPHER_up_ref(other.fetched_cipher_.get()) == 1) { fetched_cipher_.reset(other.fetched_cipher_.get()); @@ -4555,7 +4644,7 @@ Cipher::Cipher(const Cipher& other) : cipher_(other.cipher_) { Cipher& Cipher::operator=(const Cipher& other) { if (this == &other) return *this; -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER if (other.fetched_cipher_ != nullptr) { if (EVP_CIPHER_up_ref(other.fetched_cipher_.get()) == 1) { fetched_cipher_.reset(other.fetched_cipher_.get()); @@ -4572,40 +4661,59 @@ Cipher& Cipher::operator=(const Cipher& other) { return *this; } -const Cipher Cipher::FromName(const char* name) { +const Cipher Cipher::FromName(const char* name, CipherCache* cache) { const EVP_CIPHER* cipher = EVP_get_cipherbyname(name); - if (cipher != nullptr) return Cipher(cipher); + if (cipher != nullptr) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (!IsSupportedLegacyCipher(cipher)) return Cipher(); +#endif + return Cipher(cipher); + } + +#if NCRYPTO_USE_OPENSSL3_PROVIDER + // A resolution that overlaps a FIPS transition may use either property + // state. The cache retains the generation observed here, so the first + // resolution begun after the transition clears any stale entries. + const uint64_t generation = getFipsStateGeneration(); + if (cache != nullptr) { + if (const EVP_CIPHER* cached = cache->lookup(name, generation)) { + return Cipher(cached); + } + } -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV MarkPopErrorOnReturn mark_pop_error_on_return; DeleteFnPtr fetched( EVP_CIPHER_fetch(nullptr, name, nullptr)); - if (fetched == nullptr) return Cipher(); + if (!IsSupportedFetchedCipher(fetched.get())) return Cipher(); - const int mode = EVP_CIPHER_mode(fetched.get()); - const bool is_siv_mode = -#if OPENSSL_WITH_AES_SIV - mode == EVP_CIPH_SIV_MODE || -#endif -#if OPENSSL_WITH_AES_GCM_SIV - mode == EVP_CIPH_GCM_SIV_MODE || -#endif - false; - if (is_siv_mode) return Cipher(std::move(fetched)); + if (cache != nullptr && generation == getFipsStateGeneration()) { + if (const EVP_CIPHER* cached = + cache->insert(name, std::move(fetched), generation)) { + return Cipher(cached); + } + } - return Cipher(); + return Cipher(std::move(fetched)); #else + static_cast(cache); return Cipher(); #endif } -const Cipher Cipher::FromNid(int nid) { +const Cipher Cipher::FromNid(int nid, CipherCache* cache) { const EVP_CIPHER* cipher = EVP_get_cipherbynid(nid); - if (cipher != nullptr) return Cipher(cipher); + if (cipher != nullptr) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + if (!IsSupportedLegacyCipher(cipher)) return Cipher(); +#endif + return Cipher(cipher); + } -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER const char* name = OBJ_nid2sn(nid); - if (name != nullptr) return FromName(name); + if (name != nullptr) return FromName(name, cache); +#else + static_cast(cache); #endif return Cipher(); @@ -4615,27 +4723,79 @@ const Cipher Cipher::FromCtx(const CipherCtxPointer& ctx) { return Cipher(GetCipherCtxCipher(ctx.get())); } -const Cipher Cipher::EMPTY = Cipher(); -const Cipher Cipher::AES_128_CBC = Cipher::FromNid(NID_aes_128_cbc); -const Cipher Cipher::AES_192_CBC = Cipher::FromNid(NID_aes_192_cbc); -const Cipher Cipher::AES_256_CBC = Cipher::FromNid(NID_aes_256_cbc); -const Cipher Cipher::AES_128_CTR = Cipher::FromNid(NID_aes_128_ctr); -const Cipher Cipher::AES_192_CTR = Cipher::FromNid(NID_aes_192_ctr); -const Cipher Cipher::AES_256_CTR = Cipher::FromNid(NID_aes_256_ctr); -const Cipher Cipher::AES_128_GCM = Cipher::FromNid(NID_aes_128_gcm); -const Cipher Cipher::AES_192_GCM = Cipher::FromNid(NID_aes_192_gcm); -const Cipher Cipher::AES_256_GCM = Cipher::FromNid(NID_aes_256_gcm); -const Cipher Cipher::AES_128_KW = Cipher::FromNid(NID_id_aes128_wrap); -const Cipher Cipher::AES_192_KW = Cipher::FromNid(NID_id_aes192_wrap); -const Cipher Cipher::AES_256_KW = Cipher::FromNid(NID_id_aes256_wrap); +namespace { +template +const Cipher& GetPredefinedCipher() { + static const Cipher cipher = Cipher::FromNid(nid); + return cipher; +} +} // namespace + +const Cipher& Cipher::AES_128_CBC() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_192_CBC() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_256_CBC() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_128_CTR() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_192_CTR() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_256_CTR() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_128_GCM() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_192_GCM() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_256_GCM() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_128_KW() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_192_KW() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_256_KW() { + return GetPredefinedCipher(); +} #ifndef OPENSSL_IS_BORINGSSL -const Cipher Cipher::AES_128_OCB = Cipher::FromNid(NID_aes_128_ocb); -const Cipher Cipher::AES_192_OCB = Cipher::FromNid(NID_aes_192_ocb); -const Cipher Cipher::AES_256_OCB = Cipher::FromNid(NID_aes_256_ocb); +const Cipher& Cipher::AES_128_OCB() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_192_OCB() { + return GetPredefinedCipher(); +} + +const Cipher& Cipher::AES_256_OCB() { + return GetPredefinedCipher(); +} #endif -const Cipher Cipher::CHACHA20_POLY1305 = Cipher::FromNid(NID_chacha20_poly1305); +const Cipher& Cipher::CHACHA20_POLY1305() { + return GetPredefinedCipher(); +} bool Cipher::isGcmMode() const { if (!cipher_) return false; @@ -4657,6 +4817,15 @@ bool Cipher::isCcmMode() const { return getMode() == EVP_CIPH_CCM_MODE; } +bool Cipher::isCtsMode() const { + if (!cipher_) return false; +#if NCRYPTO_USE_OPENSSL3_PROVIDER + return (EVP_CIPHER_get_flags(cipher_) & EVP_CIPH_FLAG_CTS) != 0; +#else + return false; +#endif +} + bool Cipher::isOcbMode() const { if (!cipher_) return false; return getMode() == EVP_CIPH_OCB_MODE; @@ -4761,7 +4930,7 @@ const char* Cipher::getName() const { const char* name = OBJ_nid2sn(nid); if (name != nullptr) return name; } -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER return EVP_CIPHER_get0_name(cipher_); #else return {}; @@ -4858,11 +5027,57 @@ bool CipherCtxPointer::setAeadTagLength(size_t length) { ctx_.get(), EVP_CTRL_AEAD_SET_TAG, length, nullptr); } +#if NCRYPTO_USE_OPENSSL3_PROVIDER +namespace { +// OSSL_CIPHER_PARAM_XTS_STANDARD is not defined by OpenSSL 3.0. Use its +// parameter name directly so custom 3.0 providers can advertise it too. +constexpr char kCipherParamXtsStandard[] = "xts_standard"; + +bool SetCipherCtxStringParam(EVP_CIPHER_CTX* ctx, + const char* key, + const char* value) { + if (ctx == nullptr || value == nullptr) return false; + + const OSSL_PARAM* settable = EVP_CIPHER_CTX_settable_params(ctx); + const OSSL_PARAM* descriptor = + settable == nullptr ? nullptr : OSSL_PARAM_locate_const(settable, key); + if (descriptor == nullptr || + descriptor->data_type != OSSL_PARAM_UTF8_STRING) { + return false; + } + + OSSL_PARAM params[] = { + OSSL_PARAM_construct_utf8_string(key, const_cast(value), 0), + OSSL_PARAM_END, + }; + return EVP_CIPHER_CTX_set_params(ctx, params) == 1; +} +} // namespace +#endif + +bool CipherCtxPointer::setCtsMode(const char* mode) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + return SetCipherCtxStringParam(ctx_.get(), OSSL_CIPHER_PARAM_CTS_MODE, mode); +#else + static_cast(mode); + return false; +#endif +} + bool CipherCtxPointer::setPadding(bool padding) { if (!ctx_) return false; return EVP_CIPHER_CTX_set_padding(ctx_.get(), padding); } +bool CipherCtxPointer::setXtsStandard(const char* standard) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + return SetCipherCtxStringParam(ctx_.get(), kCipherParamXtsStandard, standard); +#else + static_cast(standard); + return false; +#endif +} + int CipherCtxPointer::getBlockSize() const { if (!ctx_) return 0; return EVP_CIPHER_CTX_block_size(ctx_.get()); @@ -4888,6 +5103,16 @@ bool CipherCtxPointer::isCcmMode() const { return getMode() == EVP_CIPH_CCM_MODE; } +bool CipherCtxPointer::isCtsMode() const { + if (!ctx_) return false; + return Cipher::FromCtx(*this).isCtsMode(); +} + +bool CipherCtxPointer::isXtsMode() const { + if (!ctx_) return false; + return getMode() == EVP_CIPH_XTS_MODE; +} + bool CipherCtxPointer::isWrapMode() const { if (!ctx_) return false; return getMode() == EVP_CIPH_WRAP_MODE; @@ -6359,23 +6584,7 @@ struct CipherCallbackContext { void operator()(const char* name) { cb(name); } }; -#if OPENSSL_WITH_AES_SIV -constexpr const char* kProviderOnlyAesSivCiphers[] = { - "aes-128-siv", - "aes-192-siv", - "aes-256-siv", -}; -#endif - -#if OPENSSL_WITH_AES_GCM_SIV -constexpr const char* kProviderOnlyAesGcmSivCiphers[] = { - "aes-128-gcm-siv", - "aes-192-gcm-siv", - "aes-256-gcm-siv", -}; -#endif - -#if OPENSSL_VERSION_MAJOR >= 3 +#if NCRYPTO_USE_OPENSSL3_PROVIDER template fetched( + fetch_type(nullptr, real_name, nullptr)); + if (!IsSupportedFetchedCipher(fetched.get())) return; - free_type(fetched); auto& cb = *(static_cast(arg)); cb(from); } + +void array_push_back_provider_name(const char* name, void* arg) { + if (name == nullptr) return; + + const std::string_view name_view(name); + const bool is_dotted_decimal = + name_view.find('.') != std::string_view::npos && + std::all_of(name_view.begin(), name_view.end(), [](unsigned char c) { + return (c >= '0' && c <= '9') || c == '.'; + }); + if (is_dotted_decimal) return; + + std::string normalized_name(name_view); + std::transform(normalized_name.begin(), + normalized_name.end(), + normalized_name.begin(), + [](unsigned char c) { + if (c >= 'A' && c <= 'Z') { + return static_cast(c + ('a' - 'A')); + } + return static_cast(c); + }); + auto& cb = *(static_cast(arg)); + cb(normalized_name.c_str()); +} + +void array_push_back_provider(EVP_CIPHER* cipher, void* arg) { + const char* name = EVP_CIPHER_get0_name(cipher); + if (name == nullptr) return; + + DeleteFnPtr fetched( + EVP_CIPHER_fetch(nullptr, name, nullptr)); + if (!IsSupportedFetchedCipher(fetched.get())) return; + + EVP_CIPHER_names_do_all(fetched.get(), array_push_back_provider_name, arg); +} #else template void array_push_back(const TypeName* evp_ref, @@ -6431,7 +6676,7 @@ void Cipher::ForEach(Cipher::CipherNameCallback callback) { } #else EVP_CIPHER_do_all_sorted( -#if OPENSSL_VERSION_MAJOR >= 3 +#if NCRYPTO_USE_OPENSSL3_PROVIDER array_push_back, #endif &context); -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV - auto maybe_push_provider_only_cipher = [&](const char* name) { - EVP_CIPHER* cipher = EVP_CIPHER_fetch(nullptr, name, nullptr); - if (cipher == nullptr) return; - EVP_CIPHER_free(cipher); - context.cb(name); - }; -#endif -#if OPENSSL_WITH_AES_SIV - for (const char* name : kProviderOnlyAesSivCiphers) { - maybe_push_provider_only_cipher(name); - } -#endif -#if OPENSSL_WITH_AES_GCM_SIV - for (const char* name : kProviderOnlyAesGcmSivCiphers) { - maybe_push_provider_only_cipher(name); - } +#if NCRYPTO_USE_OPENSSL3_PROVIDER + EVP_CIPHER_do_all_provided(nullptr, array_push_back_provider, &context); #endif #endif } diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 8d4091c75a98..8c09ac5f165d 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -20,6 +20,8 @@ #include #include #include +#include +#include #if defined(NCRYPTO_ENGINE_COMPAT) && NCRYPTO_ENGINE_COMPAT && \ !defined(OPENSSL_NO_ENGINE) #include @@ -498,6 +500,32 @@ DataPointer xofHashDigest(const Buffer& data, const EVP_MD* md, size_t length); +class CipherCache final { + public: + CipherCache() = default; + NCRYPTO_DISALLOW_COPY_AND_MOVE(CipherCache) + + const EVP_CIPHER* lookup(const char* name, uint64_t generation); +#if NCRYPTO_USE_OPENSSL3_PROVIDER + const EVP_CIPHER* insert(const char* name, + DeleteFnPtr&& cipher, + uint64_t generation); +#endif + + private: +#if NCRYPTO_USE_OPENSSL3_PROVIDER + using EVPCipherPointer = DeleteFnPtr; + + uint64_t generation_ = 0; + std::vector ciphers_; + std::unordered_map + aliases_; +#endif +}; + class Cipher final { public: static constexpr size_t MAX_KEY_LENGTH = EVP_MAX_KEY_LENGTH; @@ -519,7 +547,7 @@ class Cipher final { Cipher(const Cipher& other); Cipher& operator=(const Cipher& other); inline Cipher& operator=(const EVP_CIPHER* cipher) { -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER fetched_cipher_.reset(); #endif cipher_ = cipher; @@ -543,6 +571,7 @@ class Cipher final { bool isWrapMode() const; bool isCtrMode() const; bool isCcmMode() const; + bool isCtsMode() const; bool isOcbMode() const; bool isSivMode() const; bool isGcmSivMode() const; @@ -556,8 +585,8 @@ class Cipher final { unsigned char* key, unsigned char* iv) const; - static const Cipher FromName(const char* name); - static const Cipher FromNid(int nid); + static const Cipher FromName(const char* name, CipherCache* cache = nullptr); + static const Cipher FromNid(int nid, CipherCache* cache = nullptr); static const Cipher FromCtx(const CipherCtxPointer& ctx); using CipherNameCallback = std::function; @@ -566,28 +595,24 @@ class Cipher final { // is able to do so. static void ForEach(CipherNameCallback callback); - // Utilities to get various ciphers by type. If the underlying - // implementation does not support the requested cipher, then - // the result will be an empty Cipher object whose bool operator - // will return false. - - static const Cipher EMPTY; - static const Cipher AES_128_CBC; - static const Cipher AES_192_CBC; - static const Cipher AES_256_CBC; - static const Cipher AES_128_CTR; - static const Cipher AES_192_CTR; - static const Cipher AES_256_CTR; - static const Cipher AES_128_GCM; - static const Cipher AES_192_GCM; - static const Cipher AES_256_GCM; - static const Cipher AES_128_KW; - static const Cipher AES_192_KW; - static const Cipher AES_256_KW; - static const Cipher AES_128_OCB; - static const Cipher AES_192_OCB; - static const Cipher AES_256_OCB; - static const Cipher CHACHA20_POLY1305; + // Lazily resolves common ciphers. If the underlying implementation does not + // support the requested cipher, the returned Cipher will be empty. + static const Cipher& AES_128_CBC(); + static const Cipher& AES_192_CBC(); + static const Cipher& AES_256_CBC(); + static const Cipher& AES_128_CTR(); + static const Cipher& AES_192_CTR(); + static const Cipher& AES_256_CTR(); + static const Cipher& AES_128_GCM(); + static const Cipher& AES_192_GCM(); + static const Cipher& AES_256_GCM(); + static const Cipher& AES_128_KW(); + static const Cipher& AES_192_KW(); + static const Cipher& AES_256_KW(); + static const Cipher& AES_128_OCB(); + static const Cipher& AES_192_OCB(); + static const Cipher& AES_256_OCB(); + static const Cipher& CHACHA20_POLY1305(); struct CipherParams { int padding; @@ -617,7 +642,7 @@ class Cipher final { private: const EVP_CIPHER* cipher_ = nullptr; -#if OPENSSL_WITH_AES_SIV || OPENSSL_WITH_AES_GCM_SIV +#if NCRYPTO_USE_OPENSSL3_PROVIDER explicit Cipher(DeleteFnPtr cipher); DeleteFnPtr fetched_cipher_; #endif @@ -1007,7 +1032,9 @@ class CipherCtxPointer final { bool setIvLength(size_t length); bool setAeadTag(const Buffer& tag); bool setAeadTagLength(size_t length); + bool setCtsMode(const char* mode); bool setPadding(bool padding); + bool setXtsStandard(const char* standard); bool init(const Cipher& cipher, bool encrypt, const unsigned char* key = nullptr, @@ -1020,6 +1047,8 @@ class CipherCtxPointer final { bool isGcmMode() const; bool isOcbMode() const; bool isCcmMode() const; + bool isCtsMode() const; + bool isXtsMode() const; bool isWrapMode() const; bool isSivMode() const; bool isGcmSivMode() const; diff --git a/doc/api/crypto.md b/doc/api/crypto.md index c11ed29a5c35..e490ad49da6d 100644 --- a/doc/api/crypto.md +++ b/doc/api/crypto.md @@ -633,6 +633,10 @@ The [`crypto.createCipheriv()`][] method is used to create `Cipheriv` instances. `Cipheriv` objects are not to be created directly using the `new` keyword. +The selected algorithm may impose additional restrictions on streaming and +calls to [`cipher.update()`][]. See [CCM mode][], [CBC-CTS mode][], [XTS mode][], +[AES key wrap modes][], and [SIV and GCM-SIV modes][]. + Example: Using `Cipheriv` objects as streams: ```mjs @@ -905,14 +909,15 @@ added: v0.7.1 * `autoPadding` {boolean} **Default:** `true` * Returns: {Cipheriv} The same `Cipheriv` instance for method chaining. -When using block encryption algorithms, the `Cipheriv` class will automatically -add padding to the input data to the appropriate block size. To disable the -default padding call `cipher.setAutoPadding(false)`. +When using block ciphers that use standard block padding, the `Cipheriv` class +will automatically add padding to the input data to the appropriate block size. +To disable the default padding call `cipher.setAutoPadding(false)`. -When `autoPadding` is `false`, the length of the entire input data must be a -multiple of the cipher's block size or [`cipher.final()`][] will throw an error. -Disabling automatic padding is useful for non-standard padding, for instance -using `0x0` instead of PKCS padding. +For block ciphers that use standard block padding, when `autoPadding` is +`false`, the length of the entire input data must be a multiple of the cipher's +block size or [`cipher.final()`][] will throw an error. Disabling automatic +padding is useful for non-standard padding, for instance using `0x0` instead of +PKCS padding. The `cipher.setAutoPadding()` method must be called before [`cipher.final()`][]. @@ -946,9 +951,12 @@ is specified, a string using the specified encoding is returned. If no When `outputEncoding` is specified, it must use the same encoding as previous calls to `cipher.update()`. -The `cipher.update()` method can be called multiple times with new data until -[`cipher.final()`][] is called. Calling `cipher.update()` after -[`cipher.final()`][] will result in an error being thrown. +For most algorithms, `cipher.update()` can be called multiple times with new +data until [`cipher.final()`][] is called. Some algorithms restrict calls to +`cipher.update()`. For example, [CCM mode][], [CBC-CTS mode][], [XTS mode][], +[AES key wrap modes][], and [SIV and GCM-SIV modes][] require the whole message +in a single call. Calling `cipher.update()` after [`cipher.final()`][] will +result in an error being thrown. ## Class: `Decipheriv` @@ -970,6 +978,10 @@ The [`crypto.createDecipheriv()`][] method is used to create `Decipheriv` instances. `Decipheriv` objects are not to be created directly using the `new` keyword. +The selected algorithm may impose additional restrictions on streaming and +calls to [`decipher.update()`][]. See [CCM mode][], [CBC-CTS mode][], +[XTS mode][], [AES key wrap modes][], and [SIV and GCM-SIV modes][]. + Example: Using `Decipheriv` objects as streams: ```mjs @@ -1267,8 +1279,8 @@ When data has been encrypted without standard block padding, calling `decipher.setAutoPadding(false)` will disable automatic padding to prevent [`decipher.final()`][] from checking for and removing padding. -Turning auto padding off will only work if the input data's length is a -multiple of the ciphers block size. +For block ciphers that use standard block padding, disabling it requires the +input data's length to be a multiple of the cipher's block size. The `decipher.setAutoPadding()` method must be called before [`decipher.final()`][]. @@ -1291,19 +1303,23 @@ changes: Updates the decipher with `data`. If the `inputEncoding` argument is given, the `data` argument is a string using the specified encoding. If the `inputEncoding` -argument is not given, `data` must be a [`Buffer`][]. If `data` is a -[`Buffer`][] then `inputEncoding` is ignored. +argument is not given, `data` must be a [`Buffer`][], `TypedArray`, or +`DataView`. If `data` is a [`Buffer`][], `TypedArray`, or `DataView`, then +`inputEncoding` is ignored. -The `outputEncoding` specifies the output format of the enciphered +The `outputEncoding` specifies the output format of the deciphered data. If the `outputEncoding` is specified, a string using the specified encoding is returned. If no `outputEncoding` is provided, a [`Buffer`][] is returned. When `outputEncoding` is specified, it must use the same encoding as previous calls to `decipher.update()`. -The `decipher.update()` method can be called multiple times with new data until -[`decipher.final()`][] is called. Calling `decipher.update()` after -[`decipher.final()`][] will result in an error being thrown. +For most algorithms, `decipher.update()` can be called multiple times with new +data until [`decipher.final()`][] is called. Some algorithms restrict calls to +`decipher.update()`. For example, [CCM mode][], [CBC-CTS mode][], [XTS mode][], +[AES key wrap modes][], and [SIV and GCM-SIV modes][] require the whole message +in a single call. Calling `decipher.update()` after [`decipher.final()`][] will +result in an error being thrown. Even if the underlying cipher implements authentication, the authenticity and integrity of the plaintext returned from this function may be uncertain at this @@ -3554,6 +3570,12 @@ operations. The specific constants currently defined are described in + +> Stability: 1 - Experimental + +Enable experimental support for the DTLS protocol. See the +[dtls documentation][] for details. + ### `--experimental-eventsource` + + + +> Stability: 1 - Experimental + + + +The `node:dtls` module provides an implementation of the Datagram Transport +Layer Security (DTLS) protocol over UDP. DTLS provides TLS-equivalent +security guarantees for datagram-based communication, including +confidentiality, integrity, and authentication. + +To use this module, it must be enabled at build time with the +`--experimental-dtls` configure flag and at runtime with the +`--experimental-dtls` CLI flag. + +```bash +node --experimental-dtls app.mjs +``` + +```mjs +import { listen, connect } from 'node:dtls'; +``` + +```cjs +const { listen, connect } = require('node:dtls'); +``` + +## Permission model + +When using the [Permission Model][], the `--allow-net` flag must be passed to +allow DTLS network operations. Without it, calling [`dtls.connect()`][] or +[`dtls.listen()`][] will throw an `ERR_ACCESS_DENIED` error. + +```console +node --permission --allow-fs-read=* --experimental-dtls index.mjs +Error: Access to this API has been restricted. Use --allow-net to manage permissions. + code: 'ERR_ACCESS_DENIED', + permission: 'Net', +} +``` + +Creating a [`DTLSEndpoint`][] instance without connecting or listening +is permitted even without `--allow-net`, since no network I/O occurs until +[`dtls.connect()`][] or [`dtls.listen()`][] is called. + +## DTLS vs TLS + +DTLS is designed for UDP transport and differs from TLS in several key ways: + +* No stream guarantees: Messages may arrive out of order or be lost. + DTLS preserves datagram semantics. +* One socket, many peers: A single UDP socket can serve multiple DTLS + sessions. The `DTLSEndpoint` manages this multiplexing. +* Cookie exchange: DTLS servers use a stateless cookie mechanism + (HelloVerifyRequest) to prevent denial-of-service amplification attacks. +* Retransmission: DTLS handles handshake retransmission internally since + UDP does not guarantee delivery. + +## `dtls.listen(callback, options)` + + + +* `callback` {Function} Called for each new DTLS session accepted by the + server. + * `session` {DTLSSession} The new session. +* `options` {Object} + * `cert` {string|Buffer} Server certificate in PEM format. **Required.** + * `key` {string|Buffer} Server private key in PEM format. **Required.** + * `port` {number} Port to bind to. **Required.** + * `host` {string} Address to bind to. **Default:** `'0.0.0.0'`. + * `ca` {string|Buffer|string\[]|Buffer\[]} CA certificates in PEM format. + * `ciphers` {string} OpenSSL cipher list string. + * `alpn` {string\[]|Buffer} ALPN protocol names. + * `srtp` {string} Colon-separated SRTP protection profile names + (e.g., `'SRTP_AES128_CM_SHA1_80:SRTP_AEAD_AES_128_GCM'`). + * `requestCert` {boolean} Request client certificate. **Default:** `false`. + * `mtu` {number} Maximum transmission unit for DTLS records. + **Default:** `1200`. +* Returns: {DTLSEndpoint} + +Creates a DTLS server bound to the specified address and port. The server +uses automatic HMAC-based cookie exchange for DoS protection. + +```mjs +import { listen } from 'node:dtls'; +import { readFileSync } from 'node:fs'; + +const endpoint = listen((session) => { + session.onmessage = (data) => { + console.log('Received:', data.toString()); + session.send('pong'); + }; + + session.onhandshake = (protocol) => { + console.log('Handshake complete:', protocol); + }; +}, { + cert: readFileSync('server-cert.pem'), + key: readFileSync('server-key.pem'), + port: 4433, +}); + +console.log('DTLS server listening on', endpoint.address); +``` + +## `dtls.connect(host, port[, options])` + + + +* `host` {string} Remote host to connect to. +* `port` {number} Remote port to connect to. +* `options` {Object} + * `ca` {string|Buffer|string\[]|Buffer\[]} CA certificates in PEM format. + * `cert` {string|Buffer} Client certificate in PEM format. + * `key` {string|Buffer} Client private key in PEM format. + * `rejectUnauthorized` {boolean} Reject connections with unverifiable + certificates. **Default:** `true`. + * `bindHost` {string} Local bind address. **Default:** `'0.0.0.0'`. + * `bindPort` {number} Local bind port. **Default:** `0` (ephemeral). + * `alpn` {string\[]|Buffer} ALPN protocol names. + * `srtp` {string} SRTP protection profile names. + * `mtu` {number} Maximum transmission unit. **Default:** `1200`. +* Returns: {DTLSSession} + +Connects to a DTLS server. Returns a `DTLSSession` whose `opened` property +is a `Promise` that resolves when the handshake completes. + +```mjs +import { connect } from 'node:dtls'; +import { readFileSync } from 'node:fs'; + +const session = connect('localhost', 4433, { + ca: [readFileSync('ca-cert.pem')], +}); + +await session.opened; +session.send('hello'); + +session.onmessage = (data) => { + console.log('Received:', data.toString()); +}; +``` + +## Class: `DTLSEndpoint` + + + +Manages a UDP socket and multiplexes DTLS sessions. + +### `endpoint.address` + +* Returns: {Object} `{ address, family, port }` + +The local address the endpoint is bound to. + +### `endpoint.state` + +* Returns: {DTLSEndpointState} + +Shared state object with properties: + +* `bound` {boolean} +* `listening` {boolean} +* `closing` {boolean} +* `destroyed` {boolean} +* `sessionCount` {number} +* `busy` {boolean} + +### `endpoint.busy` + +* {boolean} + +When `true`, the endpoint rejects new incoming connections. Can be set +to implement backpressure. + +### `endpoint.close()` + +* Returns: {Promise} Resolves when the endpoint is fully closed. + +Gracefully closes the endpoint. All active sessions are closed with +`close_notify` alerts before the UDP socket is released. + +### `endpoint.destroy([error])` + +Immediately destroys the endpoint without sending `close_notify` alerts. + +### `endpoint.closed` + +* {Promise} Resolves when the endpoint has fully closed. + +### `endpoint[Symbol.asyncDispose]()` + +Equivalent to calling `endpoint.close()`. + +## Class: `DTLSSession` + + + +Represents a DTLS association with a single remote peer. + +### `session.send(data)` + +* `data` {string|Buffer} The data to send. +* Returns: {number} The number of bytes written to the DTLS layer. + +Send application data to the peer. The data is encrypted by DTLS before +being sent over UDP. Can only be called after the handshake completes +(`session.opened` has resolved). + +### `session.close()` + +* Returns: {Promise} Resolves when the session is closed. + +Initiates a graceful DTLS shutdown by sending a `close_notify` alert. + +### `session.destroy([error])` + +Immediately destroys the session without sending `close_notify`. + +### `session.opened` + +* {Promise} Resolves with `{ protocol }` when the DTLS handshake completes. + +### `session.closed` + +* {Promise} Resolves when the session is fully closed. + +### `session.remoteAddress` + +* Returns: {Object} `{ address, family, port }` + +### `session.protocol` + +* Returns: {string} The negotiated DTLS protocol version + (e.g., `'DTLSv1.2'`). + +### `session.cipher` + +* Returns: {Object} `{ name, standardName, version }` + +### `session.peerCertificate` + +* Returns: {string|undefined} The peer's certificate in PEM format. + +### `session.alpnProtocol` + +* Returns: {string|undefined} The negotiated ALPN protocol. + +### `session.srtpProfile` + +* Returns: {string|undefined} The negotiated SRTP protection profile name. + +### `session.exportKeyingMaterial(length, label[, context])` + +* `length` {number} Number of bytes to export. +* `label` {string} The label for the exported keying material. +* `context` {Buffer} Optional context value. +* Returns: {Buffer} + +Exports keying material from the DTLS session, as defined in +[RFC 5705][]. This is commonly used with DTLS-SRTP to derive +encryption keys for media streams. + +### Callback properties + +#### `session.onmessage` + +* {Function} + * `data` {Buffer} + +Set to receive application data from the peer. + +#### `session.onerror` + +* {Function} + * `error` {Error} + +Set to receive error notifications. + +#### `session.onhandshake` + +* {Function} + * `protocol` {string} + +Set to receive handshake completion notifications. + +#### `session.onkeylog` + +* {Function} + * `line` {string} + +Set to receive TLS key log lines (for debugging with Wireshark). + +### `session[Symbol.asyncDispose]()` + +Equivalent to calling `session.close()`. + +## DTLS-SRTP example + +DTLS-SRTP is used by WebRTC for media encryption. The DTLS handshake +negotiates the SRTP protection profile and provides keying material. + +```mjs +import { listen, connect } from 'node:dtls'; +import { readFileSync } from 'node:fs'; + +// Server with SRTP +const server = listen((session) => { + session.onhandshake = () => { + console.log('SRTP profile:', session.srtpProfile); + const keys = session.exportKeyingMaterial( + 60, + 'EXTRACTOR-dtls_srtp', + ); + console.log('SRTP keying material:', keys); + }; +}, { + cert: readFileSync('server-cert.pem'), + key: readFileSync('server-key.pem'), + port: 5004, + srtp: 'SRTP_AES128_CM_SHA1_80:SRTP_AEAD_AES_128_GCM', +}); + +// Client with SRTP +const session = connect('localhost', 5004, { + rejectUnauthorized: false, + srtp: 'SRTP_AEAD_AES_128_GCM:SRTP_AES128_CM_SHA1_80', +}); + +await session.opened; +console.log('Negotiated SRTP:', session.srtpProfile); +const keys = session.exportKeyingMaterial(60, 'EXTRACTOR-dtls_srtp'); +``` + +## MTU considerations + +Since libuv does not currently support path MTU discovery, the DTLS module +uses a conservative default MTU of 1200 bytes. This value works across +virtually all network paths but may be suboptimal for local networks. + +The MTU can be configured via the `mtu` option: + +```mjs +// For a local network where you know the path MTU +const endpoint = listen(callback, { + // ... + mtu: 1400, +}); +``` + +The minimum allowed MTU is 256 bytes. The maximum is 65535. + +[Permission Model]: permissions.md#permission-model +[RFC 5705]: https://www.rfc-editor.org/rfc/rfc5705 +[`DTLSEndpoint`]: #class-dtlsendpoint +[`dtls.connect()`]: #dtlsconnecthost-port-options +[`dtls.listen()`]: #dtlslistencallback-options diff --git a/doc/node.1 b/doc/node.1 index 96532ee48376..ecd77c38610a 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -741,6 +741,10 @@ If present, Node.js will look for a \fBnode.config.json\fR file in the current working directory and load it as a configuration file. . +.It Fl -experimental-dtls +Enable experimental support for the DTLS protocol. See the +dtls documentation for details. +. .It Fl -experimental-eventsource Enable exposition of EventSource Web API on the global scope. . @@ -1986,6 +1990,8 @@ one is included in the list below. .It \fB--experimental-detect-module\fR .It +\fB--experimental-dtls\fR +.It \fB--experimental-eventsource\fR .It \fB--experimental-ffi\fR diff --git a/lib/dtls.js b/lib/dtls.js new file mode 100644 index 000000000000..c4dc01052ea6 --- /dev/null +++ b/lib/dtls.js @@ -0,0 +1,36 @@ +'use strict'; + +const { + ObjectCreate, + ObjectSeal, +} = primordials; + +const { + emitExperimentalWarning, +} = require('internal/util'); +emitExperimentalWarning('dtls'); + +const { + connect, + listen, + DTLSEndpoint, + DTLSSession, +} = require('internal/dtls/dtls'); + +function getEnumerableConstant(value) { + return { + __proto__: null, + value, + enumerable: true, + configurable: false, + writable: false, + }; +} + +module.exports = ObjectSeal(ObjectCreate(null, { + __proto__: null, + connect: getEnumerableConstant(connect), + listen: getEnumerableConstant(listen), + DTLSEndpoint: getEnumerableConstant(DTLSEndpoint), + DTLSSession: getEnumerableConstant(DTLSSession), +})); diff --git a/lib/internal/bootstrap/node.js b/lib/internal/bootstrap/node.js index 8bb014426a03..bde4cb2be84b 100644 --- a/lib/internal/bootstrap/node.js +++ b/lib/internal/bootstrap/node.js @@ -285,6 +285,11 @@ const features = { get require_module() { return getOptionValue('--require-module'); }, + get dtls() { + return process.config.variables.node_use_dtls && + hasOpenSSL && + getOptionValue('--experimental-dtls'); + }, get quic() { // TODO(@jasnell): When the implementation is updated to support Boring, // then this should be refactored to depend not only on the OpenSSL version. diff --git a/lib/internal/bootstrap/realm.js b/lib/internal/bootstrap/realm.js index 4e45a85a12b2..8a4d179806aa 100644 --- a/lib/internal/bootstrap/realm.js +++ b/lib/internal/bootstrap/realm.js @@ -124,6 +124,7 @@ const legacyWrapperList = new SafeSet([ // beginning with "internal/". // Modules that can only be imported via the node: scheme. const schemelessBlockList = new SafeSet([ + 'dtls', 'ffi', 'sea', 'sqlite', @@ -134,6 +135,7 @@ const schemelessBlockList = new SafeSet([ ]); // Modules that will only be enabled at run time. const experimentalModuleList = new SafeSet([ + 'dtls', 'ffi', 'quic', 'sqlite', diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js new file mode 100644 index 000000000000..d797f74b41e0 --- /dev/null +++ b/lib/internal/dtls/dtls.js @@ -0,0 +1,637 @@ +'use strict'; + +const { + ArrayIsArray, + FunctionPrototypeBind, + PromiseWithResolvers, + SafeSet, + SymbolAsyncDispose, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +// DTLS requires that Node.js be compiled with crypto support. +if (!process.features.dtls || !getOptionValue('--experimental-dtls')) { + return; +} + +const { + codes: { + ERR_ILLEGAL_CONSTRUCTOR, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_STATE, + ERR_MISSING_ARGS, + }, +} = require('internal/errors'); + +const { + validateFunction, + validateObject, + validateString, + validateInteger, +} = require('internal/validators'); + +const { + Buffer, +} = require('buffer'); + +const { + DTLSEndpointState, + DTLSSessionState, +} = require('internal/dtls/state'); + +const { + kOwner, + kPrivateConstructor, + kSessionHandshake, + kSessionMessage, + kSessionError, + kSessionClose, + kSessionKeylog, +} = require('internal/dtls/symbols'); + +const { + DTLSContext: DTLSContext_, + DTLSEndpoint: DTLSEndpoint_, + SSL_VERIFY_NONE_VALUE, + SSL_VERIFY_PEER_VALUE, + SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE, +} = internalBinding('dtls'); + +const kEmptyObject = { __proto__: null }; + +// ============================================================================ +// DTLSSession -- represents a single DTLS peer association +// ============================================================================ + +class DTLSSession { + #handle; + #endpoint; + #state; + #pendingOpen; + #pendingClose; + #onmessage; + #onerror; + #onhandshake; + #onkeylog; + #ownsEndpoint = false; + + constructor(privateSymbol, handle, endpoint) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + + this.#handle = handle; + this.#handle[kOwner] = this; + this.#endpoint = endpoint; + this.#state = new DTLSSessionState( + kPrivateConstructor, handle.getState()); + this.#pendingOpen = PromiseWithResolvers(); + this.#pendingClose = PromiseWithResolvers(); + } + + // --- Callback setters --- + + set onmessage(fn) { + if (fn !== undefined && fn !== null) { + validateFunction(fn, 'onmessage'); + this.#onmessage = FunctionPrototypeBind(fn, this); + this.#state.hasMessageListener = true; + } else { + this.#onmessage = undefined; + this.#state.hasMessageListener = false; + } + } + + get onmessage() { return this.#onmessage; } + + set onerror(fn) { + if (fn !== undefined && fn !== null) { + validateFunction(fn, 'onerror'); + this.#onerror = FunctionPrototypeBind(fn, this); + } else { + this.#onerror = undefined; + } + } + + get onerror() { return this.#onerror; } + + set onhandshake(fn) { + if (fn !== undefined && fn !== null) { + validateFunction(fn, 'onhandshake'); + this.#onhandshake = FunctionPrototypeBind(fn, this); + } else { + this.#onhandshake = undefined; + } + } + + get onhandshake() { return this.#onhandshake; } + + set onkeylog(fn) { + if (fn !== undefined && fn !== null) { + validateFunction(fn, 'onkeylog'); + this.#onkeylog = FunctionPrototypeBind(fn, this); + } else { + this.#onkeylog = undefined; + } + } + + get onkeylog() { return this.#onkeylog; } + + // --- Send data --- + + send(data) { + if (this.#handle === null) { + throw new ERR_INVALID_STATE('Session is destroyed'); + } + if (typeof data === 'string') { + data = Buffer.from(data); + } + if (!Buffer.isBuffer(data)) { + throw new ERR_INVALID_ARG_TYPE('data', ['string', 'Buffer'], data); + } + return this.#handle.send(data); + } + + // --- Lifecycle --- + + close() { + if (this.#handle === null) return this.closed; + const handle = this.#handle; + this.#handle = null; + handle.close(); + return this.closed; + } + + destroy(error) { + if (this.#handle === null) return; + const handle = this.#handle; + this.#handle = null; + handle.destroy(); + if (error) { + this.#pendingClose.reject(error); + } else { + this.#pendingClose.resolve(); + } + } + + get opened() { return this.#pendingOpen.promise; } + get closed() { return this.#pendingClose.promise; } + + // --- Properties --- + + get remoteAddress() { + if (this.#handle === null) return undefined; + return this.#handle.getRemoteAddress(); + } + + get protocol() { + if (this.#handle === null) return undefined; + return this.#handle.getProtocol(); + } + + get cipher() { + if (this.#handle === null) return undefined; + return this.#handle.getCipher(); + } + + get peerCertificate() { + if (this.#handle === null) return undefined; + return this.#handle.getPeerCertificate(); + } + + get alpnProtocol() { + if (this.#handle === null) return undefined; + return this.#handle.getALPNProtocol(); + } + + get srtpProfile() { + if (this.#handle === null) return undefined; + return this.#handle.getSRTPProfile(); + } + + get servername() { + if (this.#handle === null) return undefined; + return this.#handle.getServername(); + } + + get state() { return this.#state; } + get endpoint() { return this.#endpoint; } + + exportKeyingMaterial(length, label, context) { + if (this.#handle === null) { + throw new ERR_INVALID_STATE('Session is destroyed'); + } + return this.#handle.exportKeyingMaterial(length, label, context); + } + + // --- Internal callbacks (called from C++ via endpoint dispatch) --- + + [kSessionHandshake](protocol) { + this.#pendingOpen.resolve({ protocol }); + if (this.#onhandshake) { + this.#onhandshake(protocol); + } + } + + [kSessionMessage](data) { + if (this.#onmessage) { + this.#onmessage(data); + } + } + + [kSessionError](message) { + const error = new ERR_INVALID_STATE(message); + if (this.#onerror) { + this.#onerror(error); + } + this.#pendingOpen.reject(error); + } + + [kSessionClose]() { + this.#pendingClose.resolve(); + this.#handle = null; + // Remove from the endpoint's JS-side session set. + if (this.#endpoint) { + this.#endpoint.sessions.delete(this); + } + // If this session owns its endpoint (client-side connect()), + // close the endpoint too so the process can exit. + if (this.#ownsEndpoint && this.#endpoint) { + this.#endpoint.close(); + } + } + + // Mark that this session owns its endpoint (for client sessions + // created by connect() where the endpoint is internal). + get ownsEndpoint() { return this.#ownsEndpoint; } + set ownsEndpoint(val) { this.#ownsEndpoint = val; } + + [kSessionKeylog](line) { + if (this.#onkeylog) { + this.#onkeylog(line); + } + } + + async [SymbolAsyncDispose]() { + await this.close(); + } +} + +// ============================================================================ +// DTLSEndpoint -- manages a UDP socket and routes datagrams to sessions +// ============================================================================ + +class DTLSEndpoint { + #handle; + #state; + #sessions = new SafeSet(); + #pendingClose; + #onsession; + #onerror; + + constructor(options = kEmptyObject) { + this.#handle = new DTLSEndpoint_(); + this.#handle[kOwner] = this; + this.#state = new DTLSEndpointState( + kPrivateConstructor, this.#handle.getState()); + this.#pendingClose = PromiseWithResolvers(); + + if (options.mtu !== undefined) { + validateInteger(options.mtu, 'options.mtu', 256, 65535); + this.#handle.setMTU(options.mtu); + } + + // Set up the callback dispatch from C++ to JS. + this.#handle.setCallbacks({ + __proto__: null, + onEndpointClose: () => this.#onEndpointClose(), + onEndpointError: (msg) => this.#onEndpointError(msg), + onSessionNew: (handle) => this.#onSessionNew(handle), + onSessionClose: function() { + this[kOwner]?.[kSessionClose](); + }, + onSessionError: function(msg) { + this[kOwner]?.[kSessionError](msg); + }, + onSessionHandshake: function(protocol) { + this[kOwner]?.[kSessionHandshake](protocol); + }, + onSessionMessage: function(data) { + this[kOwner]?.[kSessionMessage](data); + }, + onSessionKeylog: function(line) { + this[kOwner]?.[kSessionKeylog](line); + }, + onSessionTicket: function() { + // Session ticket handling - placeholder for resumption. + }, + }); + } + + // --- Server mode --- + + listen(callback, context) { + validateFunction(callback, 'callback'); + this.#onsession = callback; + this.#handle.listen(context); + return this; + } + + // --- Client mode --- + + connect(context, host, port, servername) { + const sessionHandle = this.#handle.connect(context, host, port); + if (servername) { + sessionHandle.setServername(servername); + } + const session = new DTLSSession( + kPrivateConstructor, sessionHandle, this); + this.#sessions.add(session); + return session; + } + + // --- Bind --- + + bind(host, port) { + this.#handle.bind(host, port); + return this; + } + + // --- Lifecycle --- + + close() { + if (this.#handle === null) return this.closed; + const handle = this.#handle; + this.#handle = null; + handle.close(); + return this.closed; + } + + destroy(error) { + if (this.#handle === null) return; + const handle = this.#handle; + this.#handle = null; + handle.destroy(); + if (error) { + this.#pendingClose.reject(error); + } else { + this.#pendingClose.resolve(); + } + } + + get closed() { return this.#pendingClose.promise; } + + // --- Properties --- + + get address() { + if (this.#handle === null) return undefined; + return this.#handle.getAddress(); + } + + get state() { return this.#state; } + get sessions() { return this.#sessions; } + + get onerror() { return this.#onerror; } + set onerror(fn) { + if (fn !== undefined && fn !== null) { + validateFunction(fn, 'onerror'); + this.#onerror = fn; + } else { + this.#onerror = undefined; + } + } + + set busy(val) { + this.#state.busy = !!val; + } + + get busy() { + return this.#state.busy; + } + + // --- Internal callbacks --- + + #onEndpointClose() { + this.#sessions.clear(); + this.#pendingClose.resolve(); + this.#handle = null; + } + + #onEndpointError(message) { + if (this.#onerror) { + this.#onerror(new ERR_INVALID_STATE(message)); + } + } + + #onSessionNew(handle) { + const session = new DTLSSession(kPrivateConstructor, handle, this); + this.#sessions.add(session); + if (this.#onsession) { + this.#onsession(session); + } + } + + async [SymbolAsyncDispose]() { + await this.close(); + } +} + +// ============================================================================ +// Public API functions +// ============================================================================ + +function createContext(options = kEmptyObject) { + validateObject(options, 'options'); + + const isServer = options.isServer === true; + const context = new DTLSContext_(isServer); + + // Certificate + if (options.cert !== undefined) { + let cert = options.cert; + if (Buffer.isBuffer(cert)) cert = cert.toString(); + validateString(cert, 'options.cert'); + context.setCert(cert); + } + + // Private key + if (options.key !== undefined) { + let key = options.key; + if (Buffer.isBuffer(key)) key = key.toString(); + validateString(key, 'options.key'); + context.setKey(key); + } + + // CA certificates: if custom CAs are provided, use only those. + // Otherwise load system default CAs. This matches Node.js TLS behavior. + if (options.ca !== undefined) { + const cas = ArrayIsArray(options.ca) ? options.ca : [options.ca]; + for (let ca of cas) { + if (Buffer.isBuffer(ca)) ca = ca.toString(); + validateString(ca, 'options.ca'); + context.addCACert(ca); + } + } else { + context.loadDefaultCAs(); + } + + // Ciphers + if (options.ciphers !== undefined) { + validateString(options.ciphers, 'options.ciphers'); + context.setCiphers(options.ciphers); + } + + // ECDH curve (default: 'auto' = OpenSSL default selection) + const ecdhCurve = options.ecdhCurve || 'auto'; + validateString(ecdhCurve, 'options.ecdhCurve'); + context.setECDHCurve(ecdhCurve); + + // ALPN protocols + if (options.alpn !== undefined) { + let protocols = options.alpn; + if (ArrayIsArray(protocols)) { + // Convert string array to wire-format buffer. + const bufs = []; + for (const proto of protocols) { + validateString(proto, 'options.alpn[]'); + const buf = Buffer.from(proto); + bufs.push(Buffer.from([buf.length]), buf); + } + protocols = Buffer.concat(bufs); + } + if (!Buffer.isBuffer(protocols)) { + throw new ERR_INVALID_ARG_TYPE( + 'options.alpn', ['string[]', 'Buffer'], protocols); + } + context.setALPN(protocols); + } + + // SRTP profiles + if (options.srtp !== undefined) { + validateString(options.srtp, 'options.srtp'); + context.setSRTP(options.srtp); + } + + // Verification mode + if (options.rejectUnauthorized !== undefined) { + const mode = options.rejectUnauthorized ? + (SSL_VERIFY_PEER_VALUE | SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE) : + SSL_VERIFY_NONE_VALUE; + context.setVerifyMode(mode); + } else if (options.requestCert) { + context.setVerifyMode( + SSL_VERIFY_PEER_VALUE | SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE); + } + + return context; +} + +/** + * Start a DTLS server. + * @param {Function} onsession Callback invoked for each new DTLS session. + * @param {object} options Server configuration. + * @param {string|Buffer} options.cert Server certificate (PEM). + * @param {string|Buffer} options.key Server private key (PEM). + * @param {string|Buffer|Array} [options.ca] CA certificates (PEM). + * @param {string} [options.host] Bind address. + * @param {number} options.port Bind port. + * @param {number} [options.mtu] MTU for DTLS records. + * @param {string[]} [options.alpn] ALPN protocol list. + * @param {string} [options.srtp] SRTP profile string. + * @param {boolean} [options.requestCert] Request client certificates. + * @returns {DTLSEndpoint} + */ +function listen(onsession, options = kEmptyObject) { + validateFunction(onsession, 'onsession'); + validateObject(options, 'options'); + + if (options.cert === undefined) { + throw new ERR_MISSING_ARGS('options.cert'); + } + if (options.key === undefined) { + throw new ERR_MISSING_ARGS('options.key'); + } + if (options.port === undefined) { + throw new ERR_MISSING_ARGS('options.port'); + } + + const host = options.host || '0.0.0.0'; + const port = options.port; + + validateString(host, 'options.host'); + validateInteger(port, 'options.port', 0, 65535); + + const context = createContext({ + ...options, + isServer: true, + }); + + const endpoint = new DTLSEndpoint({ + mtu: options.mtu, + }); + + endpoint.bind(host, port); + endpoint.listen(onsession, context); + + return endpoint; +} + +/** + * Connect to a DTLS server. + * @param {string} host Remote host. + * @param {number} port Remote port. + * @param {object} [options] Client configuration. + * @param {string|Buffer|Array} [options.ca] CA certificates (PEM). + * @param {string|Buffer} [options.cert] Client certificate (PEM). + * @param {string|Buffer} [options.key] Client private key (PEM). + * @param {boolean} [options.rejectUnauthorized] Reject unauthorized. + * @param {string} [options.bindHost] Local bind address. + * @param {number} [options.bindPort] Local bind port (0 = ephemeral). + * @param {number} [options.mtu] MTU for DTLS records. + * @param {string[]} [options.alpn] ALPN protocol list. + * @param {string} [options.srtp] SRTP profile string. + * @returns {DTLSSession} + */ +function connect(host, port, options = kEmptyObject) { + validateString(host, 'host'); + validateInteger(port, 'port', 0, 65535); + validateObject(options, 'options'); + + const bindHost = options.bindHost || '0.0.0.0'; + const bindPort = options.bindPort || 0; + + const context = createContext({ + ...options, + isServer: false, + rejectUnauthorized: options.rejectUnauthorized !== false, + }); + + const endpoint = new DTLSEndpoint({ + mtu: options.mtu, + }); + + endpoint.bind(bindHost, bindPort); + + // Default SNI servername to the host argument (matching Node.js TLS). + // Can be overridden with options.servername, or disabled with '' or false. + const servername = options.servername !== undefined ? + (options.servername || undefined) : + host; + + const session = endpoint.connect(context, host, port, servername); + // Mark that this session owns the endpoint so it gets closed + // automatically when the session closes, allowing process exit. + session.ownsEndpoint = true; + return session; +} + +module.exports = { + connect, + listen, + createContext, + DTLSEndpoint, + DTLSSession, +}; diff --git a/lib/internal/dtls/state.js b/lib/internal/dtls/state.js new file mode 100644 index 000000000000..be8272661740 --- /dev/null +++ b/lib/internal/dtls/state.js @@ -0,0 +1,168 @@ +'use strict'; + +const { + DataView, + DataViewPrototypeGetByteLength, + DataViewPrototypeGetUint32, + DataViewPrototypeGetUint8, + DataViewPrototypeSetUint8, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +if (!process.features.dtls || !getOptionValue('--experimental-dtls')) { + return; +} + +const { + codes: { + ERR_ILLEGAL_CONSTRUCTOR, + ERR_INVALID_STATE, + }, +} = require('internal/errors'); + +const { + kPrivateConstructor, +} = require('internal/dtls/symbols'); + +const { + IDX_ENDPOINT_STATE_BOUND, + IDX_ENDPOINT_STATE_LISTENING, + IDX_ENDPOINT_STATE_CLOSING, + IDX_ENDPOINT_STATE_DESTROYED, + IDX_ENDPOINT_STATE_SESSION_COUNT, + IDX_ENDPOINT_STATE_BUSY, + IDX_SESSION_STATE_HANDSHAKING, + IDX_SESSION_STATE_OPEN, + IDX_SESSION_STATE_CLOSING, + IDX_SESSION_STATE_DESTROYED, + IDX_SESSION_STATE_HAS_MESSAGE_LISTENER, +} = internalBinding('dtls'); + +function isAlive(view) { + return DataViewPrototypeGetByteLength(view) > 0; +} + +// DTLSEndpointState wraps the shared ArrayBuffer from C++. +// The C++ struct layout (DTLSEndpointStateData) is: +// uint8_t bound; // offset 0 +// uint8_t listening; // offset 1 +// uint8_t closing; // offset 2 +// uint8_t destroyed; // offset 3 +// uint32_t session_count; // offset 4 (4-byte aligned) +// uint8_t busy; // offset 8 +class DTLSEndpointState { + #handle; + + constructor(privateSymbol, buffer) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + this.#handle = new DataView(buffer); + } + + get bound() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_ENDPOINT_STATE_BOUND) === 1; + } + + get listening() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_ENDPOINT_STATE_LISTENING) === 1; + } + + get closing() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_ENDPOINT_STATE_CLOSING) === 1; + } + + get destroyed() { + if (!isAlive(this.#handle)) return true; + return DataViewPrototypeGetUint8( + this.#handle, IDX_ENDPOINT_STATE_DESTROYED) === 1; + } + + get sessionCount() { + if (!isAlive(this.#handle)) return 0; + return DataViewPrototypeGetUint32( + this.#handle, IDX_ENDPOINT_STATE_SESSION_COUNT, true); + } + + get busy() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_ENDPOINT_STATE_BUSY) === 1; + } + + set busy(val) { + if (!isAlive(this.#handle)) { + throw new ERR_INVALID_STATE('Endpoint is destroyed'); + } + DataViewPrototypeSetUint8( + this.#handle, IDX_ENDPOINT_STATE_BUSY, val ? 1 : 0); + } +} + +// DTLSSessionState wraps the shared ArrayBuffer from C++. +// The C++ struct layout (DTLSSessionStateData) is: +// uint8_t handshaking; // offset 0 +// uint8_t open; // offset 1 +// uint8_t closing; // offset 2 +// uint8_t destroyed; // offset 3 +// uint8_t has_message_listener; // offset 4 +class DTLSSessionState { + #handle; + + constructor(privateSymbol, buffer) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + this.#handle = new DataView(buffer); + } + + get handshaking() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_SESSION_STATE_HANDSHAKING) === 1; + } + + get open() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_SESSION_STATE_OPEN) === 1; + } + + get closing() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_SESSION_STATE_CLOSING) === 1; + } + + get destroyed() { + if (!isAlive(this.#handle)) return true; + return DataViewPrototypeGetUint8( + this.#handle, IDX_SESSION_STATE_DESTROYED) === 1; + } + + get hasMessageListener() { + if (!isAlive(this.#handle)) return false; + return DataViewPrototypeGetUint8( + this.#handle, IDX_SESSION_STATE_HAS_MESSAGE_LISTENER) === 1; + } + + set hasMessageListener(val) { + if (!isAlive(this.#handle)) return; + DataViewPrototypeSetUint8( + this.#handle, IDX_SESSION_STATE_HAS_MESSAGE_LISTENER, val ? 1 : 0); + } +} + +module.exports = { + DTLSEndpointState, + DTLSSessionState, +}; diff --git a/lib/internal/dtls/stats.js b/lib/internal/dtls/stats.js new file mode 100644 index 000000000000..645c57fc6c05 --- /dev/null +++ b/lib/internal/dtls/stats.js @@ -0,0 +1,43 @@ +'use strict'; + +// Placeholder for DTLS statistics tracking. +// Will be expanded as the implementation matures. + +const { + getOptionValue, +} = require('internal/options'); + +if (!process.features.dtls || !getOptionValue('--experimental-dtls')) { + return; +} + +const { + codes: { + ERR_ILLEGAL_CONSTRUCTOR, + }, +} = require('internal/errors'); + +const { + kPrivateConstructor, +} = require('internal/dtls/symbols'); + +class DTLSEndpointStats { + constructor(privateSymbol) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + } +} + +class DTLSSessionStats { + constructor(privateSymbol) { + if (privateSymbol !== kPrivateConstructor) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + } +} + +module.exports = { + DTLSEndpointStats, + DTLSSessionStats, +}; diff --git a/lib/internal/dtls/symbols.js b/lib/internal/dtls/symbols.js new file mode 100644 index 000000000000..0fe418d023d4 --- /dev/null +++ b/lib/internal/dtls/symbols.js @@ -0,0 +1,37 @@ +'use strict'; + +const { + Symbol, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +if (!process.features.dtls || !getOptionValue('--experimental-dtls')) { + return; +} + +module.exports = { + // Private symbols for internal communication between classes. + kOwner: Symbol('kOwner'), + kHandle: Symbol('kHandle'), + kListen: Symbol('kListen'), + kConnect: Symbol('kConnect'), + kFinishClose: Symbol('kFinishClose'), + kNewSession: Symbol('kNewSession'), + kRemoveSession: Symbol('kRemoveSession'), + kHandshake: Symbol('kHandshake'), + kReceive: Symbol('kReceive'), + kError: Symbol('kError'), + kClose: Symbol('kClose'), + kMessage: Symbol('kMessage'), + kKeylog: Symbol('kKeylog'), + kTicket: Symbol('kTicket'), + kPrivateConstructor: Symbol('kPrivateConstructor'), + kSessionHandshake: Symbol('dtls.session.handshake'), + kSessionMessage: Symbol('dtls.session.message'), + kSessionError: Symbol('dtls.session.error'), + kSessionClose: Symbol('dtls.session.close'), + kSessionKeylog: Symbol('dtls.session.keylog'), +}; diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index 554746b6e2b3..d5de950e3c8e 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -493,6 +493,9 @@ function initializeCJS() { // This need to be done at runtime in case --expose-internals is set. let modules = Module.builtinModules = BuiltinModule.getAllBuiltinModuleIds(); + if (!getOptionValue('--experimental-dtls')) { + modules = modules.filter((i) => i !== 'node:dtls'); + } if (!getOptionValue('--experimental-quic')) { modules = modules.filter((i) => i !== 'node:quic'); } diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 59c63d59ed64..e89d453284ab 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -117,6 +117,7 @@ function prepareExecution(options) { setupFFI(); setupSQLite(); setupStreamIter(); + setupDTLS(); setupVfs(); setupQuic(); setupWebStorage(); @@ -418,6 +419,15 @@ function setupStreamIter() { BuiltinModule.allowRequireByUsers('zlib/iter'); } +function setupDTLS() { + if (!getOptionValue('--experimental-dtls')) { + return; + } + + const { BuiltinModule } = require('internal/bootstrap/realm'); + BuiltinModule.allowRequireByUsers('dtls'); +} + function setupQuic() { if (!getOptionValue('--experimental-quic')) { return; diff --git a/node.gyp b/node.gyp index 66196b411062..7471e43002d3 100644 --- a/node.gyp +++ b/node.gyp @@ -40,6 +40,7 @@ 'node_use_node_snapshot%': 'false', 'node_use_openssl%': 'true', 'node_use_quic%': 'false', + 'node_use_dtls%': 'false', 'node_use_sqlite%': 'true', 'node_use_ffi%': 'false', 'node_use_v8_platform%': 'true', @@ -370,6 +371,16 @@ 'src/quic/tlscontext.h', 'src/quic/guard.h', ], + 'node_dtls_sources': [ + 'src/dtls/dtls.cc', + 'src/dtls/dtls_context.cc', + 'src/dtls/dtls_endpoint.cc', + 'src/dtls/dtls_session.cc', + 'src/dtls/dtls.h', + 'src/dtls/dtls_context.h', + 'src/dtls/dtls_endpoint.h', + 'src/dtls/dtls_session.h', + ], 'node_crypto_sources': [ 'src/crypto/crypto_aes.cc', 'src/crypto/crypto_argon2.cc', @@ -992,6 +1003,14 @@ '<@(node_quic_sources)', ], }], + [ 'node_use_dtls=="true"', { + 'sources': [ + '<@(node_dtls_sources)', + ], + 'defines': [ + 'HAVE_DTLS=1', + ], + }], [ 'OS in "linux freebsd mac solaris openharmony" and ' 'target_arch=="x64" and ' 'node_target_type=="executable"', { diff --git a/src/async_wrap.h b/src/async_wrap.h index bf9267545477..8c8f1e59de36 100644 --- a/src/async_wrap.h +++ b/src/async_wrap.h @@ -52,6 +52,8 @@ namespace node { V(HTTPINCOMINGMESSAGE) \ V(HTTPCLIENTREQUEST) \ V(LOCKS) \ + V(DTLS_ENDPOINT) \ + V(DTLS_SESSION) \ V(JSSTREAM) \ V(JSUDPWRAP) \ V(MESSAGEPORT) \ diff --git a/src/dtls/dtls.cc b/src/dtls/dtls.cc new file mode 100644 index 000000000000..ccd8b98eaab8 --- /dev/null +++ b/src/dtls/dtls.cc @@ -0,0 +1,80 @@ +#include "dtls.h" + +#if HAVE_OPENSSL && HAVE_DTLS + +#include "dtls_context.h" +#include "dtls_endpoint.h" +#include "dtls_session.h" + +#include +#include +#include +#include +#include + +namespace node { + +using v8::Context; +using v8::Local; +using v8::Object; +using v8::ObjectTemplate; +using v8::Value; + +namespace dtls { + +void CreatePerContextProperties(Local target, + Local unused, + Local context, + void* priv) { + Environment* env = Environment::GetCurrent(context); + + // Register constructors. + DTLSContext::InitPerContext(target, context, env); + DTLSEndpoint::InitPerContext(target, context, env); + DTLSSession::InitPerContext(target, context, env); + + // Endpoint state indices + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_BOUND); + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_LISTENING); + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_CLOSING); + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_DESTROYED); + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_SESSION_COUNT); + NODE_DEFINE_CONSTANT(target, IDX_ENDPOINT_STATE_BUSY); + + // Session state indices + NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_HANDSHAKING); + NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_OPEN); + NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_CLOSING); + NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_DESTROYED); + NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_HAS_MESSAGE_LISTENER); + + // SSL verify mode constants + constexpr auto SSL_VERIFY_NONE_VALUE = SSL_VERIFY_NONE; + constexpr auto SSL_VERIFY_PEER_VALUE = SSL_VERIFY_PEER; + constexpr auto SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE = + SSL_VERIFY_FAIL_IF_NO_PEER_CERT; + NODE_DEFINE_CONSTANT(target, SSL_VERIFY_NONE_VALUE); + NODE_DEFINE_CONSTANT(target, SSL_VERIFY_PEER_VALUE); + NODE_DEFINE_CONSTANT(target, SSL_VERIFY_FAIL_IF_NO_PEER_CERT_VALUE); +} + +void CreatePerIsolateProperties(IsolateData* isolate_data, + Local target) { + // Per-isolate initialization (currently none needed). +} + +void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + DTLSContext::RegisterExternalReferences(registry); + DTLSEndpoint::RegisterExternalReferences(registry); + DTLSSession::RegisterExternalReferences(registry); +} + +} // namespace dtls +} // namespace node + +NODE_BINDING_CONTEXT_AWARE_INTERNAL(dtls, + node::dtls::CreatePerContextProperties) +NODE_BINDING_PER_ISOLATE_INIT(dtls, node::dtls::CreatePerIsolateProperties) +NODE_BINDING_EXTERNAL_REFERENCE(dtls, node::dtls::RegisterExternalReferences) + +#endif // HAVE_OPENSSL && HAVE_DTLS diff --git a/src/dtls/dtls.h b/src/dtls/dtls.h new file mode 100644 index 000000000000..1b27c2fbf574 --- /dev/null +++ b/src/dtls/dtls.h @@ -0,0 +1,59 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include + +namespace node::dtls { + +// State indices shared between C++ and JS via AliasedStruct/DataView. +// Keep in sync with lib/internal/dtls/state.js. +enum DTLSEndpointStateIndex { + IDX_ENDPOINT_STATE_BOUND = 0, + IDX_ENDPOINT_STATE_LISTENING, + IDX_ENDPOINT_STATE_CLOSING, + IDX_ENDPOINT_STATE_DESTROYED, + IDX_ENDPOINT_STATE_SESSION_COUNT, + IDX_ENDPOINT_STATE_BUSY, + IDX_ENDPOINT_STATE_COUNT +}; + +enum DTLSSessionStateIndex { + IDX_SESSION_STATE_HANDSHAKING = 0, + IDX_SESSION_STATE_OPEN, + IDX_SESSION_STATE_CLOSING, + IDX_SESSION_STATE_DESTROYED, + IDX_SESSION_STATE_HAS_MESSAGE_LISTENER, + IDX_SESSION_STATE_COUNT +}; + +// Callback indices for JS dispatch +enum DTLSCallbackIndex { + DTLS_CB_ENDPOINT_CLOSE = 0, + DTLS_CB_ENDPOINT_ERROR, + DTLS_CB_SESSION_NEW, + DTLS_CB_SESSION_CLOSE, + DTLS_CB_SESSION_ERROR, + DTLS_CB_SESSION_HANDSHAKE, + DTLS_CB_SESSION_MESSAGE, + DTLS_CB_SESSION_KEYLOG, + DTLS_CB_SESSION_TICKET, + DTLS_CB_COUNT +}; + +void CreatePerContextProperties(v8::Local target, + v8::Local unused, + v8::Local context, + void* priv); +void CreatePerIsolateProperties(IsolateData* isolate_data, + v8::Local target); +void RegisterExternalReferences(ExternalReferenceRegistry* registry); + +} // namespace node::dtls + +#endif // HAVE_OPENSSL && HAVE_DTLS +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/dtls/dtls_context.cc b/src/dtls/dtls_context.cc new file mode 100644 index 000000000000..ca5003df46c2 --- /dev/null +++ b/src/dtls/dtls_context.cc @@ -0,0 +1,460 @@ +#include "dtls_context.h" +#include "dtls_session.h" + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace node { + +using v8::Context; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::Isolate; +using v8::Local; +using v8::Object; +using v8::Value; + +namespace dtls { + +namespace { +// The cookie secret is 32 bytes (256 bits). +constexpr size_t kCookieSecretLen = 32; +} // namespace + +DTLSContext::DTLSContext(Environment* env, + Local wrap, + SSL_CTX* ctx, + bool is_server) + : BaseObject(env, wrap), + ctx_(ctx), + is_server_(is_server), + cookie_secret_(kCookieSecretLen) { + MakeWeak(); + + // Generate random cookie secret for HMAC-based cookie generation. + CHECK_EQ(RAND_bytes(cookie_secret_.data(), kCookieSecretLen), 1); + + // Cookie generate/verify callbacks are registered on the SSL_CTX so they + // are inherited by all SSL objects created from it. However, we do NOT set + // SSL_OP_COOKIE_EXCHANGE on the context -- DTLSv1_listen() sets this option + // automatically on the per-SSL object when it runs (see d1_lib.c:804 in + // OpenSSL). This is important: if SSL_OP_COOKIE_EXCHANGE were set on the + // context, any SSL created from it would attempt a fresh cookie exchange, + // which is wrong for session SSLs that have already completed cookie + // verification via DTLSv1_listen(). + SSL_CTX_set_cookie_generate_cb(ctx_.get(), CookieGenerateCallback); + SSL_CTX_set_cookie_verify_cb(ctx_.get(), CookieVerifyCallback); + + // Store pointer to this context in the SSL_CTX app data for callbacks. + SSL_CTX_set_app_data(ctx_.get(), this); +} + +void DTLSContext::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackFieldWithSize("cookie_secret", cookie_secret_.size()); + tracker->TrackFieldWithSize("alpn_protos", alpn_protos_.size()); +} + +Local DTLSContext::GetConstructorTemplate(Environment* env) { + auto tmpl = env->dtls_context_constructor_template(); + if (tmpl.IsEmpty()) { + Isolate* isolate = env->isolate(); + tmpl = NewFunctionTemplate(isolate, New); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "DTLSContext")); + tmpl->InstanceTemplate()->SetInternalFieldCount( + BaseObject::kInternalFieldCount); + + SetProtoMethod(isolate, tmpl, "setCert", SetCert); + SetProtoMethod(isolate, tmpl, "setKey", SetKey); + SetProtoMethod(isolate, tmpl, "addCACert", AddCACert); + SetProtoMethod(isolate, tmpl, "setCiphers", SetCiphers); + SetProtoMethod(isolate, tmpl, "setALPN", SetALPN); + SetProtoMethod(isolate, tmpl, "setSRTP", SetSRTP); + SetProtoMethod(isolate, tmpl, "setVerifyMode", SetVerifyMode); + SetProtoMethod(isolate, tmpl, "loadDefaultCAs", LoadDefaultCAs); + SetProtoMethod(isolate, tmpl, "setECDHCurve", SetECDHCurve); + + env->set_dtls_context_constructor_template(tmpl); + } + return tmpl; +} + +void DTLSContext::InitPerContext(Local target, + Local context, + Environment* env) { + SetConstructorFunction( + context, target, "DTLSContext", GetConstructorTemplate(env)); +} + +void DTLSContext::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(New); + registry->Register(SetCert); + registry->Register(SetKey); + registry->Register(AddCACert); + registry->Register(SetCiphers); + registry->Register(SetALPN); + registry->Register(SetSRTP); + registry->Register(SetVerifyMode); + registry->Register(LoadDefaultCAs); + registry->Register(SetECDHCurve); +} + +// new DTLSContext(isServer) +void DTLSContext::New(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK(args.IsConstructCall()); + + bool is_server = args[0]->IsTrue(); + + const SSL_METHOD* method; + if (is_server) { + method = DTLS_server_method(); + } else { + method = DTLS_client_method(); + } + + SSL_CTX* ctx = SSL_CTX_new(method); + if (ctx == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to create DTLS SSL_CTX"); + } + + // Default to DTLS 1.2 only. DTLS 1.0 (based on TLS 1.1) is deprecated + // by RFC 8996 and lacks AEAD cipher suites. + SSL_CTX_set_min_proto_version(ctx, DTLS1_2_VERSION); + SSL_CTX_set_max_proto_version(ctx, DTLS1_2_VERSION); + + // Disable OpenSSL's MTU querying (we manage MTU manually). + SSL_CTX_set_options(ctx, SSL_OP_NO_QUERY_MTU); + + // Enable all workarounds for maximum compatibility. + SSL_CTX_set_options(ctx, SSL_OP_ALL); + + if (is_server) { + // NOTE: SSL_OP_COOKIE_EXCHANGE must NOT be set on the context. + // DTLSv1_listen() sets it per-SSL automatically (see d1_lib.c:804). + // Setting it here would cause session SSLs created via CreateFromSSL() + // to attempt a redundant cookie exchange, hanging the handshake. + + // Enable session caching for session resumption. + SSL_CTX_set_session_cache_mode( + ctx, SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_AUTO_CLEAR); + } else { + // Client session caching for resumption. + SSL_CTX_set_session_cache_mode( + ctx, SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL); + } + + // NOTE: We do NOT call SSL_CTX_set_default_verify_paths() here. + // CA loading is handled in JS: if the user provides custom CAs, only + // those are loaded (via addCACert). Otherwise, system default CAs are + // loaded via loadDefaultCAs(). This matches Node.js TLS behavior. + + new DTLSContext(env, args.This(), ctx, is_server); +} + +void DTLSContext::SetCert(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!args[0]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE(env, "cert must be a string (PEM)"); + } + + Utf8Value cert_pem(env->isolate(), args[0]); + + BIO* bio = BIO_new_mem_buf(*cert_pem, cert_pem.length()); + if (bio == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "BIO_new_mem_buf failed"); + } + + X509* x509 = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr); + if (x509 == nullptr) { + BIO_free(bio); + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "PEM_read_bio_X509 failed"); + } + + int ret = SSL_CTX_use_certificate(ctx->ctx_.get(), x509); + X509_free(x509); + + // Read any additional chain certificates. + while ((x509 = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr)) != + nullptr) { + SSL_CTX_add_extra_chain_cert(ctx->ctx_.get(), x509); + // Note: SSL_CTX_add_extra_chain_cert takes ownership, don't free x509. + } + + // Clear any error from the chain reading loop (expected EOF). + ERR_clear_error(); + BIO_free(bio); + + if (ret != 1) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "SSL_CTX_use_certificate failed"); + } +} + +void DTLSContext::SetKey(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!args[0]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE(env, "key must be a string (PEM)"); + } + + Utf8Value key_pem(env->isolate(), args[0]); + + BIO* bio = BIO_new_mem_buf(*key_pem, key_pem.length()); + if (bio == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "BIO_new_mem_buf failed"); + } + + EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr); + BIO_free(bio); + + if (pkey == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "PEM_read_bio_PrivateKey failed"); + } + + int ret = SSL_CTX_use_PrivateKey(ctx->ctx_.get(), pkey); + EVP_PKEY_free(pkey); + + if (ret != 1) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "SSL_CTX_use_PrivateKey failed"); + } + + // Verify that the private key matches the certificate. + if (SSL_CTX_check_private_key(ctx->ctx_.get()) != 1) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "Private key does not match certificate"); + } +} + +void DTLSContext::AddCACert(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!args[0]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE(env, "ca must be a string (PEM)"); + } + + Utf8Value ca_pem(env->isolate(), args[0]); + + BIO* bio = BIO_new_mem_buf(*ca_pem, ca_pem.length()); + if (bio == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "BIO_new_mem_buf failed"); + } + + X509_STORE* store = SSL_CTX_get_cert_store(ctx->ctx_.get()); + X509* x509; + int count = 0; + while ((x509 = PEM_read_bio_X509(bio, nullptr, nullptr, nullptr)) != + nullptr) { + X509_STORE_add_cert(store, x509); + X509_free(x509); + count++; + } + ERR_clear_error(); + BIO_free(bio); + + if (count == 0) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "No CA certificates found in PEM data"); + } +} + +void DTLSContext::SetCiphers(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!args[0]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE(env, "ciphers must be a string"); + } + + Utf8Value ciphers(env->isolate(), args[0]); + if (SSL_CTX_set_cipher_list(ctx->ctx_.get(), *ciphers) != 1) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "SSL_CTX_set_cipher_list failed"); + } +} + +void DTLSContext::SetALPN(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!Buffer::HasInstance(args[0])) { + return THROW_ERR_INVALID_ARG_TYPE(env, "alpnProtocols must be a Buffer"); + } + + const uint8_t* data = reinterpret_cast(Buffer::Data(args[0])); + size_t len = Buffer::Length(args[0]); + + if (ctx->is_server_) { + // Server: store protocols for the selection callback. + ctx->alpn_protos_.assign(data, data + len); + SSL_CTX_set_alpn_select_cb(ctx->ctx_.get(), ALPNSelectCallback, ctx); + } else { + // Client: advertise protocols to the server. + SSL_CTX_set_alpn_protos(ctx->ctx_.get(), data, len); + } +} + +void DTLSContext::SetSRTP(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + if (!args[0]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE(env, "srtpProfiles must be a string"); + } + + Utf8Value profiles(env->isolate(), args[0]); + if (SSL_CTX_set_tlsext_use_srtp(ctx->ctx_.get(), *profiles) != 0) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "SSL_CTX_set_tlsext_use_srtp failed"); + } +} + +void DTLSContext::SetVerifyMode(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + + int mode = args[0]->Int32Value(ctx->env()->context()).FromJust(); + SSL_CTX_set_verify(ctx->ctx_.get(), mode, nullptr); +} + +void DTLSContext::LoadDefaultCAs(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + SSL_CTX_set_default_verify_paths(ctx->ctx_.get()); +} + +void DTLSContext::SetECDHCurve(const FunctionCallbackInfo& args) { + DTLSContext* ctx; + ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This()); + Environment* env = ctx->env(); + + CHECK(args[0]->IsString()); + Utf8Value curve(env->isolate(), args[0]); + + // "auto" means use OpenSSL's default curve selection. + if (strcmp(*curve, "auto") != 0) { + if (!SSL_CTX_set1_curves_list(ctx->ctx_.get(), *curve)) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to set ECDH curve"); + } + } +} + +// HMAC-SHA256 based cookie generation using the peer's address. +// During DTLSv1_listen(), the peer address is taken from +// DTLSContext::current_cookie_peer_ (set synchronously before the call). +// During session handshake, the peer address is taken from the +// DTLSSession stored in SSL app_data. +int DTLSContext::CookieGenerateCallback(SSL* ssl, + unsigned char* cookie, + unsigned int* cookie_len) { + SSL_CTX* ctx = SSL_get_SSL_CTX(ssl); + DTLSContext* dtls_ctx = static_cast(SSL_CTX_get_app_data(ctx)); + CHECK_NOT_NULL(dtls_ctx); + + unsigned char addr_buf[sizeof(struct sockaddr_storage)]; + size_t addr_len = 0; + + void* app_data = SSL_get_app_data(ssl); + if (app_data != nullptr) { + // Session handshake path. + auto* session = static_cast(app_data); + const sockaddr* sa = session->remote_address().data(); + addr_len = SocketAddress::GetLength(sa); + memcpy(addr_buf, sa, addr_len); + } else { + // DTLSv1_listen path — use the peer address stored on the context. + const sockaddr* sa = dtls_ctx->current_cookie_peer_.data(); + addr_len = SocketAddress::GetLength(sa); + memcpy(addr_buf, sa, addr_len); + } + + unsigned int hmac_len = 0; + unsigned char* result = HMAC(EVP_sha256(), + dtls_ctx->cookie_secret_.data(), + dtls_ctx->cookie_secret_.size(), + addr_buf, + addr_len, + cookie, + &hmac_len); + + if (result == nullptr) return 0; + + *cookie_len = hmac_len; + return 1; +} + +int DTLSContext::CookieVerifyCallback(SSL* ssl, + const unsigned char* cookie, + unsigned int cookie_len) { + // Generate the expected cookie and compare. + unsigned char expected[EVP_MAX_MD_SIZE]; + unsigned int expected_len = 0; + + if (CookieGenerateCallback(ssl, expected, &expected_len) != 1) { + return 0; + } + + if (cookie_len != expected_len) return 0; + + return CRYPTO_memcmp(cookie, expected, expected_len) == 0 ? 1 : 0; +} + +int DTLSContext::ALPNSelectCallback(SSL* ssl, + const unsigned char** out, + unsigned char* outlen, + const unsigned char* in, + unsigned int inlen, + void* arg) { + DTLSContext* ctx = static_cast(arg); + + if (ctx->alpn_protos_.empty()) { + return SSL_TLSEXT_ERR_NOACK; + } + + int ret = SSL_select_next_proto(const_cast(out), + outlen, + ctx->alpn_protos_.data(), + ctx->alpn_protos_.size(), + in, + inlen); + + if (ret != OPENSSL_NPN_NEGOTIATED) { + return SSL_TLSEXT_ERR_NOACK; + } + + return SSL_TLSEXT_ERR_OK; +} + +} // namespace dtls +} // namespace node + +#endif // HAVE_OPENSSL && HAVE_DTLS diff --git a/src/dtls/dtls_context.h b/src/dtls/dtls_context.h new file mode 100644 index 000000000000..11d8113d3081 --- /dev/null +++ b/src/dtls/dtls_context.h @@ -0,0 +1,94 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace node::dtls { + +// DTLSContext wraps an SSL_CTX configured for DTLS. +// It manages certificate/key configuration, cipher selection, +// ALPN, and automatic cookie generation/verification for servers. +class DTLSContext final : public BaseObject { + public: + static v8::Local GetConstructorTemplate( + Environment* env); + static void InitPerContext(v8::Local target, + v8::Local context, + Environment* env); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + DTLSContext(Environment* env, + v8::Local wrap, + SSL_CTX* ctx, + bool is_server); + + SSL_CTX* ssl_ctx() const { return ctx_.get(); } + + // Set the peer address for cookie generation during DTLSv1_listen(). + void set_cookie_peer(const SocketAddress& addr) { + current_cookie_peer_ = addr; + } + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(DTLSContext) + SET_SELF_SIZE(DTLSContext) + + private: + static void New(const v8::FunctionCallbackInfo& args); + static void SetCert(const v8::FunctionCallbackInfo& args); + static void SetKey(const v8::FunctionCallbackInfo& args); + static void AddCACert(const v8::FunctionCallbackInfo& args); + static void SetCiphers(const v8::FunctionCallbackInfo& args); + static void SetALPN(const v8::FunctionCallbackInfo& args); + static void SetSRTP(const v8::FunctionCallbackInfo& args); + static void SetVerifyMode(const v8::FunctionCallbackInfo& args); + static void LoadDefaultCAs(const v8::FunctionCallbackInfo& args); + static void SetECDHCurve(const v8::FunctionCallbackInfo& args); + + // Automatic DTLS cookie callbacks + static int CookieGenerateCallback(SSL* ssl, + unsigned char* cookie, + unsigned int* cookie_len); + static int CookieVerifyCallback(SSL* ssl, + const unsigned char* cookie, + unsigned int cookie_len); + + // ALPN selection callback (server-side) + static int ALPNSelectCallback(SSL* ssl, + const unsigned char** out, + unsigned char* outlen, + const unsigned char* in, + unsigned int inlen, + void* arg); + + ncrypto::SSLCtxPointer ctx_; + bool is_server_; + + // Secret key for HMAC-based cookie generation + std::vector cookie_secret_; + + // Peer address for current DTLSv1_listen cookie exchange. + // Set synchronously before DTLSv1_listen() and consumed by the + // cookie generate/verify callbacks during that call. + SocketAddress current_cookie_peer_; + + // ALPN protocols (server-side selection list) + std::vector alpn_protos_; +}; + +} // namespace node::dtls + +#endif // HAVE_OPENSSL && HAVE_DTLS +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/dtls/dtls_endpoint.cc b/src/dtls/dtls_endpoint.cc new file mode 100644 index 000000000000..b1c91d4f221b --- /dev/null +++ b/src/dtls/dtls_endpoint.cc @@ -0,0 +1,624 @@ +#include "dtls_endpoint.h" +#include "dtls.h" +#include "dtls_context.h" +#include "dtls_session.h" + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +namespace node { + +using v8::Context; +using v8::Function; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::HandleScope; +using v8::Int32; +using v8::Isolate; +using v8::Local; +using v8::Object; +using v8::String; +using v8::Value; + +namespace dtls { + +namespace { +struct SendReq { + uv_udp_send_t req; + uv_buf_t buf; + std::vector data; +}; +} // namespace + +DTLSEndpoint::DTLSEndpoint(Environment* env, Local wrap) + : HandleWrap(env, + wrap, + reinterpret_cast(&handle_), + PROVIDER_DTLS_ENDPOINT), + state_(env->isolate()) { + CHECK_EQ(uv_udp_init(env->event_loop(), &handle_), 0); + handle_.data = this; + MakeWeak(); +} + +Local DTLSEndpoint::GetConstructorTemplate(Environment* env) { + auto tmpl = env->dtls_endpoint_constructor_template(); + if (tmpl.IsEmpty()) { + Isolate* isolate = env->isolate(); + tmpl = NewFunctionTemplate(isolate, New); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "DTLSEndpoint")); + tmpl->InstanceTemplate()->SetInternalFieldCount( + HandleWrap::kInternalFieldCount); + + SetProtoMethod(isolate, tmpl, "bind", DoBind); + SetProtoMethod(isolate, tmpl, "listen", DoListen); + SetProtoMethod(isolate, tmpl, "connect", DoConnect); + SetProtoMethod(isolate, tmpl, "close", DoClose); + SetProtoMethod(isolate, tmpl, "destroy", DoDestroy); + SetProtoMethod(isolate, tmpl, "getState", GetState); + SetProtoMethod(isolate, tmpl, "getAddress", GetAddress); + SetProtoMethod(isolate, tmpl, "setMTU", SetMTU); + SetProtoMethod(isolate, tmpl, "setCallbacks", DoSetCallbacks); + + env->set_dtls_endpoint_constructor_template(tmpl); + } + return tmpl; +} + +void DTLSEndpoint::InitPerContext(Local target, + Local context, + Environment* env) { + SetConstructorFunction( + context, target, "DTLSEndpoint", GetConstructorTemplate(env)); +} + +void DTLSEndpoint::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(New); + registry->Register(DoBind); + registry->Register(DoListen); + registry->Register(DoConnect); + registry->Register(DoClose); + registry->Register(DoDestroy); + registry->Register(GetState); + registry->Register(GetAddress); + registry->Register(SetMTU); + registry->Register(DoSetCallbacks); +} + +void DTLSEndpoint::New(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK(args.IsConstructCall()); + new DTLSEndpoint(env, args.This()); +} + +int DTLSEndpoint::Bind(const SocketAddress& address) { + if (IsHandleClosing()) return UV_EINVAL; + if (state_->bound) return UV_EALREADY; + + unsigned int flags = 0; + if (address.family() == AF_INET6) { + flags |= UV_UDP_IPV6ONLY; + } + + int err = uv_udp_bind(&handle_, address.data(), flags); + if (err != 0) return err; + + state_->bound = 1; + + // Don't keep the event loop alive unless we're listening or have sessions. + uv_unref(reinterpret_cast(&handle_)); + + return 0; +} + +int DTLSEndpoint::Listen(DTLSContext* context) { + if (IsHandleClosing()) return UV_EINVAL; + if (listening_) return UV_EALREADY; + + server_context_.reset(context); + listening_ = true; + state_->listening = 1; + + // Start receiving UDP datagrams. + int err = uv_udp_recv_start(&handle_, OnAlloc, OnRecv); + if (err != 0) { + listening_ = false; + state_->listening = 0; + server_context_.reset(); + return err; + } + + // Ref the handle while listening. + uv_ref(reinterpret_cast(&handle_)); + + return 0; +} + +BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, + const SocketAddress& remote) { + if (IsHandleClosing()) { + THROW_ERR_INVALID_STATE(env(), "Endpoint is closing"); + return {}; + } + + // Check if we already have a session for this address. + auto it = sessions_.find(remote); + if (it != sessions_.end()) { + THROW_ERR_INVALID_STATE(env(), "Session already exists for this address"); + return {}; + } + + auto session = DTLSSession::Create( + env(), this, context->ssl_ctx(), remote, false /* is_server */); + + if (!session) return {}; + + sessions_[remote] = session; + state_->session_count = sessions_.size(); + + // Ref the handle while we have sessions. + uv_ref(reinterpret_cast(&handle_)); + + // Start receiving if not already. + if (!listening_) { + uv_udp_recv_start(&handle_, OnAlloc, OnRecv); + } + + // Initiate the DTLS handshake by running Cycle. + session->Cycle(); + + return session; +} + +int DTLSEndpoint::SendTo(const SocketAddress& dest, + const uint8_t* data, + size_t len) { + if (IsHandleClosing()) return UV_EINVAL; + + // Try synchronous send first. + uv_buf_t buf = + uv_buf_init(const_cast(reinterpret_cast(data)), len); + int err = uv_udp_try_send(&handle_, &buf, 1, dest.data()); + + if (err == static_cast(len)) { + return 0; // Sent successfully. + } + + if (err != UV_EAGAIN && err < 0) { + return err; // Real error. + } + + // Async send: copy the data since it won't outlive this call. + auto* req = new SendReq(); + req->data.assign(data, data + len); + req->buf = uv_buf_init(reinterpret_cast(req->data.data()), len); + + err = uv_udp_send(&req->req, &handle_, &req->buf, 1, dest.data(), OnSend); + if (err != 0) { + delete req; + return err; + } + + return 0; +} + +void DTLSEndpoint::RemoveSession(const SocketAddress& addr) { + sessions_.erase(addr); + state_->session_count = sessions_.size(); + + // Unref if no more sessions and not listening. + if (sessions_.empty() && !listening_ && !IsHandleClosing()) { + uv_unref(reinterpret_cast(&handle_)); + } +} + +void DTLSEndpoint::CloseGracefully() { + if (IsHandleClosing()) return; + + state_->closing = 1; + + // Close all sessions gracefully (this may send close_notify). + auto sessions_copy = sessions_; + sessions_.clear(); + state_->session_count = 0; + for (auto& [addr, session] : sessions_copy) { + session->Close(); + } + + // Stop listening. + if (listening_) { + uv_udp_recv_stop(&handle_); + listening_ = false; + state_->listening = 0; + } + + server_context_.reset(); + + // HandleWrap::Close() calls uv_close and manages the lifecycle. + HandleWrap::Close(); +} + +void DTLSEndpoint::Destroy() { + if (IsHandleClosing()) return; + + state_->destroyed = 1; + + // Copy session list to avoid iterator invalidation. + auto sessions_copy = sessions_; + sessions_.clear(); + state_->session_count = 0; + for (auto& [addr, session] : sessions_copy) { + session->Destroy(); + } + + server_context_.reset(); + + if (listening_) { + uv_udp_recv_stop(&handle_); + listening_ = false; + state_->listening = 0; + } + + HandleWrap::Close(); +} + +Local DTLSEndpoint::GetCallback(int index) const { + if (index < 0 || index >= DTLS_CB_COUNT) return Local(); + Local cb = callbacks_[index].Get(env()->isolate()); + return cb; +} + +void DTLSEndpoint::SetCallbacks(Local callbacks) { + Isolate* isolate = env()->isolate(); + Local context = env()->context(); + + const char* names[] = { + "onEndpointClose", + "onEndpointError", + "onSessionNew", + "onSessionClose", + "onSessionError", + "onSessionHandshake", + "onSessionMessage", + "onSessionKeylog", + "onSessionTicket", + }; + + for (int i = 0; i < DTLS_CB_COUNT; i++) { + Local name; + if (!String::NewFromUtf8(isolate, names[i]).ToLocal(&name)) { + THROW_ERR_OPERATION_FAILED(isolate, + "Failed to create callback name string"); + return; + } + Local val; + if (!callbacks->Get(context, name).ToLocal(&val) || !val->IsFunction()) { + THROW_ERR_MISSING_ARGS( + isolate, ("Missing DTLS callback: " + std::string(names[i])).c_str()); + return; + } + callbacks_[i].Reset(isolate, val.As()); + } +} + +// --- libuv callbacks --- + +void DTLSEndpoint::OnAlloc(uv_handle_t* handle, + size_t suggested_size, + uv_buf_t* buf) { + buf->base = new char[65536]; + buf->len = 65536; +} + +void DTLSEndpoint::OnRecv(uv_udp_t* handle, + ssize_t nread, + const uv_buf_t* buf, + const struct sockaddr* addr, + unsigned int flags) { + DTLSEndpoint* endpoint = static_cast(handle->data); + + if (nread == 0 && addr == nullptr) { + delete[] buf->base; + return; + } + + if (nread < 0) { + delete[] buf->base; + HandleScope handle_scope(endpoint->env()->isolate()); + Context::Scope context_scope(endpoint->env()->context()); + Local argv[] = { + String::NewFromUtf8(endpoint->env()->isolate(), uv_strerror(nread)) + .ToLocalChecked(), + }; + Local cb = endpoint->GetCallback(DTLS_CB_ENDPOINT_ERROR); + if (!cb.IsEmpty()) { + endpoint->MakeCallback(cb, 1, argv); + } + return; + } + + if (addr == nullptr) { + delete[] buf->base; + return; + } + + SocketAddress remote(addr); + endpoint->ProcessDatagram( + reinterpret_cast(buf->base), nread, remote); + + delete[] buf->base; +} + +void DTLSEndpoint::OnSend(uv_udp_send_t* req, int status) { + SendReq* send_req = reinterpret_cast(req); + delete send_req; +} + +void DTLSEndpoint::OnClose() { + state_->closing = 0; + state_->destroyed = 1; + + Local cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE); + if (!cb.IsEmpty()) { + Local argv[] = {}; + MakeCallback(cb, 0, argv); + } +} + +void DTLSEndpoint::ProcessDatagram(const uint8_t* data, + size_t len, + const SocketAddress& remote) { + if (IsHandleClosing()) return; + + // Look up existing session by remote address. + auto it = sessions_.find(remote); + if (it != sessions_.end()) { + it->second->Receive(data, len); + return; + } + + // No existing session. If we're in server mode, try to accept. + if (listening_ && server_context_) { + AcceptConnection(data, len, remote); + } +} + +void DTLSEndpoint::AcceptConnection(const uint8_t* data, + size_t len, + const SocketAddress& remote) { + if (state_->busy) return; + + HandleScope handle_scope(env()->isolate()); + Context::Scope context_scope(env()->context()); + + // Stateless cookie exchange via DTLSv1_listen() for DoS protection. + // + // The standard OpenSSL DTLS server flow (see s_server.c) is: + // 1. Create SSL with BIO_s_datagram() wrapping the UDP socket + // 2. DTLSv1_listen(ssl, &peer) -- stateless cookie exchange + // 3. Connect the socket to the verified peer + // 4. SSL_accept(ssl) -- continue the handshake on the SAME SSL + // + // We diverge in one key way: we use memory BIOs instead of datagram + // BIOs because Node.js manages UDP I/O through libuv (uv_udp_t), + // not through raw socket FDs. This means DTLSv1_listen()'s internal + // BIO_dgram_get_peer()/set_peer() calls are no-ops -- we provide the + // peer address to the cookie callbacks via DTLSContext::current_cookie_peer_ + // instead. After DTLSv1_listen() returns 1, we hand the SSL (with its + // memory BIOs) to a DTLSSession via CreateFromSSL(). The SSL's internal + // state machine has been prepared by DTLSv1_listen() to continue the + // handshake from TLS_ST_SR_CLNT_HELLO, so Cycle() -> SSL_do_handshake() + // immediately produces the ServerHello flight. + SSL* tmp_ssl = SSL_new(server_context_->ssl_ctx()); + if (tmp_ssl == nullptr) return; + + BIO* in = BIO_new(BIO_s_mem()); + BIO* out = BIO_new(BIO_s_mem()); + if (in == nullptr || out == nullptr) { + BIO_free(in); + BIO_free(out); + SSL_free(tmp_ssl); + return; + } + + BIO_set_mem_eof_return(in, -1); + BIO_set_mem_eof_return(out, -1); + SSL_set_bio(tmp_ssl, in, out); + SSL_set_accept_state(tmp_ssl); + SSL_set_options(tmp_ssl, SSL_OP_NO_QUERY_MTU | SSL_OP_COOKIE_EXCHANGE); + SSL_set_mtu(tmp_ssl, mtu_); + + // Set peer address on context for the cookie callbacks. + server_context_->set_cookie_peer(remote); + + BIO_write(in, data, len); + + BIO_ADDR* peer = BIO_ADDR_new(); + int ret = DTLSv1_listen(tmp_ssl, peer); + BIO_ADDR_free(peer); + + if (ret == 0) { + // Send HelloVerifyRequest. + uint8_t resp_buf[65536]; + int resp_len; + while ((resp_len = BIO_read(out, resp_buf, sizeof(resp_buf))) > 0) { + SendTo(remote, resp_buf, resp_len); + } + SSL_free(tmp_ssl); + return; + } + + if (ret < 0) { + SSL_free(tmp_ssl); + return; // Error — drop packet. + } + + // Cookie verified. Hand the SSL (which has already completed cookie + // exchange and consumed the ClientHello) to a DTLSSession. Calling + // Cycle() will drive SSL_do_handshake to produce the ServerHello. + ncrypto::SSLPointer ssl(tmp_ssl); + + auto session = + DTLSSession::CreateFromSSL(env(), this, std::move(ssl), in, out, remote); + + if (!session) return; + + sessions_[remote] = session; + state_->session_count = sessions_.size(); + + uv_ref(reinterpret_cast(&handle_)); + + // Drive the handshake forward — produces ServerHello etc. + session->Cycle(); + + // Emit the new session to JS. + Local argv[] = {session->object()}; + Local cb = GetCallback(DTLS_CB_SESSION_NEW); + if (!cb.IsEmpty()) { + MakeCallback(cb, 1, argv); + } +} + +// --- JS binding methods --- + +void DTLSEndpoint::DoBind(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + Environment* env = endpoint->env(); + + CHECK(args[0]->IsString()); // host + CHECK(args[1]->IsInt32()); // port + + Utf8Value host(env->isolate(), args[0]); + int port = args[1].As()->Value(); + + SocketAddress addr; + if (!SocketAddress::New(*host, port, &addr)) { + return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid address"); + } + + int err = endpoint->Bind(addr); + if (err != 0) { + return THROW_ERR_INVALID_STATE(env, uv_strerror(err)); + } +} + +void DTLSEndpoint::DoListen(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + Environment* env = endpoint->env(); + + THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kNet, ""); + + DTLSContext* context; + ASSIGN_OR_RETURN_UNWRAP(&context, args[0].As()); + + int err = endpoint->Listen(context); + if (err != 0) { + return THROW_ERR_INVALID_STATE(env, uv_strerror(err)); + } +} + +void DTLSEndpoint::DoConnect(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + Environment* env = endpoint->env(); + + DTLSContext* context; + ASSIGN_OR_RETURN_UNWRAP(&context, args[0].As()); + + CHECK(args[1]->IsString()); // host + CHECK(args[2]->IsInt32()); // port + + Utf8Value host(env->isolate(), args[1]); + int port = args[2].As()->Value(); + + SocketAddress remote; + if (!SocketAddress::New(*host, port, &remote)) { + return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid remote address"); + } + + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kNet, remote.ToString()); + + auto session = endpoint->Connect(context, remote); + if (session) { + args.GetReturnValue().Set(session->object()); + } +} + +void DTLSEndpoint::DoClose(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + endpoint->CloseGracefully(); +} + +void DTLSEndpoint::DoDestroy(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + endpoint->Destroy(); +} + +void DTLSEndpoint::GetState(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + args.GetReturnValue().Set(endpoint->state_.GetArrayBuffer()); +} + +void DTLSEndpoint::GetAddress(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + + if (endpoint->IsHandleClosing()) return; + + SocketAddress addr = SocketAddress::FromSockName(endpoint->handle_); + Local obj; + if (addr.ToJS(endpoint->env()).ToLocal(&obj)) { + args.GetReturnValue().Set(obj); + } +} + +void DTLSEndpoint::SetMTU(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + + CHECK(args[0]->IsInt32()); + int mtu = args[0].As()->Value(); + if (mtu < 256 || mtu > 65535) { + return THROW_ERR_OUT_OF_RANGE(endpoint->env(), + "MTU must be between 256 and 65535"); + } + endpoint->mtu_ = mtu; +} + +void DTLSEndpoint::DoSetCallbacks(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + CHECK(args[0]->IsObject()); + endpoint->SetCallbacks(args[0].As()); +} + +void DTLSEndpoint::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("sessions", sessions_.size()); +} + +} // namespace dtls +} // namespace node + +#endif // HAVE_OPENSSL && HAVE_DTLS diff --git a/src/dtls/dtls_endpoint.h b/src/dtls/dtls_endpoint.h new file mode 100644 index 000000000000..06ffe4bcc39e --- /dev/null +++ b/src/dtls/dtls_endpoint.h @@ -0,0 +1,147 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "dtls.h" +#include "dtls_context.h" +#include "dtls_session.h" + +namespace node::dtls { + +// Shared C++ <-> JS state for a DTLS endpoint. +struct DTLSEndpointStateData { + uint8_t bound = 0; + uint8_t listening = 0; + uint8_t closing = 0; + uint8_t destroyed = 0; + uint32_t session_count = 0; + uint8_t busy = 0; +}; + +// DTLSEndpoint manages a single UDP socket and dispatches incoming +// datagrams to the appropriate DTLSSession based on the remote address. +// For server mode, it handles stateless cookie exchange via DTLSv1_listen() +// before creating new sessions. +class DTLSEndpoint final : public HandleWrap { + public: + static v8::Local GetConstructorTemplate( + Environment* env); + static void InitPerContext(v8::Local target, + v8::Local context, + Environment* env); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + DTLSEndpoint(Environment* env, v8::Local wrap); + + // Bind the UDP socket to the given address. + int Bind(const SocketAddress& address); + + // Start listening for incoming DTLS connections (server mode). + // |context| provides the SSL_CTX for creating new sessions. + int Listen(DTLSContext* context); + + // Initiate a client connection to the given address. + // Returns the created DTLSSession. + BaseObjectPtr Connect(DTLSContext* context, + const SocketAddress& remote); + + // Send a raw UDP datagram to the given address. + // Called by DTLSSession to send encrypted packets. + int SendTo(const SocketAddress& dest, const uint8_t* data, size_t len); + + // Remove a session from the endpoint (called on session close/destroy). + void RemoveSession(const SocketAddress& addr); + + // Close the endpoint gracefully (close all sessions first). + void CloseGracefully(); + + // Immediately destroy the endpoint. + void Destroy(); + + // Get the JS callback function for a given callback index. + v8::Local GetCallback(int index) const; + + // Set the JS callbacks. + void SetCallbacks(v8::Local callbacks); + + bool is_listening() const { return listening_; } + uint32_t mtu() const { return mtu_; } + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(DTLSEndpoint) + SET_SELF_SIZE(DTLSEndpoint) + + private: + // JS binding methods + static void New(const v8::FunctionCallbackInfo& args); + static void DoBind(const v8::FunctionCallbackInfo& args); + static void DoListen(const v8::FunctionCallbackInfo& args); + static void DoConnect(const v8::FunctionCallbackInfo& args); + static void DoClose(const v8::FunctionCallbackInfo& args); + static void DoDestroy(const v8::FunctionCallbackInfo& args); + static void GetState(const v8::FunctionCallbackInfo& args); + static void GetAddress(const v8::FunctionCallbackInfo& args); + static void SetMTU(const v8::FunctionCallbackInfo& args); + static void DoSetCallbacks(const v8::FunctionCallbackInfo& args); + + // libuv callbacks + static void OnAlloc(uv_handle_t* handle, + size_t suggested_size, + uv_buf_t* buf); + static void OnRecv(uv_udp_t* handle, + ssize_t nread, + const uv_buf_t* buf, + const struct sockaddr* addr, + unsigned int flags); + static void OnSend(uv_udp_send_t* req, int status); + + // Called by HandleWrap after uv_close completes. + void OnClose() override; + + // Process an incoming datagram. + void ProcessDatagram(const uint8_t* data, + size_t len, + const SocketAddress& remote); + + // Handle a new client connection (server mode). + void AcceptConnection(const uint8_t* data, + size_t len, + const SocketAddress& remote); + + uv_udp_t handle_; + + // Session table: maps remote address -> session. + std::unordered_map, + SocketAddress::Hash> + sessions_; + + // Server context (set when listening). + BaseObjectPtr server_context_; + + // JS callbacks + v8::Global callbacks_[DTLS_CB_COUNT]; + + AliasedStruct state_; + + bool listening_ = false; + uint32_t mtu_ = 1200; // Conservative default MTU for data payload +}; + +} // namespace node::dtls + +#endif // HAVE_OPENSSL && HAVE_DTLS +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/dtls/dtls_session.cc b/src/dtls/dtls_session.cc new file mode 100644 index 000000000000..f423451a5302 --- /dev/null +++ b/src/dtls/dtls_session.cc @@ -0,0 +1,672 @@ +#include "dtls_session.h" +#include "dtls.h" +#include "dtls_endpoint.h" + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +namespace node { + +using v8::Context; +using v8::Function; +using v8::FunctionCallbackInfo; +using v8::FunctionTemplate; +using v8::HandleScope; +using v8::Isolate; +using v8::Local; +using v8::MaybeLocal; +using v8::Object; +using v8::String; +using v8::Value; + +namespace dtls { + +DTLSSession::DTLSSession(Environment* env, + Local wrap, + DTLSEndpoint* endpoint, + ncrypto::SSLPointer ssl, + BIO* enc_in, + BIO* enc_out, + const SocketAddress& remote, + bool is_server) + : AsyncWrap(env, wrap, PROVIDER_DTLS_SESSION), + endpoint_(endpoint), + ssl_(std::move(ssl)), + enc_in_(enc_in), + enc_out_(enc_out), + retransmit_timer_(env, + [this] { + if (destroyed_) return; + int ret = DTLSv1_handle_timeout(ssl_.get()); + if (ret < 0) { + // Handshake timeout expired. + HandleScope hs(this->env()->isolate()); + Context::Scope cs(this->env()->context()); + Local argv[] = { + String::NewFromUtf8(this->env()->isolate(), + "DTLS handshake timeout") + .ToLocalChecked(), + }; + EmitCallback(DTLS_CB_SESSION_ERROR, 1, argv); + return; + } + Cycle(); + }), + remote_address_(remote), + is_server_(is_server), + state_(env->isolate()) { + MakeWeak(); + retransmit_timer_.Unref(); + + // Update shared state. + state_->handshaking = 1; + state_->open = 0; + + // Store this session in SSL app data for callbacks. + SSL_set_app_data(ssl_.get(), this); + + // Enable keylog for TLS key export (useful for Wireshark debugging). + SSL_CTX_set_keylog_callback(SSL_get_SSL_CTX(ssl_.get()), SSLKeylogCallback); + + // Set the MTU on the SSL object. + SSL_set_mtu(ssl_.get(), endpoint->mtu()); +} + +DTLSSession::~DTLSSession() = default; + +Local DTLSSession::GetConstructorTemplate(Environment* env) { + auto tmpl = env->dtls_session_constructor_template(); + if (tmpl.IsEmpty()) { + Isolate* isolate = env->isolate(); + tmpl = NewFunctionTemplate(isolate, New); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "DTLSSession")); + tmpl->InstanceTemplate()->SetInternalFieldCount( + AsyncWrap::kInternalFieldCount); + + SetProtoMethod(isolate, tmpl, "send", DoSend); + SetProtoMethod(isolate, tmpl, "close", DoClose); + SetProtoMethod(isolate, tmpl, "destroy", DoDestroy); + SetProtoMethod(isolate, tmpl, "getState", GetState); + SetProtoMethod(isolate, tmpl, "getRemoteAddress", GetRemoteAddress); + SetProtoMethod(isolate, tmpl, "getProtocol", GetProtocol); + SetProtoMethod(isolate, tmpl, "getCipher", GetCipher); + SetProtoMethod(isolate, tmpl, "getPeerCertificate", GetPeerCertificate); + SetProtoMethod(isolate, tmpl, "getALPNProtocol", GetALPNProtocol); + SetProtoMethod(isolate, tmpl, "exportKeyingMaterial", ExportKeyingMaterial); + SetProtoMethod(isolate, tmpl, "getSRTPProfile", GetSRTPProfile); + SetProtoMethod(isolate, tmpl, "setServername", SetServername); + SetProtoMethod(isolate, tmpl, "getServername", GetServername); + + env->set_dtls_session_constructor_template(tmpl); + } + return tmpl; +} + +void DTLSSession::InitPerContext(Local target, + Local context, + Environment* env) { + SetConstructorFunction( + context, target, "DTLSSession", GetConstructorTemplate(env)); +} + +void DTLSSession::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(New); + registry->Register(DoSend); + registry->Register(DoClose); + registry->Register(DoDestroy); + registry->Register(GetState); + registry->Register(GetRemoteAddress); + registry->Register(GetProtocol); + registry->Register(GetCipher); + registry->Register(GetPeerCertificate); + registry->Register(GetALPNProtocol); + registry->Register(ExportKeyingMaterial); + registry->Register(GetSRTPProfile); + registry->Register(SetServername); + registry->Register(GetServername); +} + +BaseObjectPtr DTLSSession::Create(Environment* env, + DTLSEndpoint* endpoint, + SSL_CTX* ssl_ctx, + const SocketAddress& remote, + bool is_server) { + // Create the SSL object. + SSL* ssl_raw = SSL_new(ssl_ctx); + if (ssl_raw == nullptr) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "SSL_new failed"); + return {}; + } + + ncrypto::SSLPointer ssl(ssl_raw); + + // Create memory BIOs for encrypted data I/O. + BIO* enc_in = BIO_new(BIO_s_mem()); + BIO* enc_out = BIO_new(BIO_s_mem()); + if (enc_in == nullptr || enc_out == nullptr) { + BIO_free(enc_in); + BIO_free(enc_out); + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "BIO_new failed"); + return {}; + } + + // Make the BIOs non-blocking. + BIO_set_mem_eof_return(enc_in, -1); + BIO_set_mem_eof_return(enc_out, -1); + + // Associate BIOs with the SSL object. SSL_set_bio takes ownership. + SSL_set_bio(ssl.get(), enc_in, enc_out); + + // Set the MTU (since we use SSL_OP_NO_QUERY_MTU). + SSL_set_mtu(ssl.get(), endpoint->mtu()); + + // Set the handshake direction. + if (is_server) { + SSL_set_accept_state(ssl.get()); + } else { + SSL_set_connect_state(ssl.get()); + } + + // Create the JS wrapper object. + Local tmpl = GetConstructorTemplate(env); + Local obj; + if (!tmpl->InstanceTemplate()->NewInstance(env->context()).ToLocal(&obj)) { + return {}; + } + + auto session = MakeBaseObject( + env, obj, endpoint, std::move(ssl), enc_in, enc_out, remote, is_server); + + return session; +} + +BaseObjectPtr DTLSSession::CreateFromSSL( + Environment* env, + DTLSEndpoint* endpoint, + ncrypto::SSLPointer ssl, + BIO* enc_in, + BIO* enc_out, + const SocketAddress& remote) { + Local tmpl = GetConstructorTemplate(env); + Local obj; + if (!tmpl->InstanceTemplate()->NewInstance(env->context()).ToLocal(&obj)) { + return {}; + } + + return MakeBaseObject(env, + obj, + endpoint, + std::move(ssl), + enc_in, + enc_out, + remote, + true /* is_server */); +} + +void DTLSSession::New(const FunctionCallbackInfo& args) { + // Sessions are created internally via DTLSSession::Create, + // not directly from JS. + CHECK(args.IsConstructCall()); +} + +void DTLSSession::Receive(const uint8_t* data, size_t len) { + if (destroyed_ || closed_) return; + + // Write the encrypted datagram into enc_in_ BIO. + int written = BIO_write(enc_in_, data, len); + if (written <= 0) return; + + // Run the state machine. + Cycle(); +} + +void DTLSSession::Cycle() { + if (destroyed_) return; + + // Prevent infinite recursion. + if (++cycle_depth_ > 1) { + cycle_depth_--; + return; + } + + HandleScope handle_scope(env()->isolate()); + Context::Scope context_scope(env()->context()); + + // If handshake is not yet complete, drive it forward. + if (!handshake_complete_) { + int ret = SSL_do_handshake(ssl_.get()); + if (ret <= 0) { + int err = SSL_get_error(ssl_.get(), ret); + if (err == SSL_ERROR_SSL) { + unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) + char err_buf[256]; + ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + Local argv[] = { + String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), + }; + EmitCallback(DTLS_CB_SESSION_ERROR, 1, argv); + cycle_depth_--; + return; + } + // SSL_ERROR_WANT_READ/WRITE is normal during handshake. + } + // Flush any handshake data produced. + EncOut(); + + // Check if handshake just completed. + if (SSL_is_init_finished(ssl_.get()) && !handshake_complete_) { + handshake_complete_ = true; + state_->handshaking = 0; + state_->open = 1; + + Local argv[] = { + String::NewFromUtf8(env()->isolate(), SSL_get_version(ssl_.get())) + .ToLocalChecked(), + }; + EmitCallback(DTLS_CB_SESSION_HANDSHAKE, 1, argv); + } + } + + // Read any decrypted application data. + ClearOut(); + // Flush any pending encrypted output. + EncOut(); + + UpdateTimer(); + cycle_depth_--; +} + +void DTLSSession::ClearOut() { + if (destroyed_) return; + + // Try to read decrypted application data from OpenSSL. + uint8_t buf[65536]; + int read; + + while ((read = SSL_read(ssl_.get(), buf, sizeof(buf))) > 0) { + // Emit the data to JS via callback. + Local argv[] = { + Buffer::Copy(env(), reinterpret_cast(buf), read) + .ToLocalChecked(), + }; + EmitCallback(DTLS_CB_SESSION_MESSAGE, 1, argv); + } + + int err = SSL_get_error(ssl_.get(), read); + switch (err) { + case SSL_ERROR_WANT_READ: + case SSL_ERROR_WANT_WRITE: + // Normal - need more data or need to flush. + break; + + case SSL_ERROR_ZERO_RETURN: + // Peer sent close_notify. + if (!closed_) { + closed_ = true; + state_->closing = 1; + state_->open = 0; + // Send our close_notify back. + SSL_shutdown(ssl_.get()); + EncOut(); + Local argv[] = {}; + EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); + } + break; + + case SSL_ERROR_SSL: { + // SSL error during handshake or data exchange. + unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) + char err_buf[256]; + ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + Local argv[] = { + String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), + }; + EmitCallback(DTLS_CB_SESSION_ERROR, 1, argv); + break; + } + + default: + break; + } +} + +void DTLSSession::EncOut() { + if (destroyed_) return; + auto ep = endpoint_.get(); + if (ep == nullptr) return; + + // Read encrypted data from enc_out_ BIO and send via UDP. + // Read in a loop since there may be multiple DTLS records. + uint8_t buf[65536]; + int read; + while ((read = BIO_read(enc_out_, buf, sizeof(buf))) > 0) { + ep->SendTo(remote_address_, buf, read); + } +} + +void DTLSSession::UpdateTimer() { + if (destroyed_) return; + + struct timeval tv; + if (DTLSv1_get_timeout(ssl_.get(), &tv)) { + uint64_t timeout_ms = tv.tv_sec * 1000 + tv.tv_usec / 1000; + if (timeout_ms == 0) timeout_ms = 1; // Minimum 1ms. + retransmit_timer_.Update(timeout_ms); + } else { + // No timeout needed (handshake complete or not started). + retransmit_timer_.Stop(); + } +} + +int DTLSSession::Send(const uint8_t* data, size_t len) { + if (destroyed_ || closed_) return -1; + + if (!handshake_complete_) { + // Can't send application data before handshake. + return -1; + } + + int written = SSL_write(ssl_.get(), data, len); + if (written > 0) { + EncOut(); + } + return written; +} + +void DTLSSession::Close() { + if (destroyed_ || closed_) return; + + closed_ = true; + state_->closing = 1; + + // Send close_notify. + int ret = SSL_shutdown(ssl_.get()); + if (ret == 0) { + // Need to call again for bidirectional shutdown. + SSL_shutdown(ssl_.get()); + } + EncOut(); + + retransmit_timer_.Stop(); + + state_->open = 0; + + // Notify JS. + HandleScope handle_scope(env()->isolate()); + Context::Scope context_scope(env()->context()); + Local argv[] = {}; + EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); +} + +void DTLSSession::Destroy() { + if (destroyed_) return; + destroyed_ = true; + closed_ = true; + + state_->destroyed = 1; + state_->open = 0; + state_->handshaking = 0; + + retransmit_timer_.Close(); + + // Promote to strong ref to keep endpoint alive during removal, + // then release our weak pointer. + BaseObjectPtr ep = endpoint_; + endpoint_.reset(); + if (ep) ep->RemoveSession(remote_address_); +} + +void DTLSSession::SSLKeylogCallback(const SSL* ssl, const char* line) { + DTLSSession* session = static_cast(SSL_get_app_data(ssl)); + if (session == nullptr || session->destroyed_) return; + + HandleScope handle_scope(session->env()->isolate()); + Context::Scope context_scope(session->env()->context()); + + Local argv[] = { + String::NewFromUtf8(session->env()->isolate(), line).ToLocalChecked(), + }; + session->EmitCallback(DTLS_CB_SESSION_KEYLOG, 1, argv); +} + +MaybeLocal DTLSSession::EmitCallback(int cb_index, + int argc, + Local* argv) { + auto ep = endpoint_.get(); + if (ep == nullptr) return MaybeLocal(); + Local cb = ep->GetCallback(cb_index); + if (cb.IsEmpty()) return MaybeLocal(); + + return MakeCallback(cb, argc, argv); +} + +// --- JS binding methods --- + +void DTLSSession::DoSend(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + if (!Buffer::HasInstance(args[0])) { + return THROW_ERR_INVALID_ARG_TYPE(session->env(), "data must be a Buffer"); + } + + const uint8_t* data = reinterpret_cast(Buffer::Data(args[0])); + size_t len = Buffer::Length(args[0]); + + int written = session->Send(data, len); + args.GetReturnValue().Set(written); +} + +void DTLSSession::DoClose(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + session->Close(); +} + +void DTLSSession::DoDestroy(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + session->Destroy(); +} + +void DTLSSession::GetState(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + args.GetReturnValue().Set(session->state_.GetArrayBuffer()); +} + +void DTLSSession::GetRemoteAddress(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + Environment* env = session->env(); + + Local obj; + if (session->remote_address_.ToJS(env).ToLocal(&obj)) { + args.GetReturnValue().Set(obj); + } +} + +void DTLSSession::GetProtocol(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + const char* version = SSL_get_version(session->ssl_.get()); + args.GetReturnValue().Set( + String::NewFromUtf8(session->env()->isolate(), version).ToLocalChecked()); +} + +void DTLSSession::GetCipher(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + Environment* env = session->env(); + + const SSL_CIPHER* cipher = SSL_get_current_cipher(session->ssl_.get()); + if (cipher == nullptr) return; + + Local info = Object::New(env->isolate()); + info->Set(env->context(), + FIXED_ONE_BYTE_STRING(env->isolate(), "name"), + String::NewFromUtf8(env->isolate(), SSL_CIPHER_get_name(cipher)) + .ToLocalChecked()) + .Check(); + info->Set( + env->context(), + FIXED_ONE_BYTE_STRING(env->isolate(), "standardName"), + String::NewFromUtf8(env->isolate(), SSL_CIPHER_standard_name(cipher)) + .ToLocalChecked()) + .Check(); + info->Set(env->context(), + FIXED_ONE_BYTE_STRING(env->isolate(), "version"), + String::NewFromUtf8(env->isolate(), SSL_CIPHER_get_version(cipher)) + .ToLocalChecked()) + .Check(); + + args.GetReturnValue().Set(info); +} + +void DTLSSession::GetPeerCertificate(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + Environment* env = session->env(); + + X509* peer_cert = SSL_get0_peer_certificate(session->ssl_.get()); + if (peer_cert == nullptr) return; + + // Return the PEM-encoded certificate. + BIO* bio = BIO_new(BIO_s_mem()); + if (PEM_write_bio_X509(bio, peer_cert)) { + char* data; + long len = BIO_get_mem_data(bio, &data); // NOLINT(runtime/int) + if (len > 0) { + args.GetReturnValue().Set( + String::NewFromUtf8( + env->isolate(), data, v8::NewStringType::kNormal, len) + .ToLocalChecked()); + } + } + BIO_free(bio); +} + +void DTLSSession::GetALPNProtocol(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + const unsigned char* alpn = nullptr; + unsigned int alpn_len = 0; + SSL_get0_alpn_selected(session->ssl_.get(), &alpn, &alpn_len); + + if (alpn != nullptr && alpn_len > 0) { + args.GetReturnValue().Set( + String::NewFromUtf8(session->env()->isolate(), + reinterpret_cast(alpn), + v8::NewStringType::kNormal, + alpn_len) + .ToLocalChecked()); + } +} + +void DTLSSession::ExportKeyingMaterial( + const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + Environment* env = session->env(); + + if (!args[0]->IsNumber() || !args[1]->IsString()) { + return THROW_ERR_INVALID_ARG_TYPE( + env, "Expected (length: number, label: string[, context: Buffer])"); + } + + int length = args[0]->Int32Value(env->context()).FromJust(); + Utf8Value label(env->isolate(), args[1]); + + const uint8_t* context_value = nullptr; + size_t context_len = 0; + bool use_context = false; + + if (args.Length() > 2 && Buffer::HasInstance(args[2])) { + context_value = reinterpret_cast(Buffer::Data(args[2])); + context_len = Buffer::Length(args[2]); + use_context = true; + } + + std::vector out(length); + int ret = SSL_export_keying_material(session->ssl_.get(), + out.data(), + length, + *label, + label.length(), + context_value, + context_len, + use_context ? 1 : 0); + + if (ret != 1) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "SSL_export_keying_material failed"); + } + + args.GetReturnValue().Set( + Buffer::Copy(env, reinterpret_cast(out.data()), length) + .ToLocalChecked()); +} + +void DTLSSession::GetSRTPProfile(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + const SRTP_PROTECTION_PROFILE* profile = + SSL_get_selected_srtp_profile(session->ssl_.get()); + + if (profile != nullptr) { + args.GetReturnValue().Set( + String::NewFromUtf8(session->env()->isolate(), profile->name) + .ToLocalChecked()); + } +} + +void DTLSSession::SetServername(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + CHECK(args[0]->IsString()); + Utf8Value servername(session->env()->isolate(), args[0]); + SSL_set_tlsext_host_name(session->ssl_.get(), *servername); +} + +void DTLSSession::GetServername(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + + const char* servername = + SSL_get_servername(session->ssl_.get(), TLSEXT_NAMETYPE_host_name); + if (servername != nullptr) { + args.GetReturnValue().Set( + String::NewFromUtf8(session->env()->isolate(), servername) + .ToLocalChecked()); + } +} + +void DTLSSession::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("remote_address", remote_address_); +} + +} // namespace dtls +} // namespace node + +#endif // HAVE_OPENSSL && HAVE_DTLS diff --git a/src/dtls/dtls_session.h b/src/dtls/dtls_session.h new file mode 100644 index 000000000000..c4dee0c36d53 --- /dev/null +++ b/src/dtls/dtls_session.h @@ -0,0 +1,167 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if HAVE_OPENSSL && HAVE_DTLS + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace node::dtls { + +class DTLSEndpoint; + +// Shared C++ <-> JS state for a DTLS session. +struct DTLSSessionStateData { + uint8_t handshaking = 0; + uint8_t open = 0; + uint8_t closing = 0; + uint8_t destroyed = 0; + uint8_t has_message_listener = 0; +}; + +// DTLSSession represents a single DTLS association with a remote peer. +// It wraps an OpenSSL SSL* object configured for DTLS, using memory BIOs +// to interface with the endpoint's UDP socket. +class DTLSSession final : public AsyncWrap { + public: + static v8::Local GetConstructorTemplate( + Environment* env); + static void InitPerContext(v8::Local target, + v8::Local context, + Environment* env); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + // Create a new DTLS session. + // |endpoint| - the owning endpoint (for sending packets) + // |ssl_ctx| - the SSL_CTX to create the SSL* from + // |remote| - the peer address + // |is_server| - true if this is a server-side session + static BaseObjectPtr Create(Environment* env, + DTLSEndpoint* endpoint, + SSL_CTX* ssl_ctx, + const SocketAddress& remote, + bool is_server); + + // Create a session from an already-initialized SSL object. + // Used by the server after DTLSv1_listen() returns 1 — the SSL + // has already verified the cookie and is ready to continue. + static BaseObjectPtr CreateFromSSL(Environment* env, + DTLSEndpoint* endpoint, + ncrypto::SSLPointer ssl, + BIO* enc_in, + BIO* enc_out, + const SocketAddress& remote); + + ~DTLSSession() override; + + // Called by the endpoint when a datagram arrives from this session's peer. + void Receive(const uint8_t* data, size_t len); + + // Send application data to the peer. + int Send(const uint8_t* data, size_t len); + + // Initiate a graceful shutdown (sends close_notify). + void Close(); + + // Immediately destroy the session without sending close_notify. + void Destroy(); + + const SocketAddress& remote_address() const { return remote_address_; } + bool is_server() const { return is_server_; } + bool is_handshake_complete() const { return handshake_complete_; } + bool is_closed() const { return closed_; } + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(DTLSSession) + SET_SELF_SIZE(DTLSSession) + + // Public constructor required by MakeBaseObject<>. + DTLSSession(Environment* env, + v8::Local wrap, + DTLSEndpoint* endpoint, + ncrypto::SSLPointer ssl, + BIO* enc_in, + BIO* enc_out, + const SocketAddress& remote, + bool is_server); + + private: + static void New(const v8::FunctionCallbackInfo& args); + static void DoSend(const v8::FunctionCallbackInfo& args); + static void DoClose(const v8::FunctionCallbackInfo& args); + static void DoDestroy(const v8::FunctionCallbackInfo& args); + static void GetState(const v8::FunctionCallbackInfo& args); + static void GetRemoteAddress(const v8::FunctionCallbackInfo& args); + static void GetProtocol(const v8::FunctionCallbackInfo& args); + static void GetCipher(const v8::FunctionCallbackInfo& args); + static void GetPeerCertificate( + const v8::FunctionCallbackInfo& args); + static void GetALPNProtocol(const v8::FunctionCallbackInfo& args); + static void ExportKeyingMaterial( + const v8::FunctionCallbackInfo& args); + static void GetSRTPProfile(const v8::FunctionCallbackInfo& args); + static void SetServername(const v8::FunctionCallbackInfo& args); + static void GetServername(const v8::FunctionCallbackInfo& args); + + public: + // The core state machine pump. Processes pending OpenSSL I/O: + // 1. ClearOut() - SSL_read() -> emit decrypted data to JS + // 2. ClearIn() - SSL_write() pending cleartext + // 3. EncOut() - read enc_out_ BIO -> send via endpoint UDP + // 4. UpdateTimer() - schedule retransmit timer if needed + void Cycle(); + + private: + // Read decrypted application data from OpenSSL and emit to JS. + void ClearOut(); + + // Flush encrypted data from enc_out_ BIO and send via the endpoint. + void EncOut(); + + // Update the DTLS retransmission timer based on OpenSSL's timeout. + void UpdateTimer(); + + // OpenSSL keylog callback. + static void SSLKeylogCallback(const SSL* ssl, const char* line); + + // Emit a callback to JS via the endpoint's callback dispatch. + v8::MaybeLocal EmitCallback(int cb_index, + int argc, + v8::Local* argv); + + BaseObjectWeakPtr endpoint_; + ncrypto::SSLPointer ssl_; + + // Memory BIOs: encrypted data flows through these. + // enc_in_: network datagrams written here -> SSL_read() extracts cleartext + // enc_out_: SSL_write() puts ciphertext here -> we read and send via UDP + BIO* enc_in_ = nullptr; + BIO* enc_out_ = nullptr; + + TimerWrapHandle retransmit_timer_; + + SocketAddress remote_address_; + bool is_server_; + bool handshake_complete_ = false; + bool closed_ = false; + bool destroyed_ = false; + int cycle_depth_ = 0; + + AliasedStruct state_; +}; + +} // namespace node::dtls + +#endif // HAVE_OPENSSL && HAVE_DTLS +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/env_properties.h b/src/env_properties.h index 4f0d55225d70..eb26d3b6cf05 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -433,6 +433,9 @@ V(ephemeral_key_template, v8::DictionaryTemplate) \ V(dir_instance_template, v8::ObjectTemplate) \ V(dns_ns_record_template, v8::DictionaryTemplate) \ + V(dtls_context_constructor_template, v8::FunctionTemplate) \ + V(dtls_endpoint_constructor_template, v8::FunctionTemplate) \ + V(dtls_session_constructor_template, v8::FunctionTemplate) \ V(fd_constructor_template, v8::ObjectTemplate) \ V(fdclose_constructor_template, v8::ObjectTemplate) \ V(ffi_dynamic_library_constructor_template, v8::FunctionTemplate) \ diff --git a/src/node_binding.cc b/src/node_binding.cc index 2dde536dc229..26f8d046f6da 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -108,6 +108,7 @@ NODE_BUILTIN_ICU_BINDINGS(V) \ NODE_BUILTIN_PROFILER_BINDINGS(V) \ NODE_BUILTIN_DEBUG_BINDINGS(V) \ + NODE_BUILTIN_DTLS_BINDINGS(V) \ NODE_BUILTIN_QUIC_BINDINGS(V) \ NODE_BUILTIN_SQLITE_BINDINGS(V) \ NODE_BUILTIN_FFI_BINDINGS(V) diff --git a/src/node_binding.h b/src/node_binding.h index d785ccc2238c..b05c68ad0726 100644 --- a/src/node_binding.h +++ b/src/node_binding.h @@ -36,6 +36,12 @@ static_assert(static_cast(NM_F_LINKED) == #define NODE_BUILTIN_QUIC_BINDINGS(V) #endif +#if HAVE_OPENSSL && HAVE_DTLS +#define NODE_BUILTIN_DTLS_BINDINGS(V) V(dtls) +#else +#define NODE_BUILTIN_DTLS_BINDINGS(V) +#endif + #if HAVE_SQLITE #define NODE_BUILTIN_SQLITE_BINDINGS(V) \ V(sqlite) \ @@ -71,7 +77,8 @@ static_assert(static_cast(NM_F_LINKED) == V(url) \ V(worker) \ NODE_BUILTIN_ICU_BINDINGS(V) \ - NODE_BUILTIN_QUIC_BINDINGS(V) + NODE_BUILTIN_QUIC_BINDINGS(V) \ + NODE_BUILTIN_DTLS_BINDINGS(V) #define NODE_BINDING_CONTEXT_AWARE_CPP(modname, regfunc, priv, flags) \ static node::node_module _module = { \ diff --git a/src/node_builtins.cc b/src/node_builtins.cc index 0b447d649fd9..e7b75c152b6a 100644 --- a/src/node_builtins.cc +++ b/src/node_builtins.cc @@ -140,9 +140,14 @@ BuiltinLoader::BuiltinCategories BuiltinLoader::GetBuiltinCategories() const { "internal/quic/quic", "internal/quic/symbols", "internal/quic/stats", "internal/quic/state", #endif // !OPENSSL_NO_QUIC +#if HAVE_DTLS + "internal/dtls/dtls", "internal/dtls/symbols", "internal/dtls/stats", + "internal/dtls/state", +#endif // HAVE_DTLS #if !HAVE_FFI "internal/ffi-shared-buffer", "internal/ffi/fast-api", #endif // !HAVE_FFI + "dtls", // Experimental. "ffi", // Experimental. "quic", // Experimental. "sqlite", // Experimental. diff --git a/src/node_options.cc b/src/node_options.cc index 1a9302220db2..58cfa4ad46c1 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -610,6 +610,15 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "experimental iterable streams API (node:stream/iter)", &EnvironmentOptions::experimental_stream_iter, kAllowedInEnvvar); + AddOption("--experimental-dtls", +#if HAVE_DTLS + "experimental DTLS support", + &EnvironmentOptions::experimental_dtls, +#else + "" /* undocumented when no-op */, + NoOp{}, +#endif + kAllowedInEnvvar); AddOption("--experimental-vfs", "experimental node:vfs module", &EnvironmentOptions::experimental_vfs, diff --git a/src/node_options.h b/src/node_options.h index 24df9254cd49..f96b6c66d9ec 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -134,6 +134,7 @@ class EnvironmentOptions : public Options { bool experimental_stream_iter = EXPERIMENTALS_DEFAULT_VALUE; bool experimental_vfs = EXPERIMENTALS_DEFAULT_VALUE; bool webstorage = HAVE_SQLITE; + bool experimental_dtls = EXPERIMENTALS_DEFAULT_VALUE; bool experimental_quic = EXPERIMENTALS_DEFAULT_VALUE; std::string localstorage_file; bool experimental_global_navigator = true; diff --git a/test/common/index.js b/test/common/index.js index 0eb8b781442d..e37b354f8259 100755 --- a/test/common/index.js +++ b/test/common/index.js @@ -73,6 +73,7 @@ const hasSQLite = Boolean(process.versions.sqlite); const hasFFI = Boolean(process.config.variables.node_use_ffi); const hasPerfetto = Boolean(process.config.variables.v8_use_perfetto); +const hasDtls = hasCrypto && !!process.features.dtls; const hasQuic = hasCrypto && !!process.features.quic; const hasLocalStorage = (() => { @@ -1019,6 +1020,7 @@ const common = { hasTemporal, hasFullICU, hasCrypto, + hasDtls, hasQuic, hasInspector, hasSQLite, diff --git a/test/common/index.mjs b/test/common/index.mjs index 3327ae8df9db..108cae290999 100644 --- a/test/common/index.mjs +++ b/test/common/index.mjs @@ -16,6 +16,7 @@ const { getBufferSources, getTTYfd, hasCrypto, + hasDtls, hasQuic, hasInspector, hasSQLite, @@ -74,6 +75,7 @@ export { getPort, getTTYfd, hasCrypto, + hasDtls, hasQuic, hasInspector, hasSQLite, diff --git a/test/doctool/test-make-doc.mjs b/test/doctool/test-make-doc.mjs index e7a6f1b85e75..555193227672 100644 --- a/test/doctool/test-make-doc.mjs +++ b/test/doctool/test-make-doc.mjs @@ -46,7 +46,7 @@ const expectedJsons = linkedHtmls .map((name) => name.replace('.html', '.json')); const expectedDocs = linkedHtmls.concat(expectedJsons); const renamedDocs = ['policy.json', 'policy.html']; -const skipedDocs = ['quic.json', 'quic.html']; +const skipedDocs = ['dtls.json', 'dtls.html', 'quic.json', 'quic.html']; // Test that all the relative links in the TOC match to the actual documents. for (const expectedDoc of expectedDocs) { diff --git a/test/parallel/test-dtls-alpn.mjs b/test/parallel/test-dtls-alpn.mjs new file mode 100644 index 000000000000..b51721760fdf --- /dev/null +++ b/test/parallel/test-dtls-alpn.mjs @@ -0,0 +1,56 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: ALPN negotiation in DTLS. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const serverAlpnChecked = Promise.withResolvers(); + +const endpoint = listen(mustCall(async (session) => { + session.onmessage = () => {}; + await session.opened; + // Server should see the negotiated ALPN protocol. + strictEqual(session.alpnProtocol, 'coap'); + serverAlpnChecked.resolve(); +}), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + alpn: ['coap', 'h2'], +}); + +const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + alpn: ['coap'], +}); + +await session.opened; + +// Client should see the negotiated protocol. +strictEqual(session.alpnProtocol, 'coap'); + +await serverAlpnChecked.promise; + +await session.close(); +await endpoint.close(); diff --git a/test/parallel/test-dtls-async-dispose.mjs b/test/parallel/test-dtls-async-dispose.mjs new file mode 100644 index 000000000000..f6c46c6168b1 --- /dev/null +++ b/test/parallel/test-dtls-async-dispose.mjs @@ -0,0 +1,50 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: Symbol.asyncDispose for DTLSEndpoint and DTLSSession. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const endpoint = listen(mustCall((session) => { + session.onmessage = mustNotCall(); +}), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', +}); + +const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, +}); + +await session.opened; + +// Test that Symbol.asyncDispose exists. +strictEqual(typeof session[Symbol.asyncDispose], 'function'); +strictEqual(typeof endpoint[Symbol.asyncDispose], 'function'); + +// Dispose the session. +await session[Symbol.asyncDispose](); + +// Dispose the endpoint. +await endpoint[Symbol.asyncDispose](); diff --git a/test/parallel/test-dtls-basic.mjs b/test/parallel/test-dtls-basic.mjs new file mode 100644 index 000000000000..54f2c5b8e081 --- /dev/null +++ b/test/parallel/test-dtls-basic.mjs @@ -0,0 +1,90 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: Basic DTLS handshake and bidirectional data exchange. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, strictEqual, match } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const serverReceivedData = Promise.withResolvers(); +const clientReceivedData = Promise.withResolvers(); + +let serverHandshakeDone = false; +let clientHandshakeDone = false; + +// Start server. +const endpoint = listen(mustCall((session) => { + session.onmessage = mustCall((data) => { + strictEqual(data.toString(), 'hello from client'); + serverReceivedData.resolve(); + + // Send response back to client. + session.send('hello from server'); + }); + + session.onhandshake = mustCall((protocol) => { + ok(protocol); + match(protocol, /DTLS/i); + serverHandshakeDone = true; + }); +}), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', +}); + +const serverAddress = endpoint.address; +ok(serverAddress); +ok(serverAddress.port > 0); + +// Connect client. +const clientSession = connect('127.0.0.1', serverAddress.port, { + ca: [ca.toString()], + rejectUnauthorized: false, +}); + +clientSession.onmessage = mustCall((data) => { + strictEqual(data.toString(), 'hello from server'); + clientReceivedData.resolve(); +}); + +clientSession.onhandshake = mustCall((protocol) => { + ok(protocol); + clientHandshakeDone = true; +}); + +// Wait for handshake. +const { protocol } = await clientSession.opened; +match(protocol, /DTLS/i); + +// Send data. +clientSession.send('hello from client'); + +// Wait for bidirectional exchange. +await Promise.all([serverReceivedData.promise, clientReceivedData.promise]); + +// Verify handshakes completed. +ok(clientHandshakeDone); +ok(serverHandshakeDone); + +// Clean up. +await clientSession.close(); +await endpoint.close(); diff --git a/test/parallel/test-dtls-close.mjs b/test/parallel/test-dtls-close.mjs new file mode 100644 index 000000000000..0efc3b043487 --- /dev/null +++ b/test/parallel/test-dtls-close.mjs @@ -0,0 +1,110 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: Graceful close (close_notify) and forced destroy. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, throws } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +// Test 1: Graceful close from client side. +{ + const serverSessionClosed = Promise.withResolvers(); + + const endpoint = listen(mustCall((session) => { + session.onmessage = mustNotCall(); + session.closed.then(mustCall(() => { + serverSessionClosed.resolve(); + })); + }), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + }); + + const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + }); + + await session.opened; + + // Graceful close. + const closedPromise = session.close(); + ok(closedPromise instanceof Promise); + await closedPromise; + + // Wait for server to see the close. + await serverSessionClosed.promise; + + endpoint.close(); + await endpoint.closed; +} + +// Test 2: Forced destroy. +{ + const endpoint = listen(mustCall((session) => { + session.onmessage = mustNotCall(); + }), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + }); + + const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + }); + + await session.opened; + + // Forced destroy - no close_notify. + session.destroy(); + + // After destroy, send should fail. + throws(() => { + session.send('should fail'); + }, /destroyed/i); + + endpoint.destroy(); +} + +// Test 3: Endpoint close closes all sessions. +{ + const endpoint = listen(mustCall((session) => { + session.onmessage = mustNotCall(); + }), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + }); + + const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + }); + + await session.opened; + + // Close the endpoint - this should close all sessions. + await endpoint.close(); +} diff --git a/test/parallel/test-dtls-multiple-clients.mjs b/test/parallel/test-dtls-multiple-clients.mjs new file mode 100644 index 000000000000..c913c255d8bd --- /dev/null +++ b/test/parallel/test-dtls-multiple-clients.mjs @@ -0,0 +1,81 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: Multiple clients connecting to the same DTLS server. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const NUM_CLIENTS = 3; +let sessionsAccepted = 0; +const allClientsConnected = Promise.withResolvers(); + +const endpoint = listen(mustCall((session) => { + session.onmessage = (data) => { + // Echo back with session identifier. + session.send(`echo:${data.toString()}`); + }; + + if (++sessionsAccepted === NUM_CLIENTS) { + allClientsConnected.resolve(); + } +}, NUM_CLIENTS), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', +}); + +const serverAddress = endpoint.address; +const clients = []; +const clientResponses = []; + +for (let i = 0; i < NUM_CLIENTS; i++) { + const received = Promise.withResolvers(); + clientResponses.push(received); + + const session = connect('127.0.0.1', serverAddress.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + }); + + session.onmessage = mustCall((data) => { + strictEqual(data.toString(), `echo:client${i}`); + received.resolve(); + }); + + clients.push(session); +} + +// Wait for all handshakes. +await Promise.all(clients.map((c) => c.opened)); + +// Send data from each client. +for (let i = 0; i < NUM_CLIENTS; i++) { + clients[i].send(`client${i}`); +} + +// Wait for all echoes. +await Promise.all(clientResponses.map((r) => r.promise)); + +// Clean up. +await Promise.all(clients.map((c) => c.close())); + +await endpoint.close(); diff --git a/test/parallel/test-dtls-options.mjs b/test/parallel/test-dtls-options.mjs new file mode 100644 index 000000000000..c157222181e0 --- /dev/null +++ b/test/parallel/test-dtls-options.mjs @@ -0,0 +1,53 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: Option validation for DTLS API. + +import { hasCrypto, skip, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +const { throws } = assert; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +// Test: listen() requires a callback. +throws(() => { + listen(undefined, { cert: 'x', key: 'y', port: 0 }); +}, { code: 'ERR_INVALID_ARG_TYPE' }); + +// Test: listen() requires cert. +throws(() => { + listen(mustNotCall(), { key: 'y', port: 0 }); +}, { code: 'ERR_MISSING_ARGS' }); + +// Test: listen() requires key. +throws(() => { + listen(mustNotCall(), { cert: 'x', port: 0 }); +}, { code: 'ERR_MISSING_ARGS' }); + +// Test: listen() requires port. +throws(() => { + listen(mustNotCall(), { cert: 'x', key: 'y' }); +}, { code: 'ERR_MISSING_ARGS' }); + +// Test: connect() requires valid host. +throws(() => { + connect(123, 4433); +}, { code: 'ERR_INVALID_ARG_TYPE' }); + +// Test: connect() requires valid port. +throws(() => { + connect('localhost', 'invalid'); +}, { code: 'ERR_INVALID_ARG_TYPE' }); + +// Test: connect() rejects out-of-range port. +throws(() => { + connect('localhost', 99999); +}, { code: 'ERR_OUT_OF_RANGE' }); diff --git a/test/parallel/test-dtls-session-properties.mjs b/test/parallel/test-dtls-session-properties.mjs new file mode 100644 index 000000000000..07710083f1c9 --- /dev/null +++ b/test/parallel/test-dtls-session-properties.mjs @@ -0,0 +1,64 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLSSession properties after handshake. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, strictEqual, match } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const endpoint = listen(mustCall((session) => { + session.onmessage = mustNotCall(); +}), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', +}); + +const session = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, +}); + +await session.opened; + +// Protocol should be DTLSv1.2. +match(session.protocol, /DTLS/i); + +// Cipher should be an object with name, standardName, version. +const cipher = session.cipher; +strictEqual(typeof cipher?.name, 'string'); +strictEqual(typeof cipher?.standardName, 'string'); +strictEqual(typeof cipher?.version, 'string'); + +// Remote address should be defined. +const addr = session.remoteAddress; +ok(addr); + +// Peer certificate should be available (PEM string). +const peerCert = session.peerCertificate; +ok(peerCert); +ok(peerCert.includes('BEGIN CERTIFICATE')); + +// State should reflect open connection. +ok(session.state); + +await session.close(); +await endpoint.close(); diff --git a/test/parallel/test-permission-net-dtls.mjs b/test/parallel/test-permission-net-dtls.mjs new file mode 100644 index 000000000000..f2f2b7af2d2c --- /dev/null +++ b/test/parallel/test-permission-net-dtls.mjs @@ -0,0 +1,59 @@ +// Flags: --permission --allow-fs-read=* --experimental-dtls --no-warnings +import { hasCrypto, skip, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { connect, listen, DTLSEndpoint } = await import('node:dtls'); + +// Verify that the permission system correctly reports no net access. +assert.ok(!process.permission.has('net')); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(__dirname, '..', 'fixtures', 'keys'); +const cert = readFileSync(join(fixturesDir, 'agent1-cert.pem')).toString(); +const key = readFileSync(join(fixturesDir, 'agent1-key.pem')).toString(); +const ca = readFileSync(join(fixturesDir, 'ca1-cert.pem')).toString(); + +// Test: connect() should throw ERR_ACCESS_DENIED +{ + assert.throws( + () => connect('127.0.0.1', 12345, { ca: [ca], rejectUnauthorized: false }), + { + code: 'ERR_ACCESS_DENIED', + permission: 'Net', + }, + ); +} + +// Test: listen() should throw ERR_ACCESS_DENIED +{ + assert.throws( + () => listen(mustNotCall('onsession should not be called'), { + cert, + key, + port: 0, + host: '127.0.0.1', + }), + { + code: 'ERR_ACCESS_DENIED', + permission: 'Net', + }, + ); +} + +// Test: Creating a DTLSEndpoint without connect/listen is allowed +// since no network I/O occurs at construction time. +{ + const endpoint = new DTLSEndpoint(); + assert.ok(endpoint); +} diff --git a/test/parallel/test-process-features.js b/test/parallel/test-process-features.js index 2af4808b6c59..e12ae0029080 100644 --- a/test/parallel/test-process-features.js +++ b/test/parallel/test-process-features.js @@ -10,6 +10,7 @@ const expectedKeys = new Map([ ['uv', ['boolean']], ['ipv6', ['boolean']], ['openssl_is_boringssl', ['boolean']], + ['dtls', ['boolean', 'undefined']], ['quic', ['boolean', 'undefined']], ['tls_alpn', ['boolean']], ['tls_sni', ['boolean']], diff --git a/test/parallel/test-process-get-builtin.mjs b/test/parallel/test-process-get-builtin.mjs index 5cbf28430109..fa92dc52f969 100644 --- a/test/parallel/test-process-get-builtin.mjs +++ b/test/parallel/test-process-get-builtin.mjs @@ -36,6 +36,8 @@ if (!hasIntl) { publicBuiltins.delete('inspector'); publicBuiltins.delete('trace_events'); } +// TODO(@jasnell): Remove this once node:dtls graduates from unflagged. +publicBuiltins.delete('node:dtls'); // TODO(@jasnell): Remove this once node:quic graduates from unflagged. publicBuiltins.delete('node:quic'); // Remove this once node:vfs graduates from unflagged. diff --git a/test/sequential/test-async-wrap-getasyncid.js b/test/sequential/test-async-wrap-getasyncid.js index ef48f457a878..0842f8fdfa98 100644 --- a/test/sequential/test-async-wrap-getasyncid.js +++ b/test/sequential/test-async-wrap-getasyncid.js @@ -76,6 +76,8 @@ const { getSystemErrorName } = require('util'); delete providers.QUIC_SESSION; delete providers.QUIC_STREAM; delete providers.LOCKS; + delete providers.DTLS_ENDPOINT; + delete providers.DTLS_SESSION; const objKeys = Object.keys(providers); if (objKeys.length > 0) From faa7bb1e676370a5359ec943cab72e7cc644b32f Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 10 May 2026 11:12:37 -0700 Subject: [PATCH 005/280] src, lib: add stats to dtls PR-URL: https://github.com/nodejs/node/pull/63182 Fixes: https://github.com/nodejs/node/issues/61630 Reviewed-By: Matteo Collina Reviewed-By: Stephen Belanger Reviewed-By: Rafael Gonzaga --- doc/api/dtls.md | 206 ++++++++++++++++++++ lib/internal/dtls/dtls.js | 13 ++ lib/internal/dtls/stats.js | 300 +++++++++++++++++++++++++++++- src/dtls/dtls.cc | 18 ++ src/dtls/dtls.h | 48 +++++ src/dtls/dtls_endpoint.cc | 29 ++- src/dtls/dtls_endpoint.h | 7 + src/dtls/dtls_session.cc | 21 ++- src/dtls/dtls_session.h | 9 + test/parallel/test-dtls-stats.mjs | 154 +++++++++++++++ 10 files changed, 798 insertions(+), 7 deletions(-) create mode 100644 test/parallel/test-dtls-stats.mjs diff --git a/doc/api/dtls.md b/doc/api/dtls.md index 28f3aad5c8ce..444e5cbf0d7b 100644 --- a/doc/api/dtls.md +++ b/doc/api/dtls.md @@ -178,6 +178,17 @@ Shared state object with properties: * `sessionCount` {number} * `busy` {boolean} +### `endpoint.stats` + + + +* Type: {DTLSEndpoint.Stats} + +The statistics collected for this endpoint. Read only. The stats object is +live and updated by the C++ internals as data flows through the endpoint. + ### `endpoint.busy` * {boolean} @@ -204,6 +215,99 @@ Immediately destroys the endpoint without sending `close_notify` alerts. Equivalent to calling `endpoint.close()`. +## Class: `DTLSEndpoint.Stats` + + + +A view of the collected statistics for an endpoint. + +### `endpointStats.createdAt` + + + +* Type: {bigint} A timestamp indicating when the endpoint was created. Read only. + +### `endpointStats.destroyedAt` + + + +* Type: {bigint} A timestamp indicating when the endpoint was destroyed. Read only. + +### `endpointStats.bytesReceived` + + + +* Type: {bigint} The total number of bytes received by this endpoint. Read only. + +### `endpointStats.bytesSent` + + + +* Type: {bigint} The total number of bytes sent by this endpoint. Read only. + +### `endpointStats.packetsReceived` + + + +* Type: {bigint} The total number of UDP packets received by this endpoint. Read only. + +### `endpointStats.packetsSent` + + + +* Type: {bigint} The total number of UDP packets sent by this endpoint. Read only. + +### `endpointStats.serverSessions` + + + +* Type: {bigint} The total number of peer-initiated sessions accepted by this + endpoint. Read only. + +### `endpointStats.clientSessions` + + + +* Type: {bigint} The total number of sessions initiated by this endpoint. Read only. + +### `endpointStats.serverBusyCount` + + + +* Type: {bigint} The total number of incoming connections rejected because the + endpoint was marked busy. Read only. + +### `endpointStats.isConnected` + + + +* Type: {boolean} + +`true` if the stats object is still connected to the underlying endpoint. +Once the endpoint is destroyed, the stats become a stale snapshot. + ## Class: `DTLSSession` + +* Type: {DTLSSession.Stats} + +The statistics collected for this session. Read only. The stats object is +live and updated as data flows through the session. + ### `session.exportKeyingMaterial(length, label[, context])` * `length` {number} Number of bytes to export. @@ -275,6 +390,97 @@ Exports keying material from the DTLS session, as defined in [RFC 5705][]. This is commonly used with DTLS-SRTP to derive encryption keys for media streams. +## Class: `DTLSSession.Stats` + + + +A view of the collected statistics for a session. + +### `sessionStats.createdAt` + + + +* Type: {bigint} A timestamp indicating when the session was created. Read only. + +### `sessionStats.destroyedAt` + + + +* Type: {bigint} A timestamp indicating when the session was destroyed. Read only. + +### `sessionStats.closingAt` + + + +* Type: {bigint} A timestamp indicating when `close()` was called. Read only. + +### `sessionStats.handshakeCompletedAt` + + + +* Type: {bigint} A timestamp indicating when the DTLS handshake completed. Read only. + +### `sessionStats.bytesReceived` + + + +* Type: {bigint} The total number of application data bytes received. Read only. + +### `sessionStats.bytesSent` + + + +* Type: {bigint} The total number of application data bytes sent. Read only. + +### `sessionStats.messagesReceived` + + + +* Type: {bigint} The total number of application messages received. Read only. + +### `sessionStats.messagesSent` + + + +* Type: {bigint} The total number of application messages sent. Read only. + +### `sessionStats.retransmitCount` + + + +* Type: {bigint} The total number of DTLS handshake retransmissions. Read only. + +### `sessionStats.isConnected` + + + +* Type: {boolean} + +`true` if the stats object is still connected to the underlying session. +Once the session is destroyed, the stats become a stale snapshot. + ### Callback properties #### `session.onmessage` diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js index d797f74b41e0..aa4018239d91 100644 --- a/lib/internal/dtls/dtls.js +++ b/lib/internal/dtls/dtls.js @@ -42,6 +42,11 @@ const { DTLSSessionState, } = require('internal/dtls/state'); +const { + DTLSEndpointStats, + DTLSSessionStats, +} = require('internal/dtls/stats'); + const { kOwner, kPrivateConstructor, @@ -70,6 +75,7 @@ class DTLSSession { #handle; #endpoint; #state; + #stats; #pendingOpen; #pendingClose; #onmessage; @@ -88,6 +94,8 @@ class DTLSSession { this.#endpoint = endpoint; this.#state = new DTLSSessionState( kPrivateConstructor, handle.getState()); + this.#stats = new DTLSSessionStats( + kPrivateConstructor, handle.getStats()); this.#pendingOpen = PromiseWithResolvers(); this.#pendingClose = PromiseWithResolvers(); } @@ -218,6 +226,7 @@ class DTLSSession { } get state() { return this.#state; } + get stats() { return this.#stats; } get endpoint() { return this.#endpoint; } exportKeyingMaterial(length, label, context) { @@ -287,6 +296,7 @@ class DTLSSession { class DTLSEndpoint { #handle; #state; + #stats; #sessions = new SafeSet(); #pendingClose; #onsession; @@ -297,6 +307,8 @@ class DTLSEndpoint { this.#handle[kOwner] = this; this.#state = new DTLSEndpointState( kPrivateConstructor, this.#handle.getState()); + this.#stats = new DTLSEndpointStats( + kPrivateConstructor, this.#handle.getStats()); this.#pendingClose = PromiseWithResolvers(); if (options.mtu !== undefined) { @@ -392,6 +404,7 @@ class DTLSEndpoint { } get state() { return this.#state; } + get stats() { return this.#stats; } get sessions() { return this.#sessions; } get onerror() { return this.#onerror; } diff --git a/lib/internal/dtls/stats.js b/lib/internal/dtls/stats.js index 645c57fc6c05..c1fbd7dc16f7 100644 --- a/lib/internal/dtls/stats.js +++ b/lib/internal/dtls/stats.js @@ -1,7 +1,9 @@ 'use strict'; -// Placeholder for DTLS statistics tracking. -// Will be expanded as the implementation matures. +const { + BigUint64Array, + JSONStringify, +} = primordials; const { getOptionValue, @@ -11,29 +13,319 @@ if (!process.features.dtls || !getOptionValue('--experimental-dtls')) { return; } +const { + isArrayBuffer, +} = require('util/types'); + const { codes: { ERR_ILLEGAL_CONSTRUCTOR, + ERR_INVALID_ARG_TYPE, }, } = require('internal/errors'); +const { inspect } = require('internal/util/inspect'); +const assert = require('internal/assert'); + const { + kFinishClose, kPrivateConstructor, } = require('internal/dtls/symbols'); +// This file defines the helper objects for accessing statistics collected +// by DTLS endpoints and sessions. Each wraps a BigUint64Array backed by +// a shared ArrayBuffer that is updated by the C++ internals. + +const { + IDX_STATS_ENDPOINT_CREATED_AT, + IDX_STATS_ENDPOINT_DESTROYED_AT, + IDX_STATS_ENDPOINT_BYTES_RECEIVED, + IDX_STATS_ENDPOINT_BYTES_SENT, + IDX_STATS_ENDPOINT_PACKETS_RECEIVED, + IDX_STATS_ENDPOINT_PACKETS_SENT, + IDX_STATS_ENDPOINT_SERVER_SESSIONS, + IDX_STATS_ENDPOINT_CLIENT_SESSIONS, + IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT, + + IDX_STATS_SESSION_CREATED_AT, + IDX_STATS_SESSION_DESTROYED_AT, + IDX_STATS_SESSION_CLOSING_AT, + IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT, + IDX_STATS_SESSION_BYTES_RECEIVED, + IDX_STATS_SESSION_BYTES_SENT, + IDX_STATS_SESSION_MESSAGES_RECEIVED, + IDX_STATS_SESSION_MESSAGES_SENT, + IDX_STATS_SESSION_RETRANSMIT_COUNT, +} = internalBinding('dtls'); + +assert(IDX_STATS_ENDPOINT_CREATED_AT !== undefined); +assert(IDX_STATS_ENDPOINT_DESTROYED_AT !== undefined); +assert(IDX_STATS_ENDPOINT_BYTES_RECEIVED !== undefined); +assert(IDX_STATS_ENDPOINT_BYTES_SENT !== undefined); +assert(IDX_STATS_ENDPOINT_PACKETS_RECEIVED !== undefined); +assert(IDX_STATS_ENDPOINT_PACKETS_SENT !== undefined); +assert(IDX_STATS_ENDPOINT_SERVER_SESSIONS !== undefined); +assert(IDX_STATS_ENDPOINT_CLIENT_SESSIONS !== undefined); +assert(IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT !== undefined); +assert(IDX_STATS_SESSION_CREATED_AT !== undefined); +assert(IDX_STATS_SESSION_DESTROYED_AT !== undefined); +assert(IDX_STATS_SESSION_CLOSING_AT !== undefined); +assert(IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT !== undefined); +assert(IDX_STATS_SESSION_BYTES_RECEIVED !== undefined); +assert(IDX_STATS_SESSION_BYTES_SENT !== undefined); +assert(IDX_STATS_SESSION_MESSAGES_RECEIVED !== undefined); +assert(IDX_STATS_SESSION_MESSAGES_SENT !== undefined); +assert(IDX_STATS_SESSION_RETRANSMIT_COUNT !== undefined); + class DTLSEndpointStats { - constructor(privateSymbol) { + /** @type {BigUint64Array} */ + #handle; + /** @type {boolean} */ + #disconnected = false; + + /** + * @param {symbol} privateSymbol + * @param {ArrayBuffer} buffer + */ + constructor(privateSymbol, buffer) { if (privateSymbol !== kPrivateConstructor) { throw new ERR_ILLEGAL_CONSTRUCTOR(); } + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new BigUint64Array(buffer); + } + + /** @type {bigint} */ + get createdAt() { + return this.#handle[IDX_STATS_ENDPOINT_CREATED_AT]; + } + + /** @type {bigint} */ + get destroyedAt() { + return this.#handle[IDX_STATS_ENDPOINT_DESTROYED_AT]; + } + + /** @type {bigint} */ + get bytesReceived() { + return this.#handle[IDX_STATS_ENDPOINT_BYTES_RECEIVED]; + } + + /** @type {bigint} */ + get bytesSent() { + return this.#handle[IDX_STATS_ENDPOINT_BYTES_SENT]; + } + + /** @type {bigint} */ + get packetsReceived() { + return this.#handle[IDX_STATS_ENDPOINT_PACKETS_RECEIVED]; + } + + /** @type {bigint} */ + get packetsSent() { + return this.#handle[IDX_STATS_ENDPOINT_PACKETS_SENT]; + } + + /** @type {bigint} */ + get serverSessions() { + return this.#handle[IDX_STATS_ENDPOINT_SERVER_SESSIONS]; + } + + /** @type {bigint} */ + get clientSessions() { + return this.#handle[IDX_STATS_ENDPOINT_CLIENT_SESSIONS]; + } + + /** @type {bigint} */ + get serverBusyCount() { + return this.#handle[IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT]; + } + + toString() { + return JSONStringify(this.toJSON()); + } + + toJSON() { + return { + __proto__: null, + connected: this.isConnected, + createdAt: `${this.createdAt}`, + destroyedAt: `${this.destroyedAt}`, + bytesReceived: `${this.bytesReceived}`, + bytesSent: `${this.bytesSent}`, + packetsReceived: `${this.packetsReceived}`, + packetsSent: `${this.packetsSent}`, + serverSessions: `${this.serverSessions}`, + clientSessions: `${this.clientSessions}`, + serverBusyCount: `${this.serverBusyCount}`, + }; + } + + [inspect.custom](depth, options) { + if (depth < 0) + return this; + + const opts = { + __proto__: null, + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `DTLSEndpointStats ${inspect({ + connected: this.isConnected, + createdAt: this.createdAt, + destroyedAt: this.destroyedAt, + bytesReceived: this.bytesReceived, + bytesSent: this.bytesSent, + packetsReceived: this.packetsReceived, + packetsSent: this.packetsSent, + serverSessions: this.serverSessions, + clientSessions: this.clientSessions, + serverBusyCount: this.serverBusyCount, + }, opts)}`; + } + + /** + * True if this stats object is still connected to the underlying + * stats source. If false, the stats are stale. + * @type {boolean} + */ + get isConnected() { + return !this.#disconnected; + } + + [kFinishClose]() { + // Snapshot the stats into a new BigUint64Array since the underlying + // buffer will be destroyed. + this.#handle = new BigUint64Array(this.#handle); + this.#disconnected = true; } } class DTLSSessionStats { - constructor(privateSymbol) { + /** @type {BigUint64Array} */ + #handle; + /** @type {boolean} */ + #disconnected = false; + + /** + * @param {symbol} privateSymbol + * @param {ArrayBuffer} buffer + */ + constructor(privateSymbol, buffer) { if (privateSymbol !== kPrivateConstructor) { throw new ERR_ILLEGAL_CONSTRUCTOR(); } + if (!isArrayBuffer(buffer)) { + throw new ERR_INVALID_ARG_TYPE('buffer', ['ArrayBuffer'], buffer); + } + this.#handle = new BigUint64Array(buffer); + } + + /** @type {bigint} */ + get createdAt() { + return this.#handle[IDX_STATS_SESSION_CREATED_AT]; + } + + /** @type {bigint} */ + get destroyedAt() { + return this.#handle[IDX_STATS_SESSION_DESTROYED_AT]; + } + + /** @type {bigint} */ + get closingAt() { + return this.#handle[IDX_STATS_SESSION_CLOSING_AT]; + } + + /** @type {bigint} */ + get handshakeCompletedAt() { + return this.#handle[IDX_STATS_SESSION_HANDSHAKE_COMPLETED_AT]; + } + + /** @type {bigint} */ + get bytesReceived() { + return this.#handle[IDX_STATS_SESSION_BYTES_RECEIVED]; + } + + /** @type {bigint} */ + get bytesSent() { + return this.#handle[IDX_STATS_SESSION_BYTES_SENT]; + } + + /** @type {bigint} */ + get messagesReceived() { + return this.#handle[IDX_STATS_SESSION_MESSAGES_RECEIVED]; + } + + /** @type {bigint} */ + get messagesSent() { + return this.#handle[IDX_STATS_SESSION_MESSAGES_SENT]; + } + + /** @type {bigint} */ + get retransmitCount() { + return this.#handle[IDX_STATS_SESSION_RETRANSMIT_COUNT]; + } + + toString() { + return JSONStringify(this.toJSON()); + } + + toJSON() { + return { + __proto__: null, + connected: this.isConnected, + createdAt: `${this.createdAt}`, + destroyedAt: `${this.destroyedAt}`, + closingAt: `${this.closingAt}`, + handshakeCompletedAt: `${this.handshakeCompletedAt}`, + bytesReceived: `${this.bytesReceived}`, + bytesSent: `${this.bytesSent}`, + messagesReceived: `${this.messagesReceived}`, + messagesSent: `${this.messagesSent}`, + retransmitCount: `${this.retransmitCount}`, + }; + } + + [inspect.custom](depth, options) { + if (depth < 0) + return this; + + const opts = { + __proto__: null, + ...options, + depth: options.depth == null ? null : options.depth - 1, + }; + + return `DTLSSessionStats ${inspect({ + connected: this.isConnected, + createdAt: this.createdAt, + destroyedAt: this.destroyedAt, + closingAt: this.closingAt, + handshakeCompletedAt: this.handshakeCompletedAt, + bytesReceived: this.bytesReceived, + bytesSent: this.bytesSent, + messagesReceived: this.messagesReceived, + messagesSent: this.messagesSent, + retransmitCount: this.retransmitCount, + }, opts)}`; + } + + /** + * True if this stats object is still connected to the underlying + * stats source. If false, the stats are stale. + * @type {boolean} + */ + get isConnected() { + return !this.#disconnected; + } + + [kFinishClose]() { + // Snapshot the stats into a new BigUint64Array since the underlying + // buffer will be destroyed. + this.#handle = new BigUint64Array(this.#handle); + this.#disconnected = true; } } diff --git a/src/dtls/dtls.cc b/src/dtls/dtls.cc index ccd8b98eaab8..288d317dfa14 100644 --- a/src/dtls/dtls.cc +++ b/src/dtls/dtls.cc @@ -48,6 +48,24 @@ void CreatePerContextProperties(Local target, NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_DESTROYED); NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_HAS_MESSAGE_LISTENER); + // Endpoint stats indices (for BigUint64Array access from JS) +#define V(name, _) IDX_STATS_ENDPOINT_##name, + enum IDX_STATS_ENDPOINT { DTLS_ENDPOINT_STATS(V) IDX_STATS_ENDPOINT_COUNT }; +#undef V +#define V(name, _) NODE_DEFINE_CONSTANT(target, IDX_STATS_ENDPOINT_##name); + DTLS_ENDPOINT_STATS(V); +#undef V + NODE_DEFINE_CONSTANT(target, IDX_STATS_ENDPOINT_COUNT); + + // Session stats indices +#define V(name, _) IDX_STATS_SESSION_##name, + enum IDX_STATS_SESSION { DTLS_SESSION_STATS(V) IDX_STATS_SESSION_COUNT }; +#undef V +#define V(name, _) NODE_DEFINE_CONSTANT(target, IDX_STATS_SESSION_##name); + DTLS_SESSION_STATS(V); +#undef V + NODE_DEFINE_CONSTANT(target, IDX_STATS_SESSION_COUNT); + // SSL verify mode constants constexpr auto SSL_VERIFY_NONE_VALUE = SSL_VERIFY_NONE; constexpr auto SSL_VERIFY_PEER_VALUE = SSL_VERIFY_PEER; diff --git a/src/dtls/dtls.h b/src/dtls/dtls.h index 1b27c2fbf574..6f1737347433 100644 --- a/src/dtls/dtls.h +++ b/src/dtls/dtls.h @@ -6,10 +6,58 @@ #include #include +#include #include +#include + namespace node::dtls { +// Utilities for updating stats maintained in an AliasedStruct. +template +void IncrementStat(Stats* stats, uint64_t amt = 1) { + stats->*member += amt; +} + +template +void RecordTimestampStat(Stats* stats) { + stats->*member = uv_hrtime(); +} + +#define DTLS_STAT_INCREMENT(Type, name) \ + IncrementStat(stats_.Data()) +#define DTLS_STAT_INCREMENT_N(Type, name, amt) \ + IncrementStat(stats_.Data(), amt) +#define DTLS_STAT_RECORD_TIMESTAMP(Type, name) \ + RecordTimestampStat(stats_.Data()) + +#define DTLS_STAT_FIELD(_, name) uint64_t name; + +// ============================================================================ +// Stats X-macros: V(ENUM_NAME, field_name) + +#define DTLS_ENDPOINT_STATS(V) \ + V(CREATED_AT, created_at) \ + V(DESTROYED_AT, destroyed_at) \ + V(BYTES_RECEIVED, bytes_received) \ + V(BYTES_SENT, bytes_sent) \ + V(PACKETS_RECEIVED, packets_received) \ + V(PACKETS_SENT, packets_sent) \ + V(SERVER_SESSIONS, server_sessions) \ + V(CLIENT_SESSIONS, client_sessions) \ + V(SERVER_BUSY_COUNT, server_busy_count) + +#define DTLS_SESSION_STATS(V) \ + V(CREATED_AT, created_at) \ + V(DESTROYED_AT, destroyed_at) \ + V(CLOSING_AT, closing_at) \ + V(HANDSHAKE_COMPLETED_AT, handshake_completed_at) \ + V(BYTES_RECEIVED, bytes_received) \ + V(BYTES_SENT, bytes_sent) \ + V(MESSAGES_RECEIVED, messages_received) \ + V(MESSAGES_SENT, messages_sent) \ + V(RETRANSMIT_COUNT, retransmit_count) + // State indices shared between C++ and JS via AliasedStruct/DataView. // Keep in sync with lib/internal/dtls/state.js. enum DTLSEndpointStateIndex { diff --git a/src/dtls/dtls_endpoint.cc b/src/dtls/dtls_endpoint.cc index b1c91d4f221b..9433241a2d14 100644 --- a/src/dtls/dtls_endpoint.cc +++ b/src/dtls/dtls_endpoint.cc @@ -50,10 +50,12 @@ DTLSEndpoint::DTLSEndpoint(Environment* env, Local wrap) wrap, reinterpret_cast(&handle_), PROVIDER_DTLS_ENDPOINT), - state_(env->isolate()) { + state_(env->isolate()), + stats_(env->isolate()) { CHECK_EQ(uv_udp_init(env->event_loop(), &handle_), 0); handle_.data = this; MakeWeak(); + DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, created_at); } Local DTLSEndpoint::GetConstructorTemplate(Environment* env) { @@ -71,6 +73,7 @@ Local DTLSEndpoint::GetConstructorTemplate(Environment* env) { SetProtoMethod(isolate, tmpl, "close", DoClose); SetProtoMethod(isolate, tmpl, "destroy", DoDestroy); SetProtoMethod(isolate, tmpl, "getState", GetState); + SetProtoMethod(isolate, tmpl, "getStats", GetStats); SetProtoMethod(isolate, tmpl, "getAddress", GetAddress); SetProtoMethod(isolate, tmpl, "setMTU", SetMTU); SetProtoMethod(isolate, tmpl, "setCallbacks", DoSetCallbacks); @@ -96,6 +99,7 @@ void DTLSEndpoint::RegisterExternalReferences( registry->Register(DoClose); registry->Register(DoDestroy); registry->Register(GetState); + registry->Register(GetStats); registry->Register(GetAddress); registry->Register(SetMTU); registry->Register(DoSetCallbacks); @@ -171,6 +175,7 @@ BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, sessions_[remote] = session; state_->session_count = sessions_.size(); + DTLS_STAT_INCREMENT(DTLSEndpointStats, client_sessions); // Ref the handle while we have sessions. uv_ref(reinterpret_cast(&handle_)); @@ -197,6 +202,8 @@ int DTLSEndpoint::SendTo(const SocketAddress& dest, int err = uv_udp_try_send(&handle_, &buf, 1, dest.data()); if (err == static_cast(len)) { + DTLS_STAT_INCREMENT_N(DTLSEndpointStats, bytes_sent, len); + DTLS_STAT_INCREMENT(DTLSEndpointStats, packets_sent); return 0; // Sent successfully. } @@ -215,6 +222,8 @@ int DTLSEndpoint::SendTo(const SocketAddress& dest, return err; } + DTLS_STAT_INCREMENT_N(DTLSEndpointStats, bytes_sent, len); + DTLS_STAT_INCREMENT(DTLSEndpointStats, packets_sent); return 0; } @@ -358,6 +367,11 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, return; } + IncrementStat( + endpoint->stats_.Data(), nread); + IncrementStat( + endpoint->stats_.Data()); + SocketAddress remote(addr); endpoint->ProcessDatagram( reinterpret_cast(buf->base), nread, remote); @@ -373,6 +387,7 @@ void DTLSEndpoint::OnSend(uv_udp_send_t* req, int status) { void DTLSEndpoint::OnClose() { state_->closing = 0; state_->destroyed = 1; + DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at); Local cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE); if (!cb.IsEmpty()) { @@ -402,7 +417,10 @@ void DTLSEndpoint::ProcessDatagram(const uint8_t* data, void DTLSEndpoint::AcceptConnection(const uint8_t* data, size_t len, const SocketAddress& remote) { - if (state_->busy) return; + if (state_->busy) { + DTLS_STAT_INCREMENT(DTLSEndpointStats, server_busy_count); + return; + } HandleScope handle_scope(env()->isolate()); Context::Scope context_scope(env()->context()); @@ -481,6 +499,7 @@ void DTLSEndpoint::AcceptConnection(const uint8_t* data, sessions_[remote] = session; state_->session_count = sessions_.size(); + DTLS_STAT_INCREMENT(DTLSEndpointStats, server_sessions); uv_ref(reinterpret_cast(&handle_)); @@ -581,6 +600,12 @@ void DTLSEndpoint::GetState(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(endpoint->state_.GetArrayBuffer()); } +void DTLSEndpoint::GetStats(const FunctionCallbackInfo& args) { + DTLSEndpoint* endpoint; + ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); + args.GetReturnValue().Set(endpoint->stats_.GetArrayBuffer()); +} + void DTLSEndpoint::GetAddress(const FunctionCallbackInfo& args) { DTLSEndpoint* endpoint; ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This()); diff --git a/src/dtls/dtls_endpoint.h b/src/dtls/dtls_endpoint.h index 06ffe4bcc39e..a6fe94fff5b8 100644 --- a/src/dtls/dtls_endpoint.h +++ b/src/dtls/dtls_endpoint.h @@ -31,6 +31,11 @@ struct DTLSEndpointStateData { uint8_t busy = 0; }; +// Stats collected for a DTLS endpoint, backed by a BigUint64Array. +struct DTLSEndpointStats { + DTLS_ENDPOINT_STATS(DTLS_STAT_FIELD) +}; + // DTLSEndpoint manages a single UDP socket and dispatches incoming // datagrams to the appropriate DTLSSession based on the remote address. // For server mode, it handles stateless cookie exchange via DTLSv1_listen() @@ -93,6 +98,7 @@ class DTLSEndpoint final : public HandleWrap { static void DoClose(const v8::FunctionCallbackInfo& args); static void DoDestroy(const v8::FunctionCallbackInfo& args); static void GetState(const v8::FunctionCallbackInfo& args); + static void GetStats(const v8::FunctionCallbackInfo& args); static void GetAddress(const v8::FunctionCallbackInfo& args); static void SetMTU(const v8::FunctionCallbackInfo& args); static void DoSetCallbacks(const v8::FunctionCallbackInfo& args); @@ -136,6 +142,7 @@ class DTLSEndpoint final : public HandleWrap { v8::Global callbacks_[DTLS_CB_COUNT]; AliasedStruct state_; + AliasedStruct stats_; bool listening_ = false; uint32_t mtu_ = 1200; // Conservative default MTU for data payload diff --git a/src/dtls/dtls_session.cc b/src/dtls/dtls_session.cc index f423451a5302..8bcd06a4ae71 100644 --- a/src/dtls/dtls_session.cc +++ b/src/dtls/dtls_session.cc @@ -53,6 +53,8 @@ DTLSSession::DTLSSession(Environment* env, retransmit_timer_(env, [this] { if (destroyed_) return; + DTLS_STAT_INCREMENT(DTLSSessionStats, + retransmit_count); int ret = DTLSv1_handle_timeout(ssl_.get()); if (ret < 0) { // Handshake timeout expired. @@ -70,8 +72,10 @@ DTLSSession::DTLSSession(Environment* env, }), remote_address_(remote), is_server_(is_server), - state_(env->isolate()) { + state_(env->isolate()), + stats_(env->isolate()) { MakeWeak(); + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, created_at); retransmit_timer_.Unref(); // Update shared state. @@ -103,6 +107,7 @@ Local DTLSSession::GetConstructorTemplate(Environment* env) { SetProtoMethod(isolate, tmpl, "close", DoClose); SetProtoMethod(isolate, tmpl, "destroy", DoDestroy); SetProtoMethod(isolate, tmpl, "getState", GetState); + SetProtoMethod(isolate, tmpl, "getStats", GetStats); SetProtoMethod(isolate, tmpl, "getRemoteAddress", GetRemoteAddress); SetProtoMethod(isolate, tmpl, "getProtocol", GetProtocol); SetProtoMethod(isolate, tmpl, "getCipher", GetCipher); @@ -132,6 +137,7 @@ void DTLSSession::RegisterExternalReferences( registry->Register(DoClose); registry->Register(DoDestroy); registry->Register(GetState); + registry->Register(GetStats); registry->Register(GetRemoteAddress); registry->Register(GetProtocol); registry->Register(GetCipher); @@ -275,6 +281,7 @@ void DTLSSession::Cycle() { handshake_complete_ = true; state_->handshaking = 0; state_->open = 1; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, handshake_completed_at); Local argv[] = { String::NewFromUtf8(env()->isolate(), SSL_get_version(ssl_.get())) @@ -301,6 +308,8 @@ void DTLSSession::ClearOut() { int read; while ((read = SSL_read(ssl_.get(), buf, sizeof(buf))) > 0) { + DTLS_STAT_INCREMENT_N(DTLSSessionStats, bytes_received, read); + DTLS_STAT_INCREMENT(DTLSSessionStats, messages_received); // Emit the data to JS via callback. Local argv[] = { Buffer::Copy(env(), reinterpret_cast(buf), read) @@ -385,6 +394,8 @@ int DTLSSession::Send(const uint8_t* data, size_t len) { int written = SSL_write(ssl_.get(), data, len); if (written > 0) { + DTLS_STAT_INCREMENT_N(DTLSSessionStats, bytes_sent, written); + DTLS_STAT_INCREMENT(DTLSSessionStats, messages_sent); EncOut(); } return written; @@ -395,6 +406,7 @@ void DTLSSession::Close() { closed_ = true; state_->closing = 1; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, closing_at); // Send close_notify. int ret = SSL_shutdown(ssl_.get()); @@ -421,6 +433,7 @@ void DTLSSession::Destroy() { closed_ = true; state_->destroyed = 1; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, destroyed_at); state_->open = 0; state_->handshaking = 0; @@ -492,6 +505,12 @@ void DTLSSession::GetState(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(session->state_.GetArrayBuffer()); } +void DTLSSession::GetStats(const FunctionCallbackInfo& args) { + DTLSSession* session; + ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); + args.GetReturnValue().Set(session->stats_.GetArrayBuffer()); +} + void DTLSSession::GetRemoteAddress(const FunctionCallbackInfo& args) { DTLSSession* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); diff --git a/src/dtls/dtls_session.h b/src/dtls/dtls_session.h index c4dee0c36d53..d64d0e4d4873 100644 --- a/src/dtls/dtls_session.h +++ b/src/dtls/dtls_session.h @@ -17,6 +17,8 @@ #include #include +#include "dtls.h" + namespace node::dtls { class DTLSEndpoint; @@ -30,6 +32,11 @@ struct DTLSSessionStateData { uint8_t has_message_listener = 0; }; +// Stats collected for a DTLS session, backed by a BigUint64Array. +struct DTLSSessionStats { + DTLS_SESSION_STATS(DTLS_STAT_FIELD) +}; + // DTLSSession represents a single DTLS association with a remote peer. // It wraps an OpenSSL SSL* object configured for DTLS, using memory BIOs // to interface with the endpoint's UDP socket. @@ -102,6 +109,7 @@ class DTLSSession final : public AsyncWrap { static void DoClose(const v8::FunctionCallbackInfo& args); static void DoDestroy(const v8::FunctionCallbackInfo& args); static void GetState(const v8::FunctionCallbackInfo& args); + static void GetStats(const v8::FunctionCallbackInfo& args); static void GetRemoteAddress(const v8::FunctionCallbackInfo& args); static void GetProtocol(const v8::FunctionCallbackInfo& args); static void GetCipher(const v8::FunctionCallbackInfo& args); @@ -159,6 +167,7 @@ class DTLSSession final : public AsyncWrap { int cycle_depth_ = 0; AliasedStruct state_; + AliasedStruct stats_; }; } // namespace node::dtls diff --git a/test/parallel/test-dtls-stats.mjs b/test/parallel/test-dtls-stats.mjs new file mode 100644 index 000000000000..32c17c90cee7 --- /dev/null +++ b/test/parallel/test-dtls-stats.mjs @@ -0,0 +1,154 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS endpoint and session stats increment with data transfer. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, strictEqual, notStrictEqual } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const serverCert = readKey('agent1-cert.pem'); +const serverKey = readKey('agent1-key.pem'); +const ca = readKey('ca1-cert.pem'); + +const serverReceivedData = Promise.withResolvers(); +const clientReceivedData = Promise.withResolvers(); + +let serverSession; + +// Start server. +const endpoint = listen(mustCall((session) => { + serverSession = session; + + session.onmessage = mustCall((data) => { + strictEqual(data.toString(), 'hello from client'); + session.send('hello from server'); + serverReceivedData.resolve(); + }); + + session.onhandshake = mustCall(); +}), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', +}); + +// --- Endpoint stats should be available immediately --- + +const epStats = endpoint.stats; +ok(epStats, 'endpoint.stats should be defined'); +ok(epStats.isConnected, 'stats should be connected'); +ok(epStats.createdAt > 0n, 'createdAt should be set'); +strictEqual(epStats.destroyedAt, 0n); +strictEqual(epStats.clientSessions, 0n); +strictEqual(epStats.serverSessions, 0n); +strictEqual(epStats.serverBusyCount, 0n); + +// Connect client. +const clientSession = connect('127.0.0.1', endpoint.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, +}); + +clientSession.onmessage = mustCall((data) => { + strictEqual(data.toString(), 'hello from server'); + clientReceivedData.resolve(); +}); + +clientSession.onhandshake = mustCall(); + +// Wait for handshake. +await clientSession.opened; + +// --- Client session stats after handshake --- + +const csStats = clientSession.stats; +ok(csStats, 'session.stats should be defined'); +ok(csStats.isConnected, 'session stats should be connected'); +ok(csStats.createdAt > 0n, 'createdAt should be set'); +ok(csStats.handshakeCompletedAt > 0n, 'handshake timestamp should be set'); +ok(csStats.handshakeCompletedAt >= csStats.createdAt, + 'handshake should complete after creation'); +strictEqual(csStats.closingAt, 0n); +strictEqual(csStats.destroyedAt, 0n); + +// Record bytes before sending application data. +const csBytesSentBefore = csStats.bytesSent; +const csMessagesSentBefore = csStats.messagesSent; + +// Send data. +clientSession.send('hello from client'); + +// Wait for bidirectional exchange. +await Promise.all([serverReceivedData.promise, clientReceivedData.promise]); + +// --- Client session stats after data exchange --- + +ok(csStats.bytesSent > csBytesSentBefore, + 'bytesSent should increase after send'); +ok(csStats.messagesSent > csMessagesSentBefore, + 'messagesSent should increase after send'); +ok(csStats.bytesReceived > 0n, 'bytesReceived should be non-zero'); +ok(csStats.messagesReceived > 0n, 'messagesReceived should be non-zero'); + +// --- Server session stats after data exchange --- + +ok(serverSession, 'server session should exist'); +const ssStats = serverSession.stats; +ok(ssStats.bytesReceived > 0n, 'server bytesReceived should be non-zero'); +ok(ssStats.messagesReceived > 0n, 'server messagesReceived should be non-zero'); +ok(ssStats.bytesSent > 0n, 'server bytesSent should be non-zero'); +ok(ssStats.messagesSent > 0n, 'server messagesSent should be non-zero'); +ok(ssStats.handshakeCompletedAt > 0n, 'server handshake timestamp should be set'); + +// --- Endpoint stats after data exchange --- + +ok(epStats.bytesReceived > 0n, 'endpoint bytesReceived should be non-zero'); +ok(epStats.bytesSent > 0n, 'endpoint bytesSent should be non-zero'); +ok(epStats.packetsReceived > 0n, 'endpoint packetsReceived should be non-zero'); +ok(epStats.packetsSent > 0n, 'endpoint packetsSent should be non-zero'); +strictEqual(epStats.serverSessions, 1n); + +// The client's own endpoint should track the client session. +const clientEpStats = clientSession.endpoint.stats; +strictEqual(clientEpStats.clientSessions, 1n); +ok(clientEpStats.bytesSent > 0n); +ok(clientEpStats.bytesReceived > 0n); + +// --- toJSON / toString --- + +const epJson = epStats.toJSON(); +ok(epJson); +strictEqual(typeof epJson.bytesReceived, 'string'); +strictEqual(typeof epJson.bytesSent, 'string'); +strictEqual(typeof epJson.connected, 'boolean'); + +const ssJson = csStats.toJSON(); +ok(ssJson); +strictEqual(typeof ssJson.handshakeCompletedAt, 'string'); +strictEqual(typeof ssJson.messagesReceived, 'string'); + +const epStr = epStats.toString(); +ok(typeof epStr === 'string'); +ok(epStr.includes('bytesReceived')); + +// Clean up. +await clientSession.close(); + +// After close, session closing timestamp should be set. +notStrictEqual(csStats.closingAt, 0n); + +await endpoint.close(); From 64920aecd61abc1d8a831e4133ef4c7621503109 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 10 May 2026 11:44:34 -0700 Subject: [PATCH 006/280] src,lib: add dtls interop tests PR-URL: https://github.com/nodejs/node/pull/63182 Fixes: https://github.com/nodejs/node/issues/61630 Reviewed-By: Matteo Collina Reviewed-By: Stephen Belanger Reviewed-By: Rafael Gonzaga --- lib/internal/dtls/dtls.js | 6 + lib/internal/dtls/state.js | 6 + lib/internal/dtls/stats.js | 6 + lib/internal/dtls/symbols.js | 6 + test/doctool/test-make-doc.mjs | 3 +- .../test-dtls-interop-openssl-client.mjs | 103 ++++++++++++++++++ .../test-dtls-interop-openssl-server.mjs | 95 ++++++++++++++++ 7 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 test/sequential/test-dtls-interop-openssl-client.mjs create mode 100644 test/sequential/test-dtls-interop-openssl-server.mjs diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js index aa4018239d91..c4aab52e6e76 100644 --- a/lib/internal/dtls/dtls.js +++ b/lib/internal/dtls/dtls.js @@ -1,5 +1,9 @@ 'use strict'; +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + const { ArrayIsArray, FunctionPrototypeBind, @@ -648,3 +652,5 @@ module.exports = { DTLSEndpoint, DTLSSession, }; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/state.js b/lib/internal/dtls/state.js index be8272661740..5d86b556f1ad 100644 --- a/lib/internal/dtls/state.js +++ b/lib/internal/dtls/state.js @@ -1,5 +1,9 @@ 'use strict'; +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + const { DataView, DataViewPrototypeGetByteLength, @@ -166,3 +170,5 @@ module.exports = { DTLSEndpointState, DTLSSessionState, }; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/stats.js b/lib/internal/dtls/stats.js index c1fbd7dc16f7..b3393fe6b76d 100644 --- a/lib/internal/dtls/stats.js +++ b/lib/internal/dtls/stats.js @@ -1,5 +1,9 @@ 'use strict'; +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + const { BigUint64Array, JSONStringify, @@ -333,3 +337,5 @@ module.exports = { DTLSEndpointStats, DTLSSessionStats, }; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/symbols.js b/lib/internal/dtls/symbols.js index 0fe418d023d4..fbeeadc562a0 100644 --- a/lib/internal/dtls/symbols.js +++ b/lib/internal/dtls/symbols.js @@ -1,5 +1,9 @@ 'use strict'; +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + const { Symbol, } = primordials; @@ -35,3 +39,5 @@ module.exports = { kSessionClose: Symbol('dtls.session.close'), kSessionKeylog: Symbol('dtls.session.keylog'), }; + +/* c8 ignore stop */ diff --git a/test/doctool/test-make-doc.mjs b/test/doctool/test-make-doc.mjs index 555193227672..59e681707dd4 100644 --- a/test/doctool/test-make-doc.mjs +++ b/test/doctool/test-make-doc.mjs @@ -61,7 +61,8 @@ for (const actualDoc of actualDocs) { // Unless the old file is still available pointing to the correct location // 301 redirects are not yet automated. So keeping the old URL is a // reasonable workaround. - if (renamedDocs.includes(actualDoc) || actualDoc === 'apilinks.json') continue; + if (renamedDocs.includes(actualDoc) || skipedDocs.includes(actualDoc) || + actualDoc === 'apilinks.json') continue; assert.ok( expectedDocs.includes(actualDoc), `${actualDoc} does not match TOC`); diff --git a/test/sequential/test-dtls-interop-openssl-client.mjs b/test/sequential/test-dtls-interop-openssl-client.mjs new file mode 100644 index 000000000000..683d725e7669 --- /dev/null +++ b/test/sequential/test-dtls-interop-openssl-client.mjs @@ -0,0 +1,103 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS interop -- Node.js DTLS server with OpenSSL s_client. +// Verifies that an external DTLS client (OpenSSL CLI) can complete a +// handshake with Node's DTLS server and exchange application data. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import { createRequire } from 'module'; +import assert from 'node:assert'; +import { spawn } from 'node:child_process'; +import { setTimeout } from 'node:timers/promises'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const require = createRequire(import.meta.url); +const { opensslCli } = require('../common/crypto'); + +if (!opensslCli) { + skip('missing openssl-cli'); +} + +const { listen } = await import('node:dtls'); + +const reply = 'I AM THE WALRUS'; // Something recognizable +const serverReceivedData = Promise.withResolvers(); + +// Start Node.js DTLS server. +const endpoint = listen(mustCall((session) => { + session.onmessage = mustCall((data) => { + assert.strictEqual(data.toString().trim(), 'hello from openssl'); + session.send(reply); + serverReceivedData.resolve(); + }); + session.onhandshake = mustCall(); +}), { + cert: fixtures.readKey('agent1-cert.pem').toString(), + key: fixtures.readKey('agent1-key.pem').toString(), + port: 0, + host: '127.0.0.1', +}); + +const { port } = endpoint.address; + +// Spawn OpenSSL s_client to connect to the Node.js server. +const args = [ + 's_client', + '-dtls', + '-connect', `127.0.0.1:${port}`, + '-CAfile', fixtures.path('keys/ca1-cert.pem'), +]; + +const client = spawn(opensslCli, args, { stdio: 'pipe' }); + +let stdout = ''; +client.stdout.on('data', (data) => { stdout += data; }); + +let stderr = ''; +client.stderr.on('data', (data) => { stderr += data; }); + +const timeout = setTimeout(() => { + client.kill(); + endpoint.close(); + assert.fail('Test timed out'); +}, 10000); + +// Wait for the handshake to start (s_client writes TLS info to stdout), +// then send data. +await new Promise((resolve) => client.stdout.once('data', resolve)); +await setTimeout(500); + +client.stdin.write('hello from openssl\n'); + +// Wait for the server to receive and reply. +await serverReceivedData.promise; +await setTimeout(500); + +// Close stdin so s_client exits. +client.stdin.end(); + +// Wait for s_client to exit. +const code = await new Promise((resolve) => client.on('close', resolve)); +clearTimeout(timeout); + +// s_client should exit cleanly. +assert.strictEqual(code, 0, + `openssl s_client exited with code ${code}\n${stderr}`); + +// Verify the reply from Node's server appeared in s_client's stdout. +assert(stdout.includes(reply), + `Expected stdout to include "${reply}"\n${stdout}`); + +// Verify it was a DTLS connection. +assert(stdout.includes('DTLS'), + `Expected stdout to include "DTLS"\n${stdout}`); + +await endpoint.close(); diff --git a/test/sequential/test-dtls-interop-openssl-server.mjs b/test/sequential/test-dtls-interop-openssl-server.mjs new file mode 100644 index 000000000000..26e66710611f --- /dev/null +++ b/test/sequential/test-dtls-interop-openssl-server.mjs @@ -0,0 +1,95 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS interop -- OpenSSL s_server with Node.js DTLS client. +// Verifies that Node's DTLS client can complete a handshake with an +// external DTLS server (OpenSSL CLI) and exchange application data. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import { createRequire } from 'module'; +import assert from 'node:assert'; +import { spawn } from 'node:child_process'; +import * as fixtures from '../common/fixtures.mjs'; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const require = createRequire(import.meta.url); +const common = require('../common'); +const { opensslCli } = require('../common/crypto'); + +if (!opensslCli) { + skip('missing openssl-cli'); +} + +const { connect } = await import('node:dtls'); + +const reply = 'I AM THE WALRUS'; // Something recognizable + +// Start OpenSSL DTLS server. +const server = spawn(opensslCli, [ + 's_server', + '-dtls1_2', + '-accept', String(common.PORT), + '-cert', fixtures.path('keys/agent1-cert.pem'), + '-key', fixtures.path('keys/agent1-key.pem'), + '-listen', +], { stdio: 'pipe' }); + +let serverOut = ''; +server.stdout.on('data', (data) => { serverOut += data; }); +let serverErr = ''; +server.stderr.on('data', (data) => { serverErr += data; }); +server.on('error', mustNotCall()); + +const timeout = setTimeout(() => { + server.kill(); + assert.fail(`Test timed out\nstdout: ${serverOut}\nstderr: ${serverErr}`); +}, 10000); + +// Wait for "ACCEPT" on stdout -- this means s_server is ready. +await new Promise((resolve) => { + server.stdout.on('data', function onReady() { + if (!serverOut.includes('ACCEPT')) return; + server.stdout.removeListener('data', onReady); + resolve(); + }); +}); + +// Connect Node.js DTLS client. +const session = connect('127.0.0.1', common.PORT, { + ca: [fixtures.readKey('ca1-cert.pem').toString()], + rejectUnauthorized: false, +}); + +const { protocol } = await session.opened; +assert.match(protocol, /DTLS/i); + +// Send data from Node to OpenSSL server. +session.send('hello from node'); + +// Send data from OpenSSL server to Node client via s_server stdin. +// s_server forwards its stdin to the connected client. +server.stdin.write(reply + '\n'); + +// Wait for Node client to receive the message. +const data = await new Promise((resolve) => { + session.onmessage = mustCall(resolve); +}); +assert.strictEqual(data.toString().trim(), reply); + +// Clean up. +await session.close(); +await session.endpoint.close(); +clearTimeout(timeout); +server.kill(); + +// Wait for server to exit. +const [, signal] = await new Promise((resolve) => { + server.on('exit', mustCall((...args) => resolve(args))); +}); +assert.strictEqual(signal, 'SIGTERM'); From aa9df8134b7b074278388b11724c0fd48076f4b2 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 24 May 2026 07:41:19 -0700 Subject: [PATCH 007/280] lib: apply minor dtls cleanups Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/63539 Reviewed-By: Matteo Collina --- lib/dtls.js | 28 ++++++---------------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/lib/dtls.js b/lib/dtls.js index c4dc01052ea6..5749ee028334 100644 --- a/lib/dtls.js +++ b/lib/dtls.js @@ -1,10 +1,5 @@ 'use strict'; -const { - ObjectCreate, - ObjectSeal, -} = primordials; - const { emitExperimentalWarning, } = require('internal/util'); @@ -17,20 +12,9 @@ const { DTLSSession, } = require('internal/dtls/dtls'); -function getEnumerableConstant(value) { - return { - __proto__: null, - value, - enumerable: true, - configurable: false, - writable: false, - }; -} - -module.exports = ObjectSeal(ObjectCreate(null, { - __proto__: null, - connect: getEnumerableConstant(connect), - listen: getEnumerableConstant(listen), - DTLSEndpoint: getEnumerableConstant(DTLSEndpoint), - DTLSSession: getEnumerableConstant(DTLSSession), -})); +module.exports = { + connect, + listen, + DTLSEndpoint, + DTLSSession, +}; From 027d5285ee76d0e0175263b4777c378d6611520b Mon Sep 17 00:00:00 2001 From: James M Snell Date: Fri, 31 Jul 2026 23:15:08 -0700 Subject: [PATCH 008/280] net: improve dtls cert verification Signed-off-by: James M Snell Assisted-by: Claude/Opus PR-URL: https://github.com/nodejs/node/pull/64314 Reviewed-By: Matteo Collina --- doc/api/dtls.md | 11 +- lib/internal/dtls/dtls.js | 69 ++++++++++-- src/dtls/dtls.h | 16 +-- src/dtls/dtls_context.cc | 86 +++++++++------ src/dtls/dtls_context.h | 8 ++ src/dtls/dtls_endpoint.cc | 84 ++++++++++++-- src/dtls/dtls_endpoint.h | 19 +++- src/dtls/dtls_session.cc | 100 ++++++++++++++--- src/dtls/dtls_session.h | 11 +- test/parallel/test-dtls-accessors.mjs | 104 ++++++++++++++++++ test/parallel/test-dtls-alpn.mjs | 31 +++++- test/parallel/test-dtls-ciphers.mjs | 79 +++++++++++++ test/parallel/test-dtls-client-cert.mjs | 73 ++++++++++++ .../test-dtls-connect-error-cleanup.mjs | 46 ++++++++ .../test-dtls-destroy-in-callback.mjs | 79 +++++++++++++ test/parallel/test-dtls-errors.mjs | 48 ++++++++ test/parallel/test-dtls-keylog.mjs | 49 +++++++++ test/parallel/test-dtls-mtu.mjs | 68 ++++++++++++ test/parallel/test-dtls-options.mjs | 14 +++ .../test-dtls-reject-unauthorized.mjs | 63 +++++++++++ test/parallel/test-dtls-robustness.mjs | 49 +++++++++ test/parallel/test-dtls-send.mjs | 70 ++++++++++++ .../parallel/test-dtls-servername-invalid.mjs | 27 +++++ .../test-dtls-session-table-cleanup.mjs | 77 +++++++++++++ test/parallel/test-dtls-srtp.mjs | 78 +++++++++++++ .../test-dtls-unhandled-rejection.mjs | 50 +++++++++ test/parallel/test-dtls-verify-identity.mjs | 83 ++++++++++++++ test/parallel/test-permission-net-dtls.mjs | 11 +- .../test-dtls-interop-openssl-client.mjs | 6 +- 29 files changed, 1423 insertions(+), 86 deletions(-) create mode 100644 test/parallel/test-dtls-accessors.mjs create mode 100644 test/parallel/test-dtls-ciphers.mjs create mode 100644 test/parallel/test-dtls-client-cert.mjs create mode 100644 test/parallel/test-dtls-connect-error-cleanup.mjs create mode 100644 test/parallel/test-dtls-destroy-in-callback.mjs create mode 100644 test/parallel/test-dtls-errors.mjs create mode 100644 test/parallel/test-dtls-keylog.mjs create mode 100644 test/parallel/test-dtls-mtu.mjs create mode 100644 test/parallel/test-dtls-reject-unauthorized.mjs create mode 100644 test/parallel/test-dtls-robustness.mjs create mode 100644 test/parallel/test-dtls-send.mjs create mode 100644 test/parallel/test-dtls-servername-invalid.mjs create mode 100644 test/parallel/test-dtls-session-table-cleanup.mjs create mode 100644 test/parallel/test-dtls-srtp.mjs create mode 100644 test/parallel/test-dtls-unhandled-rejection.mjs create mode 100644 test/parallel/test-dtls-verify-identity.mjs diff --git a/doc/api/dtls.md b/doc/api/dtls.md index 444e5cbf0d7b..df0e5b50a428 100644 --- a/doc/api/dtls.md +++ b/doc/api/dtls.md @@ -123,8 +123,15 @@ added: REPLACEME * `ca` {string|Buffer|string\[]|Buffer\[]} CA certificates in PEM format. * `cert` {string|Buffer} Client certificate in PEM format. * `key` {string|Buffer} Client private key in PEM format. - * `rejectUnauthorized` {boolean} Reject connections with unverifiable - certificates. **Default:** `true`. + * `rejectUnauthorized` {boolean} When `true`, the server's certificate must + both chain to a trusted CA and match the expected identity (`servername`, + or `host` when `servername` is not set); otherwise the handshake is + aborted and `session.opened` rejects. When `false`, the certificate is not + verified. **Default:** `true`. + * `servername` {string} Server name used for the SNI (Server Name + Indication) extension and as the identity checked during certificate + verification. **Default:** the `host` argument. Set to `''` to disable SNI. + SNI is never sent for IP address literals. * `bindHost` {string} Local bind address. **Default:** `'0.0.0.0'`. * `bindPort` {number} Local bind port. **Default:** `0` (ephemeral). * `alpn` {string\[]|Buffer} ALPN protocol names. diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js index c4aab52e6e76..e6017e3e8761 100644 --- a/lib/internal/dtls/dtls.js +++ b/lib/internal/dtls/dtls.js @@ -7,6 +7,7 @@ const { ArrayIsArray, FunctionPrototypeBind, + PromisePrototypeThen, PromiseWithResolvers, SafeSet, SymbolAsyncDispose, @@ -41,6 +42,10 @@ const { Buffer, } = require('buffer'); +const { + isIP, +} = require('internal/net'); + const { DTLSEndpointState, DTLSSessionState, @@ -102,6 +107,12 @@ class DTLSSession { kPrivateConstructor, handle.getStats()); this.#pendingOpen = PromiseWithResolvers(); this.#pendingClose = PromiseWithResolvers(); + // opened/closed may reject (handshake error, destroy(error)). Attach a + // no-op rejection handler so a caller that uses the callback API and never + // awaits them does not trigger an unhandled rejection; an explicit + // await/then/catch on opened/closed still observes the rejection. + PromisePrototypeThen(this.#pendingOpen.promise, undefined, () => {}); + PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {}); } // --- Callback setters --- @@ -261,6 +272,22 @@ class DTLSSession { this.#onerror(error); } this.#pendingOpen.reject(error); + + // The session has failed and cannot continue. Tear it down so it does not + // linger in the endpoint's table, and -- for a client session that owns + // its internal endpoint -- close the endpoint too so the event loop can + // drain. destroy() removes the session from the C++ table first, so the + // endpoint.close() below won't try to re-close it. Reentrant destroy from + // within the error emit is safe: Cycle()/the timer hold a strong ref. + const endpoint = this.#endpoint; + const ownsEndpoint = this.#ownsEndpoint; + this.destroy(); + if (endpoint) { + endpoint.sessions.delete(this); + if (ownsEndpoint) { + endpoint.close(); + } + } } [kSessionClose]() { @@ -314,6 +341,9 @@ class DTLSEndpoint { this.#stats = new DTLSEndpointStats( kPrivateConstructor, this.#handle.getStats()); this.#pendingClose = PromiseWithResolvers(); + // See DTLSSession: keep an unobserved closed rejection from surfacing as an + // unhandled rejection. + PromisePrototypeThen(this.#pendingClose.promise, undefined, () => {}); if (options.mtu !== undefined) { validateInteger(options.mtu, 'options.mtu', 256, 65535); @@ -359,10 +389,24 @@ class DTLSEndpoint { // --- Client mode --- connect(context, host, port, servername) { - const sessionHandle = this.#handle.connect(context, host, port); - if (servername) { - sessionHandle.setServername(servername); + // Resolve SNI and the expected peer identity here so that every caller of + // the endpoint API -- not only the top-level dtls.connect() -- gets safe + // defaults. The identity is always bound to the requested servername (or, + // failing that, the host). OpenSSL only *enforces* it when the context is + // in a verifying mode, so binding it is a no-op for non-verifying + // (rejectUnauthorized: false) contexts. + // + // These are applied to the client SSL inside the binding, before the + // handshake's ClientHello is emitted; they cannot be set afterwards. + let sni = servername !== undefined ? (servername || undefined) : host; + if (sni !== undefined && isIP(sni) !== 0) { + sni = undefined; // SNI is never sent for IP literals (matching TLS). } + const verifyHost = servername || host; + const verifyIsIp = isIP(verifyHost) !== 0; + + const sessionHandle = this.#handle.connect( + context, host, port, sni, verifyHost, verifyIsIp); const session = new DTLSSession( kPrivateConstructor, sessionHandle, this); this.#sessions.add(session); @@ -604,7 +648,12 @@ function listen(onsession, options = kEmptyObject) { * @param {string|Buffer|Array} [options.ca] CA certificates (PEM). * @param {string|Buffer} [options.cert] Client certificate (PEM). * @param {string|Buffer} [options.key] Client private key (PEM). - * @param {boolean} [options.rejectUnauthorized] Reject unauthorized. + * @param {boolean} [options.rejectUnauthorized] When true (default), verify + * the server certificate against the trusted CAs and check its identity + * against servername (or host); aborts the handshake on failure. + * @param {string} [options.servername] Server name for the SNI extension and + * the identity checked during certificate verification. Defaults to host; + * set to '' to disable SNI. Never sent for IP address literals. * @param {string} [options.bindHost] Local bind address. * @param {number} [options.bindPort] Local bind port (0 = ephemeral). * @param {number} [options.mtu] MTU for DTLS records. @@ -632,13 +681,11 @@ function connect(host, port, options = kEmptyObject) { endpoint.bind(bindHost, bindPort); - // Default SNI servername to the host argument (matching Node.js TLS). - // Can be overridden with options.servername, or disabled with '' or false. - const servername = options.servername !== undefined ? - (options.servername || undefined) : - host; - - const session = endpoint.connect(context, host, port, servername); + // SNI and peer-identity verification are resolved inside + // DTLSEndpoint.connect(), which defaults both to the host argument (matching + // Node.js TLS). The identity is enforced whenever the context verifies, i.e. + // unless rejectUnauthorized is false. + const session = endpoint.connect(context, host, port, options.servername); // Mark that this session owns the endpoint so it gets closed // automatically when the session closes, allowing process exit. session.ownsEndpoint = true; diff --git a/src/dtls/dtls.h b/src/dtls/dtls.h index 6f1737347433..1faed3910e21 100644 --- a/src/dtls/dtls.h +++ b/src/dtls/dtls.h @@ -58,16 +58,18 @@ void RecordTimestampStat(Stats* stats) { V(MESSAGES_SENT, messages_sent) \ V(RETRANSMIT_COUNT, retransmit_count) -// State indices shared between C++ and JS via AliasedStruct/DataView. +// State "indices" shared between C++ and JS via AliasedStruct/DataView. These +// are BYTE OFFSETS into the state struct, not sequential indices: session_count +// is a uint32, so `busy`, which follows it, sits at byte offset 8. The +// static_asserts in dtls_endpoint.cc pin these to the actual struct layout. // Keep in sync with lib/internal/dtls/state.js. enum DTLSEndpointStateIndex { IDX_ENDPOINT_STATE_BOUND = 0, - IDX_ENDPOINT_STATE_LISTENING, - IDX_ENDPOINT_STATE_CLOSING, - IDX_ENDPOINT_STATE_DESTROYED, - IDX_ENDPOINT_STATE_SESSION_COUNT, - IDX_ENDPOINT_STATE_BUSY, - IDX_ENDPOINT_STATE_COUNT + IDX_ENDPOINT_STATE_LISTENING = 1, + IDX_ENDPOINT_STATE_CLOSING = 2, + IDX_ENDPOINT_STATE_DESTROYED = 3, + IDX_ENDPOINT_STATE_SESSION_COUNT = 4, + IDX_ENDPOINT_STATE_BUSY = 8, }; enum DTLSSessionStateIndex { diff --git a/src/dtls/dtls_context.cc b/src/dtls/dtls_context.cc index ca5003df46c2..d59ee74feeb9 100644 --- a/src/dtls/dtls_context.cc +++ b/src/dtls/dtls_context.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -35,6 +36,11 @@ namespace dtls { namespace { // The cookie secret is 32 bytes (256 bits). constexpr size_t kCookieSecretLen = 32; +// Cookies are bound to a coarse time window so they expire. A cookie is +// accepted for the window it was minted in and the immediately preceding one, +// giving ~30-60s of validity -- ample for the cookie exchange while bounding +// how long a captured cookie can be replayed. +constexpr uint64_t kCookieWindowNs = 30ull * 1000 * 1000 * 1000; } // namespace DTLSContext::DTLSContext(Environment* env, @@ -317,8 +323,11 @@ void DTLSContext::SetALPN(const FunctionCallbackInfo& args) { ctx->alpn_protos_.assign(data, data + len); SSL_CTX_set_alpn_select_cb(ctx->ctx_.get(), ALPNSelectCallback, ctx); } else { - // Client: advertise protocols to the server. - SSL_CTX_set_alpn_protos(ctx->ctx_.get(), data, len); + // Client: advertise protocols to the server. Returns 0 on success. + if (SSL_CTX_set_alpn_protos(ctx->ctx_.get(), data, len) != 0) { + return THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "SSL_CTX_set_alpn_protos failed"); + } } } @@ -368,64 +377,77 @@ void DTLSContext::SetECDHCurve(const FunctionCallbackInfo& args) { } } -// HMAC-SHA256 based cookie generation using the peer's address. -// During DTLSv1_listen(), the peer address is taken from -// DTLSContext::current_cookie_peer_ (set synchronously before the call). -// During session handshake, the peer address is taken from the +// HMAC-SHA256 cookie derived from the peer's address and a coarse time window +// so cookies expire (see kCookieWindowNs). During DTLSv1_listen() the peer +// address comes from DTLSContext::current_cookie_peer_ (set synchronously +// before the call); during the session handshake it comes from the // DTLSSession stored in SSL app_data. -int DTLSContext::CookieGenerateCallback(SSL* ssl, - unsigned char* cookie, - unsigned int* cookie_len) { +bool DTLSContext::ComputeCookie(SSL* ssl, + uint64_t window, + unsigned char* out, + unsigned int* out_len) { SSL_CTX* ctx = SSL_get_SSL_CTX(ssl); DTLSContext* dtls_ctx = static_cast(SSL_CTX_get_app_data(ctx)); CHECK_NOT_NULL(dtls_ctx); - unsigned char addr_buf[sizeof(struct sockaddr_storage)]; + // Message = peer address bytes followed by the 8-byte window counter. + unsigned char msg[sizeof(struct sockaddr_storage) + sizeof(uint64_t)]; size_t addr_len = 0; void* app_data = SSL_get_app_data(ssl); if (app_data != nullptr) { // Session handshake path. - auto* session = static_cast(app_data); - const sockaddr* sa = session->remote_address().data(); + const sockaddr* sa = + static_cast(app_data)->remote_address().data(); addr_len = SocketAddress::GetLength(sa); - memcpy(addr_buf, sa, addr_len); + memcpy(msg, sa, addr_len); } else { - // DTLSv1_listen path — use the peer address stored on the context. + // DTLSv1_listen path -- use the peer address stored on the context. const sockaddr* sa = dtls_ctx->current_cookie_peer_.data(); addr_len = SocketAddress::GetLength(sa); - memcpy(addr_buf, sa, addr_len); + memcpy(msg, sa, addr_len); + } + + // Append the window counter in a fixed byte order. + for (size_t i = 0; i < sizeof(uint64_t); i++) { + msg[addr_len + i] = static_cast((window >> (8 * i)) & 0xff); } - unsigned int hmac_len = 0; unsigned char* result = HMAC(EVP_sha256(), dtls_ctx->cookie_secret_.data(), dtls_ctx->cookie_secret_.size(), - addr_buf, - addr_len, - cookie, - &hmac_len); - - if (result == nullptr) return 0; + msg, + addr_len + sizeof(uint64_t), + out, + out_len); + return result != nullptr; +} - *cookie_len = hmac_len; - return 1; +int DTLSContext::CookieGenerateCallback(SSL* ssl, + unsigned char* cookie, + unsigned int* cookie_len) { + const uint64_t window = uv_hrtime() / kCookieWindowNs; + return ComputeCookie(ssl, window, cookie, cookie_len) ? 1 : 0; } int DTLSContext::CookieVerifyCallback(SSL* ssl, const unsigned char* cookie, unsigned int cookie_len) { - // Generate the expected cookie and compare. + const uint64_t window = uv_hrtime() / kCookieWindowNs; + + // Accept a cookie minted in the current window or the immediately preceding + // one, so a handshake that straddles a window boundary still succeeds. unsigned char expected[EVP_MAX_MD_SIZE]; unsigned int expected_len = 0; - - if (CookieGenerateCallback(ssl, expected, &expected_len) != 1) { - return 0; + for (int i = 0; i < 2; i++) { + if (i == 1 && window == 0) break; + if (ComputeCookie(ssl, window - i, expected, &expected_len) && + cookie_len == expected_len && + CRYPTO_memcmp(cookie, expected, expected_len) == 0) { + return 1; + } } - - if (cookie_len != expected_len) return 0; - - return CRYPTO_memcmp(cookie, expected, expected_len) == 0 ? 1 : 0; + return 0; } int DTLSContext::ALPNSelectCallback(SSL* ssl, diff --git a/src/dtls/dtls_context.h b/src/dtls/dtls_context.h index 11d8113d3081..5c994ea769de 100644 --- a/src/dtls/dtls_context.h +++ b/src/dtls/dtls_context.h @@ -57,6 +57,14 @@ class DTLSContext final : public BaseObject { static void LoadDefaultCAs(const v8::FunctionCallbackInfo& args); static void SetECDHCurve(const v8::FunctionCallbackInfo& args); + // Compute the address-and-time-window-bound cookie for |window| into |out| + // (which must have room for EVP_MAX_MD_SIZE bytes). Shared by the cookie + // generate/verify callbacks. + static bool ComputeCookie(SSL* ssl, + uint64_t window, + unsigned char* out, + unsigned int* out_len); + // Automatic DTLS cookie callbacks static int CookieGenerateCallback(SSL* ssl, unsigned char* cookie, diff --git a/src/dtls/dtls_endpoint.cc b/src/dtls/dtls_endpoint.cc index 9433241a2d14..cc2aec6b58e3 100644 --- a/src/dtls/dtls_endpoint.cc +++ b/src/dtls/dtls_endpoint.cc @@ -19,6 +19,7 @@ #include #include +#include #include namespace node { @@ -45,6 +46,21 @@ struct SendReq { }; } // namespace +// The endpoint state "indices" are byte offsets into DTLSEndpointStateData, +// accessed from JS via a DataView. Pin them to the actual struct layout so a +// mismatch (as once existed for `busy`, which follows a uint32) can't recur. +static_assert(IDX_ENDPOINT_STATE_BOUND == + offsetof(DTLSEndpointStateData, bound)); +static_assert(IDX_ENDPOINT_STATE_LISTENING == + offsetof(DTLSEndpointStateData, listening)); +static_assert(IDX_ENDPOINT_STATE_CLOSING == + offsetof(DTLSEndpointStateData, closing)); +static_assert(IDX_ENDPOINT_STATE_DESTROYED == + offsetof(DTLSEndpointStateData, destroyed)); +static_assert(IDX_ENDPOINT_STATE_SESSION_COUNT == + offsetof(DTLSEndpointStateData, session_count)); +static_assert(IDX_ENDPOINT_STATE_BUSY == offsetof(DTLSEndpointStateData, busy)); + DTLSEndpoint::DTLSEndpoint(Environment* env, Local wrap) : HandleWrap(env, wrap, @@ -155,7 +171,10 @@ int DTLSEndpoint::Listen(DTLSContext* context) { } BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, - const SocketAddress& remote) { + const SocketAddress& remote, + const char* servername, + const char* verify_host, + bool verify_is_ip) { if (IsHandleClosing()) { THROW_ERR_INVALID_STATE(env(), "Endpoint is closing"); return {}; @@ -168,8 +187,14 @@ BaseObjectPtr DTLSEndpoint::Connect(DTLSContext* context, return {}; } - auto session = DTLSSession::Create( - env(), this, context->ssl_ctx(), remote, false /* is_server */); + auto session = DTLSSession::Create(env(), + this, + context->ssl_ctx(), + remote, + false /* is_server */, + servername, + verify_host, + verify_is_ip); if (!session) return {}; @@ -259,6 +284,11 @@ void DTLSEndpoint::CloseGracefully() { server_context_.reset(); + // Keep ourselves alive until OnClose() runs, so a garbage collection while + // uv_close() is in flight cannot collect the wrapper before the close is + // reported. Released in OnClose(). + self_ref_ = BaseObjectPtr(this); + // HandleWrap::Close() calls uv_close and manages the lifecycle. HandleWrap::Close(); } @@ -284,6 +314,9 @@ void DTLSEndpoint::Destroy() { state_->listening = 0; } + // Keep ourselves alive until OnClose() (see CloseGracefully()). + self_ref_ = BaseObjectPtr(this); + HandleWrap::Close(); } @@ -331,8 +364,16 @@ void DTLSEndpoint::SetCallbacks(Local callbacks) { void DTLSEndpoint::OnAlloc(uv_handle_t* handle, size_t suggested_size, uv_buf_t* buf) { - buf->base = new char[65536]; - buf->len = 65536; + DTLSEndpoint* endpoint = static_cast(handle->data); + // Reuse a single receive buffer. libuv delivers datagrams one at a time on + // this thread, and OnRecv fully consumes each datagram (copying it into the + // session's BIO) before the next OnAlloc, so a per-endpoint buffer suffices + // and avoids a heap allocation on every packet. + if (endpoint->recv_buf_.empty()) { + endpoint->recv_buf_.resize(65536); + } + buf->base = endpoint->recv_buf_.data(); + buf->len = endpoint->recv_buf_.size(); } void DTLSEndpoint::OnRecv(uv_udp_t* handle, @@ -342,13 +383,12 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, unsigned int flags) { DTLSEndpoint* endpoint = static_cast(handle->data); + // buf->base is the endpoint's reusable recv_buf_; it is not freed here. if (nread == 0 && addr == nullptr) { - delete[] buf->base; return; } if (nread < 0) { - delete[] buf->base; HandleScope handle_scope(endpoint->env()->isolate()); Context::Scope context_scope(endpoint->env()->context()); Local argv[] = { @@ -363,7 +403,6 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, } if (addr == nullptr) { - delete[] buf->base; return; } @@ -375,8 +414,6 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle, SocketAddress remote(addr); endpoint->ProcessDatagram( reinterpret_cast(buf->base), nread, remote); - - delete[] buf->base; } void DTLSEndpoint::OnSend(uv_udp_send_t* req, int status) { @@ -389,6 +426,17 @@ void DTLSEndpoint::OnClose() { state_->destroyed = 1; DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at); + // Release the strong self-reference taken when the close was initiated. + // HandleWrap::OnClose still holds its own reference for the duration of this + // call, so this does not free us here. + self_ref_.reset(); + + // A close initiated outside CloseGracefully()/Destroy() (e.g. an endpoint + // abandoned mid-construction and closed at environment teardown) takes no + // self-reference, so its wrapper may already be collected. There is no JS + // side to notify in that case; skip it rather than touch a freed wrapper. + if (persistent().IsEmpty()) return; + Local cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE); if (!cb.IsEmpty()) { Local argv[] = {}; @@ -532,6 +580,9 @@ void DTLSEndpoint::DoBind(const FunctionCallbackInfo& args) { return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid address"); } + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kNet, addr.ToString()); + int err = endpoint->Bind(addr); if (err != 0) { return THROW_ERR_INVALID_STATE(env, uv_strerror(err)); @@ -576,7 +627,17 @@ void DTLSEndpoint::DoConnect(const FunctionCallbackInfo& args) { THROW_IF_INSUFFICIENT_PERMISSIONS( env, permission::PermissionScope::kNet, remote.ToString()); - auto session = endpoint->Connect(context, remote); + // Optional: servername (SNI), verifyHost (expected peer identity), and + // whether verifyHost is an IP literal. These are resolved in JS and applied + // to the client SSL before the handshake starts. + Utf8Value servername(env->isolate(), args[3]); + Utf8Value verify_host(env->isolate(), args[4]); + const char* servername_ptr = args[3]->IsString() ? *servername : nullptr; + const char* verify_host_ptr = args[4]->IsString() ? *verify_host : nullptr; + bool verify_is_ip = args[5]->IsTrue(); + + auto session = endpoint->Connect( + context, remote, servername_ptr, verify_host_ptr, verify_is_ip); if (session) { args.GetReturnValue().Set(session->object()); } @@ -641,6 +702,7 @@ void DTLSEndpoint::DoSetCallbacks(const FunctionCallbackInfo& args) { void DTLSEndpoint::MemoryInfo(MemoryTracker* tracker) const { tracker->TrackField("sessions", sessions_.size()); + tracker->TrackFieldWithSize("recv_buf", recv_buf_.size()); } } // namespace dtls diff --git a/src/dtls/dtls_endpoint.h b/src/dtls/dtls_endpoint.h index a6fe94fff5b8..bed49a7db0e0 100644 --- a/src/dtls/dtls_endpoint.h +++ b/src/dtls/dtls_endpoint.h @@ -14,6 +14,7 @@ #include #include +#include #include "dtls.h" #include "dtls_context.h" @@ -59,9 +60,15 @@ class DTLSEndpoint final : public HandleWrap { int Listen(DTLSContext* context); // Initiate a client connection to the given address. + // |servername|/|verify_host|/|verify_is_ip| configure SNI and peer identity + // verification on the client SSL before the handshake begins; see + // DTLSSession::Create. // Returns the created DTLSSession. BaseObjectPtr Connect(DTLSContext* context, - const SocketAddress& remote); + const SocketAddress& remote, + const char* servername = nullptr, + const char* verify_host = nullptr, + bool verify_is_ip = false); // Send a raw UDP datagram to the given address. // Called by DTLSSession to send encrypted packets. @@ -129,6 +136,11 @@ class DTLSEndpoint final : public HandleWrap { uv_udp_t handle_; + // Reusable receive buffer for uv_udp_recv (see OnAlloc). libuv delivers one + // datagram at a time and OnRecv consumes each before the next OnAlloc, so a + // single buffer per endpoint avoids a heap allocation on every packet. + std::vector recv_buf_; + // Session table: maps remote address -> session. std::unordered_map, @@ -144,6 +156,11 @@ class DTLSEndpoint final : public HandleWrap { AliasedStruct state_; AliasedStruct stats_; + // Strong self-reference held while a graceful close/destroy is in flight, so + // the wrapper is not garbage-collected before OnClose() runs and reports the + // close. Cleared in OnClose(). + BaseObjectPtr self_ref_; + bool listening_ = false; uint32_t mtu_ = 1200; // Conservative default MTU for data payload }; diff --git a/src/dtls/dtls_session.cc b/src/dtls/dtls_session.cc index 8bcd06a4ae71..02f1563abac7 100644 --- a/src/dtls/dtls_session.cc +++ b/src/dtls/dtls_session.cc @@ -18,6 +18,8 @@ #include #include #include +#include +#include #include @@ -53,6 +55,10 @@ DTLSSession::DTLSSession(Environment* env, retransmit_timer_(env, [this] { if (destroyed_) return; + // Keep ourselves alive across the callback: emitting + // an error or running Cycle() below can synchronously + // destroy this session, and this timer lives on it. + BaseObjectPtr strong_ref{this}; DTLS_STAT_INCREMENT(DTLSSessionStats, retransmit_count); int ret = DTLSv1_handle_timeout(ssl_.get()); @@ -115,7 +121,6 @@ Local DTLSSession::GetConstructorTemplate(Environment* env) { SetProtoMethod(isolate, tmpl, "getALPNProtocol", GetALPNProtocol); SetProtoMethod(isolate, tmpl, "exportKeyingMaterial", ExportKeyingMaterial); SetProtoMethod(isolate, tmpl, "getSRTPProfile", GetSRTPProfile); - SetProtoMethod(isolate, tmpl, "setServername", SetServername); SetProtoMethod(isolate, tmpl, "getServername", GetServername); env->set_dtls_session_constructor_template(tmpl); @@ -145,7 +150,6 @@ void DTLSSession::RegisterExternalReferences( registry->Register(GetALPNProtocol); registry->Register(ExportKeyingMaterial); registry->Register(GetSRTPProfile); - registry->Register(SetServername); registry->Register(GetServername); } @@ -153,7 +157,10 @@ BaseObjectPtr DTLSSession::Create(Environment* env, DTLSEndpoint* endpoint, SSL_CTX* ssl_ctx, const SocketAddress& remote, - bool is_server) { + bool is_server, + const char* servername, + const char* verify_host, + bool verify_is_ip) { // Create the SSL object. SSL* ssl_raw = SSL_new(ssl_ctx); if (ssl_raw == nullptr) { @@ -188,6 +195,43 @@ BaseObjectPtr DTLSSession::Create(Environment* env, SSL_set_accept_state(ssl.get()); } else { SSL_set_connect_state(ssl.get()); + + // Configure SNI and peer identity verification BEFORE the handshake + // starts. The caller (DTLSEndpoint::Connect) runs Cycle() immediately + // after Create() returns, which emits the ClientHello, so anything that + // must appear in that flight (SNI) has to be set here rather than via a + // post-construction setter. + if (servername != nullptr && servername[0] != '\0') { + if (!SSL_set_tlsext_host_name(ssl.get(), servername)) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to set servername (SNI)"); + return {}; + } + } + + // When identity verification is requested, bind the expected peer name + // (or IP) into the verification parameters. Combined with the context's + // SSL_VERIFY_PEER mode this makes a name mismatch fail the handshake, + // rather than accepting any certificate that merely chains to a trusted + // CA. A failure to configure it is fatal: proceeding would silently skip + // the identity check. + if (verify_host != nullptr && verify_host[0] != '\0') { + if (verify_is_ip) { + if (!X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl.get()), + verify_host)) { + THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "Failed to set peer IP address for verification"); + return {}; + } + } else { + SSL_set_hostflags(ssl.get(), X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + if (!SSL_set1_host(ssl.get(), verify_host)) { + THROW_ERR_CRYPTO_OPERATION_FAILED( + env, "Failed to set peer hostname for verification"); + return {}; + } + } + } } // Create the JS wrapper object. @@ -246,6 +290,12 @@ void DTLSSession::Receive(const uint8_t* data, size_t len) { void DTLSSession::Cycle() { if (destroyed_) return; + // Pin a strong reference to ourselves for the duration of the pump. A JS + // callback dispatched below (message/handshake/error) can synchronously + // destroy this session, which removes the endpoint's only strong reference + // and would otherwise free `this` while we are still using ssl_/state_. + BaseObjectPtr strong_ref{this}; + // Prevent infinite recursion. if (++cycle_depth_ > 1) { cycle_depth_--; @@ -264,6 +314,9 @@ void DTLSSession::Cycle() { unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) char err_buf[256]; ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + // Flush any fatal alert OpenSSL queued for the peer before emitting the + // error, which tears the session down and detaches the endpoint. + EncOut(); Local argv[] = { String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), }; @@ -303,8 +356,9 @@ void DTLSSession::Cycle() { void DTLSSession::ClearOut() { if (destroyed_) return; - // Try to read decrypted application data from OpenSSL. - uint8_t buf[65536]; + // Try to read decrypted application data from OpenSSL. A DTLS record's + // plaintext is at most 2^14 bytes, so one SSL_read yields at most that much. + uint8_t buf[16384]; int read; while ((read = SSL_read(ssl_.get(), buf, sizeof(buf))) > 0) { @@ -316,6 +370,9 @@ void DTLSSession::ClearOut() { .ToLocalChecked(), }; EmitCallback(DTLS_CB_SESSION_MESSAGE, 1, argv); + // The message handler may have destroyed the session synchronously; stop + // reading if so (Cycle()'s strong reference keeps `this` itself alive). + if (destroyed_) return; } int err = SSL_get_error(ssl_.get(), read); @@ -334,8 +391,13 @@ void DTLSSession::ClearOut() { // Send our close_notify back. SSL_shutdown(ssl_.get()); EncOut(); + // Detach from the endpoint's session table before notifying JS so an + // observer of the close sees a consistent session count. Cycle() holds + // a strong reference for the duration of the pump. + if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_); Local argv[] = {}; EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); + Destroy(); } break; @@ -344,6 +406,9 @@ void DTLSSession::ClearOut() { unsigned long ossl_err = ERR_get_error(); // NOLINT(runtime/int) char err_buf[256]; ERR_error_string_n(ossl_err, err_buf, sizeof(err_buf)); + // Flush any fatal alert OpenSSL queued for the peer before emitting the + // error, which tears the session down and detaches the endpoint. + EncOut(); Local argv[] = { String::NewFromUtf8(env()->isolate(), err_buf).ToLocalChecked(), }; @@ -404,6 +469,12 @@ int DTLSSession::Send(const uint8_t* data, size_t len) { void DTLSSession::Close() { if (destroyed_ || closed_) return; + // Emitting the close below can synchronously free this session (a client + // session that owns its endpoint tears the endpoint -- and thus itself -- + // down from the close callback), and we call Destroy() afterwards. Pin a + // strong reference so `this` survives until we return. + BaseObjectPtr strong_ref{this}; + closed_ = true; state_->closing = 1; DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, closing_at); @@ -420,11 +491,21 @@ void DTLSSession::Close() { state_->open = 0; + // Detach from the endpoint's session table before notifying JS, so an + // observer of the close (e.g. one awaiting `closed`) sees a consistent + // session count. We stay alive via strong_ref, and endpoint_ remains valid + // for the callback below; the Destroy() that follows clears it. + if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_); + // Notify JS. HandleScope handle_scope(env()->isolate()); Context::Scope context_scope(env()->context()); Local argv[] = {}; EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv); + + // Release the remaining resources. RemoveSession above already detached us, + // so the one inside Destroy() is a no-op. + Destroy(); } void DTLSSession::Destroy() { @@ -659,15 +740,6 @@ void DTLSSession::GetSRTPProfile(const FunctionCallbackInfo& args) { } } -void DTLSSession::SetServername(const FunctionCallbackInfo& args) { - DTLSSession* session; - ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); - - CHECK(args[0]->IsString()); - Utf8Value servername(session->env()->isolate(), args[0]); - SSL_set_tlsext_host_name(session->ssl_.get(), *servername); -} - void DTLSSession::GetServername(const FunctionCallbackInfo& args) { DTLSSession* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); diff --git a/src/dtls/dtls_session.h b/src/dtls/dtls_session.h index d64d0e4d4873..162752b6eb4c 100644 --- a/src/dtls/dtls_session.h +++ b/src/dtls/dtls_session.h @@ -54,11 +54,19 @@ class DTLSSession final : public AsyncWrap { // |ssl_ctx| - the SSL_CTX to create the SSL* from // |remote| - the peer address // |is_server| - true if this is a server-side session + // |servername| - SNI to advertise (client only); nullptr to omit. + // |verify_host| - expected peer identity to verify (client only); + // nullptr disables identity checking. + // |verify_is_ip| - true if |verify_host| is an IP literal (verified + // against iPAddress SANs) rather than a DNS name. static BaseObjectPtr Create(Environment* env, DTLSEndpoint* endpoint, SSL_CTX* ssl_ctx, const SocketAddress& remote, - bool is_server); + bool is_server, + const char* servername = nullptr, + const char* verify_host = nullptr, + bool verify_is_ip = false); // Create a session from an already-initialized SSL object. // Used by the server after DTLSv1_listen() returns 1 — the SSL @@ -119,7 +127,6 @@ class DTLSSession final : public AsyncWrap { static void ExportKeyingMaterial( const v8::FunctionCallbackInfo& args); static void GetSRTPProfile(const v8::FunctionCallbackInfo& args); - static void SetServername(const v8::FunctionCallbackInfo& args); static void GetServername(const v8::FunctionCallbackInfo& args); public: diff --git a/test/parallel/test-dtls-accessors.mjs b/test/parallel/test-dtls-accessors.mjs new file mode 100644 index 000000000000..41c7ca7b2382 --- /dev/null +++ b/test/parallel/test-dtls-accessors.mjs @@ -0,0 +1,104 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLSEndpoint/DTLSSession state fields and callback accessors reflect +// what is set and the connection lifecycle. + +import { + hasCrypto, skip, mustCall, mustNotCall, mustCallAtLeast, +} from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +const gotServerSession = Promise.withResolvers(); + +const server = listen(mustCall((session) => { + gotServerSession.resolve(session); +}), { cert, key, port: 0, host: '127.0.0.1' }); + +// --- Endpoint state after listen(): bound and listening. --- +const es = server.state; +strictEqual(es.bound, true); +strictEqual(es.listening, true); +strictEqual(es.closing, false); +strictEqual(es.destroyed, false); +strictEqual(es.sessionCount, 0); + +// The busy property is settable via the endpoint and reflected in the state view. +strictEqual(server.busy, false); +strictEqual(es.busy, false); +server.busy = true; +strictEqual(server.busy, true); +strictEqual(es.busy, true); +server.busy = false; +strictEqual(es.busy, false); + +// --- Endpoint onerror accessor. --- +strictEqual(server.onerror, undefined); +const onEndpointError = mustNotCall(); +server.onerror = onEndpointError; +strictEqual(server.onerror, onEndpointError); + +const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, +}); + +// --- Session state during the handshake. --- +const cs = client.state; +strictEqual(cs.handshaking, true); +strictEqual(cs.open, false); +strictEqual(cs.closing, false); +strictEqual(cs.destroyed, false); +strictEqual(cs.hasMessageListener, false); + +// --- Session callback accessors: unset, then set. --- +strictEqual(client.onmessage, undefined); +strictEqual(client.onerror, undefined); +strictEqual(client.onhandshake, undefined); +strictEqual(client.onkeylog, undefined); +// A connect() session owns its internal endpoint. +strictEqual(client.ownsEndpoint, true); + +client.onmessage = mustNotCall(); +strictEqual(typeof client.onmessage, 'function'); +// Attaching a message listener flips the shared flag. +strictEqual(cs.hasMessageListener, true); + +client.onerror = mustNotCall(); +strictEqual(typeof client.onerror, 'function'); + +client.onhandshake = mustCall(); +strictEqual(typeof client.onhandshake, 'function'); + +client.onkeylog = mustCallAtLeast(); +strictEqual(typeof client.onkeylog, 'function'); + +await client.opened; + +// --- Session state after the handshake completes. --- +strictEqual(cs.handshaking, false); +strictEqual(cs.open, true); + +const serverSession = await gotServerSession.promise; +await serverSession.opened; +strictEqual(es.sessionCount, 1); + +await client.close(); +await server.close(); diff --git a/test/parallel/test-dtls-alpn.mjs b/test/parallel/test-dtls-alpn.mjs index b51721760fdf..dd454fdf8be5 100644 --- a/test/parallel/test-dtls-alpn.mjs +++ b/test/parallel/test-dtls-alpn.mjs @@ -26,7 +26,6 @@ const ca = readKey('ca1-cert.pem'); const serverAlpnChecked = Promise.withResolvers(); const endpoint = listen(mustCall(async (session) => { - session.onmessage = () => {}; await session.opened; // Server should see the negotiated ALPN protocol. strictEqual(session.alpnProtocol, 'coap'); @@ -54,3 +53,33 @@ await serverAlpnChecked.promise; await session.close(); await endpoint.close(); + +// ALPN with no protocol in common: the handshake still completes and neither +// peer reports a negotiated protocol. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((s) => gotServerSession.resolve(s)), { + cert: serverCert.toString(), + key: serverKey.toString(), + port: 0, + host: '127.0.0.1', + alpn: ['bar'], + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca.toString()], + rejectUnauthorized: false, + alpn: ['foo'], + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + strictEqual(client.alpnProtocol, undefined); + strictEqual(serverSession.alpnProtocol, undefined); + + await client.close(); + await server.close(); +} diff --git a/test/parallel/test-dtls-ciphers.mjs b/test/parallel/test-dtls-ciphers.mjs new file mode 100644 index 000000000000..e58a1443c9c7 --- /dev/null +++ b/test/parallel/test-dtls-ciphers.mjs @@ -0,0 +1,79 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: cipher and ECDH-curve selection and validation. + +import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, throws } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256'; + +// Case 1: a specific cipher is negotiated and reported on both peers. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + gotServerSession.resolve(session); + }), { cert, key, port: 0, host: '127.0.0.1', ciphers: CIPHER }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + ciphers: CIPHER, + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + strictEqual(client.cipher.name, CIPHER); + strictEqual(serverSession.cipher.name, CIPHER); + + await client.close(); + await server.close(); +} + +// Case 2: an invalid cipher list is rejected. +throws(() => listen(mustNotCall(), { + cert, key, port: 0, host: '127.0.0.1', ciphers: 'THIS-IS-NOT-A-CIPHER', +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// Case 3: a valid ECDH curve completes a handshake. +{ + const server = listen(mustCall(), { + cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'P-256', + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + ecdhCurve: 'P-256', + }); + + await client.opened; + + await client.close(); + await server.close(); +} + +// Case 4: an invalid ECDH curve is rejected. +throws(() => listen(mustNotCall(), { + cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'not-a-curve', +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); diff --git a/test/parallel/test-dtls-client-cert.mjs b/test/parallel/test-dtls-client-cert.mjs new file mode 100644 index 000000000000..2505eb6ff7c2 --- /dev/null +++ b/test/parallel/test-dtls-client-cert.mjs @@ -0,0 +1,73 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS mutual authentication. A server with requestCert verifies the +// client's certificate; a client that presents no certificate is rejected. + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { ok, rejects } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +// Case 1: the client presents a certificate the server can verify. +{ + const gotServerSession = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + gotServerSession.resolve(session); + }), { + cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1', + }); + + const client = connect('127.0.0.1', server.address.port, { + cert, key, ca: [ca], rejectUnauthorized: false, + }); + + await client.opened; + const serverSession = await gotServerSession.promise; + await serverSession.opened; + + // The server received and verified the client's certificate. + const clientCert = serverSession.peerCertificate; + ok(clientCert); + ok(clientCert.includes('BEGIN CERTIFICATE')); + + await client.close(); + await server.close(); +} + +// Case 2: the client presents no certificate; the server requires one and +// rejects the handshake. +{ + const server = listen(mustCall(), { + cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1', + }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], rejectUnauthorized: false, + }); + + // The exact alert text varies, so assert only that the handshake is rejected. + await rejects(client.opened, { + message: /handshake failure/ + }); + + // The failed client tears down its internally-owned endpoint. + await client.endpoint.closed; + await server.close(); +} diff --git a/test/parallel/test-dtls-connect-error-cleanup.mjs b/test/parallel/test-dtls-connect-error-cleanup.mjs new file mode 100644 index 000000000000..049c24879007 --- /dev/null +++ b/test/parallel/test-dtls-connect-error-cleanup.mjs @@ -0,0 +1,46 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: a client connect() whose handshake fails must tear down its internally +// owned endpoint, so the event loop can drain. Regression test for a failed +// connect leaking the endpoint (and hanging the process). + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { rejects } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +// The client rejects the certificate mid-handshake, so this server session +// never opens; its opened rejection is handled internally by the library. +const server = listen(mustCall(), { cert, key, port: 0, host: '127.0.0.1' }); + +// A servername that does not match the certificate, under rejectUnauthorized, +// makes the client's handshake fail during verification. +const session = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: true, + servername: 'wrong.example.com', +}); + +await rejects(session.opened, /certificate verify failed/i); + +// The failed connect must have closed its internally-owned endpoint. Without +// that, this await never settles and the test times out. +await session.endpoint.closed; + +await server.close(); diff --git a/test/parallel/test-dtls-destroy-in-callback.mjs b/test/parallel/test-dtls-destroy-in-callback.mjs new file mode 100644 index 000000000000..97344bb70719 --- /dev/null +++ b/test/parallel/test-dtls-destroy-in-callback.mjs @@ -0,0 +1,79 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: destroying a DTLS session synchronously from within a callback that is +// dispatched from the session's own I/O pump must not crash. The endpoint's +// session table holds the only strong reference to the session, so a reentrant +// destroy() removes it mid-pump; the implementation must keep the object alive +// until the pump unwinds (regression test for a use-after-free). + +import { hasCrypto, skip, mustCall } from '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; + +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +// --------------------------------------------------------------------------- +// Case 1: destroy the (server) session from inside onmessage. The datagram +// carrying the message drives receive -> pump -> onmessage -> destroy(), which +// frees the session's map entry while ClearOut() is still looping over ssl_. +{ + const destroyed = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + session.onmessage = mustCall(() => { + session.destroy(); + destroyed.resolve(); + }); + }), { cert, key, port: 0, host: '127.0.0.1' }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + }); + + await client.opened; + client.send('destroy me from onmessage'); + + await destroyed.promise; + + await client.close(); + await server.close(); +} + +// --------------------------------------------------------------------------- +// Case 2: destroy the (server) session from inside onhandshake. Handshake +// completion is emitted from the middle of the pump (Cycle), so destroying +// there must not free the session before the pump finishes unwinding. +{ + const destroyed = Promise.withResolvers(); + + const server = listen(mustCall((session) => { + session.onhandshake = mustCall(() => { + session.destroy(); + destroyed.resolve(); + }); + }), { cert, key, port: 0, host: '127.0.0.1' }); + + const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, + }); + + await destroyed.promise; + + await client.close(); + await server.close(); +} diff --git a/test/parallel/test-dtls-errors.mjs b/test/parallel/test-dtls-errors.mjs new file mode 100644 index 000000000000..e17497fcac6c --- /dev/null +++ b/test/parallel/test-dtls-errors.mjs @@ -0,0 +1,48 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: DTLS error handling for invalid certificate/key material and endpoint +// state. + +import { hasCrypto, skip, mustNotCall } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { throws } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, DTLSEndpoint } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const mismatchedKey = readKey('agent2-key.pem').toString(); + +// A malformed certificate PEM is rejected. +throws(() => listen(mustNotCall(), { + cert: 'not a certificate', key, port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// A malformed private key PEM is rejected. +throws(() => listen(mustNotCall(), { + cert, key: 'not a key', port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// A private key that does not match the certificate is rejected. +throws(() => listen(mustNotCall(), { + cert, key: mismatchedKey, port: 0, +}), { code: 'ERR_CRYPTO_OPERATION_FAILED' }); + +// Binding the same endpoint twice fails. +{ + const endpoint = new DTLSEndpoint(); + endpoint.bind('127.0.0.1', 0); + throws(() => endpoint.bind('127.0.0.1', 0), { code: 'ERR_INVALID_STATE' }); + await endpoint.close(); +} diff --git a/test/parallel/test-dtls-keylog.mjs b/test/parallel/test-dtls-keylog.mjs new file mode 100644 index 000000000000..49d70a4566f5 --- /dev/null +++ b/test/parallel/test-dtls-keylog.mjs @@ -0,0 +1,49 @@ +// Flags: --experimental-dtls --no-warnings + +// Test: the onkeylog callback delivers NSS-format key material during the +// handshake (useful for decrypting captures in Wireshark). + +import { hasCrypto, skip, mustCall, mustCallAtLeast } from '../common/index.mjs'; +import assert from 'node:assert'; +import * as fixtures from '../common/fixtures.mjs'; + +const { strictEqual, match } = assert; +const { readKey } = fixtures; + +if (!hasCrypto) { + skip('missing crypto'); +} + +if (!process.features.dtls) { + skip('DTLS is not enabled'); +} + +const { listen, connect } = await import('node:dtls'); + +const cert = readKey('agent1-cert.pem').toString(); +const key = readKey('agent1-key.pem').toString(); +const ca = readKey('ca1-cert.pem').toString(); + +const gotKeylog = Promise.withResolvers(); + +const server = listen(mustCall(), { + cert, key, port: 0, host: '127.0.0.1', +}); + +const client = connect('127.0.0.1', server.address.port, { + ca: [ca], + rejectUnauthorized: false, +}); + +// A keylog line is "