lib,src: apply multiple updates to dtls implementation

Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: Opecode
PR-URL: https://github.com/nodejs/node/pull/65511
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
This commit is contained in:
James M Snell committed 2026-09-16 00:39:35 +00:00
1 parent 18a9ba509a
commit bd2b98f549
56 files changed
+8197 -404

No files matched your search

+755 -67
View File
@@ -6,7 +6,7 @@ added: REPLACEME
<!-- introduced_in=REPLACEME -->
> Stability: 1 - Experimental
> Stability: 1.1 - Active Development
<!-- source_link=lib/dtls.js -->
@@ -74,20 +74,73 @@ added: REPLACEME
* `options` {Object}
* `cert` {string|Buffer} Server certificate in PEM format. **Required.**
* `key` {string|Buffer} Server private key in PEM format. **Required.**
* `secureContext` {DTLSSecureContext} A context from
[`dtls.createSecureContext()`][] to use instead of building one from the
credential options below. Must have been created with `isServer: true`.
Cannot be combined with any option the context already carries.
* `sni` {Object|Function} Server Name Indication. A map of host names to the
identity to serve them with, or a function returning one. Cannot be
combined with `secureContext`; set it on the context instead. See
[Server Name Indication][].
* `passphrase` {string} Passphrase to decrypt `key`, if it is encrypted.
Ignored when `key` is not encrypted. Unlike `key` and `cert`, this must be
a string, matching [`tls.createSecureContext()`][].
* `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.
* `alpn` {string\[]|Buffer} ALPN protocol names. Each name must be between
1 and 255 bytes. A `Buffer` must already be in ALPN wire format: one
length byte followed by that many bytes, repeated.
* `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`.
* `requestCert` {boolean} Request a certificate from the client.
**Default:** `false`.
* `rejectUnauthorized` {boolean} Only has an effect together with
`requestCert`. When `true`, a client that presents no certificate, or one
that does not chain to a trusted CA, is rejected during the handshake and
receives a TLS alert. When `false`, the certificate is still requested and
verified but the handshake completes regardless, leaving the decision to
the application via [`session.authorized`][]. **Default:** `true`.
* `mtu` {number} Maximum size in bytes of a DTLS datagram. **Default:**
`1200`.
* `handshakeTimeout` {number} Milliseconds a handshake may take before it is
abandoned. `0` disables it. **Default:** `60000`. See
[Handshake timeout][].
* `ipv6Only` {boolean} When `true`, an IPv6 endpoint serves IPv6 only. When
`false`, binding `'::'` also accepts IPv4 peers, which arrive with mapped
addresses such as `'::ffff:203.0.113.1'` -- anything keyed on the peer
address, including `maxSessionsPerHost`, sees them in that form. Has no
effect on an IPv4 endpoint. **Default:** `false`.
* `reusePort` {boolean} When `true`, sets `SO_REUSEPORT`, so several
processes may bind the same port and the kernel spreads arriving
datagrams between them. Every one of them must set it. **Default:**
`false`.
* `udpReceiveBufferSize` {number} Size in bytes for the socket's receive
buffer (`SO_RCVBUF`). Raising it gives the endpoint room for bursts that
the default would drop. The kernel clamps this to its own maximum.
**Default:** the system default.
* `udpSendBufferSize` {number} Size in bytes for the socket's send buffer
(`SO_SNDBUF`). Clamped as above. **Default:** the system default.
* `udpTTL` {number} IP time-to-live for outgoing datagrams, from `1` to
`255`. **Default:** the system default.
* `maxSessions` {number} The maximum number of concurrent sessions the
endpoint will hold. Set to `0` for no limit. **Default:** `10000`.
* `maxSessionsPerHost` {number} The maximum number of concurrent sessions
from any single source IP address, ignoring port. Set to `0` for no limit.
**Default:** `1000`.
* `sessionIdContext` {string} Opaque identifier scoping resumable sessions
to this server, at most 32 bytes. **Default:** a value derived from
`process.argv`, as in `tls.createServer()`.
* Returns: {DTLSEndpoint}
Creates a DTLS server bound to the specified address and port. The server
uses automatic HMAC-based cookie exchange for DoS protection.
uses automatic HMAC-based cookie exchange for DoS protection. See
[Denial of service][].
Binding failures are thrown with the code the operating system gave, as in
`net` and `dgram`: an address already in use throws an error whose `code` is
`'EADDRINUSE'`, with `errno` and `syscall` set.
```mjs
import { listen } from 'node:dtls';
@@ -117,26 +170,50 @@ console.log('DTLS server listening on', endpoint.address);
added: REPLACEME
-->
* `host` {string} Remote host to connect to.
* `host` {string} Remote host to connect to, as an IPv4 or IPv6 literal.
Host names are not resolved.
* `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.
* `secureContext` {DTLSSecureContext} A context from
[`dtls.createSecureContext()`][] to use instead of building one from the
credential options below. Must **not** have been created with
`isServer: true`. Cannot be combined with any option the context already
carries.
* `psk` {Object|Function} A pre-shared key as `{ identity, key }`, or a
function returning one. See [Pre-shared keys][].
* `session` {Buffer} A session from [`session.session`][] on an earlier
connection, to resume rather than handshake in full. See
[Session resumption][].
* `passphrase` {string} Passphrase to decrypt `key`, if it is encrypted.
Ignored when `key` is not encrypted. Unlike `key` and `cert`, this must be
a string, matching [`tls.createSecureContext()`][].
* `rejectUnauthorized` {boolean} When `true`, the server's certificate must
both chain to a trusted CA and match the expected identity (`servername`,
or `host` when `servername` is not set); otherwise the handshake is
aborted and `session.opened` rejects. When `false`, the certificate is not
verified. **Default:** `true`.
aborted and `session.opened` rejects. When `false`, the certificate is
still verified and the handshake completes regardless, leaving the
decision to the application via [`session.authorized`][] and
[`session.authorizationError`][]. **Default:** `true`.
* `servername` {string} Server name used for the SNI (Server Name
Indication) extension and as the identity checked during certificate
verification. **Default:** the `host` argument. Set to `''` to disable SNI.
SNI is never sent for IP address literals.
* `bindHost` {string} Local bind address. **Default:** `'0.0.0.0'`.
* `bindHost` {string} Local bind address. **Default:** `'::'` when `host` is an
IPv6 literal, otherwise `'0.0.0.0'`. The local socket must be in the same
address family as the peer.
* `bindPort` {number} Local bind port. **Default:** `0` (ephemeral).
* `alpn` {string\[]|Buffer} ALPN protocol names.
* `alpn` {string\[]|Buffer} ALPN protocol names. Each name must be between
1 and 255 bytes. A `Buffer` must already be in ALPN wire format: one
length byte followed by that many bytes, repeated.
* `srtp` {string} SRTP protection profile names.
* `mtu` {number} Maximum transmission unit. **Default:** `1200`.
* `mtu` {number} Maximum size in bytes of a DTLS datagram. **Default:**
`1200`.
* `handshakeTimeout` {number} Milliseconds a handshake may take before it is
abandoned and `session.opened` rejects. `0` disables it. **Default:**
`60000`. See [Handshake timeout][].
* Returns: {DTLSSession}
Connects to a DTLS server. Returns a `DTLSSession` whose `opened` property
@@ -146,7 +223,7 @@ is a `Promise` that resolves when the handshake completes.
import { connect } from 'node:dtls';
import { readFileSync } from 'node:fs';
const session = connect('localhost', 4433, {
const session = connect('127.0.0.1', 4433, {
ca: [readFileSync('ca-cert.pem')],
});
@@ -158,6 +235,419 @@ session.onmessage = (data) => {
};
```
## `dtls.createSecureContext([options])`
<!-- YAML
added: REPLACEME
-->
* `options` {Object}
* `alpn` {string\[]} ALPN protocols.
* `ca` {string|Buffer|Array} CA certificates in PEM format. When omitted,
the bundled default certificate authorities are used.
* `cert` {string|Buffer} Certificate in PEM format.
* `ciphers` {string} OpenSSL cipher suite list.
* `ecdhCurve` {string} Named curve or curve list for ECDH.
* `isServer` {boolean} Build a context for a server. **Default:** `false`.
* `key` {string|Buffer} Private key in PEM format.
* `passphrase` {string} Passphrase for `key`, if it is encrypted.
* `rejectUnauthorized` {boolean} Verification behaviour, as for
[`dtls.listen()`][] and [`dtls.connect()`][].
* `requestCert` {boolean} Request a certificate from the peer. Servers only.
* `sessionIdContext` {string} Session id context. Servers only.
* `sni` {Object|Function} Server Name Indication. Servers only. See
[Server Name Indication][].
* `psk` {Object|Function} Pre-shared keys. See [Pre-shared keys][].
* `pskIdentityHint` {string} Identity hint to advertise, naming which key a
client should pick. Requires `psk`. Servers only.
* `srtp` {string} SRTP profile list.
* `ticketKeys` {Buffer} Session ticket keys, for resuming sessions across
endpoints and restarts. Servers only. See [Session resumption][].
* Returns: {DTLSSecureContext}
Options marked "Servers only" require `isServer: true`. Passing one to a
client context throws `ERR_INVALID_ARG_VALUE`, rather than being ignored or
applied where it can have no effect.
Creates a reusable secure context. Pass it to [`dtls.listen()`][] or
[`dtls.connect()`][] as `secureContext` in place of the credential options.
A context holds a parsed certificate and key and, when `ca` is given, its own
certificate store; roughly 28 KiB in total. Building one per connection is
therefore expensive in memory rather than in time -- two thousand of them cost
about 54 MiB, against 2 MiB when a single context is shared. Clients opening
many connections should build the context once.
The peer identity checked during verification is **not** part of the context.
It is bound to each connection from `servername` (or the host), so one context
can be used against different peers and still reject the wrong certificate.
`isServer` is fixed when the context is created, because it selects the
underlying OpenSSL method. Passing a server context to [`dtls.connect()`][],
or a client context to [`dtls.listen()`][], throws.
```mjs
import { connect, createSecureContext, listen } from 'node:dtls';
import { readFileSync } from 'node:fs';
const serverContext = createSecureContext({
cert: readFileSync('server-cert.pem'),
key: readFileSync('server-key.pem'),
isServer: true,
});
// One context, several endpoints.
const a = listen(onsession, { secureContext: serverContext, port: 5684 });
const b = listen(onsession, { secureContext: serverContext, port: 5685 });
const clientContext = createSecureContext({
ca: readFileSync('ca-cert.pem'),
});
// One context, many connections, each verified against its own name.
const s1 = connect('192.0.2.1', 5684, {
secureContext: clientContext,
servername: 'a.example.com',
});
const s2 = connect('192.0.2.2', 5684, {
secureContext: clientContext,
servername: 'b.example.com',
});
```
## Server Name Indication
An endpoint can serve more than one identity by giving `listen()` an `sni`
map, or a function. Each key of a map is a host name and each value is either
a
[`DTLSSecureContext`][] created with `isServer: true`, or a plain object of
the same options [`dtls.createSecureContext()`][] takes:
```mjs
import { createSecureContext, listen } from 'node:dtls';
import { readFileSync } from 'node:fs';
const endpoint = listen(onsession, {
cert: readFileSync('default-cert.pem'),
key: readFileSync('default-key.pem'),
port: 5684,
sni: {
'api.example.com': {
cert: readFileSync('api-cert.pem'),
key: readFileSync('api-key.pem'),
},
'www.example.com': createSecureContext({
cert: readFileSync('www-cert.pem'),
key: readFileSync('www-key.pem'),
isServer: true,
}),
'*': {
cert: readFileSync('default-cert.pem'),
key: readFileSync('default-key.pem'),
},
},
});
```
The `'*'` key is the fallback, used when the client's name matches nothing and
when the client sends no name at all. **Without it, an unmatched name is
refused with an `unrecognized_name` alert** rather than falling back to the
endpoint's own `cert` and `key`; providing an `sni` map is taken to mean that
only the names in it are served. [`tls.createServer()`][] differs here: its
`SNICallback` falls back to the default identity silently.
Verification follows the selected identity, so an entry carrying its own `ca`
accepts only client certificates issued under it. `requestCert` and
`rejectUnauthorized` are not per-identity: they belong to the endpoint and
apply to every name it serves.
A function may be given instead of a map, for identities that are chosen
rather than enumerated:
```mjs
listen(onsession, {
port: 5684,
cert,
key,
sni: (servername) => contexts.get(servername),
});
```
It is called with the name the client asked for, or `undefined` if the client
sent no SNI extension, and returns what a map entry holds: a
[`dtls.createSecureContext()`][] result or the options to build one. Returning
nothing declines the name, which is refused exactly as an unmatched map with no
`'*'` entry is, rather than falling back to the endpoint's own certificate.
The function runs during the handshake and must return synchronously, so it
cannot consult a database. Returning a prepared context is worth doing:
building one from options parses the certificate again on every handshake.
An exception thrown by the function fails that handshake and is reported to the
session's error handler, like any other handshake failure. It does not reach
the process as an uncaught exception.
The certificate and the cipher list both follow the selected context.
Pre-shared keys do not. OpenSSL installs the PSK callbacks on the connection
when it is created, before any name is known, and selecting an identity does
not replace them, so the keys a server accepts are always the endpoint's own.
A `psk` given on an SNI identity is never consulted, and an identity cannot be
served over PSK alone.
`sni` belongs to the secure context rather than to the endpoint, so it can be
given to [`dtls.createSecureContext()`][] and cannot be combined with a
`secureContext` that already exists. Applying it to a prepared context would
reconfigure that context for every endpoint sharing it, and the identities a
server serves are part of what its context is.
A connection refused for an unrecognized name still reaches the `listen()`
callback: the session exists once the client's address is validated, which
happens before the name is examined. It then fails like any other handshake
failure.
## Denial of service
Cookie exchange proves a peer can receive at its claimed address, but it does
not limit how many sessions that peer may then establish, and each session
holds a TLS state machine, two buffers and a timer. `maxSessions` bounds the
total; `maxSessionsPerHost` is what prevents one peer from taking all of it.
A peer refused by either cap is answered with silence rather than an alert,
because replying to an address that has not completed cookie exchange would
create an amplification vector; a legitimate client retransmits and is
admitted once there is room. Refusals are counted by
[`endpointStats.serverRefusedCount`][].
Deployments serving many clients behind a single NAT may need to raise
`maxSessionsPerHost`.
## Handshake timeout
A handshake that never finishes is abandoned after `handshakeTimeout`
milliseconds, and its session error is `DTLS handshake timeout`.
OpenSSL already gives up on its own, but only after twelve retransmits on a
doubling backoff capped at 60 seconds -- around eight minutes in total. Until
then the session holds its place against `maxSessions` (see
[Denial of service][]),
so handshakes that are started and abandoned can occupy an endpoint for the
cost of starting them. That needs no spoofing: the peer completes the cookie
exchange and then simply stops.
The two limits coexist and whichever comes first ends the handshake. The
retransmit schedule itself is untouched, deliberately -- compressing it to
force earlier failure would cause spurious retransmissions on exactly the
lossy links DTLS is meant for.
The timeout covers resumed and PSK handshakes as well, and stops applying once
the handshake completes; it is not an idle timeout.
A handshake can stall without either peer being at fault or aware.
DTLS discards records it cannot authenticate rather than answering them
(RFC 6347 section 4.1.2.1), so a mismatched pre-shared key or a cipher list
with nothing in common produces silence rather than an alert. This timeout is
what ends those.
## Pre-shared keys
DTLS can authenticate with a key both peers already hold instead of a
certificate (RFC 4279). This is how it is usually deployed to constrained
devices, which frequently have no certificate at all.
A server gives the identities it accepts; a client gives the one it is. No
certificate is needed on either side:
```mjs
import { connect, listen } from 'node:dtls';
const endpoint = listen(onsession, {
port: 5684,
psk: { 'device-42': deviceKey },
});
const client = connect('192.0.2.1', 5684, {
psk: { identity: 'device-42', key: deviceKey },
});
```
Either side may pass a function instead, for keys that are looked up or
derived rather than known up front. A server's is called with the identity the
client offered and returns the key, or nothing to refuse it. A client's is
called with the server's identity hint, if it sent one, and returns
`{ identity, key }`:
```mjs
listen(onsession, {
port: 5684,
psk: (identity) => deriveKey(masterSecret, identity),
});
```
The callback runs during the handshake and must return synchronously, so it
cannot consult a database. Where both are given, the map is checked first and
the callback is only reached when the map has no answer -- a configuration
using only the map never runs JavaScript inside the handshake.
An exception thrown by the callback fails that handshake and is reported to
the session's error handler. It does not reach the process as an uncaught
exception.
### Cipher suites
The default cipher list excludes PSK, so giving `psk` without `ciphers`
enables the PSK suites. Supplying `ciphers` disables that and uses exactly
what was asked for.
A server keeps the certificate suites as well, since it may serve both kinds
of client on one port. A client does not: a client that configured a
pre-shared key and no CA wants the key, and leaving the certificate suites
enabled would let a server choose one, failing the handshake while verifying a
certificate the caller never meant to rely on.
Forward-secret PSK key exchanges are preferred over plain PSK of the same
strength. Plain PSK derives its keys from the shared secret alone, so anyone
who later learns that key can decrypt traffic they recorded earlier. `RSA-PSK`
is excluded: it needs a certificate and adds no forward secrecy.
CoAP requires `TLS_PSK_WITH_AES_128_CCM_8` (RFC 7252), whose 64-bit
authentication tag OpenSSL rejects at security level 1 and above. Node.js
default is above it, so that suite has to be asked for explicitly and with the
security level lowered:
```mjs
listen(onsession, { port: 5684, psk, ciphers: 'PSK-AES128-CCM8@SECLEVEL=0' });
```
### Failure modes
A wrong key does not produce an error. The identity only names the key, so the
handshake proceeds and the two sides derive different secrets; the first
record that fails authentication is then discarded rather than answered, since
DTLS discards invalid records instead of replying to them (RFC 6347 section
4.1.2.1). Neither peer is told anything and both retransmit.
A cipher list with nothing in common behaves the same way, which is what makes
the `CCM8` case above present as a stall rather than a rejection. Both are
ended by [`handshakeTimeout`][], after 60 seconds by default.
An identity the server does not recognise is refused outright, and the client
sees the handshake fail.
## Session resumption
A resumed handshake skips the server's certificate, which matters more here
than it does over TCP: the `Certificate` flight is fragmented across several
datagrams, and losing any one of them costs a retransmission timeout. Measured
on loopback, a full handshake has the server send 1850 bytes in 4 packets
against 280 bytes in 3 for a resumed one.
A client reads [`session.session`][] once the session is open and passes it to
a later [`dtls.connect()`][]:
```mjs
import { connect } from 'node:dtls';
const first = connect('192.0.2.1', 5684, { ca, servername: 'device.example' });
await first.opened;
const ticket = first.session; // Buffer.
await first.close();
const second = connect('192.0.2.1', 5684, {
ca,
servername: 'device.example',
session: ticket,
});
await second.opened;
console.log(second.reused); // True.
```
A session that the server will not accept -- expired, or issued by a different
endpoint -- is not an error. The handshake simply proceeds in full, and
[`session.reused`][] is `false`.
The cookie exchange still happens for a resumed handshake, so resumption is not
a way around the address validation described under [Denial of service][].
### Binding to the authenticated host
A session may only be resumed against the identity it was authenticated for --
the `servername`, or the host when there is none. Reusing it for anything else
throws.
This is not a convenience check. A resumed handshake does not re-send or
re-verify the peer's certificate; it inherits the authenticated identity of the
original session. Replaying a session against a different host would therefore
skip verification while appearing to succeed. For the same reason a `session`
that did not come from [`session.session`][] is rejected outright: nothing
records which identity it belongs to, so it cannot be checked.
### Resuming under `rejectUnauthorized`
A session carries the verification result it was established with, so a session
established with `rejectUnauthorized: false` cannot be resumed by a connection
that asked for a verified peer. The handshake fails:
```mjs
import { connect } from 'node:dtls';
// Connected without verifying anything.
const first = connect('192.0.2.1', 5684, { rejectUnauthorized: false });
await first.opened;
console.log(first.authorized); // False.
const ticket = first.session;
await first.close();
const second = connect('192.0.2.1', 5684, {
rejectUnauthorized: true,
session: ticket,
});
await second.opened; // Rejects: verification failed.
```
The host is the same in both, so binding the session to its authenticated
identity does not cover this on its own; what differs is whether the caller
asked for the peer to be verified. Because a resumed handshake runs no
verification of its own, the recorded result is re-checked once it completes,
and a session whose peer never verified is refused wherever verification is
required. [`session.authorized`][] and [`session.authorizationError`][] report
the recorded result on a resumed session either way.
### Ticket keys
The key that encrypts session tickets is generated at random for each context,
so by default a ticket is only good for the endpoint that issued it and only
until the process restarts. Give every endpoint the same `ticketKeys` to let
tickets be resumed across a restart or a cluster:
```mjs
import { listen } from 'node:dtls';
import { randomBytes } from 'node:crypto';
const ticketKeys = randomBytes(80); // Share this between processes.
const endpoint = listen(onsession, { cert, key, port: 5684, ticketKeys });
```
The length is OpenSSL's: a key name followed by an HMAC key and an AES key. It
differs from the 48 bytes [`tls.createServer()`][] uses, which is a layout
`node:tls` defines for itself. Supplying the wrong length throws and reports
the length expected.
Ticket keys are long-lived secrets. Anyone holding them can decrypt tickets and
recover the sessions they protect, so treat them as key material and rotate
them.
## Class: `DTLSSecureContext`
<!-- YAML
added: REPLACEME
-->
An opaque, reusable bundle of credentials and TLS settings, created by
[`dtls.createSecureContext()`][]. It cannot be constructed directly.
### `secureContext.isServer`
* Returns: {boolean} `true` if the context was created for a server.
## Class: `DTLSEndpoint`
<!-- YAML
@@ -172,19 +662,6 @@ Manages a UDP socket and multiplexes DTLS sessions.
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`
<!-- YAML
@@ -194,7 +671,7 @@ added: REPLACEME
* 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.
live and updated as data flows through the endpoint.
### `endpoint.busy`
@@ -214,6 +691,10 @@ Gracefully closes the endpoint. All active sessions are closed with
Immediately destroys the endpoint without sending `close_notify` alerts.
### `endpoint.destroyed`
* {boolean} True once the endpoint has been destroyed.
### `endpoint.closed`
* {Promise} Resolves when the endpoint has fully closed.
@@ -304,6 +785,31 @@ added: REPLACEME
* Type: {bigint} The total number of incoming connections rejected because the
endpoint was marked busy. Read only.
### `endpointStats.serverRejectedCount`
<!-- YAML
added: REPLACEME
-->
* Type: {bigint} The number of datagrams discarded before a handshake was
attempted because they could not be a ClientHello. Read only.
Datagrams arriving at a listening endpoint that do not match an existing
session are screened for the shape of a DTLS ClientHello record before any
state is allocated for them. A steadily rising value indicates junk or scan
traffic rather than failing clients, which are counted as sessions that never
complete.
### `endpointStats.serverRefusedCount`
<!-- YAML
added: REPLACEME
-->
* Type: {bigint} The number of otherwise valid handshake attempts refused
because the endpoint was at `maxSessions` or the peer was at
`maxSessionsPerHost`. Read only.
### `endpointStats.isConnected`
<!-- YAML
@@ -325,13 +831,27 @@ Represents a DTLS association with a single remote peer.
### `session.send(data)`
* `data` {string|Buffer} The data to send.
* `data` {string|Buffer|TypedArray|DataView} The data to send. At most 16384
bytes. A view sends the bytes it covers, so an offset or a subarray is sent
as given rather than as the whole buffer behind it.
* 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).
DTLS carries application data in a single record per datagram and does not
fragment it, so `data` must fit in one record. Sending more throws
`ERR_OUT_OF_RANGE`. This limit is independent of the `mtu` option: a record
larger than the path MTU is still sent, and is fragmented by IP.
Throws `ERR_INVALID_STATE` if the handshake has not completed, or if the
session is closed or destroyed.
A successful return means the data was handed to the DTLS layer and written
to the socket, not that the peer received it. DTLS runs over UDP, so
application data may still be lost in transit.
### `session.close()`
* Returns: {Promise} Resolves when the session is closed.
@@ -342,13 +862,40 @@ Initiates a graceful DTLS shutdown by sending a `close_notify` alert.
Immediately destroys the session without sending `close_notify`.
### `session.destroyed`
* {boolean} True once the session has been destroyed, whether by
[`session.destroy()`][], by a close, or by its endpoint going away.
### `session.endpoint`
* {DTLSEndpoint} The endpoint carrying this session. For a session from
[`dtls.listen()`][] this is the listening endpoint, shared with every other
session on it; for one from [`dtls.connect()`][] it is the endpoint created
to carry that session alone.
### `session.servername`
* {string|undefined} The server name for this session: the name the client
sent in the SNI extension, read on either side of the connection.
`undefined` when no name was sent. See [Server name indication][].
### `session.opened`
* {Promise} Resolves with `{ protocol }` when the DTLS handshake completes.
Rejects if the handshake fails, and also if the session is closed or
destroyed before the handshake completes -- in that case with
`ERR_INVALID_STATE`, or with the error passed to
[`session.destroy()`][] if one was given. The promise always settles, so
awaiting it cannot hang.
### `session.closed`
* {Promise} Resolves when the session is fully closed.
* {Promise} Settles when the session is fully closed. Resolves when the close
was graceful, and rejects with the error when the session was destroyed with
one, or when its endpoint was. The promise always settles, so awaiting it
cannot hang.
### `session.remoteAddress`
@@ -365,11 +912,119 @@ Immediately destroys the session without sending `close_notify`.
### `session.peerCertificate`
* Returns: {string|undefined} The peer's certificate in PEM format.
* Returns: {string|undefined} The peer's certificate in PEM format, or
`undefined` if the peer sent none.
This is the leaf certificate as PEM text and nothing else. For the issuer chain
and the parsed fields, use [`session.peerX509Certificate`][], whose `toString()`
returns this same PEM. Use [`session.authorized`][] and
[`session.authorizationError`][] for the verification result rather than
parsing either.
### `session.peerX509Certificate`
<!-- YAML
added: REPLACEME
-->
* Returns: {X509Certificate|undefined} The peer's certificate, or `undefined`
if the peer sent none.
An [`X509Certificate`][] for the peer's leaf certificate. The issuer chain is
reachable through its `issuerCertificate` property, and the parsed fields --
`subject`, `issuer`, `validFrom`, `validTo`, `fingerprint256`, `serialNumber`
and the rest -- are properties of that object.
Where [`tls.TLSSocket.getPeerCertificate()`][] returns a plain dictionary with
`valid_from`, `valid_to` and a chain walked through `issuerCertificate`, this
returns the same `X509Certificate` class that
[`tls.TLSSocket.getPeerX509Certificate()`][] does. Call `toLegacyObject()` on
it to get the dictionary form.
The same object is returned on every access once the peer's certificate is
available.
### `session.session`
<!-- YAML
added: REPLACEME
-->
* Returns: {Buffer|undefined} An opaque session for resuming this connection
later, or `undefined` on a server session or before the handshake completes.
Pass it as the `session` option to a later [`dtls.connect()`][]. It is bound to
the host this connection authenticated against and is refused elsewhere; see
[Session resumption][].
Server sessions return `undefined`: a server has no identity to bind the value
to, and it is the client that carries a session between connections.
### `session.reused`
<!-- YAML
added: REPLACEME
-->
* Returns: {boolean} `true` if this connection resumed an earlier session
rather than performing a full handshake.
Like [`session.authorized`][], this reads `false` once the session is closed.
### `session.authorized`
<!-- YAML
added: REPLACEME
-->
* Returns: {boolean} `true` if the peer presented a certificate chain that
verified against the configured certificate authorities, and, for a client,
matched the requested identity. `false` before the handshake completes.
### `session.authorizationError`
<!-- YAML
added: REPLACEME
-->
* Returns: {string|undefined} The short X509 verification error code, for
example `'CERT_HAS_EXPIRED'` or `'HOSTNAME_MISMATCH'`, or `undefined` if the
peer's chain verified.
A peer that presented no certificate at all reports
`'UNABLE_TO_GET_ISSUER_CERT'`, so this can be used to distinguish "no
certificate" from "a certificate that failed to verify".
The chain is verified even when `rejectUnauthorized` is `false`; the result is
simply not enforced. That makes these two properties the way to apply a custom
authorization policy:
```mjs
import { connect } from 'node:dtls';
const session = connect('192.0.2.1', 4433, {
ca: [caCert],
servername: 'example.com',
rejectUnauthorized: false,
});
await session.opened;
if (!session.authorized && session.authorizationError !== 'CERT_HAS_EXPIRED') {
await session.close();
}
```
### `session.alpnProtocol`
* Returns: {string|undefined} The negotiated ALPN protocol.
* Returns: {string|undefined} The negotiated ALPN protocol, or `undefined` if
ALPN was not used.
If a server has `alpn` configured and a client offers only protocols the
server does not support, the server sends a fatal `no_application_protocol`
alert and the handshake fails, as required by [RFC 7301][] section 3.2. A
server with no `alpn` configured declines the extension instead, and the
handshake completes with no protocol negotiated.
### `session.srtpProfile`
@@ -388,7 +1043,8 @@ live and updated as data flows through the session.
### `session.exportKeyingMaterial(length, label[, context])`
* `length` {number} Number of bytes to export.
* `length` {number} Number of bytes to export. Must be an integer between
`1` and `65536`.
* `label` {string} The label for the exported keying material.
* `context` {Buffer} Optional context value.
* Returns: {Buffer}
@@ -397,6 +1053,45 @@ 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.
Throws `ERR_OUT_OF_RANGE` if `length` is outside the accepted range. The upper
bound is not imposed by [RFC 5705][]; it exists so that a caller cannot request
an arbitrarily large allocation, and is far above what any defined exporter
needs (DTLS-SRTP uses 60 bytes).
### 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()`.
## Class: `DTLSSession.Stats`
<!-- YAML
@@ -488,40 +1183,6 @@ added: REPLACEME
`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
@@ -549,7 +1210,7 @@ const server = listen((session) => {
});
// Client with SRTP
const session = connect('localhost', 5004, {
const session = connect('127.0.0.1', 5004, {
rejectUnauthorized: false,
srtp: 'SRTP_AEAD_AES_128_GCM:SRTP_AES128_CM_SHA1_80',
});
@@ -565,6 +1226,11 @@ 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.
This bounds the UDP payload, not the application payload: a record carries
somewhat less once its header and MAC are accounted for. It is fixed when the
endpoint is created and cannot be changed afterwards. It does not bound
[`session.send()`][], which is limited by the DTLS record size instead.
The MTU can be configured via the `mtu` option:
```mjs
@@ -577,8 +1243,30 @@ const endpoint = listen(callback, {
The minimum allowed MTU is 256 bytes. The maximum is 65535.
[Denial of service]: #denial-of-service
[Handshake timeout]: #handshake-timeout
[Permission Model]: permissions.md#permission-model
[Pre-shared keys]: #pre-shared-keys
[RFC 5705]: https://www.rfc-editor.org/rfc/rfc5705
[RFC 7301]: https://www.rfc-editor.org/rfc/rfc7301
[Server Name Indication]: #server-name-indication
[Session resumption]: #session-resumption
[`DTLSEndpoint`]: #class-dtlsendpoint
[`DTLSSecureContext`]: #class-dtlssecurecontext
[`X509Certificate`]: crypto.md#class-x509certificate
[`dtls.connect()`]: #dtlsconnecthost-port-options
[`dtls.createSecureContext()`]: #dtlscreatesecurecontextoptions
[`dtls.listen()`]: #dtlslistencallback-options
[`endpointStats.serverRefusedCount`]: #endpointstatsserverrefusedcount
[`handshakeTimeout`]: #handshake-timeout
[`session.authorizationError`]: #sessionauthorizationerror
[`session.authorized`]: #sessionauthorized
[`session.destroy()`]: #sessiondestroyerror
[`session.peerX509Certificate`]: #sessionpeerx509certificate
[`session.reused`]: #sessionreused
[`session.send()`]: #sessionsenddata
[`session.session`]: #sessionsession
[`tls.TLSSocket.getPeerCertificate()`]: tls.md#tlssocketgetpeercertificatedetailed
[`tls.TLSSocket.getPeerX509Certificate()`]: tls.md#tlssocketgetpeerx509certificate
[`tls.createSecureContext()`]: tls.md#tlscreatesecurecontextoptions
[`tls.createServer()`]: tls.md#tlscreateserveroptions-secureconnectionlistener
+4
View File
@@ -7,14 +7,18 @@ emitExperimentalWarning('dtls');
const {
connect,
createSecureContext,
listen,
DTLSEndpoint,
DTLSSecureContext,
DTLSSession,
} = require('internal/dtls/dtls');
module.exports = {
connect,
createSecureContext,
listen,
DTLSEndpoint,
DTLSSecureContext,
DTLSSession,
};
+817 -60
View File
File diff suppressed because it is too large. Load diff
+14
View File
@@ -43,6 +43,7 @@ const {
IDX_SESSION_STATE_CLOSING,
IDX_SESSION_STATE_DESTROYED,
IDX_SESSION_STATE_HAS_MESSAGE_LISTENER,
IDX_SESSION_STATE_HAS_KEYLOG_LISTENER,
} = internalBinding('dtls');
function isAlive(view) {
@@ -119,6 +120,7 @@ class DTLSEndpointState {
// uint8_t closing; // offset 2
// uint8_t destroyed; // offset 3
// uint8_t has_message_listener; // offset 4
// uint8_t has_keylog_listener; // offset 5
class DTLSSessionState {
#handle;
@@ -164,6 +166,18 @@ class DTLSSessionState {
DataViewPrototypeSetUint8(
this.#handle, IDX_SESSION_STATE_HAS_MESSAGE_LISTENER, val ? 1 : 0);
}
get hasKeylogListener() {
if (!isAlive(this.#handle)) return false;
return DataViewPrototypeGetUint8(
this.#handle, IDX_SESSION_STATE_HAS_KEYLOG_LISTENER) === 1;
}
set hasKeylogListener(val) {
if (!isAlive(this.#handle)) return;
DataViewPrototypeSetUint8(
this.#handle, IDX_SESSION_STATE_HAS_KEYLOG_LISTENER, val ? 1 : 0);
}
}
module.exports = {
+18
View File
@@ -50,6 +50,8 @@ const {
IDX_STATS_ENDPOINT_SERVER_SESSIONS,
IDX_STATS_ENDPOINT_CLIENT_SESSIONS,
IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT,
IDX_STATS_ENDPOINT_SERVER_REJECTED_COUNT,
IDX_STATS_ENDPOINT_SERVER_REFUSED_COUNT,
IDX_STATS_SESSION_CREATED_AT,
IDX_STATS_SESSION_DESTROYED_AT,
@@ -71,6 +73,8 @@ 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_ENDPOINT_SERVER_REJECTED_COUNT !== undefined);
assert(IDX_STATS_ENDPOINT_SERVER_REFUSED_COUNT !== undefined);
assert(IDX_STATS_SESSION_CREATED_AT !== undefined);
assert(IDX_STATS_SESSION_DESTROYED_AT !== undefined);
assert(IDX_STATS_SESSION_CLOSING_AT !== undefined);
@@ -146,6 +150,16 @@ class DTLSEndpointStats {
return this.#handle[IDX_STATS_ENDPOINT_SERVER_BUSY_COUNT];
}
/** @type {bigint} */
get serverRejectedCount() {
return this.#handle[IDX_STATS_ENDPOINT_SERVER_REJECTED_COUNT];
}
/** @type {bigint} */
get serverRefusedCount() {
return this.#handle[IDX_STATS_ENDPOINT_SERVER_REFUSED_COUNT];
}
toString() {
return JSONStringify(this.toJSON());
}
@@ -163,6 +177,8 @@ class DTLSEndpointStats {
serverSessions: `${this.serverSessions}`,
clientSessions: `${this.clientSessions}`,
serverBusyCount: `${this.serverBusyCount}`,
serverRejectedCount: `${this.serverRejectedCount}`,
serverRefusedCount: `${this.serverRefusedCount}`,
};
}
@@ -187,6 +203,8 @@ class DTLSEndpointStats {
serverSessions: this.serverSessions,
clientSessions: this.clientSessions,
serverBusyCount: this.serverBusyCount,
serverRejectedCount: this.serverRejectedCount,
serverRefusedCount: this.serverRefusedCount,
}, opts)}`;
}
+6
View File
@@ -25,6 +25,11 @@ module.exports = {
kFinishClose: Symbol('kFinishClose'),
kNewSession: Symbol('kNewSession'),
kRemoveSession: Symbol('kRemoveSession'),
kBind: Symbol('kBind'),
kOwnsEndpoint: Symbol('kOwnsEndpoint'),
kSNIContexts: Symbol('kSNIContexts'),
kDoListen: Symbol('kDoListen'),
kDoConnect: Symbol('kDoConnect'),
kHandshake: Symbol('kHandshake'),
kReceive: Symbol('kReceive'),
kError: Symbol('kError'),
@@ -32,6 +37,7 @@ module.exports = {
kMessage: Symbol('kMessage'),
kKeylog: Symbol('kKeylog'),
kTicket: Symbol('kTicket'),
kIsServer: Symbol('kIsServer'),
kPrivateConstructor: Symbol('kPrivateConstructor'),
kSessionHandshake: Symbol('dtls.session.handshake'),
kSessionMessage: Symbol('dtls.session.message'),
+1
View File
@@ -47,6 +47,7 @@ void CreatePerContextProperties(Local<Object> target,
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);
NODE_DEFINE_CONSTANT(target, IDX_SESSION_STATE_HAS_KEYLOG_LISTENER);
// Endpoint stats indices (for BigUint64Array access from JS)
#define V(name, _) IDX_STATS_ENDPOINT_##name,
+4 -2
View File
@@ -45,7 +45,9 @@ void RecordTimestampStat(Stats* stats) {
V(PACKETS_SENT, packets_sent) \
V(SERVER_SESSIONS, server_sessions) \
V(CLIENT_SESSIONS, client_sessions) \
V(SERVER_BUSY_COUNT, server_busy_count)
V(SERVER_BUSY_COUNT, server_busy_count) \
V(SERVER_REJECTED_COUNT, server_rejected_count) \
V(SERVER_REFUSED_COUNT, server_refused_count)
#define DTLS_SESSION_STATS(V) \
V(CREATED_AT, created_at) \
@@ -78,6 +80,7 @@ enum DTLSSessionStateIndex {
IDX_SESSION_STATE_CLOSING,
IDX_SESSION_STATE_DESTROYED,
IDX_SESSION_STATE_HAS_MESSAGE_LISTENER,
IDX_SESSION_STATE_HAS_KEYLOG_LISTENER,
IDX_SESSION_STATE_COUNT
};
@@ -91,7 +94,6 @@ enum DTLSCallbackIndex {
DTLS_CB_SESSION_HANDSHAKE,
DTLS_CB_SESSION_MESSAGE,
DTLS_CB_SESSION_KEYLOG,
DTLS_CB_SESSION_TICKET,
DTLS_CB_COUNT
};
+687 -28
View File
@@ -26,18 +26,32 @@
namespace node {
using v8::Array;
using v8::ArrayBufferView;
using v8::Context;
using v8::Exception;
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::TryCatch;
using v8::Undefined;
using v8::Value;
namespace dtls {
namespace {
// The cookie secret is 32 bytes (256 bits).
// The cookie secret is 32 bytes (256 bits), generated once per context and
// not rotated. Rotation would need the previous secret kept alive to validate
// cookies already in flight, which is the job the time window below already
// does: a cookie stops verifying once its window passes, whatever the secret.
// A fresh secret is generated on every context, so a restart invalidates
// outstanding cookies too.
constexpr size_t kCookieSecretLen = 32;
// Cookies are bound to a coarse time window so they expire. A cookie is
// accepted for the window it was minted in and the immediately preceding one,
@@ -70,6 +84,12 @@ DTLSContext::DTLSContext(Environment* env,
SSL_CTX_set_cookie_generate_cb(ctx_.get(), CookieGenerateCallback);
SSL_CTX_set_cookie_verify_cb(ctx_.get(), CookieVerifyCallback);
// Keylog is a per-SSL_CTX setting, so register it once here rather than
// re-registering it from every session constructor. The callback is inert
// unless the session it resolves to has a keylog listener, so installing it
// unconditionally costs nothing and no secret reaches the JS heap uninvited.
SSL_CTX_set_keylog_callback(ctx_.get(), DTLSSession::SSLKeylogCallback);
// Store pointer to this context in the SSL_CTX app data for callbacks.
SSL_CTX_set_app_data(ctx_.get(), this);
}
@@ -77,6 +97,46 @@ DTLSContext::DTLSContext(Environment* env,
void DTLSContext::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackFieldWithSize("cookie_secret", cookie_secret_.size());
tracker->TrackFieldWithSize("alpn_protos", alpn_protos_.size());
size_t psk_size = 0;
for (const auto& [identity, key] : psk_identities_) {
psk_size += identity.size() + key.size();
}
tracker->TrackFieldWithSize("psk_identities", psk_size);
tracker->TrackFieldWithSize("psk_client_key", psk_client_key_.size());
// Strong references to JavaScript functions held by a weak object. A
// callback that closes over the server, which is the usual way to write
// one, is a cycle through C++ that a heap snapshot cannot otherwise show.
tracker->TrackField("psk_callback", psk_callback_);
tracker->TrackField("sni_callback", sni_callback_);
tracker->TrackField("sni_contexts", sni_contexts_);
}
// SSL ex_data slot holding the DTLSContext an SSL was created from, for the
// PSK callbacks, which unlike the ALPN and SNI ones have no argument of their
// own.
//
// On the SSL and not the SSL_CTX. SSL_set_SSL_CTX(), which is how SNI selects
// an identity, reassigns ssl->ctx but does not re-copy the PSK callbacks --
// those were installed on the SSL by SSL_new() and stay. Looking the context
// up through SSL_get_SSL_CTX() therefore found whichever context SNI had
// switched to, which is not the one whose PSK configuration is in force and
// is usually not configured for PSK at all.
static int PSKContextIndex() {
static const int index =
SSL_get_ex_new_index(0, nullptr, nullptr, nullptr, nullptr);
return index;
}
void DTLSContext::BindToSSL(SSL* ssl) {
SSL_set_ex_data(ssl, PSKContextIndex(), this);
}
DTLSContext* DTLSContext::FromSSL(SSL* ssl) {
return static_cast<DTLSContext*>(SSL_get_ex_data(ssl, PSKContextIndex()));
}
bool DTLSContext::HasInstance(Environment* env, Local<Value> value) {
return GetConstructorTemplate(env)->HasInstance(value);
}
Local<FunctionTemplate> DTLSContext::GetConstructorTemplate(Environment* env) {
@@ -97,6 +157,10 @@ Local<FunctionTemplate> DTLSContext::GetConstructorTemplate(Environment* env) {
SetProtoMethod(isolate, tmpl, "setVerifyMode", SetVerifyMode);
SetProtoMethod(isolate, tmpl, "loadDefaultCAs", LoadDefaultCAs);
SetProtoMethod(isolate, tmpl, "setECDHCurve", SetECDHCurve);
SetProtoMethod(isolate, tmpl, "setSessionIdContext", SetSessionIdContext);
SetProtoMethod(isolate, tmpl, "setSNIContexts", SetSNIContexts);
SetProtoMethod(isolate, tmpl, "setTicketKeys", SetTicketKeys);
SetProtoMethod(isolate, tmpl, "setPSK", SetPSK);
env->set_dtls_context_constructor_template(tmpl);
}
@@ -125,6 +189,10 @@ void DTLSContext::RegisterExternalReferences(
registry->Register(SetVerifyMode);
registry->Register(LoadDefaultCAs);
registry->Register(SetECDHCurve);
registry->Register(SetSessionIdContext);
registry->Register(SetSNIContexts);
registry->Register(SetTicketKeys);
registry->Register(SetPSK);
}
// new DTLSContext(isServer)
@@ -149,24 +217,59 @@ void DTLSContext::New(const FunctionCallbackInfo<Value>& args) {
// 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.get(), DTLS1_2_VERSION);
SSL_CTX_set_max_proto_version(ctx.get(), DTLS1_2_VERSION);
//
// Checked because the whole point is to refuse DTLS 1.0. An OpenSSL built
// without DTLS 1.2 would fail these and, unchecked, leave a context whose
// floor is whatever the build allows -- the deprecated version this is
// meant to exclude.
if (SSL_CTX_set_min_proto_version(ctx.get(), DTLS1_2_VERSION) != 1 ||
SSL_CTX_set_max_proto_version(ctx.get(), DTLS1_2_VERSION) != 1) {
return THROW_ERR_CRYPTO_OPERATION_FAILED(
env, "Failed to restrict the DTLS version to 1.2");
}
// Disable OpenSSL's MTU querying (we manage MTU manually).
SSL_CTX_set_options(ctx.get(), SSL_OP_NO_QUERY_MTU);
// Enable all workarounds for maximum compatibility.
SSL_CTX_set_options(ctx.get(), SSL_OP_ALL);
// Name the workarounds rather than taking SSL_OP_ALL. Its membership is not
// stable -- it has held SSL_OP_LEGACY_SERVER_CONNECT in the past and does
// not now -- so using the macro means silently inheriting whatever a future
// OpenSSL decides belongs in it.
//
// All four of its current members are TLS-specific and inert under DTLS 1.2,
// which is the only version this supports. They are kept so that behaviour
// is unchanged from taking SSL_OP_ALL, not because any is known to be
// needed:
//
// DONT_INSERT_EMPTY_FRAGMENTS the 1/n-1 split for CBC in TLS 1.0; DTLS
// 1.2 has explicit IVs and does not do it
// TLSEXT_PADDING pads ClientHello for an F5 bug
// CRYPTOPRO_TLSEXT_BUG GOST client workaround
// SAFARI_ECDHE_ECDSA_BUG Safari on OS X 10.8
SSL_CTX_set_options(ctx.get(),
SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS |
SSL_OP_TLSEXT_PADDING | SSL_OP_CRYPTOPRO_TLSEXT_BUG |
SSL_OP_SAFARI_ECDHE_ECDSA_BUG);
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.
// NOTE: SSL_OP_COOKIE_EXCHANGE must not be set here, on the context. It
// would then be inherited by every SSL the context creates, including the
// session SSLs built by CreateFromSSL() for peers that have *already*
// completed the cookie exchange, which would leave them waiting for a
// second one. DTLSv1_listen() sets the option on the individual SSL it is
// given (d1_lib.c:804), which is the correct scope.
// Enable session caching for session resumption.
//
// SSL_SESS_CACHE_NO_INTERNAL, matching node:tls and the client branch
// below. The previous mode enabled OpenSSL's internal cache and then set
// NO_AUTO_CLEAR, which is only coherent as node:tls uses it -- alongside
// NO_INTERNAL, where there is no internal cache for the auto-clear to
// walk. Enabled-plus-NO_AUTO_CLEAR instead meant every accepted session
// was retained, with its master secret, for the 7200 second default
// timeout and past it.
SSL_CTX_set_session_cache_mode(
ctx.get(), SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_AUTO_CLEAR);
ctx.get(), SSL_SESS_CACHE_SERVER | SSL_SESS_CACHE_NO_INTERNAL);
} else {
// Client session caching for resumption.
SSL_CTX_set_session_cache_mode(
@@ -201,7 +304,7 @@ void DTLSContext::SetCert(const FunctionCallbackInfo<Value>& args) {
ncrypto::X509Pointer issuer;
if (crypto::SSL_CTX_use_certificate_chain(
ctx->ctx_.get(), std::move(bio), &cert, &issuer) != 1) {
return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "PEM_read_bio_X509 failed");
return crypto::ThrowCryptoError(env, ERR_get_error(), "PEM_read_bio_X509");
}
}
@@ -221,21 +324,29 @@ void DTLSContext::SetKey(const FunctionCallbackInfo<Value>& args) {
return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to create BIO");
}
switch (crypto::UsePrivateKey(ctx->ctx_.get(), bio)) {
// Optional passphrase for an encrypted key. Absent is not the same as
// empty: UsePrivateKey() substitutes an empty ByteSource when given
// nullptr, and OpenSSL reports a decryption failure either way.
crypto::ByteSource passphrase;
if (args[1]->IsString()) {
passphrase = crypto::ByteSource::FromString(env, args[1].As<String>());
}
switch (crypto::UsePrivateKey(ctx->ctx_.get(), bio, &passphrase)) {
case crypto::PrivateKeyResult::kSuccess:
break;
case crypto::PrivateKeyResult::kParseError:
return THROW_ERR_CRYPTO_OPERATION_FAILED(
env, "PEM_read_bio_PrivateKey failed");
return crypto::ThrowCryptoError(
env, ERR_get_error(), "PEM_read_bio_PrivateKey");
case crypto::PrivateKeyResult::kApplyError:
return THROW_ERR_CRYPTO_OPERATION_FAILED(env,
"SSL_CTX_use_PrivateKey failed");
return crypto::ThrowCryptoError(
env, ERR_get_error(), "SSL_CTX_use_PrivateKey");
}
// 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");
return crypto::ThrowCryptoError(
env, ERR_get_error(), "Private key does not match certificate");
}
}
@@ -319,20 +430,516 @@ void DTLSContext::SetSRTP(const FunctionCallbackInfo<Value>& args) {
}
}
namespace {
// Installed only where the application has taken responsibility for the
// authorization decision itself: a server that asked for a client certificate
// but disabled rejection. Returning 1 unconditionally keeps the handshake
// going; the verification result is still recorded and remains reachable
// through SSL_get_verify_result(), which is what session.authorized reports.
//
// Everywhere else the callback is left null so OpenSSL enforces, and a peer
// that fails verification receives a proper alert.
int AllowUnauthorizedCallback(int preverify_ok, X509_STORE_CTX* ctx) {
return 1;
}
} // namespace
void DTLSContext::SetVerifyMode(const FunctionCallbackInfo<Value>& 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);
CHECK(args[0]->IsInt32());
CHECK(args[1]->IsBoolean());
int mode = args[0].As<Int32>()->Value();
bool defer_to_application = args[1]->IsTrue();
SSL_CTX_set_verify(
ctx->ctx_.get(),
mode,
defer_to_application ? AllowUnauthorizedCallback : nullptr);
}
void DTLSContext::LoadDefaultCAs(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
// This populates the verification store only, not the client-CA list sent
// in a CertificateRequest. That is deliberate. The bundled root store holds
// on the order of 150 certificates, and advertising all of their
// distinguished names would produce a CertificateRequest of tens of
// kilobytes -- which, over a datagram transport, then has to be fragmented
// across many packets, any of which can be lost. node:tls does the same:
// SSL_CTX_add_client_CA() is called for explicitly supplied chains and PFX
// bundles, never from UseDefaultRootCertStore().
crypto::UseDefaultRootCertStore(ctx->env(), ctx->ctx_.get());
}
// Scopes cached sessions to this server. OpenSSL refuses to resume a session
// whose id context differs from the one on the accepting SSL, which is what
// stops a session established under one configuration being resumed under
// another. It matters most when client certificates are in use.
void DTLSContext::SetSessionIdContext(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
Environment* env = ctx->env();
CHECK(args[0]->IsString());
Utf8Value sid_ctx(env->isolate(), args[0]);
if (!SSL_CTX_set_session_id_context(
ctx->ctx_.get(),
reinterpret_cast<const unsigned char*>(*sid_ctx),
sid_ctx.length())) {
return THROW_ERR_CRYPTO_OPERATION_FAILED(
env, "Failed to set session id context");
}
}
// The wildcard key: the identity used when no host name matches.
constexpr const char* kSNIWildcard = "*";
// Ask JavaScript which identity to serve. Synchronous, like the PSK
// callbacks and for the same reason: suspending a DTLS handshake to await an
// answer would mean driving SSL_ERROR_WANT_X509_LOOKUP back through Cycle(),
// and a datagram peer is retransmitting while it waits.
DTLSContext* DTLSContext::SelectSNIContextFromCallback(SSL* ssl,
const char* servername) {
HandleScope scope(env()->isolate());
Context::Scope context_scope(env()->context());
// Called rather than MakeCallback'd: this runs inside SSL_do_handshake(),
// and MakeCallback drains the tick queue as its scope exits, which would
// run user code in the middle of OpenSSL's state machine. See
// PSKServerCallback.
TryCatch try_catch(env()->isolate());
// A client that sent no SNI extension still reaches here, and the callback
// is told so rather than being given an empty string it cannot distinguish
// from one.
Local<Value> name = Undefined(env()->isolate());
if (servername != nullptr) {
Local<Value> name_str;
if (!ToV8Value(env()->context(), servername).ToLocal(&name_str)) {
ReportCallbackError(ssl, &try_catch);
return nullptr;
}
// Reject a name that did not survive the round trip through UTF-8, so a
// peer cannot offer bytes that reach JavaScript as replacement characters
// and match a host it was never given.
Utf8Value round_trip(env()->isolate(), name_str);
if (round_trip != servername) return nullptr;
name = name_str;
}
Local<Value> argv[] = {name};
Local<Value> ret;
if (!sni_callback_.Get(env()->isolate())
->Call(env()->context(), object(), 1, argv)
.ToLocal(&ret)) {
ReportCallbackError(ssl, &try_catch);
return nullptr;
}
// Declining is not an error: it means this name is not served.
if (!HasInstance(env(), ret)) return nullptr;
DTLSContext* chosen;
ASSIGN_OR_RETURN_UNWRAP(&chosen, ret.As<Object>(), nullptr);
// Hold it. A context the callback built has no other owner once it
// returns, and the handshake is not finished with it: SSL_set_SSL_CTX()
// reassigns ssl->ctx, so a later callback resolving its configuration
// through SSL_get_SSL_CTX() lands on this context and not on the one the
// endpoint holds. Verified by pointer -- the PSK callback on a session
// whose name was chosen here reports the chosen context, not the base.
DTLSSession* session = static_cast<DTLSSession*>(SSL_get_app_data(ssl));
if (session != nullptr) session->SetSNIContext(chosen);
return chosen;
}
int DTLSContext::SNISelectCallback(SSL* ssl, int* ad, void* arg) {
auto* ctx = static_cast<DTLSContext*>(arg);
const char* servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name);
auto it = servername != nullptr ? ctx->sni_contexts_.find(servername)
: ctx->sni_contexts_.end();
if (it == ctx->sni_contexts_.end()) {
it = ctx->sni_contexts_.find(kSNIWildcard);
}
DTLSContext* chosen =
it != ctx->sni_contexts_.end() ? it->second.get() : nullptr;
// The map answers first, so a configuration using only the map still runs
// no JavaScript inside the handshake. The callback is reached only when it
// has nothing to say.
if (chosen == nullptr && !ctx->sni_callback_.IsEmpty()) {
chosen = ctx->SelectSNIContextFromCallback(ssl, servername);
}
if (chosen == nullptr) {
// Configuring sni without a "*" entry, or a callback that declines, is a
// statement that only the named hosts are served, so refuse anything
// else rather than falling back to the endpoint's own certificate.
*ad = SSL_AD_UNRECOGNIZED_NAME;
return SSL_TLSEXT_ERR_ALERT_FATAL;
}
SSL_CTX* selected = chosen->ssl_ctx();
if (SSL_set_SSL_CTX(ssl, selected) != selected) {
*ad = SSL_AD_INTERNAL_ERROR;
return SSL_TLSEXT_ERR_ALERT_FATAL;
}
// Nothing further is needed. node:tls follows SSL_set_SSL_CTX() with
// SSL_set1_verify_cert_store() and a duplicated client-CA list, which
// suggests the switch leaves verification behind; it does not.
// SSL_set_SSL_CTX() reassigns ssl->ctx (ssl_lib.c), and both the
// verification store and the client-CA list are read through it, so they
// move with the certificate. Adding the calls anyway changed no observed
// behaviour: an SNI identity trusting only ca2 rejects a ca1 client
// either way.
//
// Verify mode is deliberately not touched. requestCert and
// rejectUnauthorized belong to the endpoint rather than the identity and
// are configured once on its own context.
return SSL_TLSEXT_ERR_OK;
}
void DTLSContext::SetSNIContexts(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
Environment* env = ctx->env();
CHECK(args[0]->IsArray());
Local<Array> entries = args[0].As<Array>();
// Built to one side and swapped in, so a failure part way through cannot
// leave the context serving a half-populated map.
std::unordered_map<std::string, BaseObjectWeakPtr<DTLSContext>> next;
uint32_t length = entries->Length();
for (uint32_t i = 0; i < length; i += 2) {
Local<Value> host;
Local<Value> value;
if (!entries->Get(env->context(), i).ToLocal(&host) ||
!entries->Get(env->context(), i + 1).ToLocal(&value)) {
return;
}
CHECK(host->IsString());
CHECK(DTLSContext::HasInstance(env, value));
DTLSContext* entry;
ASSIGN_OR_RETURN_UNWRAP(&entry, value.As<Object>());
Utf8Value hostname(env->isolate(), host);
next[*hostname] = BaseObjectWeakPtr<DTLSContext>(entry);
}
ctx->sni_contexts_ = std::move(next);
// Assigned either way. Only ever setting it meant a context reconfigured
// with a map and no callback kept the callback it was given before, which
// for SNI is a fail-open: a map with no '*' entry is meant to refuse an
// unmatched name, and a leftover callback answers it instead.
if (args[1]->IsFunction()) {
ctx->sni_callback_.Reset(env->isolate(), args[1].As<Function>());
} else {
ctx->sni_callback_.Reset();
}
if (ctx->sni_contexts_.empty() && ctx->sni_callback_.IsEmpty()) {
SSL_CTX_set_tlsext_servername_callback(ctx->ctx_.get(), nullptr);
SSL_CTX_set_tlsext_servername_arg(ctx->ctx_.get(), nullptr);
return;
}
SSL_CTX_set_tlsext_servername_callback(ctx->ctx_.get(), SNISelectCallback);
SSL_CTX_set_tlsext_servername_arg(ctx->ctx_.get(), ctx);
}
// Hand an exception thrown by one of the handshake callbacks to the session
// it belongs to, so it fails that handshake instead of reaching the process
// as an uncaughtException. The exception must not still be pending when
// control returns to OpenSSL, which goes on to build an alert and unwind
// through Cycle()'s error path.
void DTLSContext::ReportPSKError(SSL* ssl, std::string_view message) {
DTLSSession* session = static_cast<DTLSSession*>(SSL_get_app_data(ssl));
if (session == nullptr) return;
Environment* env = session->env();
HandleScope scope(env->isolate());
Local<Value> str;
if (!ToV8Value(env->context(), message).ToLocal(&str)) return;
session->SetPendingError(Exception::TypeError(str.As<String>()));
}
void DTLSContext::ReportCallbackError(SSL* ssl, TryCatch* try_catch) {
if (!try_catch->HasCaught() || try_catch->HasTerminated()) return;
DTLSSession* session = static_cast<DTLSSession*>(SSL_get_app_data(ssl));
Local<Value> exception = try_catch->Exception();
// Clear before reporting: EmitCallback runs JavaScript, which cannot be
// entered with an exception still pending.
try_catch->Reset();
if (session != nullptr && !exception.IsEmpty()) {
session->SetPendingError(exception);
}
}
unsigned int DTLSContext::PSKServerCallback(SSL* ssl,
const char* identity,
unsigned char* psk,
unsigned int max_psk_len) {
DTLSContext* ctx = FromSSL(ssl);
if (ctx == nullptr || identity == nullptr) return 0;
// The map first, so a configuration that uses only the map never runs
// JavaScript inside the handshake.
auto it = ctx->psk_identities_.find(identity);
if (it != ctx->psk_identities_.end()) {
if (it->second.size() > max_psk_len) return 0;
memcpy(psk, it->second.data(), it->second.size());
return static_cast<unsigned int>(it->second.size());
}
if (ctx->psk_callback_.IsEmpty()) return 0;
Environment* env = ctx->env();
HandleScope scope(env->isolate());
Context::Scope context_scope(env->context());
// Called rather than MakeCallback'd, deliberately. MakeCallback drains the
// tick queue as its scope exits, and this runs inside SSL_do_handshake():
// measured, a nextTick scheduled by the callback runs before OpenSSL's
// state machine has finished the transition it is in the middle of. The
// module's other MakeCallback uses all fire at safe points in Cycle(),
// after SSL_do_handshake() has returned. node:tls accepts that risk here;
// this does not, having a reentrancy guard that exists because of it.
//
// The cost is that exceptions are ours to handle: a pending one would
// otherwise be carried back into OpenSSL and surface as an
// uncaughtException, killing the process over one bad callback.
TryCatch try_catch(env->isolate());
Local<Value> identity_str;
if (!ToV8Value(env->context(), identity).ToLocal(&identity_str)) {
ctx->ReportCallbackError(ssl, &try_catch);
return 0;
}
// Reject an identity that did not survive the round trip through UTF-8:
// otherwise a peer could offer bytes that reach JavaScript as replacement
// characters and match an identity it was never given. node:tls does the
// same check.
Utf8Value round_trip(env->isolate(), identity_str);
if (round_trip != identity) return 0;
Local<Value> argv[] = {identity_str};
Local<Value> ret;
if (!ctx->psk_callback_.Get(env->isolate())
->Call(env->context(), ctx->object(), 1, argv)
.ToLocal(&ret)) {
ctx->ReportCallbackError(ssl, &try_catch);
return 0;
}
if (!ret->IsArrayBufferView()) return 0;
ArrayBufferViewContents<unsigned char> key(ret.As<ArrayBufferView>());
if (key.length() > max_psk_len) return 0; // measurement: allow empty
memcpy(psk, key.data(), key.length());
return static_cast<unsigned int>(key.length());
}
unsigned int DTLSContext::PSKClientCallback(SSL* ssl,
const char* hint,
char* identity,
unsigned int max_identity_len,
unsigned char* psk,
unsigned int max_psk_len) {
DTLSContext* ctx = FromSSL(ssl);
if (ctx == nullptr) return 0;
std::string use_identity = ctx->psk_client_identity_;
std::vector<unsigned char> use_key = ctx->psk_client_key_;
if (!ctx->psk_callback_.IsEmpty()) {
Environment* env = ctx->env();
HandleScope scope(env->isolate());
Context::Scope context_scope(env->context());
// See PSKServerCallback for why this is Call() and not MakeCallback().
TryCatch try_catch(env->isolate());
// The hint is optional and frequently absent (RFC 4279 section 5.2).
Local<Value> hint_value = Undefined(env->isolate());
if (hint != nullptr) {
if (!ToV8Value(env->context(), hint).ToLocal(&hint_value)) {
ctx->ReportCallbackError(ssl, &try_catch);
return 0;
}
}
Local<Value> argv[] = {hint_value};
Local<Value> ret;
if (!ctx->psk_callback_.Get(env->isolate())
->Call(env->context(), ctx->object(), 1, argv)
.ToLocal(&ret)) {
ctx->ReportCallbackError(ssl, &try_catch);
return 0;
}
if (!ret->IsObject()) {
ReportPSKError(ssl, "The psk callback must return an object");
return 0;
}
// Reading these can throw: the object is whatever the callback returned,
// so a getter or a Proxy trap runs here. Report it rather than returning
// with it caught but unreported, which loses the error the caller threw
// and leaves it to unwind into OpenSSL when the TryCatch is destroyed.
Local<Object> obj = ret.As<Object>();
Local<Value> id_val;
Local<Value> key_val;
if (!obj->Get(env->context(),
FIXED_ONE_BYTE_STRING(env->isolate(), "identity"))
.ToLocal(&id_val) ||
!obj->Get(env->context(), FIXED_ONE_BYTE_STRING(env->isolate(), "key"))
.ToLocal(&key_val)) {
ctx->ReportCallbackError(ssl, &try_catch);
return 0;
}
if (!id_val->IsString() || !key_val->IsArrayBufferView()) {
ReportPSKError(ssl,
"The psk callback must return an object with a string "
"'identity' and a TypedArray or DataView 'key'");
return 0;
}
Utf8Value id(env->isolate(), id_val);
ArrayBufferViewContents<unsigned char> key(key_val.As<ArrayBufferView>());
use_identity.assign(*id, id.length());
use_key.assign(key.data(), key.data() + key.length());
}
// Returning 0 is how OpenSSL is told there is no PSK, so each of these
// would otherwise reach the caller as a handshake that failed for no stated
// reason. An empty identity or key is the one case that really is "no PSK".
if (use_identity.empty() || use_key.empty()) return 0;
// The identity is written as a C string, so it needs room for the
// terminator OpenSSL expects.
if (use_identity.size() + 1 > max_identity_len) {
ReportPSKError(ssl, "The psk identity is too long for this handshake");
return 0;
}
if (use_key.size() > max_psk_len) {
ReportPSKError(ssl, "The psk key is too long for this handshake");
return 0;
}
memcpy(identity, use_identity.c_str(), use_identity.size() + 1);
memcpy(psk, use_key.data(), use_key.size());
return static_cast<unsigned int>(use_key.size());
}
void DTLSContext::SetTicketKeys(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
CHECK(args[0]->IsArrayBufferView());
ArrayBufferViewContents<unsigned char> buf(args[0].As<ArrayBufferView>());
// Key name, HMAC key and AES key concatenated. node:tls installs its own
// callback and defines a 48-byte layout of its own; this uses OpenSSL's
// native keys instead, which are longer -- a 32-byte HMAC key and AES-256
// rather than 16 and AES-128 -- and need no callback. The length is asked
// for rather than hardcoded, since it comes from a private OpenSSL header.
long expected = // NOLINT(runtime/int) -- SSL_CTX_ctrl returns long
SSL_CTX_set_tlsext_ticket_keys(ctx->ctx_.get(), nullptr, 0);
if (static_cast<long>(buf.length()) != expected) { // NOLINT(runtime/int)
return THROW_ERR_INVALID_ARG_VALUE(
ctx->env(), "options.ticketKeys must be exactly %ld bytes", expected);
}
if (SSL_CTX_set_tlsext_ticket_keys(ctx->ctx_.get(),
const_cast<unsigned char*>(buf.data()),
buf.length()) != 1) {
THROW_ERR_CRYPTO_OPERATION_FAILED(ctx->env(),
"Failed to set session ticket keys");
}
}
// setPSK(entries, hint, clientIdentity, clientKey, callback)
//
// |entries| is a flattened [identity, key, ...] array for a server; the
// client fields carry the single identity a client presents. Any of them may
// be absent: a server may rely entirely on the callback, and a client may
// rely on it too.
void DTLSContext::SetPSK(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
Environment* env = ctx->env();
if (args[0]->IsArray()) {
Local<Array> entries = args[0].As<Array>();
std::unordered_map<std::string, std::vector<unsigned char>> next;
uint32_t length = entries->Length();
for (uint32_t i = 0; i < length; i += 2) {
Local<Value> identity;
Local<Value> key;
if (!entries->Get(env->context(), i).ToLocal(&identity) ||
!entries->Get(env->context(), i + 1).ToLocal(&key)) {
return;
}
CHECK(identity->IsString());
CHECK(key->IsArrayBufferView());
Utf8Value id(env->isolate(), identity);
ArrayBufferViewContents<unsigned char> buf(key.As<ArrayBufferView>());
next[std::string(*id, id.length())] =
std::vector<unsigned char>(buf.data(), buf.data() + buf.length());
}
ctx->psk_identities_ = std::move(next);
}
if (args[1]->IsString()) {
Utf8Value hint(env->isolate(), args[1]);
ctx->psk_identity_hint_.assign(*hint, hint.length());
}
if (args[2]->IsString()) {
Utf8Value identity(env->isolate(), args[2]);
ctx->psk_client_identity_.assign(*identity, identity.length());
}
if (args[3]->IsArrayBufferView()) {
ArrayBufferViewContents<unsigned char> key(args[3].As<ArrayBufferView>());
ctx->psk_client_key_.assign(key.data(), key.data() + key.length());
}
if (args[4]->IsFunction()) {
ctx->psk_callback_.Reset(env->isolate(), args[4].As<Function>());
} else {
ctx->psk_callback_.Reset();
}
if (ctx->is_server_) {
SSL_CTX_set_psk_server_callback(ctx->ctx_.get(), PSKServerCallback);
if (!ctx->psk_identity_hint_.empty() &&
SSL_CTX_use_psk_identity_hint(ctx->ctx_.get(),
ctx->psk_identity_hint_.c_str()) != 1) {
THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to set PSK identity hint");
}
} else {
SSL_CTX_set_psk_client_callback(ctx->ctx_.get(), PSKClientCallback);
}
}
void DTLSContext::SetECDHCurve(const FunctionCallbackInfo<Value>& args) {
DTLSContext* ctx;
ASSIGN_OR_RETURN_UNWRAP(&ctx, args.This());
@@ -349,8 +956,54 @@ void DTLSContext::SetECDHCurve(const FunctionCallbackInfo<Value>& args) {
}
}
// 1 byte family tag + 2 port + 16 address + 4 scope id.
constexpr size_t kMaxCanonicalAddrLen = 23;
// Writes the parts of an address that identify a peer -- family, port,
// address, and for IPv6 the scope id -- into `out`, returning the number of
// bytes written, or 0 for an address we do not understand.
//
// Deliberately not a memcpy of the sockaddr. That would fold in sin_zero and
// sin6_flowinfo: padding the kernel need not zero, and a QoS label that may
// differ between two datagrams from the same host. Either would change the
// cookie for an unchanged peer and fail the handshake. scope id is included
// because it does identify a link-local peer.
size_t CanonicalizeAddress(const sockaddr* sa, unsigned char* out) {
switch (sa->sa_family) {
case AF_INET: {
const sockaddr_in* v4 = reinterpret_cast<const sockaddr_in*>(sa);
out[0] = 4;
memcpy(out + 1, &v4->sin_port, 2);
memcpy(out + 3, &v4->sin_addr, 4);
return 7;
}
case AF_INET6: {
const sockaddr_in6* v6 = reinterpret_cast<const sockaddr_in6*>(sa);
out[0] = 6;
memcpy(out + 1, &v6->sin6_port, 2);
memcpy(out + 3, &v6->sin6_addr, 16);
memcpy(out + 19, &v6->sin6_scope_id, 4);
return 23;
}
default:
return 0;
}
}
// HMAC-SHA256 cookie derived from the peer's address and a coarse time window
// so cookies expire (see kCookieWindowNs). During DTLSv1_listen() the peer
// so cookies expire (see kCookieWindowNs).
//
// The cookie is not bound to the ClientHello, which RFC 6347 section 4.2.1
// recommends, so within one window a cookie issued to an address is accepted
// for any handshake attempt from that address. The cookie's purpose --
// proving the peer can receive at the address it claims, so the handshake
// cannot be used to amplify traffic at a third party -- is unaffected by
// that. Binding it via SSL_get_client_random() was tried and does not work:
// the random is not populated consistently across the generate and verify
// callbacks during DTLSv1_listen(), and every handshake fails with a
// handshake_failure alert. Doing it would mean lifting the 32-byte random out
// of the raw ClientHello, which CouldBeClientHello() already walks, and
// stashing it next to current_cookie_peer_. During DTLSv1_listen() the peer
// address comes from DTLSContext::current_cookie_peer_ (set synchronously
// before the call); during the session handshake it comes from the
// DTLSSession stored in SSL app_data.
@@ -362,8 +1015,8 @@ bool DTLSContext::ComputeCookie(SSL* ssl,
DTLSContext* dtls_ctx = static_cast<DTLSContext*>(SSL_CTX_get_app_data(ctx));
CHECK_NOT_NULL(dtls_ctx);
// Message = peer address bytes followed by the 8-byte window counter.
unsigned char msg[sizeof(struct sockaddr_storage) + sizeof(uint64_t)];
// Message = canonical peer address followed by the 8-byte window counter.
unsigned char msg[kMaxCanonicalAddrLen + sizeof(uint64_t)];
size_t addr_len = 0;
void* app_data = SSL_get_app_data(ssl);
@@ -371,15 +1024,17 @@ bool DTLSContext::ComputeCookie(SSL* ssl,
// Session handshake path.
const sockaddr* sa =
static_cast<DTLSSession*>(app_data)->remote_address().data();
addr_len = SocketAddress::GetLength(sa);
memcpy(msg, sa, addr_len);
addr_len = CanonicalizeAddress(sa, msg);
} 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(msg, sa, addr_len);
addr_len = CanonicalizeAddress(sa, msg);
}
// Fail closed rather than deriving a cookie from an address we could not
// make sense of.
if (addr_len == 0) return false;
// Append the window counter in a fixed byte order.
for (size_t i = 0; i < sizeof(uint64_t); i++) {
msg[addr_len + i] = static_cast<unsigned char>((window >> (8 * i)) & 0xff);
@@ -430,6 +1085,10 @@ int DTLSContext::ALPNSelectCallback(SSL* ssl,
void* arg) {
DTLSContext* ctx = static_cast<DTLSContext*>(arg);
// This server does not do ALPN. Decline the extension and let the handshake
// continue: the client offering protocols to a server that has none
// configured is not an error. OpenSSL only calls this at all when the client
// sent the extension, so a client that offered nothing never reaches here.
if (ctx->alpn_protos_.empty()) {
return SSL_TLSEXT_ERR_NOACK;
}
@@ -438,7 +1097,7 @@ int DTLSContext::ALPNSelectCallback(SSL* ssl,
{ctx->alpn_protos_.data(), ctx->alpn_protos_.size()}, {in, inlen});
if (!selected.has_value()) {
return SSL_TLSEXT_ERR_NOACK;
return SSL_TLSEXT_ERR_ALERT_FATAL;
}
crypto::SetSelectedProtocol(out, outlen, *selected);
+74 -1
View File
@@ -13,6 +13,9 @@
#include <openssl/dtls1.h>
#include <openssl/ssl.h>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace node::dtls {
@@ -22,6 +25,11 @@ namespace node::dtls {
// ALPN, and automatic cookie generation/verification for servers.
class DTLSContext final : public BaseObject {
public:
// Whether a value is a DTLSContext. ASSIGN_OR_RETURN_UNWRAP only DCHECKs
// the internal field count, so in a release build it will happily
// reinterpret some other BaseObject as this one.
static bool HasInstance(Environment* env, v8::Local<v8::Value> value);
static v8::Local<v8::FunctionTemplate> GetConstructorTemplate(
Environment* env);
static void InitPerContext(v8::Local<v8::Object> target,
@@ -36,6 +44,11 @@ class DTLSContext final : public BaseObject {
SSL_CTX* ssl_ctx() const { return ctx_.get(); }
// Record this context as the one an SSL was created from, so the callbacks
// OpenSSL invokes without an argument of their own can find it again. Bound
// to the SSL rather than the SSL_CTX because SNI reassigns the latter.
void BindToSSL(SSL* ssl);
// Set the peer address for cookie generation during DTLSv1_listen().
void set_cookie_peer(const SocketAddress& addr) {
current_cookie_peer_ = addr;
@@ -56,6 +69,42 @@ class DTLSContext final : public BaseObject {
static void SetVerifyMode(const v8::FunctionCallbackInfo<v8::Value>& args);
static void LoadDefaultCAs(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetECDHCurve(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetSessionIdContext(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetSNIContexts(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetTicketKeys(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetPSK(const v8::FunctionCallbackInfo<v8::Value>& args);
// RFC 4279 pre-shared keys. The identity map is consulted first and needs
// no call into JavaScript; the callback, if there is one, is only reached
// when the map does not answer. Both are synchronous -- OpenSSL wants the
// key returned from the callback, and node:tls does the same.
static unsigned int PSKServerCallback(SSL* ssl,
const char* identity,
unsigned char* psk,
unsigned int max_psk_len);
static unsigned int PSKClientCallback(SSL* ssl,
const char* hint,
char* identity,
unsigned int max_identity_len,
unsigned char* psk,
unsigned int max_psk_len);
static void ReportCallbackError(SSL* ssl, v8::TryCatch* try_catch);
// Report a PSK callback that did not throw but gave back something
// unusable. Returning 0 to OpenSSL is the "no PSK" signal, so without this
// the caller's mistake arrives as a handshake failure naming no cause.
static void ReportPSKError(SSL* ssl, std::string_view message);
DTLSContext* SelectSNIContextFromCallback(SSL* ssl, const char* servername);
// Recover the context a callback without an argument slot belongs to.
static DTLSContext* FromSSL(SSL* ssl);
// Server Name Indication. Selection is a lookup in sni_contexts_ with no
// call into JavaScript, so a handshake never has to be suspended and
// re-entered the way node:tls does it.
static int SNISelectCallback(SSL* ssl, int* ad, void* arg);
// Compute the address-and-time-window-bound cookie for |window| into |out|
// (which must have room for EVP_MAX_MD_SIZE bytes). Shared by the cookie
@@ -90,10 +139,34 @@ class DTLSContext final : public BaseObject {
// 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_;
//
// Value-initialised: SocketAddress's default constructor is `= default`,
// which leaves its sockaddr_storage holding whatever was on the heap. Every
// path sets this before the callbacks run, so reading it uninitialised is
// not reachable today, but a zeroed family makes CanonicalizeAddress fail
// closed instead of deriving a cookie from stale bytes.
SocketAddress current_cookie_peer_{};
// ALPN protocols (server-side selection list)
std::vector<uint8_t> alpn_protos_;
// Host name -> context, for SNI. Empty unless the application supplied an
// sni map. The "*" key, if present, is the fallback for names that do not
// match; without it an unmatched name is refused.
// Weak: a context may appear in its own map, or in a cycle with another,
// and a strong count would never reach zero. The JavaScript wrapper holds
// these so the collector can trace and break such a cycle.
std::unordered_map<std::string, BaseObjectWeakPtr<DTLSContext>> sni_contexts_;
// PSK identity -> key (server), and the single identity a client presents.
std::unordered_map<std::string, std::vector<unsigned char>> psk_identities_;
std::string psk_identity_hint_;
std::string psk_client_identity_;
std::vector<unsigned char> psk_client_key_;
// Consulted only when the map does not answer.
v8::Global<v8::Function> psk_callback_;
v8::Global<v8::Function> sni_callback_;
};
} // namespace node::dtls
+277 -45
View File
@@ -24,6 +24,8 @@
namespace node {
using v8::ArrayBufferView;
using v8::Boolean;
using v8::Context;
using v8::Function;
using v8::FunctionCallbackInfo;
@@ -32,8 +34,9 @@ using v8::HandleScope;
using v8::Int32;
using v8::Isolate;
using v8::Local;
using v8::Number;
using v8::Object;
using v8::String;
using v8::Uint32;
using v8::Value;
namespace dtls {
@@ -92,6 +95,9 @@ Local<FunctionTemplate> DTLSEndpoint::GetConstructorTemplate(Environment* env) {
SetProtoMethod(isolate, tmpl, "getStats", GetStats);
SetProtoMethod(isolate, tmpl, "getAddress", GetAddress);
SetProtoMethod(isolate, tmpl, "setMTU", SetMTU);
SetProtoMethod(isolate, tmpl, "setSocketOptions", SetSocketOptions);
SetProtoMethod(isolate, tmpl, "setHandshakeTimeout", SetHandshakeTimeout);
SetProtoMethod(isolate, tmpl, "setSessionLimits", SetSessionLimits);
SetProtoMethod(isolate, tmpl, "setCallbacks", DoSetCallbacks);
env->set_dtls_endpoint_constructor_template(tmpl);
@@ -121,6 +127,9 @@ void DTLSEndpoint::RegisterExternalReferences(
registry->Register(GetStats);
registry->Register(GetAddress);
registry->Register(SetMTU);
registry->Register(SetSocketOptions);
registry->Register(SetHandshakeTimeout);
registry->Register(SetSessionLimits);
registry->Register(DoSetCallbacks);
}
@@ -135,13 +144,34 @@ int DTLSEndpoint::Bind(const SocketAddress& address) {
if (state_->bound) return UV_EALREADY;
unsigned int flags = 0;
if (address.family() == AF_INET6) {
if (address.family() == AF_INET6 && ipv6_only_) {
flags |= UV_UDP_IPV6ONLY;
}
if (reuse_port_) {
flags |= UV_UDP_REUSEPORT;
}
int err = uv_udp_bind(&handle_, address.data(), flags);
if (err != 0) return err;
// Only once bound: there is no socket to set them on before that. A zero
// means the option was not given, so the system default stands.
auto* h = reinterpret_cast<uv_handle_t*>(&handle_);
if (udp_receive_buffer_size_ > 0) {
int size = static_cast<int>(udp_receive_buffer_size_);
err = uv_recv_buffer_size(h, &size);
if (err != 0) return err;
}
if (udp_send_buffer_size_ > 0) {
int size = static_cast<int>(udp_send_buffer_size_);
err = uv_send_buffer_size(h, &size);
if (err != 0) return err;
}
if (udp_ttl_ > 0) {
err = uv_udp_set_ttl(&handle_, static_cast<int>(udp_ttl_));
if (err != 0) return err;
}
state_->bound = 1;
// Don't keep the event loop alive unless we're listening or have sessions.
@@ -173,11 +203,13 @@ int DTLSEndpoint::Listen(DTLSContext* context) {
return 0;
}
BaseObjectPtr<DTLSSession> DTLSEndpoint::Connect(DTLSContext* context,
const SocketAddress& remote,
const char* servername,
const char* verify_host,
bool verify_is_ip) {
BaseObjectPtr<DTLSSession> DTLSEndpoint::Connect(
DTLSContext* context,
const SocketAddress& remote,
const char* servername,
const char* verify_host,
bool verify_is_ip,
const ncrypto::Buffer<const unsigned char>& resume) {
if (IsHandleClosing()) {
THROW_ERR_INVALID_STATE(env(), "Endpoint is closing");
return {};
@@ -192,30 +224,44 @@ BaseObjectPtr<DTLSSession> DTLSEndpoint::Connect(DTLSContext* context,
auto session = DTLSSession::Create(env(),
this,
context->ssl_ctx(),
context,
remote,
false /* is_server */,
servername,
verify_host,
verify_is_ip);
verify_is_ip,
resume);
if (!session) return {};
sessions_[remote] = session;
sessions_per_host_[remote]++;
state_->session_count = sessions_.size();
DTLS_STAT_INCREMENT(DTLSEndpointStats, client_sessions);
// Ref the handle while we have sessions.
uv_ref(reinterpret_cast<uv_handle_t*>(&handle_));
// Start receiving if not already.
// Start receiving if not already. Listen() checks this and unwinds; here it
// was ignored, so a failure left a session in the table that no datagram
// could ever reach, and the only report was its handshake timing out.
if (!listening_) {
uv_udp_recv_start(&handle_, OnAlloc, OnRecv);
int err = uv_udp_recv_start(&handle_, OnAlloc, OnRecv);
if (err != 0) {
RemoveSession(remote);
env()->ThrowUVException(err, "recv_start");
return {};
}
}
// Initiate the DTLS handshake by running Cycle.
session->Cycle();
// The handshake is not started here. Cycle() emits the ClientHello and can
// report a failure doing it -- a send error, or an exception from a
// callback -- and there is nothing to report it to yet: the callback
// dispatch finds the JavaScript session through the handle, and the wrapper
// that attaches itself to the handle is built from the value this returns.
// An error from the first flight was raised against a wrapper that did not
// exist, dropped, and then left to look like a handshake that timed out.
// The wrapper calls start() once it is in place.
return session;
}
@@ -229,16 +275,23 @@ int DTLSEndpoint::SendTo(const SocketAddress& dest,
uv_buf_init(const_cast<char*>(reinterpret_cast<const char*>(data)), len);
int err = uv_udp_try_send(&handle_, &buf, 1, dest.data());
if (err == static_cast<int>(len)) {
// A datagram is sent whole or not at all, and libuv documents a
// non-negative return as always matching the buffer size, so there is no
// partial send to resume. Testing for == len instead let any other
// non-negative value fall through to the queued send below and put the
// same datagram on the wire a second time.
if (err >= 0) {
DTLS_STAT_INCREMENT_N(DTLSEndpointStats, bytes_sent, len);
DTLS_STAT_INCREMENT(DTLSEndpointStats, packets_sent);
return 0; // Sent successfully.
return 0;
}
if (err != UV_EAGAIN && err < 0) {
if (err != UV_EAGAIN) {
return err; // Real error.
}
// EAGAIN: the socket buffer is full, so queue it.
// Async send: copy the data since it won't outlive this call.
auto* req = new SendReq();
req->data.assign(data, data + len);
@@ -256,7 +309,14 @@ int DTLSEndpoint::SendTo(const SocketAddress& dest,
}
void DTLSEndpoint::RemoveSession(const SocketAddress& addr) {
sessions_.erase(addr);
if (sessions_.erase(addr) != 0) {
// Drop the host entry entirely at zero so this map tracks live peers
// rather than every peer ever seen.
auto it = sessions_per_host_.find(addr);
if (it != sessions_per_host_.end() && --it->second == 0) {
sessions_per_host_.erase(it);
}
}
state_->session_count = sessions_.size();
// Unref if no more sessions and not listening.
@@ -342,12 +402,11 @@ void DTLSEndpoint::SetCallbacks(Local<Object> callbacks) {
"onSessionHandshake",
"onSessionMessage",
"onSessionKeylog",
"onSessionTicket",
};
for (int i = 0; i < DTLS_CB_COUNT; i++) {
Local<String> name;
if (!String::NewFromUtf8(isolate, names[i]).ToLocal(&name)) {
Local<Value> name;
if (!ToV8Value(context, names[i]).ToLocal(&name)) {
THROW_ERR_OPERATION_FAILED(isolate,
"Failed to create callback name string");
return;
@@ -372,6 +431,16 @@ void DTLSEndpoint::OnAlloc(uv_handle_t* handle,
// this thread, and OnRecv fully consumes each datagram (copying it into the
// session's BIO) before the next OnAlloc, so a per-endpoint buffer suffices
// and avoids a heap allocation on every packet.
//
// One datagram at a time holds only because recvmmsg is off: libuv sets
// UV_HANDLE_UDP_RECVMMSG solely when uv_udp_init_ex() is passed
// UV_UDP_RECVMMSG, and DTLSEndpoint uses plain uv_udp_init(). Enabling it
// would have libuv pack several datagrams into this one buffer and deliver
// them as UV_UDP_MMSG_CHUNK slices, which this design does not handle.
//
// The size matters too: OnRecv drops anything flagged UV_UDP_PARTIAL, so a
// buffer below the 65507-byte maximum UDP payload would start discarding
// large datagrams rather than truncating them.
if (endpoint->recv_buf_.empty()) {
endpoint->recv_buf_.resize(65536);
}
@@ -394,10 +463,12 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle,
if (nread < 0) {
HandleScope handle_scope(endpoint->env()->isolate());
Context::Scope context_scope(endpoint->env()->context());
Local<Value> argv[] = {
String::NewFromUtf8(endpoint->env()->isolate(), uv_strerror(nread))
.ToLocalChecked(),
};
Local<Value> message;
if (!ToV8Value(endpoint->env()->context(), uv_strerror(nread))
.ToLocal(&message)) {
return;
}
Local<Value> argv[] = {message};
Local<Function> cb = endpoint->GetCallback(DTLS_CB_ENDPOINT_ERROR);
if (!cb.IsEmpty()) {
endpoint->MakeCallback(cb, 1, argv);
@@ -409,6 +480,18 @@ void DTLSEndpoint::OnRecv(uv_udp_t* handle,
return;
}
// A truncated datagram is not a short DTLS record, it is a corrupt one, so
// drop it rather than hand a fragment to OpenSSL.
//
// This cannot currently fire. libuv raises UV_UDP_PARTIAL from MSG_TRUNC,
// which the kernel only sets when the datagram did not fit the buffer, and
// OnAlloc always supplies 65536 -- above the largest possible UDP payload,
// 65507 over IPv4 and 65527 over IPv6. The check is here so that shrinking
// that buffer degrades to dropped packets instead of corrupt records.
if ((flags & UV_UDP_PARTIAL) != 0) {
return;
}
IncrementStat<DTLSEndpointStats, &DTLSEndpointStats::bytes_received>(
endpoint->stats_.Data(), nread);
IncrementStat<DTLSEndpointStats, &DTLSEndpointStats::packets_received>(
@@ -452,6 +535,14 @@ void DTLSEndpoint::ProcessDatagram(const uint8_t* data,
const SocketAddress& remote) {
if (IsHandleClosing()) return;
// An empty datagram is legal UDP but can never carry a DTLS record, and
// anyone can send one. Dropping it here keeps it out of the accept path,
// where it would otherwise cost a full SSL_new()/DTLSv1_listen()/SSL_free()
// cycle, and out of the session BIOs, where a zero length write to a
// datagram BIO queues an empty datagram whose read reports EOF rather than
// "try again".
if (len == 0) return;
// Look up existing session by remote address.
auto it = sessions_.find(remote);
if (it != sessions_.end()) {
@@ -465,6 +556,48 @@ void DTLSEndpoint::ProcessDatagram(const uint8_t* data,
}
}
bool DTLSEndpoint::HasCapacityFor(const SocketAddress& remote) const {
if (max_sessions_ != 0 && sessions_.size() >= max_sessions_) return false;
if (max_sessions_per_host_ != 0) {
auto it = sessions_per_host_.find(remote);
if (it != sessions_per_host_.end() && it->second >= max_sessions_per_host_)
return false;
}
return true;
}
// Cheap structural screen for a datagram that could plausibly begin a
// handshake, applied before anything is allocated for it.
//
// Everything reaching the accept path is unauthenticated and from a source
// address that has not been validated yet, and DTLSv1_listen() only ever
// proceeds on a ClientHello. Recognising the obvious rejects here avoids
// paying SSL_new() + two BIO_new()s + SSL_free() to reach the same verdict.
// This deliberately does not attempt to parse the ClientHello itself; that is
// OpenSSL's job, and getting it wrong would mean rejecting real clients.
bool DTLSEndpoint::CouldBeClientHello(const uint8_t* data, size_t len) {
// DTLS record header is 13 bytes, then the handshake msg_type.
if (len < 14) return false;
// ContentType must be handshake(22).
if (data[0] != 22) return false;
// ProtocolVersion is DTLS 1.0 (0xfeff) or 1.2 (0xfefd); both have major
// 0xfe. DTLS 1.3 keeps the record version at 0xfefd for compatibility.
if (data[1] != 0xfe) return false;
// The record must not claim more payload than the datagram actually holds.
const size_t record_len = (static_cast<size_t>(data[11]) << 8) | data[12];
if (record_len > len - 13) return false;
// HandshakeType must be client_hello(1).
if (data[13] != 1) return false;
return true;
}
void DTLSEndpoint::AcceptConnection(const uint8_t* data,
size_t len,
const SocketAddress& remote) {
@@ -473,9 +606,29 @@ void DTLSEndpoint::AcceptConnection(const uint8_t* data,
return;
}
if (!CouldBeClientHello(data, len)) {
DTLS_STAT_INCREMENT(DTLSEndpointStats, server_rejected_count);
return;
}
// Refuse before allocating anything. Staying silent rather than sending an
// alert is deliberate: the peer has not completed the cookie exchange yet,
// so replying would make this an amplification vector. A real client simply
// retransmits and gets in once there is room.
if (!HasCapacityFor(remote)) {
DTLS_STAT_INCREMENT(DTLSEndpointStats, server_refused_count);
return;
}
HandleScope handle_scope(env()->isolate());
Context::Scope context_scope(env()->context());
// Anything reaching this point is unauthenticated and may well be garbage,
// so DTLSv1_listen() failing is routine rather than exceptional. Discard
// whatever it queues instead of letting it accumulate across packets and
// resurface as a bogus error somewhere else on this thread.
ncrypto::MarkPopErrorOnReturn mark_pop_error_on_return;
// Stateless cookie exchange via DTLSv1_listen() for DoS protection.
//
// The standard OpenSSL DTLS server flow (see s_server.c) is:
@@ -497,34 +650,48 @@ void DTLSEndpoint::AcceptConnection(const uint8_t* data,
ncrypto::SSLPointer ssl(SSL_new(server_context_->ssl_ctx()));
if (!ssl) return;
auto in = ncrypto::BIOPointer::NewMem();
auto out = ncrypto::BIOPointer::NewMem();
// These become the session's enc_in_/enc_out_, so both have to preserve
// datagram boundaries -- see DTLSSession::Create().
auto in = ncrypto::BIOPointer::New(BIO_s_dgram_mem());
auto out = ncrypto::BIOPointer::New(BIO_s_dgram_mem());
if (!in || !out) return;
BIO_set_mem_eof_return(in.get(), -1);
BIO_set_mem_eof_return(out.get(), -1);
// SSL_set_bio takes ownership of both BIOs.
BIO* in_raw = in.release();
BIO* out_raw = out.release();
SSL_set_bio(ssl.get(), in_raw, out_raw);
SSL_set_accept_state(ssl.get());
SSL_set_options(ssl.get(), SSL_OP_NO_QUERY_MTU | SSL_OP_COOKIE_EXCHANGE);
// SSL_OP_COOKIE_EXCHANGE is deliberately not set here: DTLSv1_listen() sets
// it on the SSL it is given (d1_lib.c:804) before doing anything else, so
// setting it again just invites the question of why the context does not.
SSL_set_options(ssl.get(), SSL_OP_NO_QUERY_MTU);
SSL_set_mtu(ssl.get(), mtu_);
// Set peer address on context for the cookie callbacks.
server_context_->set_cookie_peer(remote);
BIO_write(in_raw, data, len);
// Both are allocation failures rather than anything the peer did, and both
// used to be ignored: an unwritten BIO would put DTLSv1_listen() to work on
// an empty buffer, and a null BIO_ADDR is not something it accepts. Dropping
// the datagram is the right answer -- the peer retransmits.
if (BIO_write(in_raw, data, len) <= 0) return;
DeleteFnPtr<BIO_ADDR, BIO_ADDR_free> peer(BIO_ADDR_new());
if (!peer) return;
int ret = DTLSv1_listen(ssl.get(), peer.get());
if (ret == 0) {
// Send HelloVerifyRequest.
uint8_t resp_buf[65536];
int resp_len;
while ((resp_len = BIO_read(out_raw, resp_buf, sizeof(resp_buf))) > 0) {
SendTo(remote, resp_buf, resp_len);
// Send HelloVerifyRequest. `out_raw` is a datagram BIO, so BIO_pending()
// gives the exact size of the next datagram; a HelloVerifyRequest is well
// under a hundred bytes, so this never leaves the stack in practice.
MaybeStackBuffer<uint8_t, 256> resp_buf;
for (;;) {
size_t pending = BIO_pending(out_raw);
if (pending == 0) break;
resp_buf.AllocateSufficientStorage(pending);
int resp_len = BIO_read(out_raw, resp_buf.out(), pending);
if (resp_len <= 0) break;
SendTo(remote, resp_buf.out(), resp_len);
}
return;
}
@@ -536,26 +703,33 @@ void DTLSEndpoint::AcceptConnection(const uint8_t* data,
// 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.
auto session = DTLSSession::CreateFromSSL(
env(), this, std::move(ssl), in_raw, out_raw, remote);
auto session = DTLSSession::CreateFromSSL(env(),
this,
server_context_.get(),
std::move(ssl),
in_raw,
out_raw,
remote);
if (!session) return;
sessions_[remote] = session;
sessions_per_host_[remote]++;
state_->session_count = sessions_.size();
DTLS_STAT_INCREMENT(DTLSEndpointStats, server_sessions);
uv_ref(reinterpret_cast<uv_handle_t*>(&handle_));
// Drive the handshake forward — produces ServerHello etc.
session->Cycle();
// Emit the new session to JS.
// Emit the new session to JS before driving the handshake, so a listener
// is in place for anything the handshake reports. Cycle() runs second.
Local<Value> argv[] = {session->object()};
Local<Function> cb = GetCallback(DTLS_CB_SESSION_NEW);
if (!cb.IsEmpty()) {
MakeCallback(cb, 1, argv);
}
// Drive the handshake forward — produces ServerHello etc.
session->Start();
}
// --- JS binding methods ---
@@ -581,7 +755,11 @@ void DTLSEndpoint::DoBind(const FunctionCallbackInfo<Value>& args) {
int err = endpoint->Bind(addr);
if (err != 0) {
return THROW_ERR_INVALID_STATE(env, uv_strerror(err));
// A libuv failure reported as ERR_INVALID_STATE loses the errno and the
// syscall, so a caller cannot tell EADDRINUSE from anything else. Every
// other binding surfaces these as the error code libuv gave, and code
// testing err.code === 'EADDRINUSE' is the usual way this is handled.
return env->ThrowUVException(err, "bind");
}
}
@@ -592,12 +770,13 @@ void DTLSEndpoint::DoListen(const FunctionCallbackInfo<Value>& args) {
THROW_IF_INSUFFICIENT_PERMISSIONS(env, permission::PermissionScope::kNet, "");
CHECK(DTLSContext::HasInstance(env, args[0]));
DTLSContext* context;
ASSIGN_OR_RETURN_UNWRAP(&context, args[0].As<Object>());
int err = endpoint->Listen(context);
if (err != 0) {
return THROW_ERR_INVALID_STATE(env, uv_strerror(err));
return env->ThrowUVException(err, "listen");
}
}
@@ -606,6 +785,7 @@ void DTLSEndpoint::DoConnect(const FunctionCallbackInfo<Value>& args) {
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
Environment* env = endpoint->env();
CHECK(DTLSContext::HasInstance(env, args[0]));
DTLSContext* context;
ASSIGN_OR_RETURN_UNWRAP(&context, args[0].As<Object>());
@@ -632,8 +812,17 @@ void DTLSEndpoint::DoConnect(const FunctionCallbackInfo<Value>& args) {
const char* verify_host_ptr = args[4]->IsString() ? *verify_host : nullptr;
bool verify_is_ip = args[5]->IsTrue();
// Optional DER-encoded session to resume. The identity it was authenticated
// for is checked in JS before it reaches here.
ncrypto::Buffer<const unsigned char> resume{};
ArrayBufferViewContents<unsigned char> resume_buf;
if (args[6]->IsArrayBufferView()) {
resume_buf.Read(args[6].As<ArrayBufferView>());
resume = {resume_buf.data(), resume_buf.length()};
}
auto session = endpoint->Connect(
context, remote, servername_ptr, verify_host_ptr, verify_is_ip);
context, remote, servername_ptr, verify_host_ptr, verify_is_ip, resume);
if (session) {
args.GetReturnValue().Set(session->object());
}
@@ -676,6 +865,17 @@ void DTLSEndpoint::GetAddress(const FunctionCallbackInfo<Value>& args) {
}
}
void DTLSEndpoint::SetSessionLimits(const FunctionCallbackInfo<Value>& args) {
DTLSEndpoint* endpoint;
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
CHECK(args[0]->IsUint32());
CHECK(args[1]->IsUint32());
endpoint->max_sessions_ = args[0].As<Uint32>()->Value();
endpoint->max_sessions_per_host_ = args[1].As<Uint32>()->Value();
}
void DTLSEndpoint::SetMTU(const FunctionCallbackInfo<Value>& args) {
DTLSEndpoint* endpoint;
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
@@ -686,9 +886,41 @@ void DTLSEndpoint::SetMTU(const FunctionCallbackInfo<Value>& args) {
return THROW_ERR_OUT_OF_RANGE(endpoint->env(),
"MTU must be between 256 and 65535");
}
// Only affects sessions created after this point: DTLSSession reads the
// value once, when it builds its SSL. Not currently reachable after
// construction -- JS calls this from the DTLSEndpoint constructor only.
endpoint->mtu_ = mtu;
}
void DTLSEndpoint::SetSocketOptions(const FunctionCallbackInfo<Value>& args) {
DTLSEndpoint* endpoint;
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
// Validated in JavaScript, and read before Bind(), which is what applies
// them. The DTLSEndpoint constructor is the only caller.
CHECK(args[0]->IsBoolean()); // ipv6Only
CHECK(args[1]->IsBoolean()); // reusePort
CHECK(args[2]->IsUint32()); // udpReceiveBufferSize
CHECK(args[3]->IsUint32()); // udpSendBufferSize
CHECK(args[4]->IsUint32()); // udpTTL
endpoint->ipv6_only_ = args[0].As<Boolean>()->Value();
endpoint->reuse_port_ = args[1].As<Boolean>()->Value();
endpoint->udp_receive_buffer_size_ = args[2].As<Uint32>()->Value();
endpoint->udp_send_buffer_size_ = args[3].As<Uint32>()->Value();
endpoint->udp_ttl_ = args[4].As<Uint32>()->Value();
}
void DTLSEndpoint::SetHandshakeTimeout(
const FunctionCallbackInfo<Value>& args) {
DTLSEndpoint* endpoint;
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
CHECK(args[0]->IsNumber());
double timeout = args[0].As<Number>()->Value();
// Read once per session, when it is created, like the MTU above.
endpoint->handshake_timeout_ = static_cast<uint64_t>(timeout);
}
void DTLSEndpoint::DoSetCallbacks(const FunctionCallbackInfo<Value>& args) {
DTLSEndpoint* endpoint;
ASSIGN_OR_RETURN_UNWRAP(&endpoint, args.This());
+58 -11
View File
@@ -64,11 +64,13 @@ class DTLSEndpoint final : public HandleWrap {
// verification on the client SSL before the handshake begins; see
// DTLSSession::Create.
// Returns the created DTLSSession.
BaseObjectPtr<DTLSSession> Connect(DTLSContext* context,
const SocketAddress& remote,
const char* servername = nullptr,
const char* verify_host = nullptr,
bool verify_is_ip = false);
BaseObjectPtr<DTLSSession> Connect(
DTLSContext* context,
const SocketAddress& remote,
const char* servername = nullptr,
const char* verify_host = nullptr,
bool verify_is_ip = false,
const ncrypto::Buffer<const unsigned char>& resume = {});
// Send a raw UDP datagram to the given address.
// Called by DTLSSession to send encrypted packets.
@@ -91,6 +93,7 @@ class DTLSEndpoint final : public HandleWrap {
bool is_listening() const { return listening_; }
uint32_t mtu() const { return mtu_; }
uint64_t handshake_timeout() const { return handshake_timeout_; }
void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(DTLSEndpoint)
@@ -108,6 +111,10 @@ class DTLSEndpoint final : public HandleWrap {
static void GetStats(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetAddress(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetMTU(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetSocketOptions(const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetHandshakeTimeout(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void SetSessionLimits(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoSetCallbacks(const v8::FunctionCallbackInfo<v8::Value>& args);
// libuv callbacks
@@ -129,6 +136,14 @@ class DTLSEndpoint final : public HandleWrap {
size_t len,
const SocketAddress& remote);
// True if another server session can be accepted from this address without
// exceeding either cap.
bool HasCapacityFor(const SocketAddress& remote) const;
// True if the datagram could plausibly be a ClientHello. Used to reject
// obvious junk before allocating an SSL for it.
static bool CouldBeClientHello(const uint8_t* data, size_t len);
// Handle a new client connection (server mode).
void AcceptConnection(const uint8_t* data,
size_t len,
@@ -141,11 +156,16 @@ class DTLSEndpoint final : public HandleWrap {
// single buffer per endpoint avoids a heap allocation on every packet.
std::vector<char> recv_buf_;
// Session table: maps remote address -> session.
std::unordered_map<SocketAddress,
BaseObjectPtr<DTLSSession>,
SocketAddress::Hash>
sessions_;
// Session table: remote address -> session. PeerMap and not Map: two
// datagrams from one peer must find one session, and operator== would treat
// them as different peers over a sin6_flowinfo the sender is free to vary.
SocketAddress::PeerMap<BaseObjectPtr<DTLSSession>> sessions_;
// Concurrent sessions per source IP, ignoring port, so one host cannot fill
// the whole table. Entries are erased when their count reaches zero, so this
// stays proportional to the number of distinct peers, not to peers ever
// seen.
SocketAddress::IpMap<uint32_t> sessions_per_host_;
// Server context (set when listening).
BaseObjectPtr<DTLSContext> server_context_;
@@ -162,7 +182,34 @@ class DTLSEndpoint final : public HandleWrap {
BaseObjectPtr<DTLSEndpoint> self_ref_;
bool listening_ = false;
uint32_t mtu_ = 1200; // Conservative default MTU for data payload
// Maximum size of a DTLS datagram, i.e. the UDP payload, not the
// application payload -- a record carries less than this once its header
// and MAC are counted. Read by DTLSSession when it creates its SSL, so a
// change only affects sessions created afterwards.
uint32_t mtu_ = 1200;
// Applied by Bind(). Dual stack unless asked otherwise, as in node:quic and
// node:dgram; this used to be forced on for every IPv6 address, so an
// endpoint on :: could not be reached over IPv4 at all.
bool ipv6_only_ = false;
// SO_REUSEPORT: several processes bind the same port and the kernel spreads
// datagrams between them. Not SO_REUSEADDR, which on Linux lets the last
// binder take the port from a running server.
bool reuse_port_ = false;
// Applied after the bind succeeds. Zero leaves the operating system's
// default alone, which is what not naming the option means.
uint32_t udp_receive_buffer_size_ = 0;
uint32_t udp_send_buffer_size_ = 0;
uint32_t udp_ttl_ = 0;
// Milliseconds a handshake may take before it is abandoned; 0 disables it.
uint64_t handshake_timeout_ = 60000;
// Caps on accepted server sessions. Zero means unlimited.
uint32_t max_sessions_ = 0;
uint32_t max_sessions_per_host_ = 0;
};
} // namespace node::dtls
+660 -134
View File
File diff suppressed because it is too large. Load diff
+114 -9
View File
@@ -21,6 +21,7 @@
namespace node::dtls {
class DTLSContext;
class DTLSEndpoint;
// Shared C++ <-> JS state for a DTLS session.
@@ -30,6 +31,9 @@ struct DTLSSessionStateData {
uint8_t closing = 0;
uint8_t destroyed = 0;
uint8_t has_message_listener = 0;
// Gates SSLKeylogCallback. Secrets are only turned into JS strings when the
// application has actually asked for them.
uint8_t has_keylog_listener = 0;
};
// Stats collected for a DTLS session, backed by a BigUint64Array.
@@ -59,20 +63,27 @@ class DTLSSession final : public AsyncWrap {
// nullptr disables identity checking.
// |verify_is_ip| - true if |verify_host| is an IP literal (verified
// against iPAddress SANs) rather than a DNS name.
static BaseObjectPtr<DTLSSession> Create(Environment* env,
DTLSEndpoint* endpoint,
SSL_CTX* ssl_ctx,
const SocketAddress& remote,
bool is_server,
const char* servername = nullptr,
const char* verify_host = nullptr,
bool verify_is_ip = false);
// |resume| - DER-encoded SSL_SESSION to resume (client only), or an
// empty span for a full handshake. Like servername this
// has to be applied before the ClientHello is emitted, so
// it is a creation parameter rather than a setter.
static BaseObjectPtr<DTLSSession> Create(
Environment* env,
DTLSEndpoint* endpoint,
DTLSContext* context,
const SocketAddress& remote,
bool is_server,
const char* servername = nullptr,
const char* verify_host = nullptr,
bool verify_is_ip = false,
const ncrypto::Buffer<const unsigned char>& resume = {});
// 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<DTLSSession> CreateFromSSL(Environment* env,
DTLSEndpoint* endpoint,
DTLSContext* context,
ncrypto::SSLPointer ssl,
BIO* enc_in,
BIO* enc_out,
@@ -123,11 +134,17 @@ class DTLSSession final : public AsyncWrap {
static void GetCipher(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPeerCertificate(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetPeerX509Certificate(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetALPNProtocol(const v8::FunctionCallbackInfo<v8::Value>& args);
static void ExportKeyingMaterial(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetSRTPProfile(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetServername(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetSession(const v8::FunctionCallbackInfo<v8::Value>& args);
static void WasReused(const v8::FunctionCallbackInfo<v8::Value>& args);
static void GetVerifyError(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DoStart(const v8::FunctionCallbackInfo<v8::Value>& args);
public:
// The core state machine pump. Processes pending OpenSSL I/O:
@@ -137,6 +154,14 @@ class DTLSSession final : public AsyncWrap {
// 4. UpdateTimer() - schedule retransmit timer if needed
void Cycle();
// Run the first flight. Separate from creation because nothing a session
// reports has anywhere to go until its JavaScript wrapper is in place: the
// callback dispatch reaches the wrapper through the handle, and a session
// still being constructed has no wrapper attached. The server emits its new
// session and then starts it; the client's wrapper calls this once it is
// built. Repeat calls do nothing.
void Start();
private:
// Read decrypted application data from OpenSSL and emit to JS.
void ClearOut();
@@ -147,14 +172,65 @@ class DTLSSession final : public AsyncWrap {
// Update the DTLS retransmission timer based on OpenSSL's timeout.
void UpdateTimer();
// OpenSSL keylog callback.
public:
// OpenSSL keylog callback. Registered once per SSL_CTX by DTLSContext; it
// resolves the session from the SSL and does nothing unless that session has
// a keylog listener.
static void SSLKeylogCallback(const SSL* ssl, const char* line);
// Hold an exception thrown by a callback that ran inside the handshake, to
// be emitted once SSL_do_handshake() has returned. Emitting it there and
// then would mean running JavaScript -- and draining the tick queue -- in
// the middle of OpenSSL's state machine, which is what calling the
// callback with Call() rather than MakeCallback() set out to avoid.
void SetPendingError(v8::Local<v8::Value> error);
// Retain a context chosen by the SNI callback. Entries in an sni map are
// owned by the context holding the map, but one returned from a callback
// has no other owner: SSL_set_SSL_CTX() references the SSL_CTX and not the
// DTLSContext wrapping it, and callbacks reached later in the handshake
// find their configuration through that wrapper.
void SetSNIContext(DTLSContext* context);
private:
bool HandshakeDeadlineExpired() const;
void EmitHandshakeTimeout();
// The peer's chain verification result, with "no certificate, and none was
// needed" (PSK) told apart from "a certificate that verified". Shared by
// session.authorizationError and the gate below so the two agree.
long PeerVerifyResult() const; // NOLINT(runtime/int)
// Refuse a completed handshake whose peer was never verified, emitting the
// error. True to carry on. Exists for resumption, which skips verification
// and restores the result from the session being resumed, so the verify
// mode has nothing to act on.
bool PeerVerificationPassed();
// Emit a callback to JS via the endpoint's callback dispatch.
v8::MaybeLocal<v8::Value> EmitCallback(int cb_index,
int argc,
v8::Local<v8::Value>* argv);
// As EmitCallback, but a plain Call() rather than MakeCallback(): for
// callbacks OpenSSL invokes from inside its own state machine, where
// draining the microtask and tick queues would run user code in the middle
// of a transition.
v8::MaybeLocal<v8::Value> CallCallback(int cb_index,
int argc,
v8::Local<v8::Value>* argv);
// Everything Cycle() does between taking and releasing the reentrancy
// guard. Split out so the guard and the pending-error drain happen on every
// path out, rather than at each return.
void CycleInner();
// Emit an exception captured from a callback that ran inside OpenSSL.
void EmitPendingError();
// Emit a failure to put a record on the wire.
void EmitSendError();
BaseObjectWeakPtr<DTLSEndpoint> endpoint_;
ncrypto::SSLPointer ssl_;
@@ -168,11 +244,40 @@ class DTLSSession final : public AsyncWrap {
SocketAddress remote_address_;
bool is_server_;
bool started_ = false;
bool handshake_complete_ = false;
bool closed_ = false;
bool destroyed_ = false;
int cycle_depth_ = 0;
v8::Global<v8::Value> pending_error_;
// First libuv error from sending a record, or 0. Reported once Cycle() is
// done rather than from inside the send loop, which runs while the SSL is
// mid-flight.
int send_error_ = 0;
// Absolute time by which the handshake must finish, or 0 for no limit.
//
// OpenSSL already gives up on its own, but only after DTLS1_TMO_ALERT_COUNT
// retransmits on a doubling backoff capped at 60s -- measured at roughly
// eight minutes, which is a long time to hold a slot against maxSessions.
// This bounds it without touching the retransmission schedule, which has to
// stay as it is: compressing it to force earlier failure would cause
// spurious retransmits on exactly the lossy links DTLS is for. Whichever
// limit trips first ends the handshake.
uint64_t handshake_deadline_ = 0;
// The context this session was created from, kept alive for as long as the
// session is. SSL_new() takes a reference to the SSL_CTX but not to the
// DTLSContext wrapping it, and callbacks reached from the handshake find
// their configuration through that wrapper. An endpoint holds its server
// context, but nothing held a client's: it became garbage the moment
// connect() returned, and the first client-side callback to look for it
// read freed memory.
BaseObjectPtr<DTLSContext> context_;
BaseObjectPtr<DTLSContext> sni_context_;
AliasedStruct<DTLSSessionStateData> state_;
AliasedStruct<DTLSSessionStats> stats_;
};
+28 -1
View File
@@ -84,9 +84,11 @@ size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const {
case AF_INET6: {
const sockaddr_in6* ipv6 =
reinterpret_cast<const sockaddr_in6*>(addr.raw());
uint8_t buf[18];
// Must hash exactly what Equal compares, scope_id included.
uint8_t buf[22];
memcpy(buf, &ipv6->sin6_port, 2);
memcpy(buf + 2, &ipv6->sin6_addr, 16);
memcpy(buf + 18, &ipv6->sin6_scope_id, 4);
return HashBytes(buf, sizeof(buf));
}
default:
@@ -94,6 +96,31 @@ size_t SocketAddress::Hash::operator()(const SocketAddress& addr) const {
}
}
bool SocketAddress::Equal::operator()(const SocketAddress& a,
const SocketAddress& b) const {
if (a.family() != b.family()) return false;
switch (a.family()) {
case AF_INET: {
const sockaddr_in* a4 = reinterpret_cast<const sockaddr_in*>(a.raw());
const sockaddr_in* b4 = reinterpret_cast<const sockaddr_in*>(b.raw());
return a4->sin_port == b4->sin_port &&
memcmp(&a4->sin_addr, &b4->sin_addr, 4) == 0;
}
case AF_INET6: {
const sockaddr_in6* a6 = reinterpret_cast<const sockaddr_in6*>(a.raw());
const sockaddr_in6* b6 = reinterpret_cast<const sockaddr_in6*>(b.raw());
// scope_id is part of a link-local address's identity: fe80::1 on one
// interface is a different peer from fe80::1 on another. flowinfo is a
// QoS label and is not.
return a6->sin6_port == b6->sin6_port &&
a6->sin6_scope_id == b6->sin6_scope_id &&
memcmp(&a6->sin6_addr, &b6->sin6_addr, 16) == 0;
}
default:
UNREACHABLE();
}
}
size_t SocketAddress::IpHash::operator()(const SocketAddress& addr) const {
// Hash only the IP address bytes, ignoring the port.
switch (addr.family()) {
+15
View File
@@ -27,6 +27,15 @@ class SocketAddress : public MemoryRetainer {
size_t operator()(const SocketAddress& addr) const;
};
// Compares what identifies a peer: family, port, address, and scope_id for
// IPv6. operator== memcmps the whole sockaddr, so it also distinguishes
// sin6_flowinfo, which is a QoS label a peer may vary between datagrams,
// and sin_zero, which is padding. Pair this with Hash where two datagrams
// from one peer have to reach one entry.
struct Equal {
bool operator()(const SocketAddress& a, const SocketAddress& b) const;
};
// Hashes and compares only the IP address, ignoring the port.
// Useful for per-host connection counting where clients from
// the same IP but different ports should be treated as one host.
@@ -140,6 +149,12 @@ class SocketAddress : public MemoryRetainer {
template <typename T>
using Map = std::unordered_map<SocketAddress, T, Hash>;
// As Map, but keyed on what identifies a peer rather than on every byte of
// the sockaddr. Separate from Map because Map is what SocketAddressLRU
// uses, and QUIC's address validation counts entries in one of those.
template <typename T>
using PeerMap = std::unordered_map<SocketAddress, T, Hash, Equal>;
template <typename T>
using IpMap = std::unordered_map<SocketAddress, T, IpHash, IpEqual>;
+9 -6
View File
@@ -361,12 +361,15 @@ TEST_F(EnvironmentTest, WorkerInEnvironmentWithoutSnapshot) {
const v8::HandleScope handle_scope(isolate_);
const Argv argv;
Env env{handle_scope, argv};
CHECK_NULL(isolate_data_->snapshot_data());
node::LoadEnvironment(*env,
"const { Worker } = require('worker_threads');"
"new Worker('process.exit(0)', { eval: true });")
.ToLocalChecked();
EXPECT_EQ(node::SpinEventLoop(*env).FromJust(), 0);
// When using certain experimental compile options, snapshot data may
// not exist. Skip in that case.
if (isolate_data_->snapshot_data()) {
node::LoadEnvironment(*env,
"const { Worker } = require('worker_threads');"
"new Worker('process.exit(0)', { eval: true });")
.ToLocalChecked();
EXPECT_EQ(node::SpinEventLoop(*env).FromJust(), 0);
}
}
TEST_F(EnvironmentTest, StopFromExitHandlerDoesNotLeakIntoNextEnvironment) {
+87
View File
@@ -43,6 +43,93 @@ TEST(SocketAddress, SocketAddress) {
CHECK_EQ(map[addr], 2);
}
TEST(SocketAddress, HashAndEqualAgree) {
// Hash covers family, port and address. operator== memcmps the whole
// sockaddr, so it also sees sin_zero, which is padding the kernel is not
// obliged to zero for us. Two datagrams from one peer can therefore compare
// unequal, which for a session table means one peer with two sessions.
//
// Not a broken container: operator== is stricter than Hash, so equal keys
// still hash equal and unordered_map's invariant holds. Map keeps that
// pairing. PeerMap is for callers that need one peer to mean one entry.
sockaddr_storage s1, s2;
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 443, &s1);
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 443, &s2);
// Same peer, dirty padding.
reinterpret_cast<sockaddr_in*>(&s2)->sin_zero[0] = 0x01;
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&s1));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&s2));
SocketAddress::Equal equal;
// They hash the same...
CHECK_EQ(SocketAddress::Hash()(addr1), SocketAddress::Hash()(addr2));
// ...and operator== disagrees, which is the inconsistency Equal exists to
// avoid. If this ever starts passing, Equal is no longer needed.
CHECK(!(addr1 == addr2));
// Equal agrees with Hash.
CHECK(equal(addr1, addr2));
// Map pairs Hash with the default std::equal_to, which splits this single
// peer across two entries of one bucket. Unchanged deliberately: it is what
// SocketAddressLRU uses, and QUIC counts entries in one of those.
SocketAddress::Map<size_t> split;
split[addr1]++;
split[addr2]++;
CHECK_EQ(split.size(), 2);
// PeerMap is the one that treats them as the peer they are.
SocketAddress::PeerMap<size_t> map;
map[addr1]++;
map[addr2]++;
CHECK_EQ(map.size(), 1);
CHECK_EQ(map[addr1], 2);
// Port is still part of the key.
sockaddr_storage s3;
SocketAddress::ToSockAddr(AF_INET, "10.0.0.1", 8080, &s3);
SocketAddress addr3(reinterpret_cast<const sockaddr*>(&s3));
CHECK(!equal(addr1, addr3));
map[addr3]++;
CHECK_EQ(map.size(), 2);
}
TEST(SocketAddress, HashAndEqualAgreeIPv6) {
sockaddr_storage s1, s2;
SocketAddress::ToSockAddr(AF_INET6, "fe80::1", 443, &s1);
SocketAddress::ToSockAddr(AF_INET6, "fe80::1", 443, &s2);
// flowinfo is a QoS label, not identity. It must not split the key.
reinterpret_cast<sockaddr_in6*>(&s2)->sin6_flowinfo = 0xabcd;
SocketAddress addr1(reinterpret_cast<const sockaddr*>(&s1));
SocketAddress addr2(reinterpret_cast<const sockaddr*>(&s2));
SocketAddress::Equal equal;
CHECK_EQ(SocketAddress::Hash()(addr1), SocketAddress::Hash()(addr2));
CHECK(equal(addr1, addr2));
SocketAddress::PeerMap<size_t> map;
map[addr1]++;
map[addr2]++;
CHECK_EQ(map.size(), 1);
// scope_id *is* identity: the same link-local address on two interfaces is
// two different peers, and must not collapse into one entry.
sockaddr_storage s3;
SocketAddress::ToSockAddr(AF_INET6, "fe80::1", 443, &s3);
reinterpret_cast<sockaddr_in6*>(&s3)->sin6_scope_id = 2;
SocketAddress addr3(reinterpret_cast<const sockaddr*>(&s3));
CHECK(!equal(addr1, addr3));
CHECK_NE(SocketAddress::Hash()(addr1), SocketAddress::Hash()(addr3));
map[addr3]++;
CHECK_EQ(map.size(), 2);
}
TEST(SocketAddress, IpHashAndIpEqual) {
sockaddr_storage s1, s2, s3;
// Same IP, different ports.
+58 -5
View File
@@ -1,4 +1,4 @@
// Flags: --experimental-dtls --no-warnings
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: DTLSEndpoint/DTLSSession state fields and callback accessors reflect
// what is set and the connection lifecycle.
@@ -19,6 +19,13 @@ if (!process.features.dtls) {
const { listen, connect } = await import('node:dtls');
// The state view is not public API; reached the way node:quic's tests do.
const {
getDTLSEndpointState,
getDTLSSessionState,
} = (await import('internal/dtls/dtls')).default;
const { kOwnsEndpoint } = (await import('internal/dtls/symbols')).default;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
@@ -30,7 +37,7 @@ const server = listen(mustCall((session) => {
}), { cert, key, port: 0, host: '127.0.0.1' });
// --- Endpoint state after listen(): bound and listening. ---
const es = server.state;
const es = getDTLSEndpointState(server);
assert.strictEqual(es.bound, true);
assert.strictEqual(es.listening, true);
assert.strictEqual(es.closing, false);
@@ -58,7 +65,7 @@ const client = connect('127.0.0.1', server.address.port, {
});
// --- Session state during the handshake. ---
const cs = client.state;
const cs = getDTLSSessionState(client);
assert.strictEqual(cs.handshaking, true);
assert.strictEqual(cs.open, false);
assert.strictEqual(cs.closing, false);
@@ -70,8 +77,12 @@ assert.strictEqual(client.onmessage, undefined);
assert.strictEqual(client.onerror, undefined);
assert.strictEqual(client.onhandshake, undefined);
assert.strictEqual(client.onkeylog, undefined);
// A connect() session owns its internal endpoint.
assert.strictEqual(client.ownsEndpoint, true);
// A connect() session owns its internal endpoint, which is recorded on a
// symbol rather than a property: it decides whether closing the session takes
// the endpoint with it, and a server session told the same thing would take
// down the listener.
assert.strictEqual(client[kOwnsEndpoint], true);
assert.strictEqual(client.ownsEndpoint, undefined);
client.onmessage = mustNotCall();
assert.strictEqual(typeof client.onmessage, 'function');
@@ -99,3 +110,45 @@ assert.strictEqual(es.sessionCount, 1);
await client.close();
await server.close();
// ownsEndpoint is not public. It says whether closing a session should take
// its endpoint with it, which is true only for the single-session endpoint
// connect() creates. Exposed as a settable property, it let a server session
// be told it owned the listener: closing that one session then tore down the
// endpoint and every other session on it.
{
const arrived = Promise.withResolvers();
let serverSession;
const server = listen((session) => {
serverSession ??= session;
arrived.resolve();
}, { cert, key, host: '127.0.0.1', port: 0 });
const port = server.address.port;
const first = connect('127.0.0.1', port, { rejectUnauthorized: false });
await first.opened;
await arrived.promise;
assert.strictEqual(serverSession.ownsEndpoint, undefined);
assert.strictEqual(
Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(serverSession), 'ownsEndpoint'),
undefined);
// Assigning the name creates an ordinary own property and nothing more.
serverSession.ownsEndpoint = true;
await serverSession.close();
// The endpoint is still listening and still serving.
assert.notStrictEqual(server.address, undefined);
const second = connect('127.0.0.1', port, {
rejectUnauthorized: false,
handshakeTimeout: 5000,
});
await second.opened;
await second.close();
await first.close();
await server.close();
}
@@ -0,0 +1,87 @@
// Flags: --experimental-dtls --no-warnings
// Test: ALPN protocol lists are validated where they are supplied.
//
// The wire format is a sequence of one length byte followed by that many
// bytes. A name longer than 255 cannot be represented -- 256 truncated to a
// zero length byte and desynchronised the rest of the list -- and a
// pre-encoded Buffer was passed through with no checking at all. Neither was
// caught here; the first surfaced as an opaque ERR_CRYPTO_OPERATION_FAILED
// during the handshake, and a malformed Buffer just silently negotiated
// nothing.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
const base = { cert, key, host: '127.0.0.1', port: 0 };
// A name that does not fit in the length byte.
assert.throws(
() => listen(() => {}, { ...base, alpn: ['a'.repeat(256)] }),
{ code: 'ERR_OUT_OF_RANGE' });
// RFC 7301 gives ProtocolName a 1..255 length, so empty is not a valid entry.
assert.throws(
() => listen(() => {}, { ...base, alpn: [''] }),
{ code: 'ERR_OUT_OF_RANGE' });
// The index of the offending entry is reported.
assert.throws(
() => listen(() => {}, { ...base, alpn: ['h2', 'x'.repeat(300)] }),
{ code: 'ERR_OUT_OF_RANGE', message: /alpn\[1\]/ });
// 255 is the largest representable name and must still be accepted.
{
const name = 'b'.repeat(255);
const endpoint = listen(() => {}, { ...base, alpn: [name] });
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca], alpn: [name],
});
await client.opened;
assert.strictEqual(client.alpnProtocol, name);
await client.close();
await endpoint.close();
}
// Pre-encoded buffers are walked rather than trusted.
assert.throws(
() => listen(() => {}, { ...base, alpn: Buffer.from([0, 0x68, 0x32]) }),
{ code: 'ERR_INVALID_ARG_VALUE', message: /zero-length/ });
assert.throws(
() => listen(() => {}, { ...base, alpn: Buffer.from([9, 0x68, 0x32]) }),
{ code: 'ERR_INVALID_ARG_VALUE', message: /past the end/ });
// A well-formed buffer still works, so the walk is not rejecting everything.
{
const endpoint = listen(() => {}, {
...base, alpn: Buffer.from([2, 0x68, 0x32]),
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca], alpn: ['h2'],
});
await client.opened;
assert.strictEqual(client.alpnProtocol, 'h2');
await client.close();
await endpoint.close();
}
// Non-string entries are still rejected by type.
assert.throws(
() => listen(() => {}, { ...base, alpn: [42] }),
{ code: 'ERR_INVALID_ARG_TYPE' });
+64 -3
View File
@@ -51,8 +51,42 @@ await serverAlpnChecked.promise;
await session.close();
await endpoint.close();
// ALPN with no protocol in common: the handshake still completes and neither
// peer reports a negotiated protocol.
// ALPN with no protocol in common: RFC 7301 section 3.2 requires the server to
// send a fatal no_application_protocol alert, so the handshake must fail.
//
// This previously asserted the opposite -- that the handshake completed with
// neither peer reporting a protocol -- which left both ends connected with no
// agreement on what to speak. The callback returned SSL_TLSEXT_ERR_NOACK; it
// now returns SSL_TLSEXT_ERR_ALERT_FATAL, matching node:tls.
{
const server = listen(() => {}, {
cert: serverCert.toString(),
key: serverKey.toString(),
port: 0,
host: '127.0.0.1',
alpn: ['bar'],
});
const client = connect('127.0.0.1', server.address.port, {
ca: [ca.toString()],
rejectUnauthorized: false,
alpn: ['foo'],
});
await assert.rejects(
client.opened,
(err) => {
assert.match(err.message, /no application protocol/);
return true;
},
'a client offering no protocol the server supports must be rejected');
await server.close();
}
// A server with no ALPN configured must not reject a client that offers
// protocols. The alert above applies only where the server does ALPN and
// finds no overlap; declining the extension entirely is not a failure.
{
const gotServerSession = Promise.withResolvers();
@@ -61,7 +95,6 @@ await endpoint.close();
key: serverKey.toString(),
port: 0,
host: '127.0.0.1',
alpn: ['bar'],
});
const client = connect('127.0.0.1', server.address.port, {
@@ -80,3 +113,31 @@ await endpoint.close();
await client.close();
await server.close();
}
// A server that does ALPN must still accept a client that offers none: the
// selection callback only runs when the client sent the extension.
{
const gotServerSession = Promise.withResolvers();
const server = listen(mustCall((s) => gotServerSession.resolve(s)), {
cert: serverCert.toString(),
key: serverKey.toString(),
port: 0,
host: '127.0.0.1',
alpn: ['bar'],
});
const client = connect('127.0.0.1', server.address.port, {
ca: [ca.toString()],
rejectUnauthorized: false,
});
await client.opened;
const serverSession = await gotServerSession.promise;
await serverSession.opened;
assert.strictEqual(client.alpnProtocol, undefined);
await client.close();
await server.close();
}
@@ -0,0 +1,121 @@
// Flags: --experimental-dtls --no-warnings
// Test: the verification accessors are safe before the handshake completes.
//
// Reading session.authorized from the listen() callback used to segfault.
// ncrypto's verifyPeerCertificate() allows for PSK by asking
// SSL_CIPHER_get_auth_nid() what authenticated the connection, and that
// dereferences SSL_get_current_cipher() without checking it -- which is NULL
// until a cipher has been negotiated.
//
// A server session reaches JavaScript before its handshake runs, so the
// listen() callback is the first thing that can ask.
import { hasCrypto, mustCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const HOST = '127.0.0.1';
const key = (name) => fixtures.readKey(name).toString();
const cert = key('agent1-cert.pem');
const privateKey = key('agent1-key.pem');
const ca = key('ca1-cert.pem');
// Server side: read both accessors at the earliest moment they exist.
{
const checked = Promise.withResolvers();
const endpoint = listen(mustCall((session) => {
// Nothing has been verified at this point. Not undefined: undefined is
// what getVerifyError() reports for "no fault found", which would read
// as authorized.
assert.strictEqual(session.authorized, false);
assert.strictEqual(session.authorizationError, 'HANDSHAKE_INCOMPLETE');
checked.resolve();
}), { cert, key: privateKey, host: HOST, port: 0 });
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await checked.promise;
await client.close();
await endpoint.close();
}
// Client side: readable between connect() and the handshake completing.
{
const endpoint = listen(() => {}, {
cert, key: privateKey, host: HOST, port: 0,
});
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
assert.strictEqual(client.authorized, false);
assert.strictEqual(client.authorizationError, 'HANDSHAKE_INCOMPLETE');
await client.opened;
await client.close();
await endpoint.close();
}
// After the handshake the accessors report the real result, so the guard has
// not simply pinned them to a constant.
{
const endpoint = listen(() => {}, {
cert, key: privateKey, host: HOST, port: 0,
});
// Trusting the issuer: authorized, with no error.
const trusting = connect(HOST, endpoint.address.port, {
ca: [ca], servername: 'agent1',
});
await trusting.opened;
assert.strictEqual(trusting.authorized, true);
assert.strictEqual(trusting.authorizationError, undefined);
await trusting.close();
// Not trusting it: a real verification error, not the placeholder.
const untrusting = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
await untrusting.opened;
assert.strictEqual(untrusting.authorized, false);
assert.notStrictEqual(untrusting.authorizationError, undefined);
assert.notStrictEqual(untrusting.authorizationError, 'HANDSHAKE_INCOMPLETE');
await untrusting.close();
await endpoint.close();
}
// And after close, when the handle is gone.
{
const endpoint = listen(() => {}, {
cert, key: privateKey, host: HOST, port: 0,
});
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await client.close();
assert.strictEqual(client.authorized, false);
assert.strictEqual(client.authorizationError, undefined);
await endpoint.close();
}
+100
View File
@@ -0,0 +1,100 @@
// Flags: --experimental-dtls --no-warnings
// Test: session.authorized and session.authorizationError report the result of
// peer certificate chain verification.
//
// OpenSSL verifies the chain even under SSL_VERIFY_NONE -- it just does not
// abort on failure -- so these stay accurate when rejectUnauthorized is false,
// which is what makes a custom authorization policy possible.
import { hasCrypto, mustCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca1 = fixtures.readKey('ca1-cert.pem');
const ca2 = fixtures.readKey('ca2-cert.pem');
// --- Client side ---------------------------------------------------------
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
const { port } = endpoint.address;
for (const [description, options, authorized, authorizationError] of [
['a trusted chain and matching identity verifies',
{ servername: 'agent1', ca: [ca1] },
true, undefined],
['an untrusted chain reports the issuer failure',
{ servername: 'agent1', ca: [ca2], rejectUnauthorized: false },
false, 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'],
['a trusted chain with the wrong identity reports the name mismatch',
{ servername: 'not-agent1', ca: [ca1], rejectUnauthorized: false },
false, 'HOSTNAME_MISMATCH'],
]) {
const client = connect('127.0.0.1', port, options);
await client.opened;
assert.strictEqual(client.authorized, authorized, description);
assert.strictEqual(client.authorizationError, authorizationError,
description);
await client.close();
}
await endpoint.close();
// --- Server side ---------------------------------------------------------
// A peer that sent no certificate is reported as unverified rather than
// authorized, even though OpenSSL has nothing to find fault with.
{
const serverSession = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onhandshake = mustCall(() => serverSession.resolve(session));
}), { cert, key, host: '127.0.0.1', port: 0 });
const client = connect('127.0.0.1', server.address.port, {
servername: 'agent1', ca: [ca1],
});
await client.opened;
const session = await serverSession.promise;
assert.strictEqual(session.authorized, false);
assert.strictEqual(session.authorizationError, 'UNABLE_TO_GET_ISSUER_CERT');
await client.close();
await server.close();
}
// A peer that sent a chain the server trusts is authorized.
{
const serverSession = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onhandshake = mustCall(() => serverSession.resolve(session));
}), {
cert, key, ca: [ca1], requestCert: true, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', server.address.port, {
cert, key, servername: 'agent1', ca: [ca1],
});
await client.opened;
const session = await serverSession.promise;
assert.strictEqual(session.authorized, true);
assert.strictEqual(session.authorizationError, undefined);
await client.close();
await server.close();
}
+8 -1
View File
@@ -31,7 +31,14 @@ const endpoint = listen(mustCall(), {
port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port);
// `servername` is both the SNI value and the identity checked during
// certificate verification. agent1-cert.pem has no subjectAltName, so the
// identity has to be matched against its CN; connecting to the IP literal
// alone would fail with X509_V_ERR_IP_ADDRESS_MISMATCH before the CA set is
// ever exercised.
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1',
});
await client.opened;
await client.close();
+84
View File
@@ -0,0 +1,84 @@
// Flags: --experimental-dtls --no-warnings
// Test: DTLS does not leave entries behind in the OpenSSL error queue.
//
// The queue is per-thread and shared with every other OpenSSL consumer in the
// process. DTLS spends most of its time handling unauthenticated input, so
// failures are routine: rejected handshakes, and DTLSv1_listen() choking on
// garbage datagrams from the accept path. If those entries are not discarded
// they are picked up by whatever crypto operation runs next and reported as
// its error -- e.g. crypto.createPrivateKey() failing with
// "SSL routines::record too small".
import { hasCrypto, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const crypto = await import('node:crypto');
const dgram = await import('node:dgram');
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca1 = fixtures.readKey('ca1-cert.pem');
const ca2 = fixtures.readKey('ca2-cert.pem');
// The server accepts each session (cookie exchange completes) and only the
// client rejects, so sessions do arrive here; nothing needs doing with them.
const endpoint = listen(() => {}, {
cert, key, ca: [ca1], host: '127.0.0.1', port: 0,
});
const { port } = endpoint.address;
// Provoke failures from both directions.
// 1. Rejected handshakes: the server certificate does not chain to ca2.
for (let i = 0; i < 3; i++) {
const client = connect('127.0.0.1', port, {
servername: 'agent1',
ca: [ca2],
});
await assert.rejects(client.opened, (err) => {
assert.match(err.message, /certificate verify failed/);
return true;
});
}
// 2. Garbage datagrams: each one reaches DTLSv1_listen() and fails there.
// The leading 22 makes them look enough like a DTLS handshake record to get
// past a cheap length/type screen.
const socket = dgram.createSocket('udp4');
for (let i = 0; i < 32; i++) {
socket.send(Buffer.from([22, 254, 253, 0, 0, i & 0xff]), port, '127.0.0.1');
}
// Let the datagrams reach the endpoint and be processed before closing;
// dgram.close() does not flush queued sends.
await setTimeout(200);
await new Promise((resolve) => socket.close(resolve));
// An unrelated crypto failure must report its own cause. Before the error
// queue was drained this surfaced a stale "SSL routines::..." entry left by
// the DTLS activity above, with the real DECODER error demoted into
// opensslErrorStack.
assert.throws(
() => crypto.createPrivateKey(
'-----BEGIN PRIVATE KEY-----\nZm9v\n-----END PRIVATE KEY-----\n'),
(err) => {
assert.doesNotMatch(
err.message, /SSL routines/,
`crypto error contaminated by a stale DTLS entry: ${err.message}`);
return true;
},
);
await endpoint.close();
+19 -6
View File
@@ -1,4 +1,4 @@
// Flags: --experimental-dtls --no-warnings
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: DTLS error handling for invalid certificate/key material and endpoint
// state.
@@ -17,29 +17,42 @@ if (!process.features.dtls) {
const { listen, DTLSEndpoint } = await import('node:dtls');
// bind() is module plumbing rather than API; every argument reaches a
// CHECK() in the binding, so it is symbol-keyed and not callable from
// user code.
const { kBind } = (await import('internal/dtls/symbols')).default;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const mismatchedKey = fixtures.readKey('agent2-key.pem').toString();
// Bad credentials report the reason OpenSSL gave, rather than a single
// ERR_CRYPTO_OPERATION_FAILED for every kind of failure. These codes come
// from OpenSSL and could shift if the bundled version changes; the point
// being pinned is that the three cases are distinguishable.
// A malformed certificate PEM is rejected.
assert.throws(() => listen(mustNotCall(), {
cert: 'not a certificate', key, port: 0,
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
}), { code: 'ERR_OSSL_PEM_NO_START_LINE' });
// A malformed private key PEM is rejected.
assert.throws(() => listen(mustNotCall(), {
cert, key: 'not a key', port: 0,
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
}), { code: 'ERR_OSSL_UNSUPPORTED' });
// A private key that does not match the certificate is rejected.
assert.throws(() => listen(mustNotCall(), {
cert, key: mismatchedKey, port: 0,
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
}), { code: 'ERR_OSSL_X509_KEY_VALUES_MISMATCH' });
// Binding the same endpoint twice fails.
{
const endpoint = new DTLSEndpoint();
endpoint.bind('127.0.0.1', 0);
assert.throws(() => endpoint.bind('127.0.0.1', 0), { code: 'ERR_INVALID_STATE' });
endpoint[kBind]('127.0.0.1', 0);
// Reported with libuv's own code for rebinding a bound handle, rather than
// a generic ERR_INVALID_STATE that says nothing about which failure it was.
assert.throws(() => endpoint[kBind]('127.0.0.1', 0),
{ code: 'EALREADY', syscall: 'bind' });
await endpoint.close();
}
@@ -0,0 +1,90 @@
// Flags: --experimental-dtls --no-warnings
// Test: exportKeyingMaterial() rejects bad arguments instead of aborting.
//
// The length went unchecked through both layers and reached
// std::vector<uint8_t>(length). A negative value became a huge size_t and
// terminated the process:
//
// what(): cannot create std::vector larger than max_size()
//
// -1, 4294967295 and 1e12 all dumped core. The successful cases are covered
// by test-dtls-srtp.mjs; this is about the arguments being refused cleanly.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
const label = 'EXPORTER-test';
// Lengths that used to abort the process, plus the rest of the bad range.
for (const length of [-1, 0, 4294967295, 1e12, NaN, Infinity, -Infinity, 1.5]) {
assert.throws(
() => client.exportKeyingMaterial(length, label),
{ code: 'ERR_OUT_OF_RANGE' },
`length ${length} should have been refused`);
}
for (const length of ['60', null, {}, [], true]) {
assert.throws(
() => client.exportKeyingMaterial(length, label),
{ code: /^ERR_(INVALID_ARG_TYPE|OUT_OF_RANGE)$/ },
`length ${typeof length} should have been refused`);
}
// The upper bound is enforced, so a caller cannot request a huge allocation.
assert.throws(
() => client.exportKeyingMaterial(65537, label),
{ code: 'ERR_OUT_OF_RANGE' });
assert.strictEqual(client.exportKeyingMaterial(65536, label).length, 65536);
// The label and context arguments are checked too.
for (const bad of [undefined, 42, null, {}]) {
assert.throws(
() => client.exportKeyingMaterial(32, bad),
{ code: 'ERR_INVALID_ARG_TYPE' },
`label ${typeof bad} should have been refused`);
}
for (const bad of ['ctx', 42, {}]) {
assert.throws(
() => client.exportKeyingMaterial(32, label, bad),
{ code: 'ERR_INVALID_ARG_TYPE' },
`context ${typeof bad} should have been refused`);
}
// A valid call still works, so none of the above is vacuous.
assert.strictEqual(client.exportKeyingMaterial(60, label).length, 60);
assert.strictEqual(
client.exportKeyingMaterial(32, label, Buffer.from('ctx')).length, 32);
// A destroyed session reports that, rather than reaching the binding.
await client.close();
assert.throws(
() => client.exportKeyingMaterial(60, label),
{ code: 'ERR_INVALID_STATE' });
await endpoint.close();
@@ -0,0 +1,212 @@
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: handshake timeout.
//
// OpenSSL abandons a handshake on its own, but only after
// DTLS1_TMO_ALERT_COUNT retransmits on a doubling backoff capped at 60s --
// around eight minutes. Until then the session holds a slot, so abandoned
// handshakes can sit against maxSessions indefinitely for the cost of
// starting them. This bounds that without altering the retransmit schedule,
// which has to keep its own timing.
import { hasCrypto, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSEndpointState,
getDTLSSessionState,
} = (await import('internal/dtls/dtls')).default;
const { connect, listen } = dtls;
const HOST = '127.0.0.1';
const KEY = Buffer.from('0123456789abcdef');
const WRONG = Buffer.from('fedcba9876543210');
// A wrong pre-shared key is the cheapest way to stall a handshake: the
// identity only names the key, so both sides derive different secrets and
// then discard each other's records without replying, as DTLS requires
// (RFC 6347 section 4.1.2.1). Neither peer is ever told, and both retransmit.
function stall(endpoint, options = {}) {
return connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: WRONG }, ...options,
});
}
function serve(options = {}) {
return listen(() => {}, {
psk: { 'device': KEY }, host: HOST, port: 0, ...options,
});
}
// The deadline is honoured, and close to exactly.
{
for (const handshakeTimeout of [300, 700]) {
const endpoint = serve({ handshakeTimeout });
const started = Date.now();
const client = stall(endpoint, { handshakeTimeout });
await assert.rejects(client.opened, { message: /handshake timeout/ });
const elapsed = Date.now() - started;
// The retransmit schedule doubles -- 1s, 3s, 7s -- so hitting a 300ms
// deadline at all means the timer was clamped to it rather than left to
// fire on the retransmit's own schedule.
assert.ok(elapsed >= handshakeTimeout,
`gave up after ${elapsed}ms, before the ${handshakeTimeout}ms ` +
'deadline');
assert.ok(elapsed < handshakeTimeout + 1000,
`gave up after ${elapsed}ms, well past the ` +
`${handshakeTimeout}ms deadline`);
// Expiring is not the same as running out of retransmits. The deadline is
// checked before DTLSv1_handle_timeout(), so the session stops rather
// than sending again; reaching OpenSSL's own limit instead would mean
// twelve more flights on the way out.
assert.ok(Number(client.stats.retransmitCount) < 5,
`expired after ${client.stats.retransmitCount} retransmits, ` +
'which suggests OpenSSL\'s budget ran out rather than the ' +
'deadline being honoured');
await endpoint.close();
}
}
// The slot is given back. This is the point of the exercise: without it an
// abandoned handshake occupies its place against maxSessions until the
// endpoint closes.
{
const endpoint = serve({ handshakeTimeout: 300, maxSessions: 3 });
const stalled = [];
for (let i = 0; i < 3; i++) {
const client = stall(endpoint, { handshakeTimeout: 60000 });
client.opened.catch(() => {});
stalled.push(client);
}
await setTimeout(150);
assert.strictEqual(Number(getDTLSEndpointState(endpoint).sessionCount), 3);
await setTimeout(600);
assert.strictEqual(Number(getDTLSEndpointState(endpoint).sessionCount), 0);
// And the endpoint is usable again.
const good = connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: KEY },
});
await good.opened;
await good.close();
for (const client of stalled) client.destroy();
await endpoint.close();
}
// A handshake that completes is not affected, and the deadline does not
// linger to fire at an established session.
{
const endpoint = serve({ handshakeTimeout: 300 });
const client = connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: KEY }, handshakeTimeout: 300,
});
await client.opened;
await setTimeout(600);
assert.strictEqual(getDTLSSessionState(client).destroyed, false);
// Still usable well after the deadline would have passed.
const echoed = Promise.withResolvers();
client.onmessage = (data) => echoed.resolve(data);
const peer = connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: KEY },
});
await peer.opened;
await peer.close();
await client.close();
await endpoint.close();
}
// Zero disables it, leaving OpenSSL's own retransmit budget as the only
// limit.
{
const endpoint = serve({ handshakeTimeout: 0 });
const client = stall(endpoint, { handshakeTimeout: 0 });
const pending = Symbol('pending');
const outcome = await Promise.race([
client.opened.then(() => 'opened', () => 'failed'),
setTimeout(500, pending),
]);
assert.strictEqual(outcome, pending);
client.destroy();
await endpoint.close();
}
// The default does not fire early. Checking the 60s default itself would
// mean waiting 60s, so this only pins that it is not something small.
{
const endpoint = serve();
const client = stall(endpoint);
const pending = Symbol('pending');
const outcome = await Promise.race([
client.opened.then(() => 'opened', () => 'failed'),
setTimeout(500, pending),
]);
assert.strictEqual(outcome, pending);
client.destroy();
await endpoint.close();
}
// It applies to a resumed handshake too, which is a different code path
// through the state machine but the same session lifecycle.
{
const endpoint = listen(() => {}, {
psk: { 'device': KEY }, host: HOST, port: 0, handshakeTimeout: 5000,
});
const first = connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: KEY },
});
await first.opened;
const ticket = first.session;
await first.close();
const resumed = connect(HOST, endpoint.address.port, {
psk: { identity: 'device', key: KEY },
session: ticket,
handshakeTimeout: 5000,
});
await resumed.opened;
assert.strictEqual(getDTLSSessionState(resumed).destroyed, false);
await resumed.close();
await endpoint.close();
}
// Validation.
{
for (const value of [-1, 1.5, 'soon', null, {}]) {
assert.throws(() => listen(() => {}, {
psk: { 'device': KEY }, host: HOST, port: 0, handshakeTimeout: value,
}), { code: /^ERR_(INVALID_ARG_TYPE|OUT_OF_RANGE)$/ });
}
}
+98
View File
@@ -0,0 +1,98 @@
// Flags: --experimental-dtls --no-warnings
// Test: DTLS over IPv6.
//
// listen() already accepted an IPv6 address, but connect() hardcoded the
// local bind to the IPv4 wildcard regardless of the peer, so the socket was
// AF_INET and could not send to an AF_INET6 destination. The bind address now
// follows the family of the remote literal.
import { hasCrypto, hasIPv6, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
if (!hasIPv6) {
skip('no IPv6 support');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
// A full handshake and round trip over IPv6 loopback.
{
const echoed = Promise.withResolvers();
const endpoint = listen((session) => {
session.onmessage = (data) => session.send(data);
}, { cert, key, host: '::1', port: 0 });
assert.strictEqual(endpoint.address.family, 'IPv6');
const client = connect('::1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
client.onmessage = (data) => echoed.resolve(data.toString());
client.send('over v6');
assert.strictEqual(await echoed.promise, 'over v6');
await client.close();
await endpoint.close();
}
// The local socket picks the peer's family without being told. Binding the
// IPv4 wildcard here is what used to break the send.
{
const endpoint = listen(() => {}, { cert, key, host: '::1', port: 0 });
const client = connect('::1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
await client.close();
await endpoint.close();
}
// An explicit bindHost is still honoured.
{
const endpoint = listen(() => {}, { cert, key, host: '::1', port: 0 });
const client = connect('::1', endpoint.address.port, {
servername: 'agent1', ca: [ca], bindHost: '::',
});
await client.opened;
await client.close();
await endpoint.close();
}
// IPv4 is unaffected: the default must not become IPv6 for v4 peers.
{
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
assert.strictEqual(endpoint.address.family, 'IPv4');
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
await client.close();
await endpoint.close();
}
+77
View File
@@ -0,0 +1,77 @@
// Flags: --experimental-dtls --no-warnings
// Test: ipv6Only, and dual stack by default.
//
// Bind() forced UV_UDP_IPV6ONLY for every IPv6 address, so an endpoint on ::
// could not be reached over IPv4 at all and there was no way to ask for the
// dual-stack socket that node:dgram and node:quic give by default.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
// An IPv6 wildcard endpoint accepts IPv4 peers, which is the default
// everywhere else in Node.js.
{
const arrived = Promise.withResolvers();
let serverSession;
const server = listen((session) => {
serverSession ??= session;
arrived.resolve();
}, { cert, key, host: '::', port: 0 });
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
handshakeTimeout: 5000,
});
await client.opened;
await arrived.promise;
// The peer arrives mapped, which is what a dual-stack socket reports and
// what anything keyed on the address will see.
assert.match(serverSession.remoteAddress.address, /^::ffff:127\.0\.0\.1$/);
assert.strictEqual(serverSession.remoteAddress.family, 'IPv6');
await client.close();
await server.close();
}
// ipv6Only: true asks for the old behaviour back.
{
const server = listen(() => {}, {
cert, key, host: '::', port: 0, ipv6Only: true,
});
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
handshakeTimeout: 1500,
});
await assert.rejects(client.opened, { code: 'ERR_INVALID_STATE' });
client.destroy();
await server.close();
}
// It is a boolean, checked like the rest.
{
for (const value of ['yes', 1, 0, null, {}]) {
assert.throws(() => listen(() => {}, {
cert, key, host: '::', port: 0, ipv6Only: value,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
}
+78 -3
View File
@@ -1,9 +1,10 @@
// Flags: --experimental-dtls --no-warnings
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: the onkeylog callback delivers NSS-format key material during the
// handshake (useful for decrypting captures in Wireshark).
// handshake (useful for decrypting captures in Wireshark), and that key
// material is only extracted when something is listening for it.
import { hasCrypto, skip, mustCall, mustCallAtLeast } from '../common/index.mjs';
import { hasCrypto, mustCall, mustCallAtLeast, mustNotCall, skip } from '../common/index.mjs';
import assert from 'node:assert';
import * as fixtures from '../common/fixtures.mjs';
@@ -17,6 +18,12 @@ if (!process.features.dtls) {
const { listen, connect } = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSSessionState,
} = (await import('internal/dtls/dtls')).default;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
@@ -32,6 +39,12 @@ const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
// state.hasKeylogListener is the flag the native keylog callback reads to
// decide whether to extract key material at all. Without a listener the
// secrets stay inside OpenSSL and are never turned into JS strings, where
// they would be reachable from heap snapshots and core dumps.
assert.strictEqual(getDTLSSessionState(client).hasKeylogListener, false);
// A keylog line is "<LABEL> <hex> <hex>" (e.g. "CLIENT_RANDOM ...").
client.onkeylog = mustCallAtLeast((line) => {
assert.strictEqual(typeof line, 'string');
@@ -39,8 +52,70 @@ client.onkeylog = mustCallAtLeast((line) => {
gotKeylog.resolve();
});
assert.strictEqual(getDTLSSessionState(client).hasKeylogListener, true);
await client.opened;
await gotKeylog.promise;
// Clearing the listener puts the gate back.
client.onkeylog = null;
assert.strictEqual(getDTLSSessionState(client).hasKeylogListener, false);
await client.close();
await server.close();
// A session that never gets a listener never reports one, and handshakes
// normally regardless.
{
const quietServer = listen(mustCall(), {
cert, key, port: 0, host: '127.0.0.1',
});
const quietClient = connect('127.0.0.1', quietServer.address.port, {
ca: [ca],
rejectUnauthorized: false,
});
assert.strictEqual(quietClient.onkeylog, undefined);
assert.strictEqual(getDTLSSessionState(quietClient).hasKeylogListener, false);
await quietClient.opened;
assert.strictEqual(getDTLSSessionState(quietClient).hasKeylogListener, false);
await quietClient.close();
await quietServer.close();
}
// An exception from onkeylog is reported to the session, like an exception
// from any other callback that runs inside the handshake.
//
// OpenSSL calls the keylog callback from ssl_log_secret while deriving the
// master secret, so it runs inside SSL_do_handshake(). There is nowhere to
// report an exception from there, and it used to reach the process as an
// uncaught exception instead. It is parked and emitted once the handshake
// returns.
{
process.on('uncaughtException', mustNotCall('reached uncaughtException'));
const server = listen(mustCall(), { cert, key, port: 0, host: '127.0.0.1' });
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
});
const reported = Promise.withResolvers();
client.onerror = mustCall((error) => reported.resolve(error));
client.onkeylog = mustCall(() => {
throw new Error('thrown from onkeylog');
});
const error = await reported.promise;
assert.strictEqual(error.message, 'thrown from onkeylog');
// The session fails rather than continuing with a handler that threw.
await assert.rejects(client.opened, { message: 'thrown from onkeylog' });
await server.close();
process.removeAllListeners('uncaughtException');
}
+137
View File
@@ -0,0 +1,137 @@
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: maxSessions and maxSessionsPerHost bound the server session table.
//
// Each accepted session owns an SSL, two BIOs and a retransmit timer, and
// nothing used to bound how many of them a peer could accumulate.
import { hasCrypto, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSEndpointState,
} = (await import('internal/dtls/dtls')).default;
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
// Connects and resolves true if the handshake got through. A refused peer is
// answered with silence rather than an alert, so it retransmits and then
// fails; keep the wait short so the test does not sit through the full DTLS
// retransmit schedule.
async function tryConnect(port, timeout = 1500) {
const client = connect('127.0.0.1', port, { servername: 'agent1', ca: [ca] });
const timer = setTimeout(timeout, undefined, { ref: false });
const opened = await Promise.race([
client.opened.then(() => true, () => false),
timer.then(() => false),
]);
return { opened, client };
}
// Closing a client resolves as soon as the client side is down; the server
// only drops its half once the close_notify arrives.
async function waitForSessionCount(server, expected) {
for (let i = 0; i < 100; i++) {
if (getDTLSEndpointState(server).sessionCount === expected) return;
await setTimeout(10, undefined, { ref: false });
}
assert.strictEqual(getDTLSEndpointState(server).sessionCount, expected);
}
// --- maxSessions ---------------------------------------------------------
{
const server = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0, maxSessions: 2,
});
const { port } = server.address;
const first = await tryConnect(port);
const second = await tryConnect(port);
assert.strictEqual(first.opened, true);
assert.strictEqual(second.opened, true);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 2);
// The table is full, so the third is refused before anything is allocated.
const third = await tryConnect(port);
assert.strictEqual(third.opened, false);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 2);
assert.ok(server.stats.serverRefusedCount > 0n,
'expected the refusal to be counted');
// Freeing a slot lets a new peer in, so the cap is a live bound and not a
// one-way latch.
await first.client.close();
await waitForSessionCount(server, 1);
const fourth = await tryConnect(port);
assert.strictEqual(fourth.opened, true);
await fourth.client.close();
await second.client.close();
try {
await third.client.close();
} catch {
// Never opened.
}
await server.close();
}
// --- maxSessionsPerHost --------------------------------------------------
// Every client here is on 127.0.0.1, so the per-host cap binds first even
// though the overall cap is far higher.
{
const server = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
maxSessions: 100, maxSessionsPerHost: 1,
});
const { port } = server.address;
const first = await tryConnect(port);
assert.strictEqual(first.opened, true);
const second = await tryConnect(port);
assert.strictEqual(second.opened, false);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 1);
// The per-host count is released along with the session.
await first.client.close();
await waitForSessionCount(server, 0);
const third = await tryConnect(port);
assert.strictEqual(third.opened, true);
await third.client.close();
try {
await second.client.close();
} catch {
// Never opened.
}
await server.close();
}
// --- validation ----------------------------------------------------------
for (const bad of [-1, 1.5, '10']) {
assert.throws(
() => listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0, maxSessions: bad,
}),
{ code: /^ERR_(OUT_OF_RANGE|INVALID_ARG_TYPE)$/ });
}
@@ -0,0 +1,84 @@
// Flags: --experimental-dtls --no-warnings
// Test: incoming data is only copied into a JS Buffer when something is
// listening for it.
//
// The has_message_listener state flag was written by JS and never read by
// C++, so every datagram was copied onto the heap and dispatched into JS even
// with no onmessage handler, for the handler to drop it. The flag now gates
// that, in the same way has_keylog_listener gates keylog extraction.
//
// The allocation itself is not observable from JS, so what is pinned here is
// the part that would break if the gate were wrong: reading must continue
// regardless, or data would accumulate inside OpenSSL instead of being
// drained.
import { hasCrypto, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
const gotSession = Promise.withResolvers();
const endpoint = listen((session) => gotSession.resolve(session),
{ cert, key, host: '127.0.0.1', port: 0 });
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
const session = await gotSession.promise;
await session.opened;
// Wait for a stat to reach an expected value, so the test does not depend on
// a fixed sleep. dgram send callbacks fire when the kernel takes the
// datagram, not when the peer has processed it.
async function waitForMessages(count) {
for (let i = 0; i < 100; i++) {
if (session.stats.messagesReceived >= count) return;
await setTimeout(20);
}
assert.fail(`only ${session.stats.messagesReceived} of ${count} messages ` +
'were read; data is not being drained');
}
// No listener: the data must still be read out of OpenSSL and counted.
for (let i = 0; i < 5; i++) client.send(`unheard-${i}`);
await waitForMessages(5);
assert.strictEqual(session.stats.messagesReceived, 5n);
assert.ok(session.stats.bytesReceived > 0n);
// Attaching a listener starts delivery.
const delivered = [];
session.onmessage = (data) => delivered.push(data.toString());
for (let i = 0; i < 3; i++) client.send(`heard-${i}`);
await waitForMessages(8);
// Nothing sent before the listener was attached should be replayed.
assert.deepStrictEqual(delivered, ['heard-0', 'heard-1', 'heard-2']);
// Removing it stops delivery but must not stop reading.
session.onmessage = null;
for (let i = 0; i < 4; i++) client.send(`unheard-again-${i}`);
await waitForMessages(12);
assert.strictEqual(session.stats.messagesReceived, 12n);
assert.strictEqual(delivered.length, 3);
await client.close();
await endpoint.close();
@@ -0,0 +1,89 @@
// Flags: --experimental-dtls --no-warnings
// Test: DTLS records are sent as individual datagrams sized to the MTU.
//
// OpenSSL emits one BIO_write per DTLS record, each fragmented to fit
// SSL_set_mtu(). If the outbound BIO does not preserve those boundaries the
// whole handshake flight is read back as one blob and sent as a single
// oversized datagram, which defeats the MTU setting entirely and relies on IP
// fragmentation -- routinely dropped by NATs and middleboxes. Loopback has a
// 64 KiB MTU, so nothing else in the suite notices.
//
// A UDP relay sits between client and server so the datagrams the server
// actually puts on the wire can be measured.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import dgram from 'node:dgram';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
const MTU = 512;
// Send the CA alongside the leaf so the server's flight is several times the
// MTU and has to be split across records.
const endpoint = listen(() => {}, {
cert: Buffer.concat([cert, ca]),
key,
host: '127.0.0.1',
port: 0,
mtu: MTU,
});
const serverPort = endpoint.address.port;
const serverToClientSizes = [];
const clientSide = dgram.createSocket('udp4');
const serverSide = dgram.createSocket('udp4');
let clientAddr = null;
clientSide.on('message', (msg, rinfo) => {
clientAddr = rinfo;
serverSide.send(msg, serverPort, '127.0.0.1');
});
serverSide.on('message', (msg) => {
serverToClientSizes.push(msg.length);
if (clientAddr !== null) {
clientSide.send(msg, clientAddr.port, clientAddr.address);
}
});
await new Promise((resolve) => clientSide.bind(0, '127.0.0.1', resolve));
await new Promise((resolve) => serverSide.bind(0, '127.0.0.1', resolve));
const client = connect('127.0.0.1', clientSide.address().port, {
servername: 'agent1',
ca: [ca],
mtu: MTU,
});
await client.opened;
const oversized = serverToClientSizes.filter((size) => size > MTU);
assert.deepStrictEqual(
oversized, [],
`server sent datagram(s) larger than the ${MTU} byte MTU: ` +
`[${oversized}] (all: [${serverToClientSizes}])`);
// Sanity check that the flight really did exceed one MTU, so the assertion
// above is meaningful rather than vacuous.
const total = serverToClientSizes.reduce((a, b) => a + b, 0);
assert.ok(total > MTU,
`handshake was too small to exercise fragmentation: ${total} bytes`);
await client.close();
await endpoint.close();
clientSide.close();
serverSide.close();
+190
View File
@@ -0,0 +1,190 @@
// Flags: --experimental-dtls --no-warnings
// Test: session.opened always settles.
//
// close(), destroy() and a peer-initiated close settled `closed` but never
// `opened`. Tearing a session down before its handshake finished therefore
// left anyone awaiting `opened` waiting forever, with no error and no timeout.
import { hasCrypto, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
async function sleep(n) {
await setTimeout(1000);
return 'pending';
}
// Resolves to the promise's outcome, or 'pending' if it has not settled by
// the time the microtask queue and a timer have both drained. Awaiting
// `opened` directly would hang the test rather than fail it.
function outcome(promise) {
return Promise.race([
promise.then(() => 'resolved', (err) => err),
sleep(1000),
]);
}
function newClient() {
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
return { endpoint, client };
}
// close() before the handshake completes.
{
const { endpoint, client } = newClient();
client.close();
const result = await outcome(client.opened);
if (result === 'pending') assert.fail('opened must not hang after close()');
assert.strictEqual(result.code, 'ERR_INVALID_STATE');
assert.match(result.message, /before the handshake completed/);
await endpoint.close();
}
// destroy() with no error before the handshake completes.
{
const { endpoint, client } = newClient();
client.destroy();
const result = await outcome(client.opened);
if (result === 'pending') assert.fail('opened must not hang after destroy()');
assert.strictEqual(result.code, 'ERR_INVALID_STATE');
await endpoint.close();
}
// destroy(error) reports that error, rather than a generic one, so a caller
// awaiting opened learns the same thing as one awaiting closed.
{
const { endpoint, client } = newClient();
const boom = new Error('boom');
client.destroy(boom);
const result = await outcome(client.opened);
assert.strictEqual(result, boom);
await endpoint.close();
}
// A completed handshake still resolves, and a later close must not overwrite
// that with a rejection.
{
const { endpoint, client } = newClient();
await client.opened;
await client.close();
assert.strictEqual(await outcome(client.opened), 'resolved');
await endpoint.close();
}
// A handshake that fails on its own reports the real reason; teardown must
// not replace it with the generic teardown error.
{
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
// No CA for this server, so verification fails during the handshake.
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', rejectUnauthorized: true,
});
const result = await outcome(client.opened);
if (result === 'pending') assert.fail('opened must not hang on failure');
if (result === 'resolved') assert.fail('a failed handshake must not resolve');
assert.doesNotMatch(result.message, /before the handshake completed/,
'the real handshake failure must survive teardown');
await endpoint.close();
}
// Destroying an endpoint settles the promises of the sessions it holds.
//
// The binding destroys those sessions but emits no callback for them, so
// anything awaiting one waited for a session that no longer existed. The
// documentation says the promise always settles and awaiting it cannot hang.
{
const arrived = Promise.withResolvers();
let serverSession;
const endpoint = listen((session) => {
serverSession = session;
arrived.resolve();
}, { cert, key, host: '127.0.0.1', port: 0 });
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await arrived.promise;
endpoint.destroy();
await serverSession.closed;
assert.strictEqual(serverSession.destroyed, true);
await client.close();
}
// The endpoint's error reaches them, rather than being reported only on the
// endpoint while the sessions resolve as though nothing went wrong.
{
const arrived = Promise.withResolvers();
let serverSession;
const endpoint = listen((session) => {
serverSession = session;
arrived.resolve();
}, { cert, key, host: '127.0.0.1', port: 0 });
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await arrived.promise;
const failure = new Error('endpoint went away');
endpoint.destroy(failure);
await assert.rejects(serverSession.closed, { message: 'endpoint went away' });
await assert.rejects(endpoint.closed, { message: 'endpoint went away' });
await client.close();
}
// A session torn down mid-handshake settles both of its promises, not just
// the one the peer's departure would settle.
{
const endpoint = listen(() => {}, { cert, key, host: '127.0.0.1', port: 0 });
const client = connect('127.0.0.1', endpoint.address.port, {
handshakeTimeout: 500,
rejectUnauthorized: false,
});
// Before the handshake can finish.
endpoint.destroy();
await assert.rejects(client.opened, { name: 'Error' });
await client.close();
}
+160 -1
View File
@@ -4,6 +4,7 @@
import { hasCrypto, skip, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import { inspect } from 'node:util';
if (!hasCrypto) {
skip('missing crypto');
@@ -13,7 +14,7 @@ if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { listen, connect } = await import('node:dtls');
const { connect, createSecureContext, listen } = await import('node:dtls');
// Test: listen() requires a callback.
assert.throws(() => {
@@ -63,3 +64,161 @@ assert.throws(() => {
assert.throws(() => {
connect('127.0.0.1', 4433, { alpn: 123 });
}, { code: 'ERR_INVALID_ARG_TYPE' });
// Options that reach a CHECK() in the binding must be rejected in JavaScript.
// A CHECK failure aborts the process rather than throwing, so an unvalidated
// option is a way for a caller to bring the process down with a typo.
{
const { createSecureContext, DTLSEndpoint } = await import('node:dtls');
const fixtures = await import('../common/fixtures.mjs');
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
// connect() validated the remote host and port but not the local ones.
for (const bindPort of [1.5, '5000', -1, 65536, null, {}]) {
assert.throws(() => connect('127.0.0.1', 5684, { bindPort }), {
code: /^ERR_(INVALID_ARG_TYPE|OUT_OF_RANGE)$/,
}, `bindPort: ${inspect(bindPort)}`);
}
for (const bindHost of [42, null, {}, []]) {
assert.throws(() => connect('127.0.0.1', 5684, { bindHost }), {
code: 'ERR_INVALID_ARG_TYPE',
}, `bindHost: ${inspect(bindHost)}`);
}
for (const sessionIdContext of [1, {}, [], true]) {
assert.throws(() => createSecureContext({
cert, key, isServer: true, sessionIdContext,
}), { code: 'ERR_INVALID_ARG_TYPE' },
`sessionIdContext: ${inspect(sessionIdContext)}`);
}
// The documented 32-byte limit was enforced only by an opaque OpenSSL
// failure that did not mention the limit.
assert.throws(() => createSecureContext({
cert, key, isServer: true, sessionIdContext: 'x'.repeat(33),
}), { code: 'ERR_OUT_OF_RANGE' });
// Exactly at the limit is fine.
createSecureContext({
cert, key, isServer: true, sessionIdContext: 'x'.repeat(32),
});
// A multi-byte character counts for its bytes, not its length.
assert.throws(() => createSecureContext({
cert, key, isServer: true, sessionIdContext: 'é'.repeat(17),
}), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => new DTLSEndpoint(null), {
code: 'ERR_INVALID_ARG_TYPE',
});
// bind(), listen() and connect() on an endpoint were undocumented plumbing
// whose every argument reached a CHECK(). They are no longer reachable.
const endpoint = new DTLSEndpoint({});
assert.strictEqual(endpoint.bind, undefined);
assert.strictEqual(endpoint.listen, undefined);
assert.strictEqual(endpoint.connect, undefined);
}
// isServer and rejectUnauthorized are booleans, and are checked rather than
// coerced. Both decide something security-relevant, and both read a value
// that was not a boolean as the opposite of what it looked like:
//
// createSecureContext({ isServer: 'yes' }) -> a client context
// connect(..., { rejectUnauthorized: 0 }) -> verification on
//
// Neither failed open, so nothing was unsafe. Both were silent.
{
for (const value of ['yes', 1, 0, '', null, {}]) {
assert.throws(() => createSecureContext({ isServer: value }), {
code: 'ERR_INVALID_ARG_TYPE',
}, `isServer: ${inspect(value)}`);
}
// Booleans still work, and still select the side they name.
assert.strictEqual(createSecureContext({ isServer: true }).isServer, true);
assert.strictEqual(createSecureContext({ isServer: false }).isServer, false);
// Omitted means a client, as documented.
assert.strictEqual(createSecureContext({}).isServer, false);
const fixtures = await import('../common/fixtures.mjs');
const serverCert = fixtures.readKey('agent1-cert.pem').toString();
const serverKey = fixtures.readKey('agent1-key.pem').toString();
for (const value of [0, 1, '', 'no', null]) {
assert.throws(
() => connect('127.0.0.1', 4433, { rejectUnauthorized: value }),
{ code: 'ERR_INVALID_ARG_TYPE' },
`connect rejectUnauthorized: ${inspect(value)}`);
assert.throws(() => listen(() => {}, {
cert: serverCert,
key: serverKey,
host: '127.0.0.1',
port: 0,
rejectUnauthorized: value,
}), { code: 'ERR_INVALID_ARG_TYPE' },
`listen rejectUnauthorized: ${inspect(value)}`);
}
}
// Server-only options are refused on a client context, all in the same way.
//
// They used to be handled four different ways: sni threw, sessionIdContext
// was ignored, ticketKeys was applied to a client that can do nothing with
// it, and requestCert was validated and then ignored. A client naming any of
// them has misunderstood the option, and now hears so.
{
const fixtures = await import('../common/fixtures.mjs');
const pem = fixtures.readKey('agent1-cert.pem').toString();
const pemKey = fixtures.readKey('agent1-key.pem').toString();
const serverOnly = {
pskIdentityHint: 'hint',
requestCert: true,
sessionIdContext: 'ctx',
sni: { 'a.example': { cert: pem, key: pemKey } },
ticketKeys: Buffer.alloc(80),
};
for (const [name, value] of Object.entries(serverOnly)) {
assert.throws(() => createSecureContext({ [name]: value }), {
code: 'ERR_INVALID_ARG_VALUE',
message: new RegExp(`options\\.${name}`),
}, `client context accepted ${name}`);
// connect() builds a client context, so it refuses them too.
assert.throws(() => connect('127.0.0.1', 4433, { [name]: value }), {
code: 'ERR_INVALID_ARG_VALUE',
}, `connect() accepted ${name}`);
}
// Each is still accepted by a server context, so the rule is about the
// side and not about the option being unusable.
for (const [name, value] of Object.entries(serverOnly)) {
if (name === 'pskIdentityHint') continue; // Needs psk; covered below.
createSecureContext({
isServer: true, cert: pem, key: pemKey, [name]: value,
});
}
}
// An identity hint with no key to hint at.
//
// pskIdentityHint names which key a client should pick. Given without psk it
// was dropped, and the handshake then failed for want of a PSK without ever
// mentioning the option that had been set.
{
assert.throws(() => createSecureContext({
isServer: true, pskIdentityHint: 'hint',
}), { code: 'ERR_INVALID_ARG_VALUE', message: /together with options\.psk/ });
// With psk it is accepted.
createSecureContext({
isServer: true,
psk: { 'device-42': Buffer.alloc(16) },
pskIdentityHint: 'hint',
});
}
+140
View File
@@ -0,0 +1,140 @@
// Flags: --experimental-dtls --no-warnings
// Test: passphrase support for encrypted private keys.
//
// An encrypted key could not be used at all before: there was nowhere to put
// the passphrase, and the failure reported PEM_read_bio_PrivateKey rather than
// anything about decryption. The option matches node:tls -- same name, string
// only, ignored when the key is not encrypted.
import { hasCrypto, mustNotCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const PASSPHRASE = 'correct horse battery staple';
// Generate an encrypted key and a matching self-signed certificate rather
// than checking a fixture in, so the passphrase is visible in the test.
const dir = mkdtempSync(join(tmpdir(), 'dtls-passphrase-'));
let encryptedKey;
let encryptedCert;
try {
const keyPath = join(dir, 'key.pem');
const certPath = join(dir, 'cert.pem');
execFileSync('openssl', [
'genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048',
'-aes-256-cbc', '-pass', `pass:${PASSPHRASE}`, '-out', keyPath,
], { stdio: 'ignore' });
execFileSync('openssl', [
'req', '-new', '-x509', '-key', keyPath, '-passin', `pass:${PASSPHRASE}`,
'-subj', '/CN=dtls-passphrase-test', '-days', '1', '-out', certPath,
], { stdio: 'ignore' });
encryptedKey = readFileSync(keyPath, 'utf8');
encryptedCert = readFileSync(certPath, 'utf8');
} catch {
rmSync(dir, { recursive: true, force: true });
skip('openssl command not available');
}
rmSync(dir, { recursive: true, force: true });
assert.match(encryptedKey, /^-----BEGIN ENCRYPTED PRIVATE KEY-----/);
// The correct passphrase decrypts the key, and the endpoint serves with it.
{
const echoed = Promise.withResolvers();
const endpoint = listen((session) => {
session.onmessage = (data) => session.send(data);
}, {
cert: encryptedCert,
key: encryptedKey,
passphrase: PASSPHRASE,
host: '127.0.0.1',
port: 0,
});
// Self-signed and not in any CA store, so verification is off here; the
// point of the test is that the encrypted key loaded and can sign.
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
client.onmessage = (data) => echoed.resolve(data.toString());
client.send('hello');
assert.strictEqual(await echoed.promise, 'hello');
await client.close();
await endpoint.close();
}
// The wrong passphrase and no passphrase both fail, and say why. The code
// comes from OpenSSL and could shift if the bundled version changes.
for (const passphrase of [`${PASSPHRASE}-wrong`, undefined]) {
assert.throws(() => listen(mustNotCall(), {
cert: encryptedCert,
key: encryptedKey,
passphrase,
host: '127.0.0.1',
port: 0,
}), { code: 'ERR_OSSL_BAD_DECRYPT' });
}
// A passphrase supplied for a key that is not encrypted is ignored.
{
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
for (const passphrase of ['not needed', undefined, null]) {
const endpoint = listen(mustNotCall(), {
cert, key, passphrase, host: '127.0.0.1', port: 0,
});
await endpoint.close();
}
}
// The passphrase is a string, matching node:tls, even though key and cert
// also accept a Buffer.
for (const passphrase of [123, Buffer.from(PASSPHRASE), {}, []]) {
assert.throws(() => listen(mustNotCall(), {
cert: encryptedCert,
key: encryptedKey,
passphrase,
host: '127.0.0.1',
port: 0,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// connect() takes it too, for client certificates.
{
const endpoint = listen(mustNotCall(), {
cert: fixtures.readKey('agent1-cert.pem').toString(),
key: fixtures.readKey('agent1-key.pem').toString(),
host: '127.0.0.1',
port: 0,
});
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
cert: encryptedCert,
key: encryptedKey,
passphrase: `${PASSPHRASE}-wrong`,
rejectUnauthorized: false,
}), { code: 'ERR_OSSL_BAD_DECRYPT' });
await endpoint.close();
}
@@ -0,0 +1,163 @@
// Flags: --experimental-dtls --no-warnings
// Test: session.peerX509Certificate.
//
// peerCertificate returns the leaf as PEM and nothing else, so the issuer
// chain and the parsed fields were unreachable without parsing the PEM by
// hand. This exposes crypto.X509Certificate instead of node:tls's legacy
// dictionary; toLegacyObject() is there for anyone who wants that shape.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { X509Certificate } = await import('node:crypto');
const { connect, listen } = await import('node:dtls');
// A leaf issued by an intermediate, so there is a real chain to walk. The
// root that signed the intermediate is not in the fixtures, so verification
// is off throughout; what is under test is the chain's shape, not its trust.
const leafCert = fixtures.readKey('leaf-from-intermediate-cert.pem').toString();
const leafKey = fixtures.readKey('leaf-from-intermediate-key.pem').toString();
const intermediate = fixtures.readKey('intermediate-ca.pem').toString();
const chain = leafCert + intermediate;
const agentCert = fixtures.readKey('agent1-cert.pem').toString();
const agentKey = fixtures.readKey('agent1-key.pem').toString();
function commonNames(cert) {
const names = [];
for (let x = cert, i = 0; x && i < 8; i++) {
names.push(String(x.subject).split('\n')
.find((line) => line.startsWith('CN=')));
const issuer = x.issuerCertificate;
if (!issuer || issuer === x) break;
x = issuer;
}
return names;
}
// The client sees the chain the server presented.
{
const endpoint = listen(() => {}, {
cert: chain, key: leafKey, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const cert = client.peerX509Certificate;
assert.ok(cert instanceof X509Certificate);
assert.deepStrictEqual(commonNames(cert),
['CN=localhost', 'CN=NodeJS-Test-Intermediate-CA']);
// The parsed fields that were the point of the exercise.
assert.match(cert.subject, /CN=localhost/);
assert.match(cert.issuer, /CN=NodeJS-Test-Intermediate-CA/);
assert.match(cert.fingerprint256, /^([0-9A-F]{2}:){31}[0-9A-F]{2}$/);
assert.ok(cert.validFromDate instanceof Date);
assert.ok(cert.validToDate instanceof Date);
assert.ok(Buffer.isBuffer(cert.raw));
// node:tls's shape is still available for anyone who wants it.
assert.strictEqual(cert.toLegacyObject().subject.CN, 'localhost');
// Consistent with the PEM accessor, which keeps working either way round.
assert.strictEqual(cert.toString(), client.peerCertificate);
await client.close();
await endpoint.close();
}
// The server sees the chain the client presented. This is the other branch
// of GetPeerCertificateFlag: SSL_get_peer_cert_chain() omits the peer's leaf
// on the server, so the wrong flag here silently drops or repeats it.
{
const gotSession = Promise.withResolvers();
const endpoint = listen((session) => gotSession.resolve(session), {
cert: agentCert,
key: agentKey,
requestCert: true,
rejectUnauthorized: false,
host: '127.0.0.1',
port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
cert: chain, key: leafKey, rejectUnauthorized: false,
});
await client.opened;
const session = await gotSession.promise;
await session.opened;
assert.deepStrictEqual(commonNames(session.peerX509Certificate),
['CN=localhost', 'CN=NodeJS-Test-Intermediate-CA']);
await client.close();
await endpoint.close();
}
// Repeated reads return the same object.
//
// Not merely a convenience: X509Certificate::GetPeerCert() is destructive on
// the client side. Having no peer certificate of its own to start from, it
// lifts the leaf out of the SSL's chain with sk_X509_delete(), so calling it
// twice returns a shorter chain and then nothing. The accessor must call it
// once and keep the result.
{
const endpoint = listen(() => {}, {
cert: chain, key: leafKey, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const first = client.peerX509Certificate;
const second = client.peerX509Certificate;
assert.strictEqual(first, second);
assert.strictEqual(first.fingerprint256, second.fingerprint256);
assert.deepStrictEqual(commonNames(client.peerX509Certificate),
['CN=localhost', 'CN=NodeJS-Test-Intermediate-CA']);
await client.close();
await endpoint.close();
}
// A peer that sent no certificate reports undefined rather than an empty one.
{
const gotSession = Promise.withResolvers();
const endpoint = listen((session) => gotSession.resolve(session), {
cert: agentCert, key: agentKey, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const session = await gotSession.promise;
await session.opened;
assert.strictEqual(session.peerX509Certificate, undefined);
assert.strictEqual(session.peerCertificate, undefined);
await client.close();
await endpoint.close();
}
+468
View File
@@ -0,0 +1,468 @@
// Flags: --experimental-dtls --no-warnings
// Test: pre-shared keys (RFC 4279).
//
// PSK is the usual way DTLS is deployed to constrained devices, which often
// have no certificate at all. RFC 7252 makes TLS_PSK_WITH_AES_128_CCM_8
// mandatory to implement for CoAP.
import { hasCrypto, mustCall, mustNotCall, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
const { connect, listen } = dtls;
const KEY = Buffer.from('0123456789abcdef');
const OTHER = Buffer.from('fedcba9876543210');
const HOST = '127.0.0.1';
const PSK_CIPHER = 'PSK-AES128-CCM8@SECLEVEL=0';
const readKey = (name) => fixtures.readKey(name).toString();
function open(endpoint, options) {
return connect(HOST, endpoint.address.port, options);
}
// A map on the server, an identity on the client. No certificate anywhere.
{
const endpoint = listen(mustCall(), {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
// A PSK suite must actually have been negotiated -- the default cipher
// list excludes PSK, so this also pins the implicit enabling below.
assert.match(client.cipher.standardName, /_PSK_/);
assert.strictEqual(client.authorized, true);
await client.close();
await endpoint.close();
}
// A callback on either side, for keys that are looked up or derived rather
// than known up front.
{
const endpoint = listen(() => {}, {
psk: mustCall((identity) => {
assert.strictEqual(identity, 'device-42');
return KEY;
}),
host: HOST, port: 0,
});
const client = open(endpoint, {
psk: mustCall(() => ({ identity: 'device-42', key: KEY })),
});
await client.opened;
assert.match(client.cipher.standardName, /_PSK_/);
await client.close();
await endpoint.close();
}
// The map answers first; the callback is only reached on a miss. This is what
// keeps a map-only configuration from running JavaScript inside the
// handshake at all.
{
const endpoint = listen(() => {}, {
psk: { 'in-map': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'in-map', key: KEY } });
await client.opened;
await client.close();
await endpoint.close();
}
// An identity the server does not know fails closed rather than negotiating
// with an empty key.
{
const endpoint = listen(() => {}, {
psk: { 'known': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'unknown', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
await endpoint.close();
}
// The right identity with the wrong key does not fail -- it stalls.
//
// The identity only names the key, so the handshake proceeds and the two
// sides derive different secrets. The first record that fails authentication
// is then silently discarded rather than answered with an alert, because
// DTLS discards invalid records instead of replying to them (RFC 6347
// section 4.1.2.1). Neither peer is told anything, and both retransmit.
//
// Asserted rather than fixed: it is the protocol's behaviour, not this
// implementation's. What ends it is the handshake timeout, which is left at
// its default here and covered by test-dtls-handshake-timeout.mjs; the short
// wait below is only to observe the stall, not to wait one out.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: OTHER } });
const stalled = Symbol('stalled');
const outcome = await Promise.race([
client.opened.then(() => 'opened', () => 'rejected'),
setTimeout(200, stalled),
]);
assert.strictEqual(outcome, stalled);
client.destroy();
await endpoint.close();
}
// A callback returning nothing is a refusal, not an error.
{
const endpoint = listen(() => {}, {
psk: mustCall(() => undefined), host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'anyone', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
await endpoint.close();
}
// The cookie exchange still runs, so PSK is not a way around the address
// validation the endpoint's DoS protection depends on.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const before = Number(endpoint.stats.packetsReceived);
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
const received = Number(endpoint.stats.packetsReceived) - before;
assert.ok(received >= 2,
`PSK handshake cost the server ${received} packets; fewer than ` +
'2 means the cookie exchange was skipped');
await client.close();
await endpoint.close();
}
// An explicit cipher list wins, including the suite CoAP requires.
//
// TLS_PSK_WITH_AES_128_CCM_8 authenticates with a 64-bit tag, which OpenSSL
// refuses at security level 1 and above -- and Node's default is above it.
// Asking for it therefore needs @SECLEVEL=0, and asking without one does not
// report anything: the handshake stalls, because a DTLS peer with nothing to
// select discards rather than replies. Anyone implementing CoAP will meet
// this, so it is pinned here.
{
const ciphers = 'PSK-AES128-CCM8@SECLEVEL=0';
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, ciphers, host: HOST, port: 0,
});
const client = open(endpoint, {
psk: { identity: 'device-42', key: KEY }, ciphers,
});
await client.opened;
assert.strictEqual(client.cipher.standardName,
'TLS_PSK_WITH_AES_128_CCM_8');
await client.close();
await endpoint.close();
}
// Without an explicit list a forward-secret suite is chosen. Plain PSK
// derives its keys from the shared secret alone, so anyone who later learns
// the key can decrypt recorded traffic.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY }, host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await client.opened;
assert.match(client.cipher.standardName, /_(EC)?DHE_PSK_/);
await client.close();
await endpoint.close();
}
// A certificate still works alongside PSK, because DEFAULT stays on the end
// of the implicit cipher list.
{
const endpoint = listen(() => {}, {
cert: readKey('agent1-cert.pem'),
key: readKey('agent1-key.pem'),
psk: { 'device-42': KEY },
host: HOST, port: 0,
});
const psk = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await psk.opened;
assert.match(psk.cipher.standardName, /_PSK_/);
await psk.close();
const cert = open(endpoint, { rejectUnauthorized: false });
await cert.opened;
assert.doesNotMatch(cert.cipher.standardName, /_PSK_/);
await cert.close();
await endpoint.close();
}
// The identity hint is a server-side courtesy (RFC 4279 section 5.2) and
// reaches the client's callback.
{
const endpoint = listen(() => {}, {
psk: { 'device-42': KEY },
pskIdentityHint: 'example.com',
host: HOST, port: 0,
});
const client = open(endpoint, {
psk: mustCall((hint) => {
assert.strictEqual(hint, 'example.com');
return { identity: 'device-42', key: KEY };
}),
});
await client.opened;
await client.close();
await endpoint.close();
}
// A callback that throws fails that handshake and reports the error on the
// session. It must not reach the process as an uncaughtException: one bad
// callback should not take a server down.
{
process.on('uncaughtException', mustNotCall());
const thrown = new Error('from the psk callback');
const seen = Promise.withResolvers();
const endpoint = listen((session) => {
session.onerror = (err) => seen.resolve(err);
}, {
psk: () => { throw thrown; },
host: HOST, port: 0,
});
const client = open(endpoint, { psk: { identity: 'device-42', key: KEY } });
await assert.rejects(client.opened, { name: 'Error' });
// The user's own error, not something wrapped around it.
assert.strictEqual(await seen.promise, thrown);
await endpoint.close();
process.removeAllListeners('uncaughtException');
}
// Validation.
{
const port = 0;
// A server map must have entries, and they must be non-empty buffers.
assert.throws(() => listen(() => {}, { psk: {}, host: HOST, port }),
{ code: 'ERR_INVALID_ARG_VALUE' });
assert.throws(() => listen(() => {}, {
psk: { 'a': Buffer.alloc(0) }, host: HOST, port,
}), { code: 'ERR_INVALID_ARG_VALUE' });
for (const value of ['string', 42, null]) {
assert.throws(() => listen(() => {}, {
psk: { 'a': value }, host: HOST, port,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// A client needs both halves.
assert.throws(() => connect(HOST, 1234, { psk: { key: KEY } }),
{ code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => connect(HOST, 1234, { psk: { identity: 'a' } }),
{ code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => connect(HOST, 1234, {
psk: { identity: 'a', key: Buffer.alloc(0) },
}), { code: 'ERR_INVALID_ARG_VALUE' });
// The hint is a server-side notion.
assert.throws(() => connect(HOST, 1234, {
psk: { identity: 'a', key: KEY }, pskIdentityHint: 'nope',
}), { code: 'ERR_INVALID_ARG_VALUE' });
for (const value of [42, 'string', null]) {
assert.throws(() => listen(() => {}, { psk: value, host: HOST, port }),
{ code: /^ERR_INVALID_ARG_(TYPE|VALUE)$/ });
}
}
// A PSK-only server needs no certificate, but one without either is still an
// error rather than a server that can never complete a handshake.
{
assert.throws(() => listen(() => {}, { host: HOST, port: 0 }),
{ code: 'ERR_MISSING_ARGS' });
}
// A client callback may return an object whose properties are accessors, or a
// Proxy. Reading identity and key therefore runs user code that can throw,
// inside the binding, inside the handshake.
//
// That exception used to be caught and then dropped: the binding returned
// without reporting it, so the caller saw an opaque SSL error instead of what
// it threw, and the TryCatch was left to unwind with the exception still
// caught on the way back into OpenSSL.
{
const server = listen(() => {}, {
host: HOST, port: 0, psk: { 'id-1': KEY },
});
for (const build of [
() => ({ get identity() { throw new Error('identity getter threw'); },
key: KEY }),
() => ({ identity: 'id-1',
get key() { throw new Error('key getter threw'); } }),
() => new Proxy({ identity: 'id-1', key: KEY }, {
get() { throw new Error('proxy trap threw'); },
}),
]) {
const expected = (() => {
try {
const o = build();
return (o.identity, o.key, undefined);
} catch (e) {
return e.message;
}
})();
const client = connect(HOST, server.address.port, { psk: build });
const seen = Promise.withResolvers();
client.onerror = (error) => seen.resolve(error);
const error = await seen.promise;
// The caller's own error, not "error:0A0000DF:SSL routines".
assert.strictEqual(error.message, expected);
await assert.rejects(client.opened, { message: expected });
}
await server.close();
}
// Pre-shared keys keep working on a server that also serves SNI identities.
//
// The PSK callbacks have no argument of their own, so they recover their
// context from the SSL's ex_data. That used to be read through
// SSL_get_SSL_CTX(), which SNI reassigns: selecting any identity -- including
// the '*' fallback, so this needed no servername at all -- pointed the lookup
// at a context with no PSK configuration and every PSK handshake failed.
{
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const identity = { cert, key };
const server = listen(() => {}, {
host: HOST,
port: 0,
cert,
key,
psk: { 'id-1': KEY },
sni: { 'a.example': identity, '*': identity },
});
for (const servername of [undefined, 'a.example', 'unmatched.example']) {
const client = connect(HOST, server.address.port, {
psk: { identity: 'id-1', key: KEY },
servername,
});
await client.opened;
await client.close();
}
await server.close();
}
// Which identity SNI selected does not change which keys are accepted. The
// callbacks are installed on the connection by SSL_new(), before any name is
// known, and SSL_set_SSL_CTX() does not replace them -- so they stay the
// endpoint's, and a PSK configured on an SNI identity is never consulted.
{
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const other = Buffer.from('fedcba9876543210');
const server = listen(() => {}, {
host: HOST,
port: 0,
cert,
key,
psk: { 'endpoint-id': KEY },
sni: { 'a.example': { cert, key, psk: { 'sni-id': other } } },
});
const accepted = connect(HOST, server.address.port, {
psk: { identity: 'endpoint-id', key: KEY },
servername: 'a.example',
});
await accepted.opened;
await accepted.close();
const refused = connect(HOST, server.address.port, {
psk: { identity: 'sni-id', key: other },
servername: 'a.example',
});
await assert.rejects(refused.opened, { name: 'Error' });
await server.close();
}
// A psk callback that gives back something unusable says so.
//
// Returning 0 is how OpenSSL is told there is no PSK, and it was also what
// every one of these took, so a mistake in the callback arrived as
// "error:0A0000DF:SSL routines::psk identity not found" -- the same string
// for all of them, naming nothing the caller had done.
{
const server = listen(() => {}, {
psk: () => ({ key: KEY }),
pskIdentityHint: 'hint',
host: HOST,
port: 0,
ciphers: PSK_CIPHER,
});
const cases = [
[() => 42, /must return an object/],
[() => ({ key: KEY }), /string 'identity'/],
[() => ({ identity: 7, key: KEY }), /string 'identity'/],
[() => ({ identity: 'a', key: 'not a view' }), /string 'identity'/],
[() => ({ identity: 'x'.repeat(9000), key: KEY }), /identity is too long/],
[() => ({ identity: 'a', key: Buffer.alloc(9000) }), /key is too long/],
];
for (const [psk, expected] of cases) {
const session = connect(HOST, server.address.port, {
psk,
ciphers: PSK_CIPHER,
handshakeTimeout: 5000,
});
const error = await session.opened.then(() => null, (e) => e);
assert.ok(error, 'the handshake should not have completed');
assert.match(error.message, expected);
// Not the opaque OpenSSL failure these all used to produce.
assert.doesNotMatch(error.message, /psk identity not found/);
session.destroy();
}
await server.close();
}
+141
View File
@@ -0,0 +1,141 @@
// Flags: --experimental-dtls --no-warnings
// Test: the requestCert / rejectUnauthorized matrix on a DTLS server.
//
// requestCert: false -> no certificate is requested
// requestCert, rejectUnauthorized -> OpenSSL enforces, peer gets an alert
// requestCert, !rejectUnauthorized -> certificate is requested, the
// handshake completes either way, and
// the application decides using
// session.authorized
//
// The last row is the node:tls idiom for "ask for a certificate and let the
// application decide". It previously mapped to SSL_VERIFY_NONE, so no
// CertificateRequest was sent at all and the server saw no peer certificate
// even when the client offered a valid one.
import { hasCrypto, mustNotCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca1 = fixtures.readKey('ca1-cert.pem');
const ca2 = fixtures.readKey('ca2-cert.pem');
// Runs one handshake and reports what the server made of the client.
async function handshake(serverOptions, clientOptions) {
const gotSession = Promise.withResolvers();
const server = listen((session) => {
session.onhandshake = () => gotSession.resolve(session);
}, { cert, key, host: '127.0.0.1', port: 0, ...serverOptions });
const client = connect('127.0.0.1', server.address.port, {
servername: 'agent1', ca: [ca1], ...clientOptions,
});
let result;
try {
await client.opened;
const session = await gotSession.promise;
result = {
rejected: false,
sawPeerCertificate: session.peerCertificate !== undefined,
authorized: session.authorized,
authorizationError: session.authorizationError,
};
} catch (err) {
result = { rejected: true, message: err.message };
}
try {
await client.close();
} catch {
// The handshake may already have torn the session down.
}
await server.close();
return result;
}
// --- requestCert with rejectUnauthorized disabled ------------------------
// A trusted client certificate is actually requested and received. This is
// the case that silently produced no certificate at all before.
assert.deepStrictEqual(
await handshake({ requestCert: true, rejectUnauthorized: false, ca: [ca1] },
{ cert, key }),
{
rejected: false,
sawPeerCertificate: true,
authorized: true,
authorizationError: undefined,
});
// An untrusted client certificate is received, and the handshake completes so
// the application can decide.
assert.deepStrictEqual(
await handshake({ requestCert: true, rejectUnauthorized: false, ca: [ca2] },
{ cert, key }),
{
rejected: false,
sawPeerCertificate: true,
authorized: false,
authorizationError: 'SELF_SIGNED_CERT_IN_CHAIN',
});
// A client that declines to send one is tolerated rather than rejected.
assert.deepStrictEqual(
await handshake({ requestCert: true, rejectUnauthorized: false, ca: [ca1] },
{}),
{
rejected: false,
sawPeerCertificate: false,
authorized: false,
authorizationError: 'UNABLE_TO_GET_ISSUER_CERT',
});
// --- requestCert, enforced -----------------------------------------------
// OpenSSL rejects these, so the peer receives a real alert rather than having
// the session quietly dropped after the handshake.
{
const noCert = await handshake({ requestCert: true, ca: [ca1] }, {});
assert.strictEqual(noCert.rejected, true);
assert.match(noCert.message, /handshake failure/);
const untrusted = await handshake({ requestCert: true, ca: [ca2] },
{ cert, key });
assert.strictEqual(untrusted.rejected, true);
assert.match(untrusted.message, /unknown ca/);
}
// --- no requestCert ------------------------------------------------------
// rejectUnauthorized alone must not turn a server into one that demands
// client certificates.
assert.deepStrictEqual(
await handshake({ rejectUnauthorized: true, ca: [ca1] }, { cert, key }),
{
rejected: false,
sawPeerCertificate: false,
authorized: false,
authorizationError: 'UNABLE_TO_GET_ISSUER_CERT',
});
// --- validation ----------------------------------------------------------
assert.throws(
() => listen(mustNotCall(), {
cert, key, host: '127.0.0.1', port: 0, requestCert: 'yes',
}),
{ code: 'ERR_INVALID_ARG_TYPE' });
@@ -0,0 +1,129 @@
// Flags: --experimental-dtls --no-warnings
// Test: resumption does not launder an unverified peer past
// rejectUnauthorized.
//
// A resumed handshake sends no Certificate message, so OpenSSL runs no
// verification and the verify mode has nothing to abort on. What it does
// instead is restore the result recorded when the session was first
// established: SSL_set_session() copies verify_result straight onto the
// connection, and both it and the peer certificate round-trip through the DER
// blob. A session established under rejectUnauthorized: false therefore
// arrives carrying its original failure alongside a handshake that succeeded.
//
// Binding the blob to the identity it was authenticated for is not enough on
// its own: the identity here is the same host either way, and what changes is
// whether the caller asked for the peer to be verified at all. So the result
// is re-checked once the handshake completes, as node:tls checks
// verifyError() after a resumption.
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
const serverOptions = { cert, key, host: '127.0.0.1', port: 0 };
// A ticket earned without verification cannot be spent with it. The chain
// does not verify here -- no `ca` is supplied, so only the system-default CAs
// are trusted -- and rejectUnauthorized: false is what let the first
// connection through.
{
const server = listen(mustCall(2), serverOptions);
const first = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
await first.opened;
// The failure that the blob is about to carry with it.
assert.strictEqual(first.authorized, false);
assert.ok(first.authorizationError);
const ticket = first.session;
assert.ok(Buffer.isBuffer(ticket));
await first.close();
// Same host, so the identity binding permits the blob; the difference is
// that this caller asked for a verified peer.
const second = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: true, session: ticket,
});
await assert.rejects(second.opened, {
code: 'ERR_INVALID_STATE',
message: /Peer certificate verification failed/,
});
await second.endpoint.closed;
await server.close();
}
// The same check must not get in the way of a resumption that was verified
// for real. With the CA trusted and the identity matching the certificate,
// the first handshake verifies, the recorded result is X509_V_OK, and the
// resumed handshake inherits it.
{
const server = listen(mustCall(2), serverOptions);
const first = connect('127.0.0.1', server.address.port, {
ca, servername: 'agent1', rejectUnauthorized: true,
});
await first.opened;
assert.strictEqual(first.authorized, true);
assert.strictEqual(first.authorizationError, undefined);
const ticket = first.session;
await first.close();
const second = connect('127.0.0.1', server.address.port, {
ca, servername: 'agent1', rejectUnauthorized: true, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
assert.strictEqual(second.authorized, true);
assert.strictEqual(second.authorizationError, undefined);
await second.close();
await server.close();
}
// A caller that did not ask for verification still gets the connection, and
// still gets told the peer never verified. Resumption does not change that
// either way.
{
const server = listen(mustCall(2), serverOptions);
const first = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
await first.opened;
const ticket = first.session;
const firstError = first.authorizationError;
await first.close();
const second = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
assert.strictEqual(second.authorized, false);
// Restored from the session rather than verified again.
assert.strictEqual(second.authorizationError, firstError);
await second.close();
await server.close();
}
+39 -8
View File
@@ -1,9 +1,10 @@
// Flags: --experimental-dtls --no-warnings
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: a listening DTLS server drops a non-DTLS (junk) datagram without
// crashing, and still accepts a real client afterwards.
// Test: a listening DTLS server drops malformed datagrams without crashing,
// and still accepts a real client afterwards.
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
import assert from 'node:assert';
import dgram from 'node:dgram';
import * as fixtures from '../common/fixtures.mjs';
@@ -17,6 +18,12 @@ if (!process.features.dtls) {
const { listen, connect } = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSEndpointSessions,
} = (await import('internal/dtls/dtls')).default;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
@@ -27,12 +34,23 @@ const server = listen(mustCall((session) => {
const { port } = server.address;
// Fire a datagram that is not a ClientHello at the server.
// Fire datagrams that are not a ClientHello at the server. The empty one is
// legal UDP but can never carry a DTLS record; it must be dropped before it
// reaches the accept path or a session BIO.
const junk = [
Buffer.from('this is not a DTLS ClientHello'),
Buffer.alloc(0),
Buffer.from([22]),
Buffer.from([22, 254, 253, 0, 0]),
];
const raw = dgram.createSocket('udp4');
await new Promise((resolve, reject) => {
raw.send(Buffer.from('this is not a DTLS ClientHello'), port, '127.0.0.1',
(err) => (err ? reject(err) : resolve()));
});
for (const datagram of junk) {
await new Promise((resolve, reject) => {
raw.send(datagram, port, '127.0.0.1',
(err) => (err ? reject(err) : resolve()));
});
}
await new Promise((resolve) => raw.close(resolve));
// A real client still completes a handshake against the same server.
@@ -43,5 +61,18 @@ const client = connect('127.0.0.1', port, {
await client.opened;
// The junk was delivered and processed well before this handshake finished,
// so the counters are settled by now.
// Exactly one session, from the real client. None of the junk made one.
assert.strictEqual(getDTLSEndpointSessions(server).size, 1);
assert.strictEqual(server.stats.serverSessions, 1n);
// Three of the four junk datagrams were turned away by the structural screen
// before anything was allocated for them. The empty one was dropped earlier
// still, and the real ClientHello passed the screen rather than being counted
// by it.
assert.strictEqual(server.stats.serverRejectedCount, 3n);
await client.close();
await server.close();
+182
View File
@@ -0,0 +1,182 @@
// Flags: --experimental-dtls --no-warnings
// Test: dtls.createSecureContext() and the secureContext option.
//
// listen() and connect() used to build a context per call, with no way to
// share one. A context holds a parsed certificate, key and CA store, so a
// client opening many connections paid for a duplicate of all of it every
// time. This exposes the context and accepts it on both calls.
import { hasCrypto, mustNotCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
const { connect, createSecureContext, listen, DTLSSecureContext } = dtls;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
// The context reports which side it was built for.
{
const server = createSecureContext({ cert, key, isServer: true });
const client = createSecureContext({ ca: [ca] });
assert.ok(server instanceof DTLSSecureContext);
assert.strictEqual(server.isServer, true);
assert.strictEqual(client.isServer, false);
}
// It cannot be constructed directly.
assert.throws(() => new DTLSSecureContext(),
{ code: 'ERR_ILLEGAL_CONSTRUCTOR' });
// One server context serves several endpoints, and one client context
// serves connections to all of them.
{
const serverContext = createSecureContext({ cert, key, isServer: true });
const clientContext = createSecureContext({ ca: [ca] });
const endpoints = [];
for (let i = 0; i < 3; i++) {
endpoints.push(listen((session) => {
session.onmessage = (data) => session.send(data);
}, { secureContext: serverContext, host: '127.0.0.1', port: 0 }));
}
for (const endpoint of endpoints) {
const client = connect('127.0.0.1', endpoint.address.port, {
secureContext: clientContext,
servername: 'agent1',
});
await client.opened;
assert.strictEqual(client.authorized, true);
const echoed = Promise.withResolvers();
client.onmessage = (data) => echoed.resolve(data.toString());
client.send('shared');
assert.strictEqual(await echoed.promise, 'shared');
await client.close();
}
for (const endpoint of endpoints) {
await endpoint.close();
}
}
// The verified identity stays per connection. It is bound to the session
// with SSL_set1_host(), not to the context, so one context can be used
// against different peer names -- and still rejects the wrong one.
{
const serverContext = createSecureContext({ cert, key, isServer: true });
const clientContext = createSecureContext({ ca: [ca] });
const endpoint = listen(() => {}, {
secureContext: serverContext, host: '127.0.0.1', port: 0,
});
const good = connect('127.0.0.1', endpoint.address.port, {
secureContext: clientContext, servername: 'agent1',
});
await good.opened;
assert.strictEqual(good.authorized, true);
await good.close();
// Same context, a name the certificate does not cover.
const bad = connect('127.0.0.1', endpoint.address.port, {
secureContext: clientContext, servername: 'not-agent1',
});
await assert.rejects(bad.opened, { name: 'Error' });
await endpoint.close();
}
// listen() does not demand cert and key when the context already has them.
{
const serverContext = createSecureContext({ cert, key, isServer: true });
const endpoint = listen(mustNotCall(), {
secureContext: serverContext, host: '127.0.0.1', port: 0,
});
assert.ok(endpoint.address.port > 0);
await endpoint.close();
}
// A context belongs to one side and cannot be handed to the other: isServer
// picks the OpenSSL method when the context is built.
{
const serverContext = createSecureContext({ cert, key, isServer: true });
const clientContext = createSecureContext({ ca: [ca] });
const endpoint = listen(mustNotCall(), {
cert, key, host: '127.0.0.1', port: 0,
});
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
secureContext: serverContext,
}), { code: 'ERR_INVALID_ARG_VALUE' });
assert.throws(() => listen(mustNotCall(), {
secureContext: clientContext, host: '127.0.0.1', port: 0,
}), { code: 'ERR_INVALID_ARG_VALUE' });
await endpoint.close();
}
// Options that belong to the context are refused alongside one, rather than
// being silently ignored.
{
const serverContext = createSecureContext({ cert, key, isServer: true });
const clientContext = createSecureContext({ ca: [ca] });
for (const [name, value] of [
['cert', cert],
['key', key],
['ca', [ca]],
['ciphers', 'ALL'],
['requestCert', true],
['sessionIdContext', 'x'],
]) {
assert.throws(() => listen(mustNotCall(), {
secureContext: serverContext,
[name]: value,
host: '127.0.0.1',
port: 0,
}), { code: 'ERR_INVALID_ARG_VALUE' }, `listen() accepted ${name}`);
}
// This one does accept a connection below, so it gets a real handler.
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
secureContext: clientContext, rejectUnauthorized: false,
}), { code: 'ERR_INVALID_ARG_VALUE' });
// Per-connection options remain legal.
const client = connect('127.0.0.1', endpoint.address.port, {
secureContext: clientContext, servername: 'agent1',
});
await client.opened;
await client.close();
await endpoint.close();
}
// Something that is not a context is rejected by type.
for (const value of [{}, null, 'ctx', 42]) {
assert.throws(() => listen(mustNotCall(), {
secureContext: value, cert, key, host: '127.0.0.1', port: 0,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
+87
View File
@@ -0,0 +1,87 @@
// Flags: --experimental-dtls --no-warnings
// Test: a record that cannot be put on the wire is reported as such, from the
// flight that could not be sent.
//
// EncOut() discarded SendTo()'s return value, so a datagram the kernel
// refused was simply never sent. Nothing retransmits a record into existence
// when the reason it failed is EMSGSIZE or ENETUNREACH, so the handshake went
// quiet and the only report was a timeout, well after the fact and naming the
// wrong cause.
//
// Reporting it needs somewhere to report it to. The client's first flight ran
// inside the binding's connect(), before the JavaScript session existed, and
// the callback dispatch reaches that session through the handle -- so the
// first flight's error was emitted against a wrapper that was not there yet
// and dropped. What surfaced was the next attempt's, a retransmission
// timeout later. The handshake starts once the wrapper is built, so the
// error now comes from the flight that failed.
import { hasCrypto, skip } from '../common/index.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect } = await import('node:dtls');
const dgram = await import('node:dgram');
// Whether this kernel refuses a broadcast send from an unconfigured socket is
// not something the test controls, so establish it with a plain UDP socket
// first. If it does refuse, DTLS has to report that -- deciding from the DTLS
// result alone would let "the error was ignored" look like "the platform
// allowed it", which is exactly the bug.
const refusesBroadcast = await new Promise((resolve) => {
const probe = dgram.createSocket('udp4');
probe.send(Buffer.from('x'), 4433, '255.255.255.255', (error) => {
probe.close();
resolve(error?.code === 'EACCES');
});
});
if (!refusesBroadcast) {
skip('this platform permits sending to the broadcast address');
}
// Sending to the broadcast address without SO_BROADCAST is refused by the
// kernel with EACCES, on the first flight, synchronously.
{
const timeout = 5000;
const started = Date.now();
const session = connect('255.255.255.255', 4433, {
rejectUnauthorized: false,
handshakeTimeout: timeout,
});
// Reported from the first flight, which connect() runs before it returns:
// the error path tears the session down, so it is already destroyed here.
// Dropping that error left the session live until a retransmission
// rediscovered the same failure.
assert.strictEqual(session.destroyed, true);
const error = await session.opened.then(() => null, (e) => e);
assert.ok(error, 'the handshake should not have completed');
// The kernel's reason, not "DTLS handshake timeout".
assert.match(error.message, /permission denied/);
// Nothing was retransmitted. A retransmission is how this used to be
// noticed, so needing one means the first flight's error went nowhere.
assert.strictEqual(session.stats.retransmitCount, 0n);
// And promptly: noticing at the deadline is what this replaced.
assert.ok(Date.now() - started < timeout,
`took ${Date.now() - started}ms of a ${timeout}ms deadline`);
// A client session owns the endpoint connect() made for it, and the error
// path closes it. That ownership has to be settled before the handshake
// starts, now that the handshake can fail before connect() returns; set
// afterwards, this never settles and the event loop has nothing to drain.
await session.endpoint.closed;
}
+115 -5
View File
@@ -1,6 +1,12 @@
// Flags: --experimental-dtls --no-warnings
// Test: DTLSSession.send() return values and the single-record size limit.
//
// send() used to return a bare -1 both for a payload too large for a DTLS
// record and for a send before the handshake finished. Nothing distinguished
// them, the value was undocumented, and `session.send(data)` as a statement
// discards it, so the data went missing silently. It now throws, which is what
// the same method already did for a destroyed session and a bad argument.
import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
@@ -16,6 +22,7 @@ if (!process.features.dtls) {
const { listen, connect } = await import('node:dtls');
const HOST = '127.0.0.1';
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
@@ -23,8 +30,8 @@ const ca = fixtures.readKey('ca1-cert.pem').toString();
// The largest application payload that fits in a single DTLS record (2^14).
const MAX_RECORD = 16384;
// Case 1: send() before the handshake returns -1; afterwards it returns the
// number of bytes written and the message is delivered whole.
// Case 1: send() before the handshake is a state error; afterwards it returns
// the number of bytes written and the message is delivered whole.
{
const received = Promise.withResolvers();
@@ -37,7 +44,9 @@ const MAX_RECORD = 16384;
rejectUnauthorized: false,
});
assert.strictEqual(client.send('before handshake'), -1);
assert.throws(
() => client.send('before handshake'),
{ code: 'ERR_INVALID_STATE', message: /before the handshake completes/ });
await client.opened;
@@ -48,7 +57,8 @@ const MAX_RECORD = 16384;
await server.close();
}
// Case 2: a payload larger than a single record cannot be sent.
// Case 2: a payload larger than a single record is refused, and the error
// names both the size given and the limit rather than just failing.
{
const server = listen(mustCall((session) => {
session.onmessage = mustNotCall();
@@ -60,7 +70,107 @@ const MAX_RECORD = 16384;
});
await client.opened;
assert.strictEqual(client.send(Buffer.alloc(MAX_RECORD + 1, 0x61)), -1);
for (const size of [MAX_RECORD + 1, 70000]) {
assert.throws(
() => client.send(Buffer.alloc(size, 0x61)),
{
code: 'ERR_OUT_OF_RANGE',
message: new RegExp(`${size} bytes.*${MAX_RECORD} byte maximum`),
},
`${size} bytes should have been refused`);
}
await client.close();
await server.close();
}
// Case 3: the record limit is what bounds send(), not the MTU. A record
// larger than the configured MTU is still sent and still arrives; IP
// fragments it. Getting this wrong would make send() reject valid payloads.
{
const received = Promise.withResolvers();
const server = listen(mustCall((session) => {
session.onmessage = mustCall((data) => received.resolve(data.length));
}), { cert, key, port: 0, host: '127.0.0.1' });
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
mtu: 1200,
});
await client.opened;
assert.strictEqual(client.send(Buffer.alloc(4000, 0x61)), 4000);
assert.strictEqual(await received.promise, 4000);
await client.close();
await server.close();
}
// Case 4: a closed session reports that, and is not confused with the size
// or handshake errors above.
{
const server = listen(mustCall(),
{ cert, key, port: 0, host: '127.0.0.1' });
const client = connect('127.0.0.1', server.address.port, {
ca: [ca],
rejectUnauthorized: false,
});
await client.opened;
await client.close();
assert.throws(
() => client.send('after close'),
{ code: 'ERR_INVALID_STATE' });
await server.close();
}
// send() takes any view over bytes, not only a Buffer.
//
// A Uint8Array is the obvious thing to send and was refused, while
// exportKeyingMaterial() on the same object accepted one. Views with an
// offset and views whose elements are wider than a byte have to send the
// bytes they cover, not the bytes of the buffer behind them.
{
const received = [];
const done = Promise.withResolvers();
let expected = 0;
const server = listen((session) => {
session.onmessage = (data) => {
received.push(Buffer.from(data).toString('hex'));
if (received.length === expected) done.resolve();
};
}, { cert, key, host: HOST, port: 0 });
const client = connect(HOST, server.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const base = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]);
const cases = [
[base, '0001020304050607'],
[base.subarray(2, 5), '020304'],
[new Int16Array([0x0201, 0x0403]), '01020304'],
[new DataView(base.buffer, 4, 3), '040506'],
];
expected = cases.length;
for (const [value] of cases) client.send(value);
await done.promise;
cases.forEach(([, hex], i) => assert.strictEqual(received[i], hex));
// A bare ArrayBuffer is not a view and is still refused, as it is by
// exportKeyingMaterial().
assert.throws(() => client.send(new ArrayBuffer(4)),
{ code: 'ERR_INVALID_ARG_TYPE' });
await client.close();
await server.close();
@@ -0,0 +1,166 @@
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: a server is told when an incoming handshake fails.
//
// The endpoint used to drive the handshake before handing the session to
// JavaScript, so anything the first Cycle() reported was emitted to a session
// nobody had been given yet and was lost. A server saw a client fail to
// connect and heard nothing at all.
import { hasCrypto, mustCall, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSEndpointState,
} = (await import('internal/dtls/dtls')).default;
const { connect, listen } = dtls;
const HOST = '127.0.0.1';
// Bounded, so that a regression fails with something readable rather than
// hanging until the runner gives up.
async function within(promise, what, ms = 5000) {
const late = Symbol('late');
// The timer has to be cleared: left pending it holds the loop open for its
// full duration after the race is already decided.
const result = await Promise.race([promise, setTimeout(ms, late, { ref: false })]);
assert.notStrictEqual(result, late,
`${what} did not happen within ${ms}ms`);
return result;
}
const key = (name) => fixtures.readKey(name).toString();
const cert = key('agent1-cert.pem');
const privateKey = key('agent1-key.pem');
// A handshake that fails on the server's own terms is reported to the
// session, and the session is handed over in time for a listener to see it.
{
const reported = Promise.withResolvers();
const endpoint = listen(mustCall((session) => {
session.onerror = (err) => reported.resolve(err);
}), {
cert,
key: privateKey,
// Nothing the client below offers.
ciphers: 'ECDHE-RSA-AES256-GCM-SHA384',
host: HOST,
port: 0,
});
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
ciphers: 'ECDHE-RSA-AES128-GCM-SHA256',
});
await assert.rejects(client.opened, { name: 'Error' });
const err = await within(reported.promise,
'the server session error');
assert.match(err.message, /no shared cipher/);
await endpoint.close();
}
// The onsession callback runs before the handshake, so state set up there is
// in place for it. This is what makes the above work, and is worth pinning on
// its own: a listener attached there must not miss the handshake it belongs
// to.
{
const order = [];
const settled = Promise.withResolvers();
const endpoint = listen(mustCall((session) => {
order.push('onsession');
session.onerror = () => {
order.push('onerror');
settled.resolve();
};
}), {
cert,
key: privateKey,
ciphers: 'ECDHE-RSA-AES256-GCM-SHA384',
host: HOST,
port: 0,
});
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
ciphers: 'ECDHE-RSA-AES128-GCM-SHA256',
});
await assert.rejects(client.opened, { name: 'Error' });
await within(settled.promise, 'the server session error');
assert.deepStrictEqual(order, ['onsession', 'onerror']);
await endpoint.close();
}
// Destroying a session from onsession, now that it runs first, must not let
// the handshake proceed on a session that is already gone.
{
let first = true;
const endpoint = listen(mustCall((session) => {
// Only the first: the probe below has to be left alone.
if (first) {
first = false;
session.destroy();
}
}, 2), { cert, key: privateKey, host: HOST, port: 0 });
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
// The client gets nothing back and gives up; what matters is that the
// server is still standing.
const pending = Symbol('pending');
await Promise.race([
client.opened.then(() => 'opened', () => 'failed'),
setTimeout(300, pending),
]);
assert.strictEqual(Number(getDTLSEndpointState(endpoint).sessionCount), 0);
// Still serving.
const good = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
const outcome = await Promise.race([
good.opened.then(() => 'opened', () => 'failed'),
setTimeout(500, pending),
]);
assert.notStrictEqual(outcome, pending);
client.destroy();
good.destroy();
await endpoint.close();
}
// A handshake that succeeds is unaffected.
{
const endpoint = listen(mustCall((session) => {
session.onerror = () => assert.fail('handshake should have succeeded');
}), { cert, key: privateKey, host: HOST, port: 0 });
const client = connect(HOST, endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await client.close();
await endpoint.close();
}
@@ -0,0 +1,83 @@
// Flags: --experimental-dtls --no-warnings
// Test: servername must be a string, because it selects the identity the
// peer certificate is checked against.
//
// A non-string reached the binding's IsString() test, failed it, and became a
// null verify host. SSL_set1_host() was then never called, so chain
// verification still ran but hostname verification was silently skipped --
// a certificate for any name the CA had signed would be accepted.
import { hasCrypto, mustNotCall, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import { inspect } from 'node:util';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const HOST = '127.0.0.1';
const key = (name) => fixtures.readKey(name).toString();
const ca = key('ca1-cert.pem');
// agent1's certificate is issued for "agent1" by ca1.
const endpoint = listen(() => {}, {
cert: key('agent1-cert.pem'),
key: key('agent1-key.pem'),
host: HOST,
port: 0,
});
const port = endpoint.address.port;
// A name the certificate is not for is rejected. This is the check that a
// non-string used to skip.
{
const session = connect(HOST, port, { ca: [ca], servername: 'wrong.example' });
await assert.rejects(session.opened, { name: 'Error' });
}
// The right name is accepted, so the check is not simply refusing everything.
{
const session = connect(HOST, port, { ca: [ca], servername: 'agent1' });
await session.opened;
assert.strictEqual(session.authorized, true);
await session.close();
}
// Anything that is not a string is refused up front rather than quietly
// disabling the check.
for (const servername of [{}, [], 42, true, null, Symbol('x'), () => {}]) {
assert.throws(() => connect(HOST, port, { ca: [ca], servername }), {
code: 'ERR_INVALID_ARG_TYPE',
}, `servername: ${inspect(servername)}`);
}
// An object whose toString() would produce a valid name is still refused;
// coercing it would make the identity depend on user-controlled coercion.
assert.throws(() => connect(HOST, port, {
ca: [ca],
servername: { toString: mustNotCall() },
}), { code: 'ERR_INVALID_ARG_TYPE' });
// The empty string keeps its documented meaning: no SNI is sent, and the
// identity falls back to the host.
{
const session = connect(HOST, port, { ca: [ca], servername: '' });
// Verifying 127.0.0.1 against a certificate for "agent1" must fail.
await assert.rejects(session.opened, { name: 'Error' });
}
// Omitting it entirely also falls back to the host.
{
const session = connect(HOST, port, { ca: [ca], servername: undefined });
await assert.rejects(session.opened, { name: 'Error' });
}
await endpoint.close();
@@ -0,0 +1,71 @@
// Flags: --experimental-dtls --no-warnings
// Test: a server sets a session id context.
//
// OpenSSL refuses to resume a session whose id context differs from the one
// on the accepting SSL. Without it set, a session issued by one server can be
// resumed against another configured differently, which matters most when
// client certificates are involved. node:tls sets one; this did not.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem');
const key = fixtures.readKey('agent1-key.pem');
const ca = fixtures.readKey('ca1-cert.pem');
// An explicit context is accepted and does not disturb the handshake.
{
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
sessionIdContext: 'a'.repeat(32),
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
assert.strictEqual(endpoint.stats.serverSessions, 1n);
await client.close();
await endpoint.close();
}
// The default is applied when none is given, and handshakes still work.
{
const endpoint = listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'agent1', ca: [ca],
});
await client.opened;
assert.strictEqual(endpoint.stats.serverSessions, 1n);
await client.close();
await endpoint.close();
}
// SSL_CTX_set_session_id_context caps the context at 32 bytes. Rejected in
// JavaScript, so the caller is told what the limit is: OpenSSL's own failure
// arrived as "Failed to set session id context", which does not say.
{
assert.throws(
() => listen(() => {}, {
cert, key, host: '127.0.0.1', port: 0,
sessionIdContext: 'x'.repeat(33),
}),
{ code: 'ERR_OUT_OF_RANGE' });
}
@@ -55,7 +55,7 @@ assert.ok(peerCert);
assert.ok(peerCert.includes('BEGIN CERTIFICATE'));
// State should reflect open connection.
assert.ok(session.state);
assert.strictEqual(session.destroyed, false);
await session.close();
await endpoint.close();
@@ -0,0 +1,280 @@
// Flags: --experimental-dtls --no-warnings
// Test: session resumption -- session.session, the `session` option on
// connect(), session.reused, and ticketKeys.
//
// Resumption matters more here than it does over TCP. A full DTLS handshake's
// Certificate flight is fragmented across several datagrams and any one of
// them being lost costs a retransmission timeout; a resumed handshake skips
// it. Measured on loopback, the server sends 1850 bytes in 4 packets for a
// full handshake against 280 bytes in 3 for a resumed one.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
import { inspect } from 'node:util';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { randomBytes } = await import('node:crypto');
const { connect, createSecureContext, listen } = await import('node:dtls');
const key = (name) => fixtures.readKey(name).toString();
const cert = key('agent1-cert.pem');
const privateKey = key('agent1-key.pem');
const serverOptions = {
cert, key: privateKey, host: '127.0.0.1', port: 0,
};
// A session can be carried from one connection to the next.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await first.opened;
assert.strictEqual(first.reused, false);
const ticket = first.session;
assert.ok(Buffer.isBuffer(ticket));
assert.ok(ticket.length > 0);
await first.close();
const second = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
// A resumed connection can itself be resumed from.
assert.ok(Buffer.isBuffer(second.session));
await second.close();
await endpoint.close();
}
// Resumption skips the Certificate flight, which is the point of it.
{
const endpoint = listen(() => {}, serverOptions);
const sent = () => Number(endpoint.stats.bytesSent);
const beforeFull = sent();
const full = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await full.opened;
const fullBytes = sent() - beforeFull;
const ticket = full.session;
await full.close();
const beforeResumed = sent();
const resumed = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await resumed.opened;
const resumedBytes = sent() - beforeResumed;
// Read before closing: like authorized, this reports false once the
// handle is gone.
assert.strictEqual(resumed.reused, true);
await resumed.close();
assert.ok(resumedBytes < fullBytes / 2,
`resumed handshake sent ${resumedBytes} bytes, ` +
`full sent ${fullBytes}; expected well under half`);
await endpoint.close();
}
// The cookie exchange still runs for a resumed handshake. Resumption must not
// become a way around the address validation that the endpoint's DoS
// protection depends on.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await first.opened;
const ticket = first.session;
await first.close();
const before = Number(endpoint.stats.packetsReceived);
const second = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
const received = Number(endpoint.stats.packetsReceived) - before;
// At least two: the first ClientHello is answered with a
// HelloVerifyRequest and the client has to send another carrying the
// cookie. One would mean DTLSv1_listen() had been bypassed.
assert.ok(received >= 2,
`resumed handshake cost the server ${received} packets; ` +
'fewer than 2 means the cookie exchange was skipped');
assert.strictEqual(second.reused, true);
await second.close();
await endpoint.close();
}
// A session is bound to the identity it was authenticated for and refused
// anywhere else. A resumed handshake does not re-verify the peer's
// certificate, so replaying a session against another host would skip
// verification while appearing to succeed. This is CVE-2026-48934, fixed in
// node:tls; the same hazard applies to any API that lets a session travel
// between connections.
{
const endpoint = listen(() => {}, serverOptions);
const first = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-a',
});
await first.opened;
const ticket = first.session;
await first.close();
// The identity it was issued for: allowed.
const same = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-a', session: ticket,
});
await same.opened;
assert.strictEqual(same.reused, true);
await same.close();
// Any other: refused before OpenSSL sees it.
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'host-b', session: ticket,
}), {
code: 'ERR_INVALID_ARG_VALUE',
message: /authenticated for 'host-a'.*cannot be reused for 'host-b'/,
});
// A blob that did not come from session.session cannot be vouched for,
// because nothing records which identity it belongs to.
assert.throws(() => connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, session: randomBytes(64),
}), { code: 'ERR_INVALID_ARG_VALUE' });
await endpoint.close();
}
// An unusable session falls back to a full handshake rather than failing.
{
const first = listen(() => {}, serverOptions);
const client = connect('127.0.0.1', first.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const ticket = client.session;
await client.close();
await first.close();
// A different endpoint has a different, randomly generated ticket key, so
// it cannot decrypt the ticket.
const second = listen(() => {}, serverOptions);
const stale = connect('127.0.0.1', second.address.port, {
rejectUnauthorized: false, session: ticket,
});
await stale.opened;
assert.strictEqual(stale.reused, false);
await stale.close();
await second.close();
}
// ticketKeys makes tickets portable between endpoints, which is what makes
// them survive a restart or work across a cluster.
{
const ticketKeys = randomBytes(80);
const a = listen(() => {}, { ...serverOptions, ticketKeys });
const b = listen(() => {}, { ...serverOptions, ticketKeys });
const first = connect('127.0.0.1', a.address.port, {
rejectUnauthorized: false,
});
await first.opened;
const ticket = first.session;
await first.close();
const second = connect('127.0.0.1', b.address.port, {
rejectUnauthorized: false, session: ticket,
});
await second.opened;
assert.strictEqual(second.reused, true);
await second.close();
await a.close();
await b.close();
}
// ticketKeys validation. The length is OpenSSL's rather than ours, so the
// error reports what it asked for.
{
assert.throws(() => createSecureContext({
cert, key: privateKey, isServer: true, ticketKeys: randomBytes(48),
}), { code: 'ERR_INVALID_ARG_VALUE', message: /exactly 80 bytes/ });
for (const value of ['keys', 42, null, {}]) {
assert.throws(() => createSecureContext({
cert, key: privateKey, isServer: true, ticketKeys: value,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// It belongs to the context, so it cannot be passed alongside one.
const context = createSecureContext({
cert, key: privateKey, isServer: true,
});
assert.throws(() => listen(() => {}, {
secureContext: context, ticketKeys: randomBytes(80),
host: '127.0.0.1', port: 0,
}), { code: 'ERR_INVALID_ARG_VALUE' });
}
// A server session has no identity to bind a blob to, so it offers none.
{
const gotSession = Promise.withResolvers();
const endpoint = listen((s) => gotSession.resolve(s), serverOptions);
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const session = await gotSession.promise;
await session.opened;
assert.strictEqual(session.session, undefined);
assert.strictEqual(session.reused, false);
await client.close();
await endpoint.close();
}
// A session that is not a Buffer is a type error, not a value error.
//
// The Buffer check was folded in with the prefix and length checks and all
// four reported ERR_INVALID_ARG_VALUE, so passing a string got the code that
// means the type was right and the contents were wrong.
{
for (const value of ['a string', 7, {}, null, []]) {
assert.throws(() => connect('127.0.0.1', 4433, { session: value }), {
code: 'ERR_INVALID_ARG_TYPE',
}, `session: ${inspect(value)}`);
}
// A Buffer that is not one of ours is still a value error: the right type,
// contents this cannot resume.
for (const value of [Buffer.alloc(0), Buffer.alloc(40), Buffer.from('xx')]) {
assert.throws(() => connect('127.0.0.1', 4433, { session: value }), {
code: 'ERR_INVALID_ARG_VALUE',
}, `session: ${inspect(value)}`);
}
}
@@ -1,4 +1,4 @@
// Flags: --experimental-dtls --no-warnings
// Flags: --experimental-dtls --no-warnings --expose-internals
// Test: a session is removed from its endpoint's session table when it closes,
// whether closed locally or by the peer. Regression test for closed sessions
@@ -18,6 +18,12 @@ if (!process.features.dtls) {
const { listen, connect } = await import('node:dtls');
// The state and sessions views are not public API; they are reached here the
// way node:quic's tests reach theirs.
const {
getDTLSEndpointState,
} = (await import('internal/dtls/dtls')).default;
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const ca = fixtures.readKey('ca1-cert.pem').toString();
@@ -39,9 +45,9 @@ const ca = fixtures.readKey('ca1-cert.pem').toString();
await client.opened;
const session = await gotSession.promise;
assert.strictEqual(server.state.sessionCount, 1);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 1);
await session.close();
assert.strictEqual(server.state.sessionCount, 0);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 0);
await client.close();
await server.close();
@@ -65,10 +71,10 @@ const ca = fixtures.readKey('ca1-cert.pem').toString();
await client.opened;
const session = await gotSession.promise;
assert.strictEqual(server.state.sessionCount, 1);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 1);
await client.close(); // Sends close_notify to the server
await session.closed; // Server processes it, detaches, and emits its close
assert.strictEqual(server.state.sessionCount, 0);
assert.strictEqual(getDTLSEndpointState(server).sessionCount, 0);
await server.close();
}
+109
View File
@@ -0,0 +1,109 @@
// Flags: --experimental-dtls --no-warnings --expose-gc --expose-internals
// Test: invariants of the SNI binding that the public API no longer reaches.
//
// SNI is configured when a context is created and cannot be changed
// afterwards, so neither of the situations below can be produced through
// dtls.createSecureContext() or dtls.listen(). Both were reachable when `sni`
// was a listen() option applied to whatever context it was given, and both
// were bugs then. They are tested against the binding directly, because the
// binding is what has to stay correct if the option ever moves back.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { createSecureContext } = await import('node:dtls');
const { kHandle } = (await import('internal/dtls/symbols')).default;
const key = (name) => fixtures.readKey(name).toString();
const cert = key('agent1-cert.pem');
const privateKey = key('agent1-key.pem');
const serverContext = () =>
createSecureContext({ cert, key: privateKey, isServer: true });
// The binding holds SNI contexts weakly, so a context naming itself does not
// keep itself alive.
//
// BaseObjectPtr is a reference count rather than an edge the collector can
// see, so a self-reference never reached zero: the context, its SSL_CTX, its
// key and its certificate stayed for the life of the process. Enough of them
// trips the base_object_count_ assertion in ~Realm() at exit, so this test
// fails by aborting, not by asserting, if the holding goes back to strong.
{
const registry = new FinalizationRegistry(() => { finalized++; });
let finalized = 0;
const total = 20;
for (let i = 0; i < total; i++) {
// A context serving one of its own names, and a pair naming each other.
const self = serverContext();
const first = serverContext();
const second = serverContext();
registry.register(self, 'self');
registry.register(first, 'first');
registry.register(second, 'second');
self[kHandle].setSNIContexts(['self.example', self[kHandle]], undefined);
first[kHandle].setSNIContexts(['second.example', second[kHandle]],
undefined);
second[kHandle].setSNIContexts(['first.example', first[kHandle]],
undefined);
}
for (let i = 0; i < 8 && finalized < total * 3; i++) {
globalThis.gc();
await new Promise((resolve) => setImmediate(resolve));
}
// Not all of them: whether the most recent is still reachable from a local
// is not something a test can pin down. Most is the signal, and holding
// them strongly collects none of the self-referencing ones at all.
assert.ok(finalized > total,
`only ${finalized}/${total * 3} contexts were collected`);
}
// Configuring SNI without a callback clears any callback already installed.
//
// Only ever assigning it meant a context given a map after a callback kept
// the callback, which for SNI is a fail-open: a map with no '*' entry means
// only the names in it are served, and a leftover callback answers the rest.
{
const context = serverContext();
const identity = serverContext();
let calls = 0;
context[kHandle].setSNIContexts([], () => { calls++; return identity; });
context[kHandle].setSNIContexts(['known.example', identity[kHandle]],
undefined);
const { connect, listen } = await import('node:dtls');
const server = listen(() => {}, {
secureContext: context, host: '127.0.0.1', port: 0,
});
const unmatched = connect('127.0.0.1', server.address.port, {
servername: 'unknown.example',
rejectUnauthorized: false,
});
await assert.rejects(unmatched.opened, { name: 'Error' });
assert.strictEqual(calls, 0);
const matched = connect('127.0.0.1', server.address.port, {
servername: 'known.example',
rejectUnauthorized: false,
});
await matched.opened;
await matched.close();
await server.close();
}
+459
View File
@@ -0,0 +1,459 @@
// Flags: --experimental-dtls --no-warnings
// Test: server-side SNI, the `sni` option on listen().
//
// An endpoint could report session.servername but not act on it, so one port
// could only ever present one certificate. This follows the QUIC model: a
// declarative map of host name to identity, resolved in C++ during the
// handshake. Nothing calls into JavaScript mid-handshake, so the handshake is
// never suspended -- which matters more here than it does for TLS, because a
// suspended DTLS handshake keeps retransmitting.
import { hasCrypto, mustNotCall, skip } from '../common/index.mjs';
import { setTimeout } from 'node:timers/promises';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const dtls = await import('node:dtls');
const { connect, createSecureContext, listen } = dtls;
const key = (name) => fixtures.readKey(name).toString();
// Bounded, so a regression fails with something readable rather than hanging
// until the runner gives up.
async function within(promise, what, ms = 5000) {
const late = Symbol('late');
const result = await Promise.race([promise, setTimeout(ms, late, { ref: false })]);
assert.notStrictEqual(result, late,
`${what} did not happen within ${ms}ms`);
return result;
}
const agent1Cert = key('agent1-cert.pem');
const agent1Key = key('agent1-key.pem');
// A second identity with a different subject, to tell them apart.
const localhostCert = key('leaf-from-intermediate-cert.pem');
const localhostKey = key('leaf-from-intermediate-key.pem');
function servedCommonName(session) {
return String(session.peerX509Certificate.subject)
.split('\n').find((line) => line.startsWith('CN='));
}
// The name selects the certificate, and values may be either an options bag
// or a DTLSSecureContext.
{
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: {
'agent1': { cert: agent1Cert, key: agent1Key },
'localhost': createSecureContext({
cert: localhostCert, key: localhostKey, isServer: true,
}),
'*': { cert: agent1Cert, key: agent1Key },
},
});
for (const [servername, expected] of [
['agent1', 'CN=agent1'],
['localhost', 'CN=localhost'],
['no.such.host', 'CN=agent1'], // Falls to the wildcard.
]) {
const client = connect('127.0.0.1', endpoint.address.port, {
servername, rejectUnauthorized: false,
});
await client.opened;
assert.strictEqual(servedCommonName(client), expected);
await client.close();
}
await endpoint.close();
}
// Without a wildcard, an unmatched name is refused rather than falling back
// to the endpoint's own certificate.
{
// The session reaches onsession before SNI selection runs -- the server
// builds it once DTLSv1_listen() has validated the cookie, and only then
// drives the handshake that consults the map. So a refused name still
// surfaces a session here, which then fails, exactly as any other
// handshake failure does.
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: { 'agent1': { cert: agent1Cert, key: agent1Key } },
});
const client = connect('127.0.0.1', endpoint.address.port, {
servername: 'not.configured', rejectUnauthorized: false,
});
await assert.rejects(client.opened, (err) => {
assert.match(err.message, /unrecognized[ _]name/);
return true;
});
await endpoint.close();
}
// A client that sends no SNI at all also lands on the wildcard, and is
// refused when there is none.
{
const withWildcard = listen(() => {}, {
cert: agent1Cert, key: agent1Key, host: '127.0.0.1', port: 0,
sni: { '*': { cert: agent1Cert, key: agent1Key } },
});
// servername: '' disables the extension entirely.
const ok = connect('127.0.0.1', withWildcard.address.port, {
servername: '', rejectUnauthorized: false,
});
await ok.opened;
assert.strictEqual(servedCommonName(ok), 'CN=agent1');
await ok.close();
await withWildcard.close();
const withoutWildcard = listen(() => {}, {
cert: agent1Cert, key: agent1Key, host: '127.0.0.1', port: 0,
sni: { 'agent1': { cert: agent1Cert, key: agent1Key } },
});
const refused = connect('127.0.0.1', withoutWildcard.address.port, {
servername: '', rejectUnauthorized: false,
});
await assert.rejects(refused.opened, { name: 'Error' });
await withoutWildcard.close();
}
// Verification follows the selected identity. SSL_set_SSL_CTX() reassigns
// ssl->ctx and the verify store is read through it, so an identity that
// trusts only ca2 rejects a client presenting a ca1 certificate even though
// the endpoint itself trusts ca1.
{
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
ca: [key('ca1-cert.pem')],
requestCert: true,
rejectUnauthorized: true,
host: '127.0.0.1',
port: 0,
sni: {
'*': {
cert: agent1Cert, key: agent1Key, ca: [key('ca1-cert.pem')],
},
'ca2-only': {
cert: agent1Cert, key: agent1Key, ca: [key('ca2-cert.pem')],
},
},
});
// ca1 client against the ca1 identity: accepted.
const accepted = connect('127.0.0.1', endpoint.address.port, {
servername: 'anything',
cert: agent1Cert, key: agent1Key,
rejectUnauthorized: false,
});
await accepted.opened;
await accepted.close();
// The same client against the ca2-only identity: rejected.
const rejected = connect('127.0.0.1', endpoint.address.port, {
servername: 'ca2-only',
cert: agent1Cert, key: agent1Key,
rejectUnauthorized: false,
});
await assert.rejects(rejected.opened, { name: 'Error' });
// A ca2 client against the ca2-only identity: accepted.
const other = connect('127.0.0.1', endpoint.address.port, {
servername: 'ca2-only',
cert: key('agent3-cert.pem'), key: key('agent3-key.pem'),
rejectUnauthorized: false,
});
await other.opened;
await other.close();
await endpoint.close();
}
// Validation.
{
const base = { cert: agent1Cert, key: agent1Key, host: '127.0.0.1', port: 0 };
assert.throws(() => listen(mustNotCall(), { ...base, sni: {} }),
{ code: 'ERR_INVALID_ARG_VALUE' });
for (const sni of ['x', 42, null]) {
assert.throws(() => listen(mustNotCall(), { ...base, sni }),
{ code: 'ERR_INVALID_ARG_TYPE' });
}
for (const entry of ['x', 42, null]) {
assert.throws(() => listen(mustNotCall(), {
...base, sni: { 'a.example': entry },
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
// A client context cannot serve as an identity.
assert.throws(() => listen(mustNotCall(), {
...base,
sni: { 'a.example': createSecureContext({ ca: [key('ca1-cert.pem')] }) },
}), { code: 'ERR_INVALID_ARG_VALUE' });
}
// A callback may be given instead of a map, for identities that are chosen
// rather than enumerated. It returns the same two things a map entry holds.
{
const prepared = createSecureContext({
cert: localhostCert, key: localhostKey, isServer: true,
});
const seen = [];
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: (servername) => {
seen.push(servername);
if (servername === 'localhost') return prepared;
if (servername === 'agent1') return { cert: agent1Cert, key: agent1Key };
return undefined;
},
});
for (const [servername, expected] of [
['agent1', 'CN=agent1'], // An options bag.
['localhost', 'CN=localhost'], // A prepared context.
]) {
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername,
});
await client.opened;
assert.strictEqual(servedCommonName(client), expected);
await client.close();
}
// Declining is how a callback says a name is not served. It is refused the
// same way an unmatched map with no wildcard is, rather than quietly
// falling back to the endpoint's own certificate.
const refused = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'no.such.host',
});
await assert.rejects(refused.opened, { name: 'Error' });
assert.deepStrictEqual(seen, ['agent1', 'localhost', 'no.such.host']);
await endpoint.close();
}
// A client that sends no SNI extension still reaches the callback, and is
// told the name is absent rather than given an empty string it could not
// tell apart from one.
{
const seen = [];
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: (servername) => {
seen.push(servername);
return { cert: agent1Cert, key: agent1Key };
},
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false,
});
await client.opened;
assert.deepStrictEqual(seen, [undefined]);
await client.close();
await endpoint.close();
}
// A context the callback built is kept alive for as long as the session
// needs it. Nothing else holds it: SSL_set_SSL_CTX() references the SSL_CTX
// and not the object wrapping it, and a callback later in the handshake
// resolves its configuration through the switched ctx and so lands on this
// context rather than the endpoint's.
//
// This exercises the path under garbage collection, but does not reliably
// fail without the retaining reference -- whether the object is collected
// inside the handshake is not something the test can force. It is here to
// exercise callback-built contexts, not as proof of the retention.
{
const endpoint = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
// A fresh context every time, deliberately: none of them is reachable
// from JavaScript once the callback returns.
sni: () => ({ cert: localhostCert, key: localhostKey }),
});
for (let i = 0; i < 12; i++) {
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: `host-${i}.example`,
});
await client.opened;
assert.strictEqual(servedCommonName(client), 'CN=localhost');
globalThis.gc?.();
await client.close();
}
await endpoint.close();
}
// An exception from the callback fails that handshake and is reported to the
// session, like any other handshake failure. It must not reach the process as
// an uncaught exception.
{
process.on('uncaughtException', mustNotCall());
const thrown = new Error('from the sni callback');
const seen = Promise.withResolvers();
const endpoint = listen((session) => {
session.onerror = (err) => seen.resolve(err);
}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: () => { throw thrown; },
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'anything',
});
await assert.rejects(client.opened, { name: 'Error' });
// The user's own error, not something wrapped around it.
assert.strictEqual(await within(seen.promise, 'the session error'), thrown);
await endpoint.close();
process.removeAllListeners('uncaughtException');
}
// Validation of what a callback returns, which is checked the same way a map
// entry is.
{
const clientContext = createSecureContext({
cert: agent1Cert, key: agent1Key,
});
for (const [returned, code] of [
[clientContext, 'ERR_INVALID_ARG_VALUE'], // Built for a client.
['a string', 'ERR_INVALID_ARG_TYPE'],
[42, 'ERR_INVALID_ARG_TYPE'],
]) {
const failed = Promise.withResolvers();
const endpoint = listen((session) => {
session.onerror = (err) => failed.resolve(err);
}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
sni: () => returned,
});
const client = connect('127.0.0.1', endpoint.address.port, {
rejectUnauthorized: false, servername: 'anything',
});
await assert.rejects(client.opened, { name: 'Error' });
assert.strictEqual(
(await within(failed.promise, 'the session error')).code, code);
await endpoint.close();
}
}
// The SNI callback's return value is checked for being a context, not merely
// for being an object. Unwrapping validates the internal field count with a
// DCHECK only, so in a release build any other native wrapper would have been
// reinterpreted as a context and its SSL_CTX read out of whatever was there.
{
const { Gzip } = await import('node:zlib');
const nativeButNotAContext = new Gzip();
const server = listen(() => {}, {
cert: agent1Cert,
key: agent1Key,
host: '127.0.0.1',
port: 0,
// A different kind of native object, not a DTLSSecureContext.
sni: () => nativeButNotAContext,
});
const client = connect('127.0.0.1', server.address.port, {
servername: 'a.example',
rejectUnauthorized: false,
});
await assert.rejects(client.opened, { name: 'Error' });
await server.close();
}
// The sni option belongs to the context. It can be given to
// createSecureContext(), and combining it with a prepared secureContext is
// refused rather than silently reconfiguring a shared context.
{
const identity = { cert: agent1Cert, key: agent1Key };
const prepared = createSecureContext({
cert: agent1Cert,
key: agent1Key,
isServer: true,
sni: { 'named.example': identity },
});
const server = listen(() => {}, {
secureContext: prepared, host: '127.0.0.1', port: 0,
});
const named = connect('127.0.0.1', server.address.port, {
servername: 'named.example', rejectUnauthorized: false,
});
await named.opened;
await named.close();
// No '*' entry, so anything else is refused, exactly as when the map is
// given to listen() directly.
const other = connect('127.0.0.1', server.address.port, {
servername: 'other.example', rejectUnauthorized: false,
});
await assert.rejects(other.opened, { name: 'Error' });
await server.close();
// Both at once is refused: the context already carries its identities.
assert.throws(() => listen(() => {}, {
secureContext: prepared,
host: '127.0.0.1',
port: 0,
sni: { 'late.example': identity },
}), { code: 'ERR_INVALID_ARG_VALUE' });
// And it means nothing on a client.
assert.throws(() => createSecureContext({
sni: { 'a.example': identity },
}), { code: 'ERR_INVALID_ARG_VALUE' });
}
+110
View File
@@ -0,0 +1,110 @@
// Flags: --experimental-dtls --no-warnings
// Test: the UDP socket options an endpoint accepts.
//
// None of these were settable. The socket took whatever the system gave it,
// so a server could not be scaled across processes with SO_REUSEPORT and
// could not be given room for bursts that the default buffers drop.
import { hasCrypto, skip } from '../common/index.mjs';
import * as fixtures from '../common/fixtures.mjs';
import assert from 'node:assert';
if (!hasCrypto) {
skip('missing crypto');
}
if (!process.features.dtls) {
skip('DTLS is not enabled');
}
const { connect, listen } = await import('node:dtls');
const cert = fixtures.readKey('agent1-cert.pem').toString();
const key = fixtures.readKey('agent1-key.pem').toString();
const HOST = '127.0.0.1';
// reusePort lets a second endpoint take the same port, which is how a server
// is spread over several processes.
{
const first = listen(() => {}, {
cert, key, host: HOST, port: 0, reusePort: true,
});
const port = first.address.port;
const second = listen(() => {}, {
cert, key, host: HOST, port, reusePort: true,
});
assert.strictEqual(second.address.port, port);
await second.close();
await first.close();
}
// Without it the port is still exclusive, so the default has not been
// loosened on the way past.
{
const first = listen(() => {}, { cert, key, host: HOST, port: 0 });
assert.throws(() => listen(() => {}, {
cert, key, host: HOST, port: first.address.port,
}), { code: 'EADDRINUSE' });
await first.close();
}
// Buffer sizes and TTL are applied at bind, and an endpoint given them still
// serves.
//
// This does not pin that they reached the socket: nothing here reads them
// back, so removing the uv_recv_buffer_size() or uv_udp_set_ttl() call still
// passes. Verified once by hand with ss(8) -- a default endpoint reported
// rcvbuf 212992 and one asking for 1 MiB reported 425984, the kernel having
// doubled and clamped it. Reading them back would need a getter this module
// does not have.
{
const server = listen(() => {}, {
cert,
key,
host: HOST,
port: 0,
udpReceiveBufferSize: 1024 * 1024,
udpSendBufferSize: 1024 * 1024,
udpTTL: 64,
});
const client = connect(HOST, server.address.port, {
rejectUnauthorized: false,
});
await client.opened;
await client.close();
await server.close();
}
// Ranges.
{
const bad = {
reusePort: ['yes', 1, null],
udpReceiveBufferSize: [0, -1, 1.5, '65536'],
udpSendBufferSize: [0, -1, 1.5, '65536'],
udpTTL: [0, 256, -1, 1.5, '64'],
};
for (const [name, values] of Object.entries(bad)) {
for (const value of values) {
assert.throws(() => listen(() => {}, {
cert, key, host: HOST, port: 0, [name]: value,
}), (error) => {
assert.match(error.code,
/^ERR_(INVALID_ARG_TYPE|OUT_OF_RANGE)$/);
return true;
}, `${name}: ${String(value)}`);
}
}
// The ends of the TTL range are fine.
for (const value of [1, 255]) {
const server = listen(() => {}, {
cert, key, host: HOST, port: 0, udpTTL: value,
});
await server.close();
}
}
+52
View File
@@ -149,3 +149,55 @@ await clientSession.close();
assert.notStrictEqual(csStats.closingAt, 0n);
await endpoint.close();
// Stats say when they have stopped tracking anything.
//
// isConnected is documented as false once the stats are stale, and nothing
// ever set it: kFinishClose was defined on both stats classes and imported,
// and no caller in the module ever invoked it. The numbers stayed readable
// and stopped moving, and isConnected went on saying they were live.
{
const server = listen(() => {}, {
cert: serverCert, key: serverKey, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const sessionStats = client.stats;
const endpointStats = server.stats;
assert.strictEqual(sessionStats.isConnected, true);
assert.strictEqual(endpointStats.isConnected, true);
// The last values are kept rather than lost, which is the point of taking
// a copy rather than simply marking the object dead.
const createdAt = sessionStats.createdAt;
await client.close();
assert.strictEqual(sessionStats.isConnected, false);
assert.strictEqual(sessionStats.createdAt, createdAt);
await server.close();
assert.strictEqual(endpointStats.isConnected, false);
}
// destroy() reaches them too, not only the graceful path.
{
const server = listen(() => {}, {
cert: serverCert, key: serverKey, host: '127.0.0.1', port: 0,
});
const client = connect('127.0.0.1', server.address.port, {
rejectUnauthorized: false,
});
await client.opened;
const sessionStats = client.stats;
const endpointStats = server.stats;
client.destroy();
assert.strictEqual(sessionStats.isConnected, false);
server.destroy();
assert.strictEqual(endpointStats.isConnected, false);
}
+7 -2
View File
@@ -1,4 +1,4 @@
// Flags: --permission --allow-fs-read=* --experimental-dtls --no-warnings
// Flags: --permission --allow-fs-read=* --experimental-dtls --no-warnings --expose-internals
import { hasCrypto, skip, mustNotCall } from '../common/index.mjs';
import assert from 'node:assert';
import { readFileSync } from 'node:fs';
@@ -15,6 +15,11 @@ if (!process.features.dtls) {
const { connect, listen, DTLSEndpoint } = await import('node:dtls');
// bind() is not public API -- it is symbol-keyed, so that an endpoint cannot
// be bound a second time from outside. Reached here the way the other DTLS
// tests reach internals.
const { kBind } = (await import('internal/dtls/symbols')).default;
// Verify that the permission system correctly reports no net access.
assert.ok(!process.permission.has('net'));
@@ -57,7 +62,7 @@ const ca = readFileSync(join(fixturesDir, 'ca1-cert.pem')).toString();
const endpoint = new DTLSEndpoint();
assert.ok(endpoint);
assert.throws(
() => endpoint.bind('127.0.0.1', 0),
() => endpoint[kBind]('127.0.0.1', 0),
{
code: 'ERR_ACCESS_DENIED',
permission: 'Net',