Skip to content

Commit 36be49d

Browse files
committed
src: fixup dtls resumption and first-flight error
Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opecode
1 parent 4c278d3 commit 36be49d

8 files changed

Lines changed: 351 additions & 31 deletions

File tree

doc/api/dtls.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,37 @@ skip verification while appearing to succeed. For the same reason a `session`
580580
that did not come from [`session.session`][] is rejected outright: nothing
581581
records which identity it belongs to, so it cannot be checked.
582582

583+
### Resuming under `rejectUnauthorized`
584+
585+
A session carries the verification result it was established with, so a session
586+
established with `rejectUnauthorized: false` cannot be resumed by a connection
587+
that asked for a verified peer. The handshake fails:
588+
589+
```mjs
590+
import { connect } from 'node:dtls';
591+
592+
// Connected without verifying anything.
593+
const first = connect('192.0.2.1', 5684, { rejectUnauthorized: false });
594+
await first.opened;
595+
console.log(first.authorized); // False.
596+
const ticket = first.session;
597+
await first.close();
598+
599+
const second = connect('192.0.2.1', 5684, {
600+
rejectUnauthorized: true,
601+
session: ticket,
602+
});
603+
await second.opened; // Rejects: verification failed.
604+
```
605+
606+
The host is the same in both, so binding the session to its authenticated
607+
identity does not cover this on its own; what differs is whether the caller
608+
asked for the peer to be verified. Because a resumed handshake runs no
609+
verification of its own, the recorded result is re-checked once it completes,
610+
and a session whose peer never verified is refused wherever verification is
611+
required. [`session.authorized`][] and [`session.authorizationError`][] report
612+
the recorded result on a resumed session either way.
613+
583614
### Ticket keys
584615

585616
The key that encrypts session tickets is generated at random for each context,

lib/internal/dtls/dtls.js

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,7 @@ class DTLSEndpoint {
628628

629629
// --- Client mode ---
630630

631-
[kDoConnect](context, host, port, servername, session) {
631+
[kDoConnect](context, host, port, servername, session, ownsEndpoint = false) {
632632
// Resolve SNI and the expected peer identity here so that every caller of
633633
// the endpoint API -- not only the top-level dtls.connect() -- gets safe
634634
// defaults. The identity is always bound to the requested servername (or,
@@ -660,11 +660,21 @@ class DTLSEndpoint {
660660
const resume = session !== undefined ?
661661
unwrapSession(session, verifyHost) : undefined;
662662

663+
// The binding's connect() builds the session but does not start its
664+
// handshake, because the first flight can fail synchronously and until
665+
// the wrapper below exists there is nowhere for that failure to go: the
666+
// callback dispatch reaches the session through its handle. Whether the
667+
// session owns the endpoint has to be settled before the handshake too,
668+
// since the error path closes the endpoint only for a session that owns
669+
// it -- set afterwards, a first-flight failure left the endpoint open and
670+
// the event loop with nothing to drain it.
663671
const sessionHandle = this.#handle.connect(
664672
context, host, port, sni, verifyHost, verifyIsIp, resume);
665673
const newSession = new DTLSSession(
666674
kPrivateConstructor, sessionHandle, this, verifyHost);
667675
this.#sessions.add(newSession);
676+
newSession[kOwnsEndpoint] = ownsEndpoint;
677+
sessionHandle.start();
668678
return newSession;
669679
}
670680

@@ -1425,12 +1435,12 @@ function connect(host, port, options = kEmptyObject) {
14251435
// DTLSEndpoint.connect(), which defaults both to the host argument.
14261436
// The identity is enforced whenever the context verifies, i.e.
14271437
// unless rejectUnauthorized is false.
1428-
const session = endpoint[kDoConnect](
1429-
context, host, port, options.servername, options.session);
1430-
// Mark that this session owns the endpoint so it gets closed
1431-
// automatically when the session closes, allowing process exit.
1432-
session[kOwnsEndpoint] = true;
1433-
return session;
1438+
// The session owns the endpoint, so the endpoint gets closed automatically
1439+
// when the session closes, allowing process exit. Passed in rather than set
1440+
// on the way back out because the handshake starts inside, and a handshake
1441+
// that fails immediately has to find the flag already set.
1442+
return endpoint[kDoConnect](
1443+
context, host, port, options.servername, options.session, true);
14341444
}
14351445

14361446
module.exports = {

src/dtls/dtls_endpoint.cc

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,14 @@ BaseObjectPtr<DTLSSession> DTLSEndpoint::Connect(
254254
}
255255
}
256256

257-
// Initiate the DTLS handshake by running Cycle.
258-
session->Cycle();
259-
257+
// The handshake is not started here. Cycle() emits the ClientHello and can
258+
// report a failure doing it -- a send error, or an exception from a
259+
// callback -- and there is nothing to report it to yet: the callback
260+
// dispatch finds the JavaScript session through the handle, and the wrapper
261+
// that attaches itself to the handle is built from the value this returns.
262+
// An error from the first flight was raised against a wrapper that did not
263+
// exist, dropped, and then left to look like a handshake that timed out.
264+
// The wrapper calls start() once it is in place.
260265
return session;
261266
}
262267

@@ -724,7 +729,7 @@ void DTLSEndpoint::AcceptConnection(const uint8_t* data,
724729
}
725730

726731
// Drive the handshake forward — produces ServerHello etc.
727-
session->Cycle();
732+
session->Start();
728733
}
729734

