Skip to content

Commit 7d55980

Browse files
authored
Merge pull request #31 from jasnell/jasnell/dtls-tests
net: additional dtls fixups and tests
2 parents 8f199d9 + 9ea498c commit 7d55980

20 files changed

Lines changed: 691 additions & 36 deletions

src/dtls/dtls.h

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,18 @@ void RecordTimestampStat(Stats* stats) {
5858
V(MESSAGES_SENT, messages_sent) \
5959
V(RETRANSMIT_COUNT, retransmit_count)
6060

61-
// State indices shared between C++ and JS via AliasedStruct/DataView.
61+
// State "indices" shared between C++ and JS via AliasedStruct/DataView. These
62+
// are BYTE OFFSETS into the state struct, not sequential indices: session_count
63+
// is a uint32, so `busy`, which follows it, sits at byte offset 8. The
64+
// static_asserts in dtls_endpoint.cc pin these to the actual struct layout.
6265
// Keep in sync with lib/internal/dtls/state.js.
6366
enum DTLSEndpointStateIndex {
6467
IDX_ENDPOINT_STATE_BOUND = 0,
65-
IDX_ENDPOINT_STATE_LISTENING,
66-
IDX_ENDPOINT_STATE_CLOSING,
67-
IDX_ENDPOINT_STATE_DESTROYED,
68-
IDX_ENDPOINT_STATE_SESSION_COUNT,
69-
IDX_ENDPOINT_STATE_BUSY,
70-
IDX_ENDPOINT_STATE_COUNT
68+
IDX_ENDPOINT_STATE_LISTENING = 1,
69+
IDX_ENDPOINT_STATE_CLOSING = 2,
70+
IDX_ENDPOINT_STATE_DESTROYED = 3,
71+
IDX_ENDPOINT_STATE_SESSION_COUNT = 4,
72+
IDX_ENDPOINT_STATE_BUSY = 8,
7173
};
7274

7375
enum DTLSSessionStateIndex {

src/dtls/dtls_endpoint.cc

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include <openssl/err.h>
2020
#include <openssl/ssl.h>
2121

22+
#include <cstddef>
2223
#include <cstring>
2324

2425
namespace node {
@@ -45,6 +46,22 @@ struct SendReq {
4546
};
4647
} // namespace
4748

49+
// The endpoint state "indices" are byte offsets into DTLSEndpointStateData,
50+
// accessed from JS via a DataView. Pin them to the actual struct layout so a
51+
// mismatch (as once existed for `busy`, which follows a uint32) can't recur.
52+
static_assert(IDX_ENDPOINT_STATE_BOUND ==
53+
offsetof(DTLSEndpointStateData, bound));
54+
static_assert(IDX_ENDPOINT_STATE_LISTENING ==
55+
offsetof(DTLSEndpointStateData, listening));
56+
static_assert(IDX_ENDPOINT_STATE_CLOSING ==
57+
offsetof(DTLSEndpointStateData, closing));
58+
static_assert(IDX_ENDPOINT_STATE_DESTROYED ==
59+
offsetof(DTLSEndpointStateData, destroyed));
60+
static_assert(IDX_ENDPOINT_STATE_SESSION_COUNT ==
61+
offsetof(DTLSEndpointStateData, session_count));
62+
static_assert(IDX_ENDPOINT_STATE_BUSY ==
63+
offsetof(DTLSEndpointStateData, busy));
64+
4865
DTLSEndpoint::DTLSEndpoint(Environment* env, Local<Object> wrap)
4966
: HandleWrap(env,
5067
wrap,
@@ -268,6 +285,11 @@ void DTLSEndpoint::CloseGracefully() {
268285

269286
server_context_.reset();
270287

288+
// Keep ourselves alive until OnClose() runs, so a garbage collection while
289+
// uv_close() is in flight cannot collect the wrapper before the close is
290+
// reported. Released in OnClose().
291+
self_ref_ = BaseObjectPtr<DTLSEndpoint>(this);
292+
271293
// HandleWrap::Close() calls uv_close and manages the lifecycle.
272294
HandleWrap::Close();
273295
}
@@ -293,6 +315,9 @@ void DTLSEndpoint::Destroy() {
293315
state_->listening = 0;
294316
}
295317

318+
// Keep ourselves alive until OnClose() (see CloseGracefully()).
319+
self_ref_ = BaseObjectPtr<DTLSEndpoint>(this);
320+
296321
HandleWrap::Close();
297322
}
298323

@@ -402,6 +427,17 @@ void DTLSEndpoint::OnClose() {
402427
state_->destroyed = 1;
403428
DTLS_STAT_RECORD_TIMESTAMP(DTLSEndpointStats, destroyed_at);
404429

430+
// Release the strong self-reference taken when the close was initiated.
431+
// HandleWrap::OnClose still holds its own reference for the duration of this
432+
// call, so this does not free us here.
433+
self_ref_.reset();
434+
435+
// A close initiated outside CloseGracefully()/Destroy() (e.g. an endpoint
436+
// abandoned mid-construction and closed at environment teardown) takes no
437+
// self-reference, so its wrapper may already be collected. There is no JS
438+
// side to notify in that case; skip it rather than touch a freed wrapper.
439+
if (persistent().IsEmpty()) return;
440+
405441
Local<Function> cb = GetCallback(DTLS_CB_ENDPOINT_CLOSE);
406442
if (!cb.IsEmpty()) {
407443
Local<Value> argv[] = {};

src/dtls/dtls_endpoint.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ class DTLSEndpoint final : public HandleWrap {
156156
AliasedStruct<DTLSEndpointStateData> state_;
157157
AliasedStruct<DTLSEndpointStats> stats_;
158158

159+
// Strong self-reference held while a graceful close/destroy is in flight, so
160+
// the wrapper is not garbage-collected before OnClose() runs and reports the
161+
// close. Cleared in OnClose().
162+
BaseObjectPtr<DTLSEndpoint> self_ref_;
163+
159164
bool listening_ = false;
160165
uint32_t mtu_ = 1200; // Conservative default MTU for data payload
161166
};

src/dtls/dtls_session.cc

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,11 +391,12 @@ void DTLSSession::ClearOut() {
391391
// Send our close_notify back.
392392
SSL_shutdown(ssl_.get());
393393
EncOut();
394+
// Detach from the endpoint's session table before notifying JS so an
395+
// observer of the close sees a consistent session count. Cycle() holds
396+
// a strong reference for the duration of the pump.
397+
if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_);
394398
Local<Value> argv[] = {};
395399
EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv);
396-
// Drop ourselves from the endpoint's session table now that the peer
397-
// has closed. Safe here: Cycle() holds a strong reference for the
398-
// duration of the pump.
399400
Destroy();
400401
}
401402
break;
@@ -490,16 +491,20 @@ void DTLSSession::Close() {
490491

491492
state_->open = 0;
492493

494+
// Detach from the endpoint's session table before notifying JS, so an
495+
// observer of the close (e.g. one awaiting `closed`) sees a consistent
496+
// session count. We stay alive via strong_ref, and endpoint_ remains valid
497+
// for the callback below; the Destroy() that follows clears it.
498+
if (auto ep = endpoint_.get()) ep->RemoveSession(remote_address_);
499+
493500
// Notify JS.
494501
HandleScope handle_scope(env()->isolate());
495502
Context::Scope context_scope(env()->context());
496503
Local<Value> argv[] = {};
497504
EmitCallback(DTLS_CB_SESSION_CLOSE, 0, argv);
498505

499-
// Remove ourselves from the endpoint's session table and release resources.
500-
// Without this, a gracefully-closed session would linger in the table for
501-
// the life of the endpoint, leaking memory and blocking reuse of its
502-
// address.
506+
// Release the remaining resources. RemoveSession above already detached us,
507+
// so the one inside Destroy() is a no-op.
503508
Destroy();
504509
}
505510

test/parallel/test-dtls-alpn.mjs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ const ca = readKey('ca1-cert.pem');
2626
const serverAlpnChecked = Promise.withResolvers();
2727

2828
const endpoint = listen(mustCall(async (session) => {
29-
session.onmessage = () => {};
3029
await session.opened;
3130
// Server should see the negotiated ALPN protocol.
3231
strictEqual(session.alpnProtocol, 'coap');
@@ -54,3 +53,33 @@ await serverAlpnChecked.promise;
5453

5554
await session.close();
5655
await endpoint.close();
56+
57+
// ALPN with no protocol in common: the handshake still completes and neither
58+
// peer reports a negotiated protocol.
59+
{
60+
const gotServerSession = Promise.withResolvers();
61+
62+
const server = listen(mustCall((s) => gotServerSession.resolve(s)), {
63+
cert: serverCert.toString(),
64+
key: serverKey.toString(),
65+
port: 0,
66+
host: '127.0.0.1',
67+
alpn: ['bar'],
68+
});
69+
70+
const client = connect('127.0.0.1', server.address.port, {
71+
ca: [ca.toString()],
72+
rejectUnauthorized: false,
73+
alpn: ['foo'],
74+
});
75+
76+
await client.opened;
77+
const serverSession = await gotServerSession.promise;
78+
await serverSession.opened;
79+
80+
strictEqual(client.alpnProtocol, undefined);
81+
strictEqual(serverSession.alpnProtocol, undefined);
82+
83+
await client.close();
84+
await server.close();
85+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Flags: --experimental-dtls --no-warnings
2+
3+
// Test: cipher and ECDH-curve selection and validation.
4+
5+
import { hasCrypto, skip, mustCall, mustNotCall } from '../common/index.mjs';
6+
import assert from 'node:assert';
7+
import * as fixtures from '../common/fixtures.mjs';
8+
9+
const { strictEqual, throws } = assert;
10+
const { readKey } = fixtures;
11+
12+
if (!hasCrypto) {
13+
skip('missing crypto');
14+
}
15+
16+
if (!process.features.dtls) {
17+
skip('DTLS is not enabled');
18+
}
19+
20+
const { listen, connect } = await import('node:dtls');
21+
22+
const cert = readKey('agent1-cert.pem').toString();
23+
const key = readKey('agent1-key.pem').toString();
24+
const ca = readKey('ca1-cert.pem').toString();
25+
26+
const CIPHER = 'ECDHE-RSA-AES128-GCM-SHA256';
27+
28+
// Case 1: a specific cipher is negotiated and reported on both peers.
29+
{
30+
const gotServerSession = Promise.withResolvers();
31+
32+
const server = listen(mustCall((session) => {
33+
gotServerSession.resolve(session);
34+
}), { cert, key, port: 0, host: '127.0.0.1', ciphers: CIPHER });
35+
36+
const client = connect('127.0.0.1', server.address.port, {
37+
ca: [ca],
38+
rejectUnauthorized: false,
39+
ciphers: CIPHER,
40+
});
41+
42+
await client.opened;
43+
const serverSession = await gotServerSession.promise;
44+
await serverSession.opened;
45+
46+
strictEqual(client.cipher.name, CIPHER);
47+
strictEqual(serverSession.cipher.name, CIPHER);
48+
49+
await client.close();
50+
await server.close();
51+
}
52+
53+
// Case 2: an invalid cipher list is rejected.
54+
throws(() => listen(mustNotCall(), {
55+
cert, key, port: 0, host: '127.0.0.1', ciphers: 'THIS-IS-NOT-A-CIPHER',
56+
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
57+
58+
// Case 3: a valid ECDH curve completes a handshake.
59+
{
60+
const server = listen(mustCall(), {
61+
cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'P-256',
62+
});
63+
64+
const client = connect('127.0.0.1', server.address.port, {
65+
ca: [ca],
66+
rejectUnauthorized: false,
67+
ecdhCurve: 'P-256',
68+
});
69+
70+
await client.opened;
71+
72+
await client.close();
73+
await server.close();
74+
}
75+
76+
// Case 4: an invalid ECDH curve is rejected.
77+
throws(() => listen(mustNotCall(), {
78+
cert, key, port: 0, host: '127.0.0.1', ecdhCurve: 'not-a-curve',
79+
}), { code: 'ERR_CRYPTO_OPERATION_FAILED' });
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Flags: --experimental-dtls --no-warnings
2+
3+
// Test: DTLS mutual authentication. A server with requestCert verifies the
4+
// client's certificate; a client that presents no certificate is rejected.
5+
6+
import { hasCrypto, skip, mustCall } from '../common/index.mjs';
7+
import assert from 'node:assert';
8+
import * as fixtures from '../common/fixtures.mjs';
9+
10+
const { ok, rejects } = assert;
11+
const { readKey } = fixtures;
12+
13+
if (!hasCrypto) {
14+
skip('missing crypto');
15+
}
16+
17+
if (!process.features.dtls) {
18+
skip('DTLS is not enabled');
19+
}
20+
21+
const { listen, connect } = await import('node:dtls');
22+
23+
const cert = readKey('agent1-cert.pem').toString();
24+
const key = readKey('agent1-key.pem').toString();
25+
const ca = readKey('ca1-cert.pem').toString();
26+
27+
// Case 1: the client presents a certificate the server can verify.
28+
{
29+
const gotServerSession = Promise.withResolvers();
30+
31+
const server = listen(mustCall((session) => {
32+
gotServerSession.resolve(session);
33+
}), {
34+
cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1',
35+
});
36+
37+
const client = connect('127.0.0.1', server.address.port, {
38+
cert, key, ca: [ca], rejectUnauthorized: false,
39+
});
40+
41+
await client.opened;
42+
const serverSession = await gotServerSession.promise;
43+
await serverSession.opened;
44+
45+
// The server received and verified the client's certificate.
46+
const clientCert = serverSession.peerCertificate;
47+
ok(clientCert);
48+
ok(clientCert.includes('BEGIN CERTIFICATE'));
49+
50+
await client.close();
51+
await server.close();
52+
}
53+
54+
// Case 2: the client presents no certificate; the server requires one and
55+
// rejects the handshake.
56+
{
57+
const server = listen(mustCall(), {
58+
cert, key, ca: [ca], requestCert: true, port: 0, host: '127.0.0.1',
59+
});
60+
61+
const client = connect('127.0.0.1', server.address.port, {
62+
ca: [ca], rejectUnauthorized: false,
63+
});
64+
65+
// The exact alert text varies, so assert only that the handshake is rejected.
66+
await rejects(client.opened, {
67+
message: /handshake failure/
68+
});
69+
70+
// The failed client tears down its internally-owned endpoint.
71+
await client.endpoint.closed;
72+
await server.close();
73+
}

test/parallel/test-dtls-connect-error-cleanup.mjs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,9 @@ const cert = readKey('agent1-cert.pem').toString();
2525
const key = readKey('agent1-key.pem').toString();
2626
const ca = readKey('ca1-cert.pem').toString();
2727

28-
const server = listen(mustCall((session) => {
29-
// The client rejects the certificate mid-handshake, so this server session
30-
// never opens; ignore its unsettled promise.
31-
session.opened.catch(() => {});
32-
}), { cert, key, port: 0, host: '127.0.0.1' });
28+
// The client rejects the certificate mid-handshake, so this server session
29+
// never opens; its opened rejection is handled internally by the library.
30+
const server = listen(mustCall(), { cert, key, port: 0, host: '127.0.0.1' });
3331

3432
// A servername that does not match the certificate, under rejectUnauthorized,
3533
// makes the client's handshake fail during verification.

test/parallel/test-dtls-destroy-in-callback.mjs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,6 @@ const ca = readKey('ca1-cert.pem').toString();
7272
rejectUnauthorized: false,
7373
});
7474

75-
// The server tears its session down the moment its handshake completes. The
76-
// client's own handshake normally resolves, but guard against it rejecting
77-
// (racing the teardown) so it can't surface as an unhandled rejection.
78-
client.opened.catch(() => {});
79-
8075
await destroyed.promise;
8176

8277
await client.close();

0 commit comments

Comments
 (0)