From 1c0b4f63634272f058b03acc821cf9d1b5c5c65e Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 7 May 2026 20:59:58 -0700 Subject: [PATCH 1/3] src,lib: implement experimental DTLS API Decided to take a short break from the work on QUIC to implement a DTLS API. Very experimental at this point but the basic API is there (inspired by the QUIC API work). The implementation is based on OpenSSL's built-in DTLS support and no other dependencies are required. DTLS is a datagram-based version of TLS that is used for things like WebRTC and CoAP. It provides similar security guarantees as TLS but is designed to work over UDP instead of TCP. This shouldn't be considered ready for production but it is a good starting point for experimentation and feedback. ```bash ./configure --experimental-dtls make -j{nproc} ./node --experimental-dtls my-dtls-app.js ``` Signed-off-by: James M Snell Assisted-by: Opencode:Opus 4.6 --- configure.py | 11 + doc/api/cli.md | 13 + doc/api/dtls.md | 371 ++++++++++ doc/node.1 | 5 + lib/dtls.js | 36 + lib/internal/bootstrap/node.js | 5 + lib/internal/bootstrap/realm.js | 3 +- lib/internal/dtls/dtls.js | 637 +++++++++++++++++ lib/internal/dtls/state.js | 168 +++++ lib/internal/dtls/stats.js | 43 ++ lib/internal/dtls/symbols.js | 37 + lib/internal/modules/cjs/loader.js | 3 + lib/internal/process/pre_execution.js | 10 + node.gyp | 19 + src/async_wrap.h | 2 + src/dtls/dtls.cc | 80 +++ src/dtls/dtls.h | 59 ++ src/dtls/dtls_context.cc | 460 ++++++++++++ src/dtls/dtls_context.h | 94 +++ src/dtls/dtls_endpoint.cc | 624 ++++++++++++++++ src/dtls/dtls_endpoint.h | 147 ++++ src/dtls/dtls_session.cc | 672 ++++++++++++++++++ src/dtls/dtls_session.h | 167 +++++ src/env_properties.h | 3 + src/node_binding.cc | 1 + src/node_binding.h | 9 +- src/node_builtins.cc | 5 + src/node_options.cc | 9 + src/node_options.h | 1 + test/common/index.js | 2 + test/common/index.mjs | 2 + test/doctool/test-make-doc.mjs | 2 +- test/parallel/test-dtls-alpn.mjs | 56 ++ test/parallel/test-dtls-async-dispose.mjs | 50 ++ test/parallel/test-dtls-basic.mjs | 90 +++ test/parallel/test-dtls-close.mjs | 110 +++ test/parallel/test-dtls-multiple-clients.mjs | 81 +++ test/parallel/test-dtls-options.mjs | 53 ++ .../parallel/test-dtls-session-properties.mjs | 64 ++ test/parallel/test-permission-net-dtls.mjs | 59 ++ test/parallel/test-process-features.js | 1 + test/parallel/test-process-get-builtin.mjs | 2 + test/sequential/test-async-wrap-getasyncid.js | 2 + 43 files changed, 4265 insertions(+), 3 deletions(-) create mode 100644 doc/api/dtls.md create mode 100644 lib/dtls.js create mode 100644 lib/internal/dtls/dtls.js create mode 100644 lib/internal/dtls/state.js create mode 100644 lib/internal/dtls/stats.js create mode 100644 lib/internal/dtls/symbols.js create mode 100644 src/dtls/dtls.cc create mode 100644 src/dtls/dtls.h create mode 100644 src/dtls/dtls_context.cc create mode 100644 src/dtls/dtls_context.h create mode 100644 src/dtls/dtls_endpoint.cc create mode 100644 src/dtls/dtls_endpoint.h create mode 100644 src/dtls/dtls_session.cc create mode 100644 src/dtls/dtls_session.h create mode 100644 test/parallel/test-dtls-alpn.mjs create mode 100644 test/parallel/test-dtls-async-dispose.mjs create mode 100644 test/parallel/test-dtls-basic.mjs create mode 100644 test/parallel/test-dtls-close.mjs create mode 100644 test/parallel/test-dtls-multiple-clients.mjs create mode 100644 test/parallel/test-dtls-options.mjs create mode 100644 test/parallel/test-dtls-session-properties.mjs create mode 100644 test/parallel/test-permission-net-dtls.mjs diff --git a/configure.py b/configure.py index 7b82e0c5e86e7d..1022dfc02de5df 100755 --- a/configure.py +++ b/configure.py @@ -1065,6 +1065,12 @@ default=None, help='build with experimental QUIC support') +parser.add_argument('--experimental-dtls', + action='store_true', + dest='experimental_dtls', + default=None, + help='build with experimental DTLS support') + parser.add_argument('--ninja', action='store_true', dest='use_ninja', @@ -2350,6 +2356,10 @@ def configure_quic(o): o['variables']['node_use_quic'] = b(options.experimental_quic and not options.without_ssl) +def configure_dtls(o): + o['variables']['node_use_dtls'] = b(options.experimental_dtls and + not options.without_ssl) + def configure_static(o): if options.fully_static or options.partly_static: if flavor == 'mac': @@ -2808,6 +2818,7 @@ def make_bin_override(): configure_v8(output, configurations) configure_openssl(output) configure_quic(output) +configure_dtls(output) configure_intl(output) configure_static(output) configure_inspector(output) diff --git a/doc/api/cli.md b/doc/api/cli.md index 80f2fa74818b92..1ee11555400931 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -1212,6 +1212,17 @@ If present, Node.js will look for a `node.config.json` file in the current working directory and load it as a configuration file. +### `--experimental-dtls` + + + +> 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 6604a480f7be29..da8a435f9779cd 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -719,6 +719,9 @@ 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. +. .It Fl -experimental-eventsource Enable exposition of EventSource Web API on the global scope. . @@ -1910,6 +1913,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 00000000000000..c4dc01052ea6ab --- /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 8bb014426a0359..bde4cb2be84b2d 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 0415763e360246..0fa7a8c4c1bcb7 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', @@ -132,7 +133,7 @@ const schemelessBlockList = new SafeSet([ 'test/reporters', ]); // Modules that will only be enabled at run time. -const experimentalModuleList = new SafeSet(['ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter']); +const experimentalModuleList = new SafeSet(['dtls', 'ffi', 'sqlite', 'quic', 'stream/iter', 'zlib/iter']); // Set up process.binding() and process._linkedBinding(). { diff --git a/lib/internal/dtls/dtls.js b/lib/internal/dtls/dtls.js new file mode 100644 index 00000000000000..d797f74b41e0df --- /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 00000000000000..be8272661740fe --- /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 00000000000000..645c57fc6c05c1 --- /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 00000000000000..0fe418d023d4de --- /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 65a35299eb6552..824214b55a2cb5 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -487,6 +487,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 16a80c2d4f410f..394c18887f72ce 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(); setupQuic(); setupWebStorage(); setupWebsocket(); @@ -412,6 +413,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 c06e95a98e5ce9..1a724ccb771342 100644 --- a/node.gyp +++ b/node.gyp @@ -37,6 +37,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', @@ -380,6 +381,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', @@ -1086,6 +1097,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 bf926754547706..8c8f1e59de366a 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 00000000000000..ccd8b98eaab8bb --- /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 00000000000000..1b27c2fbf5740d --- /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 00000000000000..ca5003df46c295 --- /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 00000000000000..11d8113d308125 --- /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 00000000000000..b1c91d4f221b8e --- /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 00000000000000..06ffe4bcc39ed2 --- /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 00000000000000..f423451a5302b9 --- /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 00000000000000..c4dee0c36d53dd --- /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 6530f89ec918ac..113cc066ab2c5d 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -415,6 +415,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 ee6fda2947db77..6ba22f5519b4c4 100644 --- a/src/node_binding.cc +++ b/src/node_binding.cc @@ -106,6 +106,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 d785ccc2238c71..b05c68ad07260f 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 b098a41cca9ea4..63dde770cc0195 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", #endif // !HAVE_FFI + "dtls", // Experimental. "ffi", // Experimental. "quic", // Experimental. "sqlite", // Experimental. diff --git a/src/node_options.cc b/src/node_options.cc index bbb72d2ba1bcf4..d7206e5b4d954b 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-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support", diff --git a/src/node_options.h b/src/node_options.h index e910cb011431ab..5d689912f582ca 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -133,6 +133,7 @@ class EnvironmentOptions : public Options { bool experimental_sqlite = HAVE_SQLITE; bool experimental_stream_iter = 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 09962cecd31932..7301ada71067b9 100755 --- a/test/common/index.js +++ b/test/common/index.js @@ -72,6 +72,7 @@ const hasInspector = Boolean(process.features.inspector); const hasSQLite = Boolean(process.versions.sqlite); const hasFFI = Boolean(process.config.variables.node_use_ffi); +const hasDtls = hasCrypto && !!process.features.dtls; const hasQuic = hasCrypto && !!process.features.quic; const hasLocalStorage = (() => { @@ -984,6 +985,7 @@ const common = { hasTemporal, hasFullICU, hasCrypto, + hasDtls, hasQuic, hasInspector, hasSQLite, diff --git a/test/common/index.mjs b/test/common/index.mjs index d42172ff18f984..0bece9113a13db 100644 --- a/test/common/index.mjs +++ b/test/common/index.mjs @@ -16,6 +16,7 @@ const { getBufferSources, getTTYfd, hasCrypto, + hasDtls, hasQuic, hasInspector, hasSQLite, @@ -73,6 +74,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 e7a6f1b85e75f8..555193227672cc 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 00000000000000..b51721760fdfad --- /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 00000000000000..f6c46c6168b187 --- /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 00000000000000..54f2c5b8e081ac --- /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 00000000000000..0efc3b043487d8 --- /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 00000000000000..c913c255d8bdb2 --- /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 00000000000000..c157222181e0f8 --- /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 00000000000000..07710083f1c922 --- /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 00000000000000..f2f2b7af2d2c3b --- /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 2af4808b6c5953..e12ae0029080dd 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 3582dcebca9eba..2295c160a874ac 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'); diff --git a/test/sequential/test-async-wrap-getasyncid.js b/test/sequential/test-async-wrap-getasyncid.js index 5db4a77631582c..9a976b7b8d86ce 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 ee1a20a616e5dc87706318f4fac4042958fb1997 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 10 May 2026 11:12:37 -0700 Subject: [PATCH 2/3] src, lib: add stats to dtls --- 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 28f3aad5c8ce49..444e5cbf0d7bb1 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 d797f74b41e0df..aa4018239d911a 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 645c57fc6c05c1..c1fbd7dc16f73a 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 ccd8b98eaab8bb..288d317dfa14b6 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 1b27c2fbf5740d..6f1737347433f2 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 b1c91d4f221b8e..9433241a2d1433 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 06ffe4bcc39ed2..a6fe94fff5b8cc 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 f423451a5302b9..8bcd06a4ae71d9 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 c4dee0c36d53dd..d64d0e4d48736a 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 00000000000000..32c17c90cee746 --- /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 1c2c85e46657965c62281c1bd56ee85654c90865 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 10 May 2026 11:44:34 -0700 Subject: [PATCH 3/3] src,lib: add dtls interop tests --- 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 aa4018239d911a..c4aab52e6e76f3 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 be8272661740fe..5d86b556f1ada8 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 c1fbd7dc16f73a..b3393fe6b76d44 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 0fe418d023d4de..fbeeadc562a0b0 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 555193227672cc..59e681707dd473 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 00000000000000..683d725e76698c --- /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 00000000000000..26e66710611fcf --- /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');