730735
// --- JS binding methods ---

src/dtls/dtls_session.cc

Lines changed: 109 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ Local<FunctionTemplate> DTLSSession::GetConstructorTemplate(Environment* env) {
193193
SetProtoMethod(isolate, tmpl, "getSession", GetSession);
194194
SetProtoMethod(isolate, tmpl, "wasReused", WasReused);
195195
SetProtoMethod(isolate, tmpl, "getVerifyError", GetVerifyError);
196+
SetProtoMethod(isolate, tmpl, "start", DoStart);
196197

197198
env->set_dtls_session_constructor_template(tmpl);
198199
}
@@ -229,6 +230,7 @@ void DTLSSession::RegisterExternalReferences(
229230
registry->Register(GetVerifyError);
230231
registry->Register(GetSession);
231232
registry->Register(WasReused);
233+
registry->Register(DoStart);
232234
}
233235

234236
BaseObjectPtr<DTLSSession> DTLSSession::Create(
@@ -280,13 +282,17 @@ BaseObjectPtr<DTLSSession> DTLSSession::Create(
280282
SSL_set_connect_state(ssl.get());
281283

282284
// Offer a previous session for resumption. Like SNI this has to happen
283-
// before Cycle() emits the ClientHello, since the session id and ticket
285+
// before Start() emits the ClientHello, since the session id and ticket
284286
// ride in it.
285287
//
286288
// A session that OpenSSL rejects is not an error: it falls back to a full
287289
// handshake, which is what an expired or unknown ticket should do. Only a
288290
// blob that will not parse is worth reporting, and that is the caller
289291
// handing over something that is not a session at all.
292+
//
293+
// This also copies the recorded verification result onto the connection,
294+
// where a resumed handshake leaves it: PeerVerificationPassed() is what
295+
// stops it standing in for verification that never ran.
290296
if (resume.len > 0) {
291297
const unsigned char* p = resume.data;
292298
ncrypto::SSLSessionPointer sess(d2i_SSL_SESSION(nullptr, &p, resume.len));
@@ -300,10 +306,10 @@ BaseObjectPtr<DTLSSession> DTLSSession::Create(
300306
}
301307

302308
// Configure SNI and peer identity verification BEFORE the handshake
303-
// starts. The caller (DTLSEndpoint::Connect) runs Cycle() immediately
304-
// after Create() returns, which emits the ClientHello, so anything that
305-
// must appear in that flight (SNI) has to be set here rather than via a
306-
// post-construction setter.
309+
// starts. Start() emits the ClientHello and is called as soon as the
310+
// JavaScript wrapper is built, so anything that must appear in that
311+
// flight (SNI) has to be set here rather than via a post-construction
312+
// setter.
307313
if (servername != nullptr && servername[0] != '\0') {
308314
if (!SSL_set_tlsext_host_name(ssl.get(), servername)) {
309315
THROW_ERR_CRYPTO_OPERATION_FAILED(env,
@@ -412,6 +418,16 @@ void DTLSSession::Receive(const uint8_t* data, size_t len) {
412418
Cycle();
413419
}
414420

421+
void DTLSSession::Start() {
422+
// A datagram can arrive for a server session between the new-session emit
423+
// and this call, and Receive() runs the pump. Starting again after that is
424+
// harmless but pointless, and a start() reaching the binding twice should
425+
// not re-enter the handshake.
426+
if (started_ || destroyed_) return;
427+
started_ = true;
428+
Cycle();
429+
}
430+
415431
void DTLSSession::Cycle() {
416432
if (destroyed_) return;
417433

@@ -484,6 +500,19 @@ void DTLSSession::CycleInner() {
484500

485501
// Check if handshake just completed.
486502
if (SSL_is_init_finished(ssl_.get()) && !handshake_complete_) {
503+
// A resumed handshake carries no Certificate message, so OpenSSL runs
504+
// no verification and the verify mode has nothing to abort on. What it
505+
// does instead is restore the result recorded when the session was
506+
// first established -- SSL_set_session() copies verify_result straight
507+
// onto the connection, and both it and the peer certificate round-trip
508+
// through the DER blob. A session authenticated under
509+
// rejectUnauthorized: false therefore arrives here carrying its
510+
// original failure alongside a handshake that succeeded, and nothing
511+
// downstream re-checks it: a caller that asked for a verified peer
512+
// would be handed one that never verified. node:tls checks
513+
// verifyError() after a resumption for this reason.
514+
if (!PeerVerificationPassed()) return;
515+
487516
handshake_complete_ = true;
488517
state_->handshaking = 0;
489518
state_->open = 1;
@@ -625,6 +654,74 @@ bool DTLSSession::HandshakeDeadlineExpired() const {
625654
return uv_hrtime() / 1000000 >= handshake_deadline_;
626655
}
627656

657+
long DTLSSession::PeerVerifyResult() const { // NOLINT(runtime/int)
658+
// SSL_get_verify_result() reports X509_V_OK when the peer sent no
659+
// certificate at all, because there was nothing to find fault with. Route
660+
// through ncrypto, which reports std::nullopt for that case (allowing for
661+
// PSK, where the identity was authenticated by the key instead) so it can
662+
// be distinguished from a certificate that actually verified.
663+
return ssl_.verifyPeerCertificate().value_or(
664+
X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT);
665+
}
666+
667+
bool DTLSSession::PeerVerificationPassed() {
668+
// Consulted on every completed handshake rather than only a resumed one. It
669+
// costs nothing where OpenSSL already enforced -- there the handshake
670+
// failed and never reached here -- and it also covers a resumption the
671+
// server refused, where the result restored by SSL_set_session() stays on
672+
// the connection unless the full handshake that replaced it verified
673+
// something of its own.
674+
//
675+
// Whether a failure is fatal is decided by the verify mode OpenSSL itself
676+
// was given, read back off the SSL rather than tracked beside it, so the
677+
// two cannot disagree -- including after an SNI callback swapped the
678+
// SSL_CTX out from under this connection.
679+
//
680+
// A client aborts on a bad chain whenever the mode is not SSL_VERIFY_NONE.
681+
// That is the test OpenSSL itself applies when processing the server's
682+
// certificate -- it validates "if any flag is set", not only for
683+
// SSL_VERIFY_PEER -- and it is the whole of what rejectUnauthorized selects
684+
// between.
685+
//
686+
// A server sets SSL_VERIFY_PEER for requestCert and adds
687+
// SSL_VERIFY_FAIL_IF_NO_PEER_CERT only when rejectUnauthorized is set too:
688+
// requestCert on its own installs a permissive verify callback precisely so
689+
// the application can judge the certificate itself, so it is the added flag
690+
// and not SSL_VERIFY_PEER that marks a failure fatal there.
691+
int mode = SSL_get_verify_mode(ssl_.get());
692+
bool fatal = is_server_ ? (mode & SSL_VERIFY_FAIL_IF_NO_PEER_CERT) != 0
693+
: mode != SSL_VERIFY_NONE;
694+
if (!fatal) return true;
695+
696+
// Exactly the result session.authorized reports, so what is refused here
697+
// and what reads as unauthorized there cannot drift apart. It is also the
698+
// predicate node:tls applies -- TLSWrap::VerifyError() is the same
699+
// verifyPeerCertificate().value_or() -- so a peer authenticated by a PSK
700+
// rather than a certificate passes on the strength of the cipher's auth
701+
// method, as it does there.
702+
//
703+
// One difference from node:tls, which additionally requires a peer
704+
// certificate because ncrypto reports X509_V_OK for a TLS 1.3 resumption
705+
// without one: a DTLS 1.2 resumption restores the peer certificate along
706+
// with the result, so there is a real result to read here. Enabling DTLS
707+
// 1.3, once the bundled OpenSSL offers it, means revisiting this.
708+
long verify_error = PeerVerifyResult(); // NOLINT(runtime/int)
709+
if (verify_error == X509_V_OK) return true;
710+
711+
// enc_out_ is deliberately not flushed, unlike the alert path in
712+
// CycleInner(): there is no alert queued to get out, and what is queued is
713+
// this side's own Finished. The peer is left to time out, as it is for
714+
// every other handshake failure that OpenSSL did not itself alert on.
715+
std::string message = "Peer certificate verification failed: ";
716+
message += ncrypto::X509Pointer::ErrorCode(verify_error);
717+
Local<Value> str;
718+
if (ToV8Value(env()->context(), message).ToLocal(&str)) {
719+
Local<Value> argv[] = {str};
720+
EmitCallback(DTLS_CB_SESSION_ERROR, 1, argv);
721+
}
722+
return false;
723+
}
724+
628725
void DTLSSession::EmitHandshakeTimeout() {
629726
HandleScope hs(env()->isolate());
630727
Context::Scope cs(env()->context());
@@ -861,6 +958,12 @@ MaybeLocal<Value> DTLSSession::EmitCallback(int cb_index,
861958

862959
// --- JS binding methods ---
863960

961+
void DTLSSession::DoStart(const FunctionCallbackInfo<Value>& args) {
962+
DTLSSession* session;
963+
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
964+
session->Start();
965+
}
966+
864967
void DTLSSession::DoSend(const FunctionCallbackInfo<Value>& args) {
865968
DTLSSession* session;
866969
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
@@ -1114,14 +1217,7 @@ void DTLSSession::GetVerifyError(const FunctionCallbackInfo<Value>& args) {
11141217
return;
11151218
}
11161219

1117-
// SSL_get_verify_result() reports X509_V_OK when the peer sent no
1118-
// certificate at all, because there was nothing to find fault with. Route
1119-
// through ncrypto, which reports std::nullopt for that case (allowing for
1120-
// PSK and resumption, where the absence is legitimate) so it can be
1121-
// distinguished from a certificate that actually verified.
1122-
long verify_error = // NOLINT(runtime/int)
1123-
session->ssl_.verifyPeerCertificate().value_or(
1124-
X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT);
1220+
long verify_error = session->PeerVerifyResult(); // NOLINT(runtime/int)
11251221

11261222
// undefined means authorized; anything else is the short error code, e.g.
11271223
// "UNABLE_TO_GET_ISSUER_CERT" or "CERT_HAS_EXPIRED".

src/dtls/dtls_session.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ class DTLSSession final : public AsyncWrap {
144144
static void GetSession(const v8::FunctionCallbackInfo<v8::Value>& args);
145145
static void WasReused(const v8::FunctionCallbackInfo<v8::Value>& args);
146146
static void GetVerifyError(const v8::FunctionCallbackInfo<v8::Value>& args);
147+
static void DoStart(const v8::FunctionCallbackInfo<v8::Value>& args);
147148

148149
public:
149150
// The core state machine pump. Processes pending OpenSSL I/O:
@@ -153,6 +154,14 @@ class DTLSSession final : public AsyncWrap {
153154
// 4. UpdateTimer() - schedule retransmit timer if needed
154155
void Cycle();
155156

157+
// Run the first flight. Separate from creation because nothing a session
158+
// reports has anywhere to go until its JavaScript wrapper is in place: the
159+
// callback dispatch reaches the wrapper through the handle, and a session
160+
// still being constructed has no wrapper attached. The server emits its new
161+
// session and then starts it; the client's wrapper calls this once it is
162+
// built. Repeat calls do nothing.
163+
void Start();
164+
156165
private:
157166
// Read decrypted application data from OpenSSL and emit to JS.
158167
void ClearOut();
@@ -187,6 +196,17 @@ class DTLSSession final : public AsyncWrap {
187196
bool HandshakeDeadlineExpired() const;
188197
void EmitHandshakeTimeout();
189198

199+
// The peer's chain verification result, with "no certificate, and none was
200+
// needed" (PSK) told apart from "a certificate that verified". Shared by
201+
// session.authorizationError and the gate below so the two agree.
202+
long PeerVerifyResult() const; // NOLINT(runtime/int)
203+
204+
// Refuse a completed handshake whose peer was never verified, emitting the
205+
// error. True to carry on. Exists for resumption, which skips verification
206+
// and restores the result from the session being resumed, so the verify
207+
// mode has nothing to act on.
208+
bool PeerVerificationPassed();
209+
190210
// Emit a callback to JS via the endpoint's callback dispatch.
191211
v8::MaybeLocal<v8::Value> EmitCallback(int cb_index,
192212
int argc,
@@ -224,6 +244,7 @@ class DTLSSession final : public AsyncWrap {
224244

225245
SocketAddress remote_address_;
226246
bool is_server_;
247+
bool started_ = false;
227248
bool handshake_complete_ = false;
228249
bool closed_ = false;
229250
bool destroyed_ = false;

test/cctest/test_environment.cc

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,12 +361,15 @@ TEST_F(EnvironmentTest, WorkerInEnvironmentWithoutSnapshot) {
361361
const v8::HandleScope handle_scope(isolate_);
362362
const Argv argv;
363363
Env env{handle_scope, argv};
364-
CHECK_NULL(isolate_data_->snapshot_data());
365-
node::LoadEnvironment(*env,
366-
"const { Worker } = require('worker_threads');"
367-
"new Worker('process.exit(0)', { eval: true });")
368-
.ToLocalChecked();
369-
EXPECT_EQ(node::SpinEventLoop(*env).FromJust(), 0);
364+
// When using certain experimental compile options, snapshot data may
365+
// not exist. Skip in that case.
366+
if (isolate_data_->snapshot_data()) {
367+
node::LoadEnvironment(*env,
368+
"const { Worker } = require('worker_threads');"
369+
"new Worker('process.exit(0)', { eval: true });")
370+
.ToLocalChecked();
371+
EXPECT_EQ(node::SpinEventLoop(*env).FromJust(), 0);
372+
}
370373
}
371374

372375
TEST_F(EnvironmentTest, StopFromExitHandlerDoesNotLeakIntoNextEnvironment) {

0 commit comments

Comments
 (0)