net: improve dtls cert verification

Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Claude/Opus
PR-URL: https://github.com/nodejs/node/pull/64314
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
James M Snell authored and GitHub committed 2026-08-01 06:15:08 +00:00
1 parent a6a1bd4de0
commit a7d16a8cc6
29 files changed
+1423 -86

No files matched your search

+9 -2
View File
@@ -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.
+58 -11
View File
@@ -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;
+9 -7
View File
@@ -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 {
+54 -32
View File
@@ -10,6 +10,7 @@
#include <node_errors.h>
#include <node_sockaddr-inl.h>
#include <util-inl.h>
#include <uv.h>
#include <openssl/err.h>
#include <openssl/evp.h>
@@ -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<Value>& 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<Value>& 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<DTLSContext*>(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<DTLSSession*>(app_data);
const sockaddr* sa = session->remote_address().data();
const sockaddr* sa =
static_cast<DTLSSession*>(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<unsigned char>((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);
msg,
addr_len + sizeof(uint64_t),
out,
out_len);
return result != nullptr;
}
if (result == nullptr) return 0;
*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,
+8
View File
@@ -57,6 +57,14 @@ class DTLSContext final : public BaseObject {
static void LoadDefaultCAs(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetECDHCurve(const v8::FunctionCallbackInfo<v8::Value>& 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,
+73 -11
View File
@@ -19,6 +19,7 @@
#include <openssl/err.h>
#include <openssl/ssl.h>
#include <cstddef>
#include <cstring>
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<Object> wrap)
: HandleWrap(env,
wrap,
@@ -155,7 +171,10 @@ int DTLSEndpoint::Listen(DTLSContext* context) {
}
BaseObjectPtr<DTLSSession> 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<DTLSSession> 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<DTLSEndpoint>(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<DTLSEndpoint>(this);
HandleWrap::Close();
}
@@ -331,8 +364,16 @@ void DTLSEndpoint::SetCallbacks(Local<Object> 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<DTLSEndpoint*>(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<DTLSEndpoint*>(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<Value> 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<const uint8_t*>(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<Function> cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE);
if (!cb.IsEmpty()) {
Local<Value> argv[] = {};
@@ -532,6 +580,9 @@ void DTLSEndpoint::DoBind(const FunctionCallbackInfo<Value>& 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<Value>& 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<Value>& args) {
void DTLSEndpoint::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackField("sessions", sessions_.size());
tracker->TrackFieldWithSize("recv_buf", recv_buf_.size());
}
} // namespace dtls
+18 -1
View File
@@ -14,6 +14,7 @@
#include <openssl/ssl.h>
#include <unordered_map>
#include <vector>
#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<DTLSSession> 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<char> recv_buf_;
// Session table: maps remote address -> session.
std::unordered_map<SocketAddress,
BaseObjectPtr<DTLSSession>,
@@ -144,6 +156,11 @@ class DTLSEndpoint final : public HandleWrap {
AliasedStruct<DTLSEndpointStateData> state_;
AliasedStruct<DTLSEndpointStats> 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<DTLSEndpoint> self_ref_;
bool listening_ = false;
uint32_t mtu_ = 1200; // Conservative default MTU for data payload
};
+86 -14
View File
@@ -18,6 +18,8 @@
#include <openssl/err.h>
#include <openssl/srtp.h>
#include <openssl/ssl.h>
#include <openssl/x509_vfy.h>
#include <openssl/x509v3.h>
#include <cstring>
@@ -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<DTLSSession> strong_ref{this};
DTLS_STAT_INCREMENT(DTLSSessionStats,
retransmit_count);
int ret = DTLSv1_handle_timeout(ssl_.get());
@@ -115,7 +121,6 @@ Local<FunctionTemplate> 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> 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> 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<DTLSSession> 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<Value> 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<Value> 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<Value> 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<DTLSSession> 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<Value> 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<Value>& args) {
}
}
void DTLSSession::SetServername(const FunctionCallbackInfo<Value>& 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<Value>& args) {
DTLSSession* session;
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
+9 -2
View File
@@ -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<DTLSSession> 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<v8::Value>& args);
static void GetSRTPProfile(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetServername(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetServername(const v8::FunctionCallbackInfo<v8::Value>& args);
public:
+104
View File
@@ -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();
+30 -1
View File
@@ -23,7 +23,6 @@ const ca = fixtures.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.
assert.strictEqual(session.alpnProtocol, 'coap');
@@ -51,3 +50,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();
}
+79
View File
@@ -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' });
+73
View File
@@ -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();
}
@@ -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();
@@ -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();
}
+48
View File
@@ -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();
}
+49
View File
@@ -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 "<LABEL> <hex> <hex>" (e.g. "CLIENT_RANDOM ...").
client.onkeylog = mustCallAtLeast((line) => {
strictEqual(typeof line, 'string');
match(line, /^\S+ [0-9a-f]+ [0-9a-f]+$/i);
gotKeylog.resolve();
});
await client.opened;
await gotKeylog.promise;
await client.close();
await server.close();
+68
View File
@@ -0,0 +1,68 @@
// Flags: --experimental-dtls --no-warnings
// Test: MTU option validation and that a small (but valid) MTU still completes
// a handshake and data exchange (exercising DTLS record fragmentation).
import { hasCrypto, skip, mustCall } 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, DTLSEndpoint } = 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();
// MTU must be within [256, 65535].
throws(() => new DTLSEndpoint({ mtu: 255 }), { code: 'ERR_OUT_OF_RANGE' });
throws(() => new DTLSEndpoint({ mtu: 65536 }), { code: 'ERR_OUT_OF_RANGE' });
// A valid boundary MTU is accepted.
{
const endpoint = new DTLSEndpoint({ mtu: 256 });
await endpoint.close();
}
// A small valid MTU forces the handshake flights (e.g. the certificate) to be
// fragmented across records, and the exchange still succeeds.
{
const received = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onmessage = mustCall((data) => {
strictEqual(data.toString(), 'hello');
session.send('world');
});
}), {
cert, key, port: 0, host: '127.0.0.1', mtu: 256,
});
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
mtu: 256,
});
client.onmessage = mustCall((data) => {
strictEqual(data.toString(), 'world');
received.resolve();
});
await client.opened;
client.send('hello');
await received.promise;
await client.close();
await server.close();
}
+14
View File
@@ -49,3 +49,17 @@ assert.throws(() => {
assert.throws(() => {
connect('localhost', 99999);
}, { code: 'ERR_OUT_OF_RANGE' });
// Test: mtu must be an integer within [256, 65535].
throws(() => {
connect('127.0.0.1', 4433, { mtu: 100 });
}, { code: 'ERR_OUT_OF_RANGE' });
throws(() => {
connect('127.0.0.1', 4433, { mtu: 70000 });
}, { code: 'ERR_OUT_OF_RANGE' });
// Test: alpn must be a string array or Buffer.
throws(() => {
connect('127.0.0.1', 4433, { alpn: 123 });
}, { code: 'ERR_INVALID_ARG_TYPE' });
@@ -0,0 +1,63 @@
// Flags: --experimental-dtls --no-warnings
// Test: rejectUnauthorized gates certificate-chain verification. A certificate
// that does not chain to a trusted CA is rejected when rejectUnauthorized is
// true and accepted when it is false.
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { match, 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();
// Case 1: with rejectUnauthorized, a certificate that does not chain to a
// trusted CA fails verification. The servername matches the certificate, so
// the failure is the untrusted chain and not an identity mismatch. No `ca` is
// supplied, so only the system-default CAs are trusted (which do not include
// this test's CA).
{
const server = listen(mustCall(), {
cert, key, port: 0, host: '127.0.0.1',
});
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: true,
servername: 'agent1',
});
await rejects(client.opened, /certificate verify failed/i);
await client.endpoint.closed;
await server.close();
}
// Case 2: the same untrusted certificate is accepted when verification is off.
{
const server = listen(mustCall(), {
cert, key, port: 0, host: '127.0.0.1',
});
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
const { protocol } = await client.opened;
match(protocol, /DTLS/i);
await client.close();
await server.close();
}
+49
View File
@@ -0,0 +1,49 @@
// Flags: --experimental-dtls --no-warnings
// Test: a listening DTLS server drops a non-DTLS (junk) datagram without
// crashing, and still accepts a real client afterwards.
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
import dgram from 'node:dgram';
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();
const server = listen(mustCall((session) => {
session.onhandshake = mustCall();
}), { cert, key, port: 0, host: '127.0.0.1' });
const { port } = server.address;
// Fire a datagram that is not a ClientHello at the server.
const raw = dgram.createSocket('udp4');
await new Promise((resolve, reject) => {
raw.send(Buffer.from('this is not a DTLS ClientHello'), port, '127.0.0.1',
(err) => (err ? reject(err) : resolve()));
});
await new Promise((resolve) => raw.close(resolve));
// A real client still completes a handshake against the same server.
const client = connect('127.0.0.1', port, {
ca: [ca],
rejectUnauthorized: false,
});
await client.opened;
await client.close();
await server.close();
+70
View File
@@ -0,0 +1,70 @@
// Flags: --experimental-dtls --no-warnings
// Test: DTLSSession.send() return values and the single-record size limit.
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 cert = readKey('agent1-cert.pem').toString();
const key = readKey('agent1-key.pem').toString();
const ca = readKey('ca1-cert.pem').toString();
// The largest application payload that fits in a single DTLS record (2^14).
const MAX_RECORD = 16384;
// Case 1: send() before the handshake returns -1; afterwards it returns the
// number of bytes written and the message is delivered whole.
{
const received = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onmessage = mustCall((data) => received.resolve(data.length));
}), { cert, key, port: 0, host: '127.0.0.1' });
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
});
strictEqual(client.send('before handshake'), -1);
await client.opened;
strictEqual(client.send(Buffer.alloc(MAX_RECORD, 0x61)), MAX_RECORD);
strictEqual(await received.promise, MAX_RECORD);
await client.close();
await server.close();
}
// Case 2: a payload larger than a single record cannot be sent.
{
const server = listen(mustCall((session) => {
session.onmessage = mustNotCall();
}), { 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;
strictEqual(client.send(Buffer.alloc(MAX_RECORD + 1, 0x61)), -1);
await client.close();
await server.close();
}
@@ -0,0 +1,27 @@
// Flags: --experimental-dtls --no-warnings
// Test: connect() surfaces a failure to set the SNI servername (for example a
// name longer than the TLS maximum of 255 bytes) as an error, rather than
// silently ignoring it.
import { hasCrypto, skip } from '../common/index.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect } = await import('node:dtls');
// SNI host names are limited to 255 bytes; a longer one must throw.
assert.throws(
() => connect('127.0.0.1', 12345, {
servername: 'a'.repeat(256),
rejectUnauthorized: false,
}),
{ code: 'ERR_CRYPTO_OPERATION_FAILED' },
);
@@ -0,0 +1,77 @@
// Flags: --experimental-dtls --no-warnings
// Test: a session is removed from its endpoint's session table when it closes,
// whether closed locally or by the peer. Regression test for closed sessions
// leaking in the table (which also blocks reuse of the peer's address).
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 cert = readKey('agent1-cert.pem').toString();
const key = readKey('agent1-key.pem').toString();
const ca = readKey('ca1-cert.pem').toString();
// ---------------------------------------------------------------------------
// Case 1: the server closes its own session -> removed from the table.
{
const gotSession = Promise.withResolvers();
const server = listen(mustCall((session) => {
gotSession.resolve(session);
}), { 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;
const session = await gotSession.promise;
strictEqual(server.state.sessionCount, 1);
await session.close();
strictEqual(server.state.sessionCount, 0);
await client.close();
await server.close();
}
// ---------------------------------------------------------------------------
// Case 2: the client closes -> the server sees the peer close_notify and
// removes the session from the table.
{
const gotSession = Promise.withResolvers();
const server = listen(mustCall((session) => {
gotSession.resolve(session);
}), { 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;
const session = await gotSession.promise;
strictEqual(server.state.sessionCount, 1);
await client.close(); // Sends close_notify to the server
await session.closed; // Server processes it, detaches, and emits its close
strictEqual(server.state.sessionCount, 0);
await server.close();
}
+78
View File
@@ -0,0 +1,78 @@
// Flags: --experimental-dtls --no-warnings
// Test: SRTP profile negotiation and keying-material export (DTLS-SRTP). Both
// peers must negotiate the same SRTP profile and derive identical keying
// material from exportKeyingMaterial.
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { strictEqual, deepStrictEqual, notDeepStrictEqual } = 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 SRTP_PROFILE = 'SRTP_AEAD_AES_128_GCM';
const gotServerSession = Promise.withResolvers();
const server = listen(mustCall((session) => {
gotServerSession.resolve(session);
}), {
cert,
key,
port: 0,
host: '127.0.0.1',
srtp: SRTP_PROFILE,
});
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
srtp: SRTP_PROFILE,
});
await client.opened;
const serverSession = await gotServerSession.promise;
await serverSession.opened;
// Both peers negotiate the same SRTP profile.
strictEqual(client.srtpProfile, SRTP_PROFILE);
strictEqual(serverSession.srtpProfile, SRTP_PROFILE);
// Both peers derive identical keying material for the same label and length.
const label = 'EXTRACTOR-dtls_srtp';
const clientKM = client.exportKeyingMaterial(60, label);
const serverKM = serverSession.exportKeyingMaterial(60, label);
strictEqual(clientKM.length, 60);
deepStrictEqual(clientKM, serverKM);
// The optional context is bound into the derivation: the same context yields
// the same material on both peers, and a different context yields different
// material.
const contextA = Buffer.from('context-a');
const contextB = Buffer.from('context-b');
deepStrictEqual(
client.exportKeyingMaterial(32, label, contextA),
serverSession.exportKeyingMaterial(32, label, contextA),
);
notDeepStrictEqual(
client.exportKeyingMaterial(32, label, contextA),
client.exportKeyingMaterial(32, label, contextB),
);
await client.close();
await server.close();
@@ -0,0 +1,50 @@
// Flags: --experimental-dtls --no-warnings
// Test: using the callback style (onerror) without awaiting opened/closed must
// not produce an unhandled promise rejection when the session errors.
import { hasCrypto, skip, mustCall, mustNotCall } 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');
}
// Any unhandled rejection fails the test.
process.on('unhandledRejection', mustNotCall());
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 server = listen(mustCall(), {
cert, key, port: 0, host: '127.0.0.1',
});
const errored = Promise.withResolvers();
// A verification failure makes connect() reject its opened promise. Handle the
// error only through the callback API and never touch opened/closed.
const session = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: true,
servername: 'wrong.example.com',
});
session.onerror = mustCall(() => errored.resolve());
await errored.promise;
// The failed session tears itself down; awaiting its closed promise is a real
// boundary that also gives any (incorrectly) unhandled opened rejection a turn
// to surface before the process exits.
await session.closed;
await server.close();
@@ -0,0 +1,83 @@
// Flags: --experimental-dtls --no-warnings
// Test: the DTLS client verifies the server certificate's identity (hostname
// or IP address) against the expected name, not merely that the certificate
// chains to a trusted CA. Also exercises that SNI is actually carried in the
// ClientHello (it is applied before the handshake begins).
import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
const { match, rejects, 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');
// The agent1 certificate has CN=agent1 and no subjectAltName, so OpenSSL
// matches the identity "agent1" (via the subject CN) and rejects other names.
const cert = readKey('agent1-cert.pem').toString();
const key = readKey('agent1-key.pem').toString();
const ca = readKey('ca1-cert.pem').toString();
// ---------------------------------------------------------------------------
// Case 1: a matching servername with rejectUnauthorized succeeds, and the
// server observes the SNI the client sent (proving it is in the ClientHello).
{
const sawServername = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onhandshake = mustCall(() => {
sawServername.resolve(session.servername);
});
}), { cert, key, port: 0, host: '127.0.0.1' });
const { port } = server.address;
const session = connect('127.0.0.1', port, {
ca: [ca],
rejectUnauthorized: true,
servername: 'agent1',
});
const { protocol } = await session.opened;
match(protocol, /DTLS/i);
strictEqual(await sawServername.promise, 'agent1');
await session.close();
await server.close();
}
// ---------------------------------------------------------------------------
// Case 2: a servername that does not match the certificate is rejected during
// the handshake, even though the certificate chain is otherwise valid.
{
const server = listen(mustCall((session) => {
// The client aborts after verifying the certificate, so the server-side
// handshake never completes.
session.onhandshake = mustNotCall();
}), { cert, key, port: 0, host: '127.0.0.1' });
const { port } = server.address;
const session = connect('127.0.0.1', port, {
ca: [ca],
rejectUnauthorized: true,
servername: 'wrong.example.com',
});
await rejects(session.opened, /certificate verify failed/i);
// Closing the session tears down the internally-owned client endpoint.
await session.close();
await server.close();
}
+9 -2
View File
@@ -51,9 +51,16 @@ const ca = readFileSync(join(fixturesDir, 'ca1-cert.pem')).toString();
);
}
// Test: Creating a DTLSEndpoint without connect/listen is allowed
// since no network I/O occurs at construction time.
// Test: Creating a DTLSEndpoint is allowed (no network I/O at construction
// time), but bind() requires net permission.
{
const endpoint = new DTLSEndpoint();
assert.ok(endpoint);
assert.throws(
() => endpoint.bind('127.0.0.1', 0),
{
code: 'ERR_ACCESS_DENIED',
permission: 'Net',
},
);
}
@@ -8,7 +8,7 @@ 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 { setTimeout as sleep } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
if (!hasCrypto) {
@@ -73,13 +73,13 @@ const timeout = setTimeout(() => {
// 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);
await sleep(500);
client.stdin.write('hello from openssl\n');
// Wait for the server to receive and reply.
await serverReceivedData.promise;
await setTimeout(500);
await sleep(500);
// Close stdin so s_client exits.
client.stdin.end();