mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
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:
1 parent
18a9ba509a
commit
bd2b98f549
56 files changed
+8197
-404
No files matched your search
+755
-67
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 = {
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+114
-9
@@ -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
@@ -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()) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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)$/ });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user