Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions doc/api/dtls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 58 additions & 11 deletions lib/internal/dtls/dtls.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const {
ArrayIsArray,
FunctionPrototypeBind,
PromisePrototypeThen,
PromiseWithResolvers,
SafeSet,
SymbolAsyncDispose,
Expand Down Expand Up @@ -41,6 +42,10 @@ const {
Buffer,
} = require('buffer');

const {
isIP,
} = require('internal/net');

const {
DTLSEndpointState,
DTLSSessionState,
Expand Down Expand Up @@ -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 ---
Expand Down Expand Up @@ -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]() {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
86 changes: 54 additions & 32 deletions src/dtls/dtls_context.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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,
Expand Down Expand Up @@ -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");
}
}
}

Expand Down Expand Up @@ -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);

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,
Expand Down
8 changes: 8 additions & 0 deletions src/dtls/dtls_context.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading