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.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} + +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: `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` + + + +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.stats` + + + +* 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. +* `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. + +## 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` + +* {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..c4aab52e6e76f3 --- /dev/null +++ b/lib/internal/dtls/dtls.js @@ -0,0 +1,656 @@ +'use strict'; + +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + +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 { + DTLSEndpointStats, + DTLSSessionStats, +} = require('internal/dtls/stats'); + +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; + #stats; + #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.#stats = new DTLSSessionStats( + kPrivateConstructor, handle.getStats()); + 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 stats() { return this.#stats; } + 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; + #stats; + #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.#stats = new DTLSEndpointStats( + kPrivateConstructor, this.#handle.getStats()); + 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 stats() { return this.#stats; } + 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, +}; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/state.js b/lib/internal/dtls/state.js new file mode 100644 index 00000000000000..5d86b556f1ada8 --- /dev/null +++ b/lib/internal/dtls/state.js @@ -0,0 +1,174 @@ +'use strict'; + +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + +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, +}; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/stats.js b/lib/internal/dtls/stats.js new file mode 100644 index 00000000000000..b3393fe6b76d44 --- /dev/null +++ b/lib/internal/dtls/stats.js @@ -0,0 +1,341 @@ +'use strict'; + +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + +const { + BigUint64Array, + JSONStringify, +} = primordials; + +const { + getOptionValue, +} = require('internal/options'); + +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 { + /** @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 { + /** @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; + } +} + +module.exports = { + DTLSEndpointStats, + DTLSSessionStats, +}; + +/* c8 ignore stop */ diff --git a/lib/internal/dtls/symbols.js b/lib/internal/dtls/symbols.js new file mode 100644 index 00000000000000..fbeeadc562a0b0 --- /dev/null +++ b/lib/internal/dtls/symbols.js @@ -0,0 +1,43 @@ +'use strict'; + +// TODO(@jasnell) Temporarily ignoring c8 coverage for this file while tests +// are still being developed. +/* c8 ignore start */ + +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'), +}; + +/* c8 ignore stop */ 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..288d317dfa14b6 --- /dev/null +++ b/src/dtls/dtls.cc @@ -0,0 +1,98 @@ +#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); + + // 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; + 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..6f1737347433f2 --- /dev/null +++ b/src/dtls/dtls.h @@ -0,0 +1,107 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#if HAVE_OPENSSL && HAVE_DTLS + +#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 { + 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..9433241a2d1433 --- /dev/null +++ b/src/dtls/dtls_endpoint.cc @@ -0,0 +1,649 @@ +#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()), + 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) { + 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, "getStats", GetStats); + 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(GetStats); + 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(); + DTLS_STAT_INCREMENT(DTLSEndpointStats, client_sessions); + + // 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)) { + DTLS_STAT_INCREMENT_N(DTLSEndpointStats, bytes_sent, len); + DTLS_STAT_INCREMENT(DTLSEndpointStats, packets_sent); + 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; + } + + DTLS_STAT_INCREMENT_N(DTLSEndpointStats, bytes_sent, len); + DTLS_STAT_INCREMENT(DTLSEndpointStats, packets_sent); + 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; + } + + IncrementStat( + endpoint->stats_.Data(), nread); + IncrementStat( + endpoint->stats_.Data()); + + 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; + DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at); + + 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) { + DTLS_STAT_INCREMENT(DTLSEndpointStats, server_busy_count); + 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(); + DTLS_STAT_INCREMENT(DTLSEndpointStats, server_sessions); + + 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::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()); + + 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..a6fe94fff5b8cc --- /dev/null +++ b/src/dtls/dtls_endpoint.h @@ -0,0 +1,154 @@ +#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; +}; + +// 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() +// 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 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); + + // 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_; + AliasedStruct stats_; + + 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..8bcd06a4ae71d9 --- /dev/null +++ b/src/dtls/dtls_session.cc @@ -0,0 +1,691 @@ +#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; + DTLS_STAT_INCREMENT(DTLSSessionStats, + retransmit_count); + 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()), + stats_(env->isolate()) { + MakeWeak(); + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, created_at); + 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, "getStats", GetStats); + 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(GetStats); + 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; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, handshake_completed_at); + + 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) { + 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) + .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) { + DTLS_STAT_INCREMENT_N(DTLSSessionStats, bytes_sent, written); + DTLS_STAT_INCREMENT(DTLSSessionStats, messages_sent); + EncOut(); + } + return written; +} + +void DTLSSession::Close() { + if (destroyed_ || closed_) return; + + closed_ = true; + state_->closing = 1; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, closing_at); + + // 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; + DTLS_STAT_RECORD_TIMESTAMP(DTLSSessionStats, destroyed_at); + 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::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()); + 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..d64d0e4d48736a --- /dev/null +++ b/src/dtls/dtls_session.h @@ -0,0 +1,176 @@ +#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 + +#include "dtls.h" + +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; +}; + +// 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. +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 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); + 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_; + AliasedStruct stats_; +}; + +} // 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..59e681707dd473 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) { @@ -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/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-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(); 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) 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');