From 63deaf98a196c8e2d1c41f358a0e2e6cfb67144c Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Mon, 24 Aug 2026 16:25:34 +0100 Subject: [PATCH 1/5] quic: reuse TLS pause machinery to drop event deferral & improve 0RTT With this change, the TLS pause mechanisms used in node:tls are now made generic, and used in QUIC to pause the handshake entirely for the session event, thereby stopping almost all events being emitted (everything except qlog) so that all the previously existing deferral machinery is unnecessary. This is useful immediately to clean up & simplify things, but also medium term because this makes the 0RTT flow significantly clearer, which will help with the upcoming dynamic attach for QUIC (which requires deferring ticket processing & 0RTT delivery until after user code). As part of this, this changes the internal flow of how ALPN & SNI are resolved. Instead of calculating them synchronously within the OpenSSL callbacks, we precalculate them at the pause, and then the OpenSSL callbacks just return the precalculated values. This is intended to only create visible changes to node:quic. Though it touches node:tls, it preserves all existing behaviour (including callback order and other details). Signed-off-by: Tim Perry --- node.gyp | 1 + src/crypto/crypto_client_hello.h | 248 ++++++++++++++++++ src/crypto/crypto_context.cc | 8 - src/crypto/crypto_context.h | 6 - src/crypto/crypto_tls.cc | 71 ++--- src/dtls/dtls_context.cc | 12 +- src/quic/README.md | 54 +++- src/quic/application.cc | 48 +--- src/quic/application.h | 45 +--- src/quic/endpoint.cc | 6 +- src/quic/http3.cc | 39 --- src/quic/http3.h | 6 - src/quic/session.cc | 187 +++++-------- src/quic/session.h | 56 ++-- src/quic/tlscontext.cc | 168 ++++++++---- src/quic/tlscontext.h | 64 ++++- .../test-quic-early-selection-order.mjs | 118 +++++++++ 17 files changed, 726 insertions(+), 411 deletions(-) create mode 100644 src/crypto/crypto_client_hello.h create mode 100644 test/parallel/test-quic-early-selection-order.mjs diff --git a/node.gyp b/node.gyp index b99755575020..699a3dd1f2ec 100644 --- a/node.gyp +++ b/node.gyp @@ -420,6 +420,7 @@ 'src/crypto/crypto_spkac.h', 'src/crypto/crypto_util.h', 'src/crypto/crypto_cipher.h', + 'src/crypto/crypto_client_hello.h', 'src/crypto/crypto_common.h', 'src/crypto/crypto_dsa.h', 'src/crypto/crypto_hash.h', diff --git a/src/crypto/crypto_client_hello.h b/src/crypto/crypto_client_hello.h new file mode 100644 index 000000000000..d8e329704e19 --- /dev/null +++ b/src/crypto/crypto_client_hello.h @@ -0,0 +1,248 @@ +#ifndef SRC_CRYPTO_CRYPTO_CLIENT_HELLO_H_ +#define SRC_CRYPTO_CRYPTO_CLIENT_HELLO_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include +#include +#include +#include +#include +#include + +namespace node::crypto { + +// Support for the early ClientHello callback, which the TLS library invokes +// once a complete ClientHello has been received but before version +// negotiation and before any session or ticket resumption. This lets us +// inspect what the client asks for and prepare our configuration (with JS +// involvement potentially) before we actually process the handshake. +// +// OpenSSL and BoringSSL disagree on various details, which are centralized +// here so callers can ignore the differences. + +// What the callback wants the TLS library to do next. +enum class ClientHelloResult { + // Carry on with the handshake. + kContinue, + // Unwind out of the TLS library, leaving the handshake suspended. The + // callback runs again from the start when the handshake is resumed. + kRetry, + // Abort the handshake. + kFail, +}; + +// The ClientHello being processed, and the inputs from it that are usable +// this early. Instances are only valid for the duration of the callback. +class ClientHelloContext final { + public: +#ifdef OPENSSL_IS_BORINGSSL + using Handle = const SSL_CLIENT_HELLO*; + using Result = ssl_select_cert_result_t; +#else + using Handle = SSL*; + using Result = int; +#endif + + explicit ClientHelloContext(Handle handle, int* alert = nullptr) + : handle_(handle), alert_(alert) {} + + inline SSL* ssl() const { +#ifdef OPENSSL_IS_BORINGSSL + return handle_->ssl; +#else + return handle_; +#endif + } + + // The legacy_session_id. Note that a TLS 1.3 ClientHello carries a fake + // one for middlebox compatibility, so a non-empty value here does not + // mean the client is attempting session-id resumption. + inline std::span session_id() const { +#ifdef OPENSSL_IS_BORINGSSL + return {handle_->session_id, handle_->session_id_len}; +#else + const uint8_t* id = nullptr; + size_t len = SSL_client_hello_get0_session_id(handle_, &id); + if (id == nullptr) return {}; + return {id, len}; +#endif + } + + inline bool has_session_ticket() const { + auto ext = extension(TLSEXT_TYPE_session_ticket); + return ext.has_value() && !ext->empty(); + } + + // The host_name from the server_name extension, or an empty view when the + // client did not send a usable one. SSL_get_servername() cannot be used + // this early because the servername callback has not run yet, so this + // applies the same constraints that callback's parser would: exactly one + // entry, of type host_name, of a sane length and free of NULs. Anything + // else reads as absent here and is rejected outright by the TLS stack a + // moment later, once it parses extensions for itself. + inline std::string_view servername() const { + auto ext = extension(TLSEXT_TYPE_server_name); + if (!ext.has_value()) return {}; + // RFC 6066: a 16-bit list length, then one entry of an 8-bit name type, + // a 16-bit name length and that many name bytes. + auto list = ReadVector16(*ext); + if (!list.has_value() || list->size() < 3) return {}; + if ((*list)[0] != TLSEXT_NAMETYPE_host_name) return {}; + auto name = ReadVector16(list->subspan(1)); + if (!name.has_value()) return {}; + // The entry must be the only one, and must be a plausible host name. + if (name->size() + 3 != list->size()) return {}; + if (name->empty() || name->size() > TLSEXT_MAXLEN_host_name) return {}; + if (std::find(name->begin(), name->end(), 0) != name->end()) return {}; + return {reinterpret_cast(name->data()), name->size()}; + } + + // The protocols the client offered, in ALPN wire format: each entry is a + // length byte followed by that many name bytes. Empty when the client did + // not offer ALPN. This is the format SelectNextProtocol() expects. + inline std::span alpn_protocols() const { + auto ext = extension(TLSEXT_TYPE_application_layer_protocol_negotiation); + if (!ext.has_value()) return {}; + // RFC 7301: a 16-bit list length, then the protocol names. + return ReadVector16(*ext).value_or(std::span()); + } + + // The raw body of an extension the client sent, or nothing when the + // client did not send that extension. + inline std::optional> extension( + unsigned int type) const { + const uint8_t* data = nullptr; + size_t len = 0; +#ifdef OPENSSL_IS_BORINGSSL + if (!SSL_early_callback_ctx_extension_get(handle_, type, &data, &len)) { + return std::nullopt; + } +#else + if (SSL_client_hello_get0_ext(handle_, type, &data, &len) != 1) { + return std::nullopt; + } +#endif + return std::span(data, len); + } + + // Sets the TLS alert to send if the callback fails. Ignored by TLS + // libraries that choose the alert themselves. + inline void set_alert(int alert) const { + if (alert_ != nullptr) *alert_ = alert; + } + + static inline Result Encode(ClientHelloResult result) { +#ifdef OPENSSL_IS_BORINGSSL + switch (result) { + case ClientHelloResult::kContinue: + return ssl_select_cert_success; + case ClientHelloResult::kRetry: + return ssl_select_cert_retry; + case ClientHelloResult::kFail: + break; + } + return ssl_select_cert_error; +#else + switch (result) { + case ClientHelloResult::kContinue: + return SSL_CLIENT_HELLO_SUCCESS; + case ClientHelloResult::kRetry: + return SSL_CLIENT_HELLO_RETRY; + case ClientHelloResult::kFail: + break; + } + return SSL_CLIENT_HELLO_ERROR; +#endif + } + + private: + // Reads a 16-bit length prefixed vector from the front of data. Returns + // nothing if the length does not fit within data. + static inline std::optional> ReadVector16( + std::span data) { + if (data.size() < 2) return std::nullopt; + const size_t len = (static_cast(data[0]) << 8) | data[1]; + if (data.size() < 2 + len) return std::nullopt; + return data.subspan(2, len); + } + + Handle handle_; + int* alert_; +}; + +// Adapts a portable callback into the one the linked TLS library expects, +// e.g. SSL_CTX_set_client_hello_cb(ctx, ClientHelloCallback::Invoke, +// nullptr). +template +struct ClientHelloCallback final { +#ifdef OPENSSL_IS_BORINGSSL + static ssl_select_cert_result_t Invoke(const SSL_CLIENT_HELLO* hello) { + return ClientHelloContext::Encode(Fn(ClientHelloContext(hello))); + } +#else + static int Invoke(SSL* ssl, int* alert, void* arg) { + return ClientHelloContext::Encode(Fn(ClientHelloContext(ssl, alert))); + } +#endif +}; + +// Registers Fn as the early ClientHello callback on ctx. +template +inline void SetClientHelloCallback(SSL_CTX* ctx) { +#ifdef OPENSSL_IS_BORINGSSL + SSL_CTX_set_select_certificate_cb(ctx, ClientHelloCallback::Invoke); +#else + SSL_CTX_set_client_hello_cb(ctx, ClientHelloCallback::Invoke, nullptr); +#endif +} + +// Whether protocols, a list in ALPN wire format, contains protocol. +inline bool AlpnListContains(std::span protocols, + std::string_view protocol) { + for (size_t n = 0; n < protocols.size();) { + const size_t len = protocols[n]; + if (len == 0 || n + 1 + len > protocols.size()) return false; + if (std::string_view( + reinterpret_cast(protocols.data() + n + 1), len) == + protocol) { + return true; + } + n += 1 + len; + } + return false; +} + +inline void SetSelectedProtocol(const unsigned char** out, + unsigned char* outlen, + std::string_view protocol) { + *out = reinterpret_cast(protocol.data()); + *outlen = static_cast(protocol.size()); +} + +// Selects the protocol to use from the offered and supported lists, both in +// ALPN wire format. Returns nothing when the two lists do not overlap. +inline std::optional SelectNextProtocol( + std::span supported, std::span offered) { + if (supported.empty() || offered.empty()) return std::nullopt; + uint8_t* selected = nullptr; + uint8_t selected_len = 0; + if (SSL_select_next_proto(&selected, + &selected_len, + supported.data(), + static_cast(supported.size()), + offered.data(), + static_cast(offered.size())) != + OPENSSL_NPN_NEGOTIATED) { + return std::nullopt; + } + return std::string_view(reinterpret_cast(selected), + selected_len); +} + +} // namespace node::crypto + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#endif // SRC_CRYPTO_CRYPTO_CLIENT_HELLO_H_ diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 95564f02b343..5534dc9ee1ad 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -1650,14 +1650,6 @@ void SecureContext::SetNewSessionCallback(NewSessionCb cb) { SSL_CTX_sess_set_new_cb(ctx_.get(), cb); } -void SecureContext::SetClientHelloCallback(ClientHelloCb cb) { -#ifdef OPENSSL_IS_BORINGSSL - SSL_CTX_set_select_certificate_cb(ctx_.get(), cb); -#else - SSL_CTX_set_client_hello_cb(ctx_.get(), cb, nullptr); -#endif -} - void SecureContext::SetGetSessionCallback(GetSessionCb cb) { SSL_CTX_sess_set_get_cb(ctx_.get(), cb); } diff --git a/src/crypto/crypto_context.h b/src/crypto/crypto_context.h index 8cf19a724493..73aff5b628a1 100644 --- a/src/crypto/crypto_context.h +++ b/src/crypto/crypto_context.h @@ -35,11 +35,6 @@ class SecureContext final : public BaseObject { using KeylogCb = void (*)(const SSL*, const char*); using NewSessionCb = int (*)(SSL*, SSL_SESSION*); using SelectSNIContextCb = int (*)(SSL*, int*, void*); -#ifdef OPENSSL_IS_BORINGSSL - using ClientHelloCb = ssl_select_cert_result_t (*)(const SSL_CLIENT_HELLO*); -#else - using ClientHelloCb = int (*)(SSL*, int*, void*); -#endif ~SecureContext() override; @@ -79,7 +74,6 @@ class SecureContext final : public BaseObject { ncrypto::SSLPointer CreateSSL(); - void SetClientHelloCallback(ClientHelloCb cb); void SetGetSessionCallback(GetSessionCb cb); void SetKeylogCallback(KeylogCb cb); void SetNewSessionCallback(NewSessionCb cb); diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index 5a531209e232..68af8b938722 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -23,6 +23,7 @@ #include #include "async_wrap-inl.h" #include "crypto/crypto_bio.h" +#include "crypto/crypto_client_hello.h" #include "crypto/crypto_common.h" #include "crypto/crypto_context.h" #include "crypto/crypto_util.h" @@ -104,40 +105,18 @@ SSL_SESSION* GetSessionCallback( // The TLS library invokes this before version negotiation and before session // or ticket resumption, which makes async session lookup possible. If required // the handshake is suspended here and resumed once JS has answered. -#ifdef OPENSSL_IS_BORINGSSL -ssl_select_cert_result_t EarlyClientHelloCallback(const SSL_CLIENT_HELLO* ch) { - TLSWrap* w = static_cast(SSL_get_app_data(ch->ssl)); - if (!w->should_suspend_for_client_hello()) return ssl_select_cert_success; - - const uint8_t* ext; - size_t ext_len; - bool has_ticket = SSL_early_callback_ctx_extension_get( - ch, TLSEXT_TYPE_session_ticket, &ext, &ext_len) && - ext_len > 0; - - return w->OnEarlyClientHello(ch->session_id, ch->session_id_len, has_ticket) - ? ssl_select_cert_success - : ssl_select_cert_retry; -} -#else -int EarlyClientHelloCallback(SSL* s, int* al, void* arg) { - TLSWrap* w = static_cast(SSL_get_app_data(s)); - if (!w->should_suspend_for_client_hello()) return SSL_CLIENT_HELLO_SUCCESS; - - const unsigned char* session_id; - size_t session_id_len = SSL_client_hello_get0_session_id(s, &session_id); - - const unsigned char* ext; - size_t ext_len; - bool has_ticket = SSL_client_hello_get0_ext( - s, TLSEXT_TYPE_session_ticket, &ext, &ext_len) == 1 && - ext_len > 0; +ClientHelloResult EarlyClientHelloCallback(const ClientHelloContext& hello) { + TLSWrap* w = static_cast(SSL_get_app_data(hello.ssl())); + if (!w->should_suspend_for_client_hello()) { + return ClientHelloResult::kContinue; + } - return w->OnEarlyClientHello(session_id, session_id_len, has_ticket) - ? SSL_CLIENT_HELLO_SUCCESS - : SSL_CLIENT_HELLO_RETRY; + auto session_id = hello.session_id(); + return w->OnEarlyClientHello( + session_id.data(), session_id.size(), hello.has_session_ticket()) + ? ClientHelloResult::kContinue + : ClientHelloResult::kRetry; } -#endif void KeylogCallback(const SSL* s, const char* line) { TLSWrap* w = static_cast(SSL_get_app_data(s)); @@ -265,11 +244,13 @@ int SelectALPNCallback( unsigned int result_int = callback_result.As()->Value(); - // The callback returns an offset into the given buffer, for the selected - // protocol that should be returned. We then set outlen & out to point - // to the selected input length & value directly: - *outlen = *(in + result_int); - *out = (in + result_int + 1); + // The callback returns an offset into the given buffer, at which the + // selected protocol sits in ALPN wire format: a length byte followed by + // that many name bytes. + SetSelectedProtocol( + out, + outlen, + {reinterpret_cast(in + result_int + 1), in[result_int]}); return SSL_TLSEXT_ERR_OK; } @@ -278,20 +259,18 @@ int SelectALPNCallback( if (alpn_protos.empty()) return SSL_TLSEXT_ERR_NOACK; - int status = SSL_select_next_proto(const_cast(out), - outlen, - alpn_protos.data(), - alpn_protos.size(), - in, - inlen); + auto selected = + SelectNextProtocol({alpn_protos.data(), alpn_protos.size()}, {in, inlen}); // Previous versions of Node.js returned SSL_TLSEXT_ERR_NOACK if no protocol // match was found. This would neither cause a fatal alert nor would it result // in a useful ALPN response as part of the Server Hello message. // We now return SSL_TLSEXT_ERR_ALERT_FATAL in that case as per Section 3.2 // of RFC 7301, which causes a fatal no_application_protocol alert. - return status == OPENSSL_NPN_NEGOTIATED ? SSL_TLSEXT_ERR_OK - : SSL_TLSEXT_ERR_ALERT_FATAL; + if (!selected.has_value()) return SSL_TLSEXT_ERR_ALERT_FATAL; + + SetSelectedProtocol(out, outlen, *selected); + return SSL_TLSEXT_ERR_OK; } MaybeLocal GetSSLOCSPResponse(Environment* env, SSL* ssl) { @@ -410,7 +389,7 @@ TLSWrap::TLSWrap(Environment* env, ssl_ = sc_->CreateSSL(); CHECK(ssl_); - sc_->SetClientHelloCallback(EarlyClientHelloCallback); + SetClientHelloCallback(sc_->ctx().get()); sc_->SetGetSessionCallback(GetSessionCallback); sc_->SetNewSessionCallback(NewSessionCallback); diff --git a/src/dtls/dtls_context.cc b/src/dtls/dtls_context.cc index 329ba4aafe65..b5ee22fb6475 100644 --- a/src/dtls/dtls_context.cc +++ b/src/dtls/dtls_context.cc @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -430,17 +431,14 @@ int DTLSContext::ALPNSelectCallback(SSL* ssl, return SSL_TLSEXT_ERR_NOACK; } - int ret = SSL_select_next_proto(const_cast(out), - outlen, - ctx->alpn_protos_.data(), - ctx->alpn_protos_.size(), - in, - inlen); + auto selected = crypto::SelectNextProtocol( + {ctx->alpn_protos_.data(), ctx->alpn_protos_.size()}, {in, inlen}); - if (ret != OPENSSL_NPN_NEGOTIATED) { + if (!selected.has_value()) { return SSL_TLSEXT_ERR_NOACK; } + crypto::SetSelectedProtocol(out, outlen, *selected); return SSL_TLSEXT_ERR_OK; } diff --git a/src/quic/README.md b/src/quic/README.md index 3d90cacdbb6c..8c23ed2f4af9 100644 --- a/src/quic/README.md +++ b/src/quic/README.md @@ -146,9 +146,9 @@ ALPN-specific behavior to. Two implementations exist: server push, and stream prioritization. Manages unidirectional control streams internally. -The Application is selected during ALPN negotiation — immediately for -clients (ALPN known upfront), during the `OnSelectAlpn` TLS callback for -servers. +The Application is selected as soon as the ALPN protocol is known: +immediately for clients, and for servers from the `OnClientHello` TLS +callback (see [Server handshake ordering](#server-handshake-ordering)). ### Thread-Local Allocator @@ -183,8 +183,44 @@ succeed but memory tracking is silently skipped. **Server**: `Endpoint::Receive()` processes an Initial packet through address validation (retry tokens, LRU cache), then calls `Session::Create()` -→ `ngtcp2_conn_server_new()`. The Application is selected later, during ALPN -negotiation in the TLS handshake. +→ `ngtcp2_conn_server_new()`. The Application is selected later, once the +ClientHello names an ALPN protocol. + +### Server handshake ordering + +A server has to make several decisions from the ClientHello, and the order +matters: the ALPN protocol depends on which identity SNI selected, the +Application depends on the ALPN protocol, and JavaScript needs a +`QuicSession` object before any 0-RTT request arrives on it. TLS is +therefore stopped at the ClientHello, before the point where a session +ticket could be accepted and early data could start flowing: + +```text +ngtcp2_conn_read_pkt() + → SSL_do_handshake() + → TLSContext::OnClientHello() // SSL_CTX_set_client_hello_cb + ├── SelectSNIContext() + SSL_set_SSL_CTX() + ├── crypto::SelectNextProtocol() // against that identity's list + ├── Session::InstallApplicationForAlpn() + └── return SSL_CLIENT_HELLO_RETRY // handshake suspended here + ← SSL_ERROR_WANT_CLIENT_HELLO_CB // ngtcp2 treats this as "not done" + ← 0 +Session::AfterNgtcp2Read() + → Endpoint::EmitNewSession() // JS sees the session, TLS paused + → Session::ResumeHandshake() + → ngtcp2_conn_continue_handshake() + → OnClientHello() again // returns success immediately + → session ticket, 0-RTT keys, early data, ... +``` + +The later servername and ALPN callbacks do not repeat any of this; they +only hand the cached result back to OpenSSL, which is what OpenSSL needs +in order to emit the corresponding extensions. + +Because nothing application-level can happen before the resume, no event +deferral is needed. The one exception is qlog: ngtcp2 writes it from the +first inbound packet, so those chunks are buffered on the Session and +flushed immediately after the new-session callback returns. ### The Receive Path @@ -363,10 +399,10 @@ The `Http3ApplicationImpl` wraps `nghttp3_conn` and handles: CONNECT protocol, datagrams) are negotiated and enforced. Datagram support follows RFC 9297 — when the peer's SETTINGS disable datagrams, `sendDatagram()` is blocked. -* **0-RTT**: Early data settings are validated during ticket extraction - (`ValidateTicketData` in `ExtractSessionTicketAppData`). If the server's - settings changed incompatibly, the ticket is rejected before TLS accepts - it. +* **0-RTT**: Early data settings are validated in + `ExtractSessionTicketAppData`. If the server's settings changed + incompatibly, the ticket is rejected before TLS accepts it and the + connection falls back to a full 1-RTT handshake. ## Error Handling diff --git a/src/quic/application.cc b/src/quic/application.cc index d3c5e2611f5f..6b2e8450e592 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -195,47 +195,19 @@ void Session::Application::CollectSessionTicketAppData( SessionTicket::AppData::Status Session::Application::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { - // By default we do not have any application data to retrieve. + // CollectSessionTicketAppData writes just the application type byte, so by + // default all there is to check is that the ticket was issued by a session + // that negotiated this same application. + auto data = app_data.Get(); + if (!data || data->len != 1 || + static_cast(data->base[0]) != static_cast(type())) { + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; + } return flag == Flag::STATUS_RENEW ? SessionTicket::AppData::Status::TICKET_USE_RENEW : SessionTicket::AppData::Status::TICKET_USE; } -std::optional Session::Application::ParseTicketData( - const uv_buf_t& data) { - if (data.len == 0 || data.base == nullptr) return std::nullopt; - auto app_type = - static_cast(reinterpret_cast(data.base)[0]); - switch (app_type) { - case Type::DEFAULT: - return DefaultTicketData{}; - case Type::HTTP3: - return ParseHttp3TicketData(data); - default: - return std::nullopt; - } -} - -bool Session::Application::ValidateTicketData( - const PendingTicketAppData& data, const Application_Options& options) { - if (std::holds_alternative(data)) { - // TODO(@jasnell): This validation probably belongs in http3.cc but keeping - // it here for now. - const auto& ticket = std::get(data); - return options.max_field_section_size >= ticket.max_field_section_size && - options.qpack_max_dtable_capacity >= - ticket.qpack_max_dtable_capacity && - options.qpack_encoder_max_dtable_capacity >= - ticket.qpack_encoder_max_dtable_capacity && - options.qpack_blocked_streams >= ticket.qpack_blocked_streams && - (!ticket.enable_connect_protocol || - options.enable_connect_protocol) && - (!ticket.enable_datagrams || options.enable_datagrams); - } - // DefaultTicketData always validates. - return true; -} - void Session::Application::ReceiveStreamClose(Stream* stream, QuicError&& error) { DCHECK_NOT_NULL(stream); @@ -297,10 +269,6 @@ class DefaultApplication final : public Session::Application { } } - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - return std::holds_alternative(data); - } - bool ReceiveStreamOpen(stream_id id) override { auto stream = session().CreateStream(id); if (!stream || session().is_destroyed()) [[unlikely]] { diff --git a/src/quic/application.h b/src/quic/application.h index ace6035a0ab7..98caded6880b 100644 --- a/src/quic/application.h +++ b/src/quic/application.h @@ -2,9 +2,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS -#include -#include - #include "base_object.h" #include "bindingdata.h" #include "defs.h" @@ -14,21 +11,6 @@ namespace node::quic { -// Parsed session ticket application data, produced by -// Application::ParseTicketData() before ALPN negotiation and consumed -// by Application::ApplySessionTicketData() after. -struct DefaultTicketData {}; -struct Http3TicketData { - uint64_t max_field_section_size; - uint64_t qpack_max_dtable_capacity; - uint64_t qpack_encoder_max_dtable_capacity; - uint64_t qpack_blocked_streams; - bool enable_connect_protocol; - bool enable_datagrams; -}; -using PendingTicketAppData = - std::variant; - // An Application implements the ALPN-protocol specific semantics on behalf // of a QUIC Session. class Session::Application : public MemoryRetainer { @@ -164,32 +146,15 @@ class Session::Application : public MemoryRetainer { virtual void CollectSessionTicketAppData( SessionTicket::AppData* app_data) const; - // Different Applications may set some application data in the session - // ticket (e.g. http/3 would set server settings in the application data). - // By default, there's nothing to get. + // Validates the application data embedded in a session ticket offered by + // a resuming client, and decides whether the ticket may be used. The + // Application is always installed by the time a ticket can be decrypted, + // so each Application checks its own data here. By default, there's + // nothing to get. virtual SessionTicket::AppData::Status ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, SessionTicket::AppData::Source::Flag flag); - // Validates parsed ticket data against current application options. - // Returns false if the stored settings are more permissive than the - // current config (e.g., a feature was enabled when the ticket was - // issued but is now disabled). - static bool ValidateTicketData(const PendingTicketAppData& data, - const Application_Options& options); - - // Parse session ticket app data before ALPN negotiation. Reads the - // type byte and dispatches to the appropriate application-specific - // parser. Returns std::nullopt if parsing fails. - static std::optional ParseTicketData( - const uv_buf_t& data); - - // Called after ALPN negotiation to validate and apply previously - // parsed session ticket app data. Returns false if the data is - // incompatible (e.g., type mismatch or settings downgrade), which - // causes the handshake to fail. - virtual bool ApplySessionTicketData(const PendingTicketAppData& data) = 0; - // Notifies the Application that the identified stream has been closed. virtual void ReceiveStreamClose(Stream* stream, QuicError&& error = QuicError()); diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index d83b70ba949f..a6a4109509ae 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -1999,10 +1999,10 @@ void Endpoint::EmitNewSession(const BaseObjectPtr& session) { // exists but it is in a destroyed state. Care should be taken accessing // session after this point. - // Deliver any stream events that were held until the stream was setup, - // e.g. 0-RTT streams from the first flight. + // Deliver any qlog written while processing the packets that carried the + // ClientHello, which is the only output that can predate this callback. if (!session->is_destroyed()) { - session->ReplayDeferredEmits(); + session->FlushPendingQlog(); } } diff --git a/src/quic/http3.cc b/src/quic/http3.cc index ff54bba0354f..cbb555f80270 100644 --- a/src/quic/http3.cc +++ b/src/quic/http3.cc @@ -486,21 +486,6 @@ class Http3ApplicationImpl final : public Session::Application { : SessionTicket::AppData::Status::TICKET_USE; } - bool ApplySessionTicketData(const PendingTicketAppData& data) override { - if (!std::holds_alternative(data)) return false; - const auto& ticket = std::get(data); - // Validate that current settings are >= stored settings. - return options_.max_field_section_size >= ticket.max_field_section_size && - options_.qpack_max_dtable_capacity >= - ticket.qpack_max_dtable_capacity && - options_.qpack_encoder_max_dtable_capacity >= - ticket.qpack_encoder_max_dtable_capacity && - options_.qpack_blocked_streams >= ticket.qpack_blocked_streams && - (!ticket.enable_connect_protocol || - options_.enable_connect_protocol) && - (!ticket.enable_datagrams || options_.enable_datagrams); - } - void ReceiveStreamClose(Stream* stream, QuicError&& error = QuicError()) override { Debug( @@ -1434,30 +1419,6 @@ class Http3ApplicationImpl final : public Session::Application { on_stream_close}; }; -std::optional ParseHttp3TicketData(const uv_buf_t& data) { - if (data.len != kSessionTicketAppDataSize) return std::nullopt; - - const uint8_t* buf = reinterpret_cast(data.base); - - // buf[0] is the type byte (already checked by caller), buf[1] is version. - if (buf[1] != kSessionTicketAppDataVersion) return std::nullopt; - - const uint8_t* payload = buf + kSessionTicketAppDataHeaderSize; - uint32_t stored_crc = ReadBE32(buf + 2); - uLong computed_crc = crc32(0L, Z_NULL, 0); - computed_crc = crc32(computed_crc, payload, kSessionTicketAppDataPayloadSize); - if (stored_crc != static_cast(computed_crc)) return std::nullopt; - - return Http3TicketData{ - ReadBE64(payload), - ReadBE64(payload + 8), - ReadBE64(payload + 16), - ReadBE64(payload + 24), - payload[32] != 0, - payload[33] != 0, - }; -} - std::unique_ptr CreateHttp3Application( Session* session, const Session::Application_Options& options) { Debug(session, "Selecting HTTP/3 application"); diff --git a/src/quic/http3.h b/src/quic/http3.h index f1a1b674d969..09033ca78b3d 100644 --- a/src/quic/http3.h +++ b/src/quic/http3.h @@ -3,7 +3,6 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include -#include #include "application.h" #include "session.h" @@ -15,11 +14,6 @@ namespace node::quic { std::unique_ptr CreateHttp3Application( Session* session, const Session::Application_Options& options); -// Parse HTTP/3 specific session ticket app data. Called from -// Application::ParseTicketData() when the type byte is HTTP3. -// The data includes the type byte prefix. -std::optional ParseHttp3TicketData(const uv_buf_t& data); - } // namespace node::quic #endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/session.cc b/src/quic/session.cc index 2266f74a1277..778807511d5f 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -17,6 +17,7 @@ #include #include #include +#include #include "application.h" #include "bindingdata.h" #include "cid.h" @@ -785,8 +786,8 @@ struct Session::Impl final : public MemoryRetainer { SocketAddress remote_address_; std::unique_ptr application_; StreamsMap streams_; - // Emits deferred until after session setup is completed - std::vector> deferred_emits_; + // qlog chunks produced before the server session was handed to JS. + std::vector> pending_qlog_; TimerWrapHandle timer_; size_t send_scope_depth_ = 0; QuicError last_error_; @@ -797,11 +798,6 @@ struct Session::Impl final : public MemoryRetainer { PendingStream::PendingStreamQueue pending_bidi_stream_queue_; PendingStream::PendingStreamQueue pending_uni_stream_queue_; - // Session ticket app data parsed before ALPN negotiation. - // Validated and applied in SetApplication() after ALPN selects - // the application type. - std::optional pending_ticket_data_; - // When true, the handshake is deferred until the first stream or // datagram is sent. This is set for client sessions with a session // ticket, enabling 0-RTT: the first send triggers the handshake @@ -881,6 +877,9 @@ struct Session::Impl final : public MemoryRetainer { tracker->TrackField("remote_address", remote_address_); tracker->TrackField("application", application_); tracker->TrackField("timer", timer_); + size_t qlog_size = 0; + for (const auto& [flags, data] : pending_qlog_) qlog_size += data.size(); + tracker->TrackFieldWithSize("pending_qlog", qlog_size); } SET_SELF_SIZE(Impl) SET_MEMORY_INFO_NAME(Session::Impl) @@ -2265,13 +2264,11 @@ Session::Session(Endpoint* endpoint, DCHECK(impl_); STAT_RECORD_TIMESTAMP(Stats, created_at); - // For clients, select the Application immediately — the ALPN is + // For clients, select the Application immediately - the ALPN is // known upfront from the options. For servers, application_ stays - // null until OnSelectAlpn fires during the TLS handshake. + // null until the ClientHello names a protocol. if (config.side == Side::CLIENT) { - auto app = - SelectApplicationFromAlpn(DecodeAlpn(config.options.tls_options.alpn)); - if (app) SetApplication(std::move(app)); + InstallApplicationForAlpn(DecodeAlpn(config.options.tls_options.alpn)); } // For client sessions with a session ticket and early data enabled, @@ -2632,21 +2629,22 @@ std::unique_ptr Session::SelectApplicationFromAlpn( return CreateDefaultApplication(this, config().options.application_options); } +void Session::InstallApplicationForAlpn(std::string_view alpn) { + // Acting on the ClientHello twice would install a second Application over + // a live one; TLSSession::EarlySelection is what prevents that. + CHECK(!has_application()); + SetApplication(SelectApplicationFromAlpn(alpn)); +} + +void Session::SetEarlyRemoteTransportParams(std::span params) { + DCHECK(!is_destroyed()); + if (params.empty()) return; + USE(ngtcp2_conn_decode_and_set_remote_transport_params( + *this, params.data(), params.size())); +} + void Session::SetApplication(std::unique_ptr app) { DCHECK(!impl_->application_); - // If we have pending ticket data from a session ticket that was - // parsed before ALPN negotiation, validate it against the selected - // application now. If the type doesn't match or the application - // rejects the data, the handshake will fail (application_ stays null - // and the caller returns an error). - if (impl_->pending_ticket_data_.has_value()) { - auto data = std::move(*impl_->pending_ticket_data_); - impl_->pending_ticket_data_.reset(); - if (!app->ApplySessionTicketData(data)) { - Debug(this, "Session ticket app data rejected by application"); - return; - } - } impl_->state()->application_type = static_cast(app->type()); impl_->state()->headers_supported = static_cast( app->SupportsHeaders() ? HeadersSupportState::SUPPORTED @@ -2701,9 +2699,8 @@ const Session::Options& Session::options() const { void Session::EmitQlog(uint32_t flags, std::string_view data) { if (!env()->can_call_into_js()) return; - if (!is_destroyed() && must_defer_emits()) { - QueueDeferredEmit( - [this, flags, held = std::string(data)]() { EmitQlog(flags, held); }); + if (!is_destroyed() && is_server() && !impl_->state()->wrapped) { + impl_->pending_qlog_.emplace_back(flags, std::string(data)); return; } @@ -2821,23 +2818,48 @@ bool Session::ReadPacket(const uint8_t* data, Debug(this, "Session receiving %zu-byte packet with result %d", len, err); + if (err == 0 && !is_destroyed()) [[likely]] { + STAT_INCREMENT_N(Stats, bytes_received, len); + } + return AfterNgtcp2Read(err); +} + +bool Session::ResumeHandshake() { + DCHECK(!is_destroyed()); + DCHECK(is_server()); + // From here on the ClientHello callback is a no-op, so the handshake + // runs on into ticket decryption, early data and the rest. + tls_session().set_early_selection(TLSSession::EarlySelection::kComplete); + Debug(this, "Resuming the TLS handshake"); + int err; + { + NgTcp2CallbackScope callback_scope(this); + err = ngtcp2_conn_continue_handshake(*this, uv_hrtime()); + } + if (is_destroyed()) return false; + return AfterNgtcp2Read(err); +} + +bool Session::AfterNgtcp2Read(int err) { switch (err) { case 0: { - Debug(this, "Session successfully received %zu-byte packet", len); if (!is_destroyed()) [[likely]] { - STAT_INCREMENT_N(Stats, bytes_received, len); // Process deferred application operations after ALPN selection - not // necessarily resolved yet as ClientHello can span multiple packets. if (has_application()) application().PostReceive(); - // Surface a server session to JS once its ClientHello has been - // processed (OnSelectAlpn fired: SNI + ALPN are known and reliable). - // Held first-flight events - including 0-RTT request streams - replay - // at emit. The !wrapped guard makes this fire exactly once, on - // whichever packet completes the ClientHello (so a multi-datagram - // ClientHello is handled correctly). - if (is_server() && hello_processed_ && !impl_->state()->wrapped && - !is_destroyed()) { + + if (is_destroyed()) return true; + + // The ClientHello has been processed: SNI and ALPN are selected and + // the Application is installed, but the handshake is stopped short + // of ticket decryption, so no early data exists yet. Surface the + // session, then let the handshake run on. The guard makes this fire + // exactly once, on whichever packet completed the ClientHello, so a + // ClientHello split across datagrams is handled correctly. + if (is_server() && tls_session().early_selection() == + TLSSession::EarlySelection::kSelected) { endpoint().EmitNewSession(BaseObjectPtr(this)); + return !is_destroyed() && ResumeHandshake(); } } return true; @@ -3389,42 +3411,10 @@ void Session::CollectSessionTicketAppData( SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { DCHECK(!is_destroyed()); - // If the application is already selected (client side, or server after - // ALPN), delegate directly. - if (impl_->application_) { - return application().ExtractSessionTicketAppData(app_data, flag); - } - // The application is not yet selected (server during ClientHello - // processing, before ALPN). Parse the ticket data now while the - // SSL_SESSION is still valid, and stash the result for validation - // after ALPN negotiation in SetApplication(). - auto data = app_data.Get(); - if (!data.has_value() || data->len == 0) { - // No app data in the ticket. Accept optimistically. - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; - } - auto parsed = Application::ParseTicketData(*data); - if (!parsed.has_value()) { - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - // Pre-validate the ticket data against the current application options. - // If the stored settings are more permissive than the current config - // (e.g., a feature was enabled when the ticket was issued but is now - // disabled), reject the ticket so 0-RTT is not used. This must happen - // here (during TLS ticket processing) rather than in SetApplication, - // because by SetApplication time the TLS layer has already accepted - // the ticket and told the client 0-RTT is ok. - if (!Application::ValidateTicketData(*parsed, - config().options.application_options)) { - Debug(this, "Session ticket app data incompatible with current settings"); - return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; - } - impl_->pending_ticket_data_ = std::move(parsed); - return flag == Flag::STATUS_RENEW - ? SessionTicket::AppData::Status::TICKET_USE_RENEW - : SessionTicket::AppData::Status::TICKET_USE; + if (!has_application()) [[unlikely]] { + return SessionTicket::AppData::Status::TICKET_IGNORE; + } + return application().ExtractSessionTicketAppData(app_data, flag); } void Session::MemoryInfo(MemoryTracker* tracker) const { @@ -3528,33 +3518,20 @@ void Session::set_wrapped() { impl_->state()->wrapped = 1; } -bool Session::must_defer_emits() const { - // Server sessions are surfaced to JS (via the deferred new-session emit) - // only after the ClientHello has been processed and wrapped; anything - // emitted before then has no JS wrapper to receive it and must be held - // for replay. - return is_server() && !impl_->state()->wrapped; -} - bool Session::tls_info_ready() const { // hello_processed_ is set server-side, handshake_completed covers // the client. Together they mark the point when SNI/ALPN are final. return hello_processed_ || impl_->state()->handshake_completed; } -void Session::QueueDeferredEmit(std::function fn) { - impl_->deferred_emits_.emplace_back(std::move(fn)); -} - -void Session::ReplayDeferredEmits() { +void Session::FlushPendingQlog() { if (is_destroyed()) return; DCHECK(impl_->state()->wrapped); - // Runs synchronously immediately after the new-session callback - // returns (still within first-flight processing). - auto emits = std::move(impl_->deferred_emits_); - for (auto& emit : emits) { + // Runs synchronously immediately after the new-session callback returns. + auto pending = std::move(impl_->pending_qlog_); + for (auto& [flags, data] : pending) { if (is_destroyed()) return; - emit(); + EmitQlog(flags, data); } } @@ -4069,7 +4046,6 @@ void Session::set_max_datagram_size(uint16_t size) { void Session::EmitGoaway(stream_id last_stream_id) { if (is_destroyed()) return; - if (DeferEmit([this, last_stream_id] { EmitGoaway(last_stream_id); })) return; if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -4085,13 +4061,6 @@ void Session::EmitGoaway(stream_id last_stream_id) { void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { DCHECK(!is_destroyed()); - if (must_defer_emits()) { - QueueDeferredEmit([this, datagram = std::move(datagram), flag]() mutable { - EmitDatagram(std::move(datagram), flag); - }); - return; - } - if (!env()->can_call_into_js()) return; CallbackScope cbv_scope(this); @@ -4107,8 +4076,6 @@ void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) { void Session::EmitDatagramStatus(datagram_id id, quic::DatagramStatus status) { DCHECK(!is_destroyed()); - if (DeferEmit([this, id, status] { EmitDatagramStatus(id, status); })) return; - if (!env()->can_call_into_js()) return; CallbackScope cb_scope(this); @@ -4270,7 +4237,6 @@ void Session::EmitSessionTicket(Store&& ticket) { void Session::EmitApplication() { if (is_destroyed()) return; - if (DeferEmit([this] { EmitApplication(); })) return; if (!env()->can_call_into_js()) return; if (!has_application()) { @@ -4341,8 +4307,6 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) { void Session::EmitStream(const BaseObjectWeakPtr& stream) { DCHECK(!is_destroyed()); - if (DeferEmit([this, stream] { EmitStream(stream); })) return; - if (!stream) return; if (!env()->can_call_into_js()) return; @@ -4401,13 +4365,6 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd, void Session::EmitOrigins(std::vector&& origins) { DCHECK(!is_destroyed()); - if (must_defer_emits()) { - QueueDeferredEmit([this, origins = std::move(origins)]() mutable { - EmitOrigins(std::move(origins)); - }); - return; - } - if (!HasListenerFlag(impl_->state()->listener_flags, SessionListenerFlags::ORIGIN)) return; @@ -4434,12 +4391,6 @@ void Session::EmitOrigins(std::vector&& origins) { void Session::EmitKeylog(const char* line) { DCHECK(!is_destroyed()); - if (must_defer_emits()) { - QueueDeferredEmit( - [this, str = std::string(line)]() { EmitKeylog(str.c_str()); }); - return; - } - if (!env()->can_call_into_js()) return; Local argv[] = {Undefined(env()->isolate())}; diff --git a/src/quic/session.h b/src/quic/session.h index 3b2a9773a380..7c55407fb21c 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -12,6 +12,7 @@ #include #include #include +#include #include "bindingdata.h" #include "cid.h" #include "data.h" @@ -123,19 +124,6 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { std::string ToString() const; }; - // Decode the first ALPN protocol name from wire format (length-prefixed). - static std::string_view DecodeAlpn(std::string_view wire); - - // Select the Application implementation based on the negotiated ALPN. - // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map - // to DefaultApplication. Sets the application_type state field. - std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); - - // Install the Application on the session. Called at construction for - // clients (ALPN known upfront) or from OnSelectAlpn for servers - // (ALPN negotiated during handshake). Must be called before any - // application data is received. - void SetApplication(std::unique_ptr app); // Controls which datagram to drop when the pending datagram queue is full. enum class DatagramDropPolicy : uint8_t { DROP_OLDEST = 0, // Drop the oldest queued datagram (default). @@ -431,6 +419,29 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { const PacketInfo& pkt_info = PacketInfo(), uint64_t ts = 0); + // Handles the result of an ngtcp2 call that drives inbound processing + // (ngtcp2_conn_read_pkt or ngtcp2_conn_continue_handshake). + bool AfterNgtcp2Read(int err); + + // Decode the first ALPN protocol name from wire format (length-prefixed). + static std::string_view DecodeAlpn(std::string_view wire); + + // Select the Application implementation based on the negotiated ALPN. + // h3 (and h3-XX variants) map to Http3ApplicationImpl; all others map + // to DefaultApplication. Sets the application_type state field. + std::unique_ptr SelectApplicationFromAlpn(std::string_view alpn); + + // Install the Application on the session. Called at construction for + // clients (ALPN known upfront) or from the ClientHello callback for + // servers (ALPN negotiated during handshake). Must be called before any + // application data is received. + void SetApplication(std::unique_ptr app); + + void InstallApplicationForAlpn(std::string_view alpn); + + // ngtcp2 ignores the duplicate when the TLS stack reports these again. + void SetEarlyRemoteTransportParams(std::span params); + // Called by BindingData's flush callback to trigger SendPendingData // on this session. Encapsulates the application() access so that // bindingdata.cc doesn't need the full Application type definition. @@ -610,24 +621,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // defined there to manage it. void set_wrapped(); - // True while JS emits must be held for later replay, before the handshake - // is complete and the server session event has been emitted. - bool must_defer_emits() const; - - // Replays, in order, any emits held while must_defer_emits() was true. - // Called synchronously right after the new-session emit. - void ReplayDeferredEmits(); - - // Queues fn to be replayed by ReplayDeferredEmits(). Out-of-line so the - // header does not need the full Impl definition. - void QueueDeferredEmit(std::function fn); + bool ResumeHandshake(); - template - bool DeferEmit(F&& fn) { - if (!must_defer_emits()) return false; - QueueDeferredEmit(std::forward(fn)); - return true; - } + void FlushPendingQlog(); enum class CloseMethod : uint8_t { // Immediate close with a roundtrip through JavaScript, causing all diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index e816bad3dbf6..37859c2145b3 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -334,52 +334,98 @@ TLSContext::operator SSL_CTX*() const { return ctx_.get(); } +crypto::ClientHelloResult TLSContext::OnClientHello( + const crypto::ClientHelloContext& hello) { + auto& tls_session = TLSSession::From(hello.ssl()); + auto& session = tls_session.session(); + + using EarlySelection = TLSSession::EarlySelection; + switch (tls_session.early_selection()) { + case EarlySelection::kPending: + break; + case EarlySelection::kSelected: + // Selected already, but the Session has not been surfaced yet, so + // the handshake must stay where it is. + return crypto::ClientHelloResult::kRetry; + case EarlySelection::kComplete: + // Don't let HelloRetryRequests change SNI: + if (hello.servername() != tls_session.servername()) { + Debug(&session, "ClientHello changed the requested servername"); + hello.set_alert(SSL_AD_ILLEGAL_PARAMETER); + return crypto::ClientHelloResult::kFail; + } + // Everything else the TLS library needs from here on is cached on the + // TLSSession, so just let the handshake run. + return crypto::ClientHelloResult::kContinue; + } + + auto servername = hello.servername(); + auto* selected = tls_session.context().SelectSNIContext(servername); + if (selected == nullptr) { + Debug(&session, "No TLS context for servername %s", servername); + hello.set_alert(SSL_AD_UNRECOGNIZED_NAME); + return crypto::ClientHelloResult::kFail; + } + if (selected != &tls_session.context()) { + SSL_set_SSL_CTX(hello.ssl(), *selected); + } + tls_session.set_servername(servername); + + // The client's QUIC transport parameters travel in the ClientHello, but + // the TLS stack does not hand them to ngtcp2 until it parses extensions, + // which is after this callback. + auto params = hello.extension(TLSEXT_TYPE_quic_transport_parameters); + if (params.has_value()) session.SetEarlyRemoteTransportParams(*params); + + const auto& supported = selected->options().alpn; + if (supported.empty()) { + // The server has no ALPN preference, so there is nothing to select and + // no Application to install. QUIC requires ALPN so this will fail later. + tls_session.set_early_selection(EarlySelection::kComplete); + return crypto::ClientHelloResult::kContinue; + } + + auto negotiated = crypto::SelectNextProtocol( + {reinterpret_cast(supported.data()), supported.size()}, + hello.alpn_protocols()); + if (!negotiated.has_value()) { + Debug(&session, "ALPN negotiation failed"); + hello.set_alert(SSL_AD_NO_APPLICATION_PROTOCOL); + return crypto::ClientHelloResult::kFail; + } + Debug(&session, "ALPN negotiation succeeded: %s", *negotiated); + tls_session.set_alpn(*negotiated); + + // Install the Application while the handshake is still stopped, so that + // it is in place before a session ticket can be accepted and early data + // can start arriving. + session.InstallApplicationForAlpn(*negotiated); + session.set_hello_processed(); + + // Stop here. Session::AfterNgtcp2Read surfaces the server session to + // JavaScript and then resumes the handshake, at which point this callback + // runs again and takes the kComplete path. + tls_session.set_early_selection(EarlySelection::kSelected); + return crypto::ClientHelloResult::kRetry; +} + int TLSContext::OnSelectAlpn(SSL* ssl, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg) { - auto& tls_session = TLSSession::From(ssl); - - const auto& requested = tls_session.context().options().alpn; - if (requested.empty()) return SSL_TLSEXT_ERR_NOACK; - - // The requested ALPN string is in wire format (one or more - // length-prefixed protocol names). SSL_select_next_proto finds the - // first match between the server's list and the client's list. - if (SSL_select_next_proto( - const_cast(out), - outlen, - reinterpret_cast(requested.data()), - requested.length(), - in, - inlen) == OPENSSL_NPN_NO_OVERLAP) { - Debug(&tls_session.session(), "ALPN negotiation failed"); + // The protocol was already chosen from the ClientHello. OpenSSL still + // requires it to be handed back here for the ALPN extension to be sent. + const auto& negotiated = TLSSession::From(ssl).alpn(); + if (negotiated.empty()) return SSL_TLSEXT_ERR_NOACK; + // The list offered here should be the one the choice was made from, but + // a peer can send a second ClientHello after HelloRetryRequest; never + // answer with a protocol this one does not offer. + if (!crypto::AlpnListContains({in, inlen}, negotiated)) { return SSL_TLSEXT_ERR_ALERT_FATAL; } - - // ALPN negotiated successfully. *out/*outlen point to the selected - // protocol name (without the length prefix). Select the Application - // implementation based on the negotiated ALPN. This must happen now - // because early data (0-RTT) may arrive in the same ngtcp2_conn_read_pkt - // call and needs the Application to be ready. - std::string_view negotiated(reinterpret_cast(*out), *outlen); - Debug(&tls_session.session(), - "ALPN negotiation succeeded: %s", - std::string(negotiated).c_str()); - - auto& session = tls_session.session(); - auto app = session.SelectApplicationFromAlpn(negotiated); - if (!app) { - Debug(&session, - "Failed to create Application for ALPN %s", - std::string(negotiated).c_str()); - return SSL_TLSEXT_ERR_NOACK; - } - session.SetApplication(std::move(app)); - session.set_hello_processed(); - + crypto::SetSelectedProtocol(out, outlen, negotiated); return SSL_TLSEXT_ERR_OK; } @@ -486,6 +532,7 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { SSL_CTX_set_mode(ctx.get(), SSL_MODE_RELEASE_BUFFERS); SSL_CTX_set_alpn_select_cb(ctx.get(), OnSelectAlpn, this); + crypto::SetClientHelloCallback(ctx.get()); if (SSL_CTX_set_session_id_context( ctx.get(), kSidCtx, sizeof(kSidCtx) - 1) != 1) { @@ -506,8 +553,9 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { nullptr), 1); + // The SNI context is selected from the ClientHello; this callback + // exists only so that OpenSSL acknowledges the extension. SSL_CTX_set_tlsext_servername_callback(ctx.get(), OnSNI); - SSL_CTX_set_tlsext_servername_arg(ctx.get(), this); break; } case Side::CLIENT: { @@ -654,23 +702,22 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) { return ctx; } -int TLSContext::OnSNI(SSL* ssl, int* ad, void* arg) { - auto* default_ctx = static_cast(arg); - const char* servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name); - if (servername != nullptr) { - auto it = default_ctx->sni_contexts_.find(servername); - if (it != default_ctx->sni_contexts_.end()) { - SSL_set_SSL_CTX(ssl, it->second->ctx_.get()); - return SSL_TLSEXT_ERR_OK; - } - } - // No matching hostname found. If the default context has a certificate - // (from the sni['*'] wildcard identity), fall through to use it. - // Otherwise, reject the connection with an unrecognized_name alert. - if (SSL_CTX_get0_certificate(default_ctx->ctx_.get()) == nullptr) { - *ad = SSL_AD_UNRECOGNIZED_NAME; - return SSL_TLSEXT_ERR_ALERT_FATAL; +TLSContext* TLSContext::SelectSNIContext(std::string_view servername) { + DCHECK_EQ(side_, Side::SERVER); + if (!servername.empty()) { + auto it = sni_contexts_.find(servername); + if (it != sni_contexts_.end()) return it->second.get(); } + // No matching hostname. If this context has a certificate (from the + // sni['*'] wildcard identity), fall back to it. Otherwise there is no + // identity that can serve this connection. + if (SSL_CTX_get0_certificate(ctx_.get()) == nullptr) return nullptr; + return this; +} + +int TLSContext::OnSNI(SSL* ssl, int* ad, void* arg) { + // The context for this host name was already selected and applied from + // the ClientHello. OpenSSL only needs the extension acknowledged here. return SSL_TLSEXT_ERR_OK; } @@ -687,7 +734,7 @@ bool TLSContext::AddSNIContext(Environment* env, bool TLSContext::SetSNIContexts( Environment* env, const std::unordered_map& entries) { DCHECK_EQ(side_, Side::SERVER); - std::unordered_map> new_contexts; + decltype(sni_contexts_) new_contexts; for (const auto& [hostname, options] : entries) { auto ctx = std::make_shared(env, Side::SERVER, options); if (!*ctx) return false; @@ -789,7 +836,7 @@ const TLSContext::Options TLSContext::Options::kDefault = {}; // ============================================================================ -const TLSSession& TLSSession::From(const SSL* ssl) { +TLSSession& TLSSession::From(const SSL* ssl) { auto ref = static_cast(SSL_get_app_data(ssl)); CHECK_NOT_NULL(ref); return *static_cast(ref->user_data); @@ -990,12 +1037,19 @@ MaybeLocal TLSSession::cipher_version(Environment* env) const { } const std::string_view TLSSession::servername() const { + // A server caches the name from the ClientHello, which is available + // earlier than SSL_get_servername() and stays available while the + // handshake is paused. A client just reports what it asked for. + if (context_->side() == Side::SERVER) return servername_; SSLPointerRef ssl(ossl_context_); return ssl->getServerName().value_or(std::string_view()); } const std::string TLSSession::protocol() const { CHECK(ossl_context_); + // As with servername(), a server has the answer cached from the + // ClientHello; a client learns it from the ServerHello. + if (context_->side() == Side::SERVER) return alpn_; return ossl_context_.get_selected_alpn(); } diff --git a/src/quic/tlscontext.h b/src/quic/tlscontext.h index f1d6451bcfa7..8209eda12dbc 100644 --- a/src/quic/tlscontext.h +++ b/src/quic/tlscontext.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #include +#include #include #include #include @@ -91,7 +92,7 @@ class TLSSession final : public MemoryRetainer { // for each. // Gets the TLSSession from the SSL pointer app data. - static const TLSSession& From(const SSL* ssl); + static TLSSession& From(const SSL* ssl); // The constructor is public in order to satisfy the call to std::make_unique // in TLSContext::NewSession. It should not be called directly. @@ -124,6 +125,38 @@ class TLSSession final : public MemoryRetainer { // The ALPN (protocol name) negotiated for the session const std::string protocol() const; + // Server-side early selection. A server picks the SNI context and the + // ALPN protocol from the ClientHello itself, before the TLS library + // reaches ticket decryption and early data, and holds the handshake + // there while the Session is handed to JavaScript. The results are + // cached here because the SSL object does not carry them until the + // later servername/ALPN callbacks run, which is after the pause. + // + // The state is explicit because the ClientHello callback can run more + // than once per handshake: the TLS stack restarts ClientHello + // processing when the handshake is resumed, and ngtcp2 can hand it + // further CRYPTO data - a reordered chunk replayed by + // conn_emit_pending_crypto_data, another CRYPTO frame in the same + // packet - while the handshake is still suspended. Only kPending does + // the work; kSelected keeps the handshake suspended until the Session + // has been surfaced. + enum class EarlySelection : uint8_t { + // Nothing selected yet. + kPending, + // Selected, and the handshake is suspended waiting for the Session + // to be handed to JavaScript. + kSelected, + // The Session has been surfaced; the handshake may run on. + kComplete, + }; + inline EarlySelection early_selection() const { return early_selection_; } + inline void set_early_selection(EarlySelection state) { + early_selection_ = state; + } + inline void set_servername(std::string_view name) { servername_ = name; } + inline void set_alpn(std::string_view alpn) { alpn_ = alpn; } + inline const std::string& alpn() const { return alpn_; } + // Triggers key update to begin. This will fail and return false if either a // previous key update is in progress or if the initial handshake has not yet // been confirmed. @@ -161,6 +194,9 @@ class TLSSession final : public MemoryRetainer { Session* session_; ncrypto::BIOPointer bio_trace_; std::string validation_error_ = ""; + std::string servername_; + std::string alpn_; + EarlySelection early_selection_ = EarlySelection::kPending; }; // The TLSContext is used to create a TLSSession. For the client, there is @@ -326,6 +362,17 @@ class TLSContext final : public MemoryRetainer, ncrypto::SSLCtxPointer Initialize(Environment* env); operator SSL_CTX*() const; + // Returns the context to use for the requested host name, which is this + // context when nothing more specific matches. Returns nullptr when the + // connection cannot be served at all. + TLSContext* SelectSNIContext(std::string_view servername); + + // Performs the server's early selection: SNI, then ALPN, then the + // Application, and then suspends the handshake. See the comment on + // TLSSession::EarlySelection. + static crypto::ClientHelloResult OnClientHello( + const crypto::ClientHelloContext& hello); + static void OnKeylog(const SSL* ssl, const char* line); static int OnNewSession(SSL* ssl, SSL_SESSION* session); static int OnSelectAlpn(SSL* ssl, @@ -337,13 +384,26 @@ class TLSContext final : public MemoryRetainer, static int OnVerifyClientCertificate(int preverify_ok, X509_STORE_CTX* ctx); static int OnSNI(SSL* ssl, int* ad, void* arg); + // Lets sni_contexts_ be looked up by string_view, so a connection that + // sends SNI does not have to build a std::string key to find its identity. + struct StringHash { + using is_transparent = void; + size_t operator()(std::string_view value) const { + return std::hash{}(value); + } + }; + Side side_; Options options_; ncrypto::X509Pointer cert_; ncrypto::X509Pointer issuer_; std::string validation_error_ = ""; ncrypto::SSLCtxPointer ctx_; - std::unordered_map> sni_contexts_; + std::unordered_map, + StringHash, + std::equal_to<>> + sni_contexts_; friend class TLSSession; }; diff --git a/test/parallel/test-quic-early-selection-order.mjs b/test/parallel/test-quic-early-selection-order.mjs new file mode 100644 index 000000000000..f7cc89fb04c0 --- /dev/null +++ b/test/parallel/test-quic-early-selection-order.mjs @@ -0,0 +1,118 @@ +// Flags: --experimental-quic --experimental-stream-iter --no-warnings + +// Test: the server stops the TLS handshake at the ClientHello, so a session +// reaches JavaScript before anything that belongs to it - even a 0-RTT +// request the client put in its very first flight. The stop also has to +// survive the ClientHello being seen more than once. + +import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import assert from 'node:assert'; + +if (!hasQuic) { + skip('QUIC is not enabled'); +} + +const { listen, connect } = await import('../common/quic.mjs'); +const { bytes } = await import('stream/iter'); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +// Send two requests, the 2nd via 0RTT, make sure the stream event always +// fires _after_ the session event has completed (even though 0RTT is +// delivered already in the first flight - delivery must be deferred). +{ + let ticket; + let token; + const gotTicket = Promise.withResolvers(); + const gotToken = Promise.withResolvers(); + + const endpoint = await listen(mustCall((ss) => { + // No streams initially, stream must arrive in the onstream event, for + // both the normal and the 0RTT sessions: + assert.strictEqual(ss.stats.bidiInStreamCount, 0n); + ss.onstream = mustCall(async (stream) => { + await stream.closed; + ss.close(); + }); + }, 2), { + alpn: ['h3'], + onheaders: mustCall(function() { + this.sendHeaders({ ':status': '200' }); + this.writer.writeSync(encoder.encode('hello')); + this.writer.endSync(); + }, 2), + }); + + const request = { + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }; + + // Open a 1st session, send a request, get session ticket & token: + const cs1 = await connect(endpoint.address, { + servername: 'localhost', + alpn: 'h3', + onsessionticket: mustCall((t) => { ticket = t; gotTicket.resolve(); }, 2), + onnewtoken: mustCall((t) => { token = t; gotToken.resolve(); }), + }); + await cs1.opened; + await Promise.all([gotTicket.promise, gotToken.promise]); + const s1 = await cs1.createBidirectionalStream({ + headers: request, + onheaders: mustCall(), + }); + await bytes(s1); + await Promise.all([s1.closed, cs1.closed]); + + // Open 2nd session, reusing the ticket & token: + const cs2 = await connect(endpoint.address, { + servername: 'localhost', + alpn: 'h3', + sessionTicket: ticket, + token, + }); + + // Send a 0RTT request immediately, before the handshake completes: + const s2 = await cs2.createBidirectionalStream({ + headers: request, + onheaders: mustCall(), + }); + + const info = await cs2.opened; + assert.strictEqual(info.earlyDataAccepted, true); + assert.strictEqual(decoder.decode(await bytes(s2)), 'hello'); + await Promise.all([s2.closed, cs2.closed]); + await endpoint.close(); +} + +// When a handshake does HelloRetryRequest (HRR) and runs the hello flow twice, +// we must preserve the ALPN and server name selection from the first hello. To +// trigger this, we send a hello with an offer for X25519 & P-521, but only a +// key share for X25519 (the *) so an automatic HRR is required. +{ + const serverDone = Promise.withResolvers(); + const endpoint = await listen(mustCall((ss) => { + ss.opened.then(mustCall((info) => { + assert.strictEqual(info.servername, 'example.test'); + assert.strictEqual(info.protocol, 'quic-test'); + serverDone.resolve(); + })); + }), { groups: 'P-521' }); + + const cs = await connect(endpoint.address, { + servername: 'example.test', + groups: '*X25519:P-521', + }); + + const info = await cs.opened; + assert.strictEqual(info.protocol, 'quic-test'); + // Validate the HRR happened: we fell back to 2nd group + assert.strictEqual(cs.ephemeralKeyInfo.name, 'secp521r1'); + + await serverDone.promise; + cs.close(); + await endpoint.close(); +} From a9abbf9bc743ca63ead469c2ca81ff6ff238ed0c Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 25 Aug 2026 18:51:10 +0100 Subject: [PATCH 2/5] Handle review comments --- node.gyp | 1 + src/crypto/crypto_client_hello.cc | 142 ++++++++++++++++++++++ src/crypto/crypto_client_hello.h | 189 ++++++------------------------ src/crypto/crypto_tls.cc | 6 +- src/quic/application.cc | 5 +- src/quic/session.cc | 11 +- src/quic/session.h | 2 +- src/quic/tlscontext.cc | 15 ++- 8 files changed, 201 insertions(+), 170 deletions(-) create mode 100644 src/crypto/crypto_client_hello.cc diff --git a/node.gyp b/node.gyp index 699a3dd1f2ec..85430a04d087 100644 --- a/node.gyp +++ b/node.gyp @@ -391,6 +391,7 @@ 'src/crypto/crypto_sig.cc', 'src/crypto/crypto_timing.cc', 'src/crypto/crypto_cipher.cc', + 'src/crypto/crypto_client_hello.cc', 'src/crypto/crypto_context.cc', 'src/crypto/crypto_tls_certificates.cc', 'src/crypto/crypto_ec.cc', diff --git a/src/crypto/crypto_client_hello.cc b/src/crypto/crypto_client_hello.cc new file mode 100644 index 000000000000..75ecc3cc8748 --- /dev/null +++ b/src/crypto/crypto_client_hello.cc @@ -0,0 +1,142 @@ +#include "crypto/crypto_client_hello.h" + +#include + +namespace node::crypto { + +SSL* ClientHelloContext::ssl() const { +#ifdef OPENSSL_IS_BORINGSSL + return handle_->ssl; +#else + return handle_; +#endif +} + +std::span ClientHelloContext::session_id() const { +#ifdef OPENSSL_IS_BORINGSSL + return {handle_->session_id, handle_->session_id_len}; +#else + const uint8_t* id = nullptr; + size_t len = SSL_client_hello_get0_session_id(handle_, &id); + if (id == nullptr) return {}; + return {id, len}; +#endif +} + +bool ClientHelloContext::has_session_ticket() const { + auto ext = extension(TLSEXT_TYPE_session_ticket); + return ext.has_value() && !ext->empty(); +} + +std::optional ClientHelloContext::servername() const { + auto ext = extension(TLSEXT_TYPE_server_name); + if (!ext.has_value()) return std::string_view(); + // RFC 6066: a 16-bit list length, then one entry of an 8-bit name type, + // a 16-bit name length and that many name bytes. The constraints applied + // here are the ones the TLS stack's own parser applies: exactly one + // entry, of type host_name, of a sane length and free of NULs. + auto list = ReadVector16(*ext); + if (!list.has_value() || list->size() < 3) return std::nullopt; + if ((*list)[0] != TLSEXT_NAMETYPE_host_name) return std::nullopt; + auto name = ReadVector16(list->subspan(1)); + if (!name.has_value()) return std::nullopt; + if (name->size() + 3 != list->size()) return std::nullopt; + if (name->empty() || name->size() > TLSEXT_MAXLEN_host_name) { + return std::nullopt; + } + if (std::find(name->begin(), name->end(), 0) != name->end()) { + return std::nullopt; + } + return std::string_view(reinterpret_cast(name->data()), + name->size()); +} + +std::span ClientHelloContext::alpn_protocols() const { + auto ext = extension(TLSEXT_TYPE_application_layer_protocol_negotiation); + if (!ext.has_value()) return {}; + // RFC 7301: a 16-bit list length, then the protocol names. + return ReadVector16(*ext).value_or(std::span()); +} + +std::optional> ClientHelloContext::extension( + unsigned int type) const { + const uint8_t* data = nullptr; + size_t len = 0; +#ifdef OPENSSL_IS_BORINGSSL + if (!SSL_early_callback_ctx_extension_get(handle_, type, &data, &len)) { + return std::nullopt; + } +#else + if (SSL_client_hello_get0_ext(handle_, type, &data, &len) != 1) { + return std::nullopt; + } +#endif + return std::span(data, len); +} + +void ClientHelloContext::set_alert(int alert) const { + if (alert_ != nullptr) *alert_ = alert; +} + +ClientHelloContext::Result ClientHelloContext::Encode( + ClientHelloResult result) { + switch (result) { + case ClientHelloResult::kContinue: + return kContinueResult; + case ClientHelloResult::kRetry: + return kRetryResult; + case ClientHelloResult::kFail: + break; + } + return kFailResult; +} + +std::optional> ClientHelloContext::ReadVector16( + std::span data) { + if (data.size() < 2) return std::nullopt; + const size_t len = (static_cast(data[0]) << 8) | data[1]; + if (data.size() < 2 + len) return std::nullopt; + return data.subspan(2, len); +} + +bool AlpnListContains(std::span protocols, + std::string_view protocol) { + for (size_t n = 0; n < protocols.size();) { + const size_t len = protocols[n]; + if (len == 0 || n + 1 + len > protocols.size()) return false; + if (std::string_view( + reinterpret_cast(protocols.data() + n + 1), len) == + protocol) { + return true; + } + n += 1 + len; + } + return false; +} + +void SetSelectedProtocol(const unsigned char** out, + unsigned char* outlen, + std::string_view protocol) { + *out = reinterpret_cast(protocol.data()); + *outlen = static_cast(protocol.size()); +} + +std::optional SelectNextProtocol( + std::span supported, std::span offered) { + if (supported.empty() || offered.empty()) return std::nullopt; + uint8_t* selected = nullptr; + uint8_t selected_len = 0; + if (SSL_select_next_proto(&selected, + &selected_len, + supported.data(), + static_cast(supported.size()), + offered.data(), + static_cast(offered.size())) != + OPENSSL_NPN_NEGOTIATED) { + return std::nullopt; + } + return std::string_view(reinterpret_cast(selected), + selected_len); +} + +} // namespace node::crypto diff --git a/src/crypto/crypto_client_hello.h b/src/crypto/crypto_client_hello.h index d8e329704e19..37b8124e8cc7 100644 --- a/src/crypto/crypto_client_hello.h +++ b/src/crypto/crypto_client_hello.h @@ -5,7 +5,6 @@ #include #include -#include #include #include #include @@ -15,16 +14,9 @@ namespace node::crypto { // Support for the early ClientHello callback, which the TLS library invokes // once a complete ClientHello has been received but before version -// negotiation and before any session or ticket resumption. This lets us -// inspect what the client asks for and prepare our configuration (with JS -// involvement potentially) before we actually process the handshake. -// -// OpenSSL and BoringSSL disagree on various details, which are centralized -// here so callers can ignore the differences. - -// What the callback wants the TLS library to do next. +// negotiation and before any session or ticket resumption. + enum class ClientHelloResult { - // Carry on with the handshake. kContinue, // Unwind out of the TLS library, leaving the handshake suspended. The // callback runs again from the start when the handshake is resumed. @@ -40,141 +32,57 @@ class ClientHelloContext final { #ifdef OPENSSL_IS_BORINGSSL using Handle = const SSL_CLIENT_HELLO*; using Result = ssl_select_cert_result_t; + static constexpr Result kContinueResult = ssl_select_cert_success; + static constexpr Result kRetryResult = ssl_select_cert_retry; + static constexpr Result kFailResult = ssl_select_cert_error; #else using Handle = SSL*; using Result = int; + static constexpr Result kContinueResult = SSL_CLIENT_HELLO_SUCCESS; + static constexpr Result kRetryResult = SSL_CLIENT_HELLO_RETRY; + static constexpr Result kFailResult = SSL_CLIENT_HELLO_ERROR; #endif explicit ClientHelloContext(Handle handle, int* alert = nullptr) : handle_(handle), alert_(alert) {} - inline SSL* ssl() const { -#ifdef OPENSSL_IS_BORINGSSL - return handle_->ssl; -#else - return handle_; -#endif - } + SSL* ssl() const; // The legacy_session_id. Note that a TLS 1.3 ClientHello carries a fake // one for middlebox compatibility, so a non-empty value here does not // mean the client is attempting session-id resumption. - inline std::span session_id() const { -#ifdef OPENSSL_IS_BORINGSSL - return {handle_->session_id, handle_->session_id_len}; -#else - const uint8_t* id = nullptr; - size_t len = SSL_client_hello_get0_session_id(handle_, &id); - if (id == nullptr) return {}; - return {id, len}; -#endif - } + std::span session_id() const; - inline bool has_session_ticket() const { - auto ext = extension(TLSEXT_TYPE_session_ticket); - return ext.has_value() && !ext->empty(); - } + bool has_session_ticket() const; - // The host_name from the server_name extension, or an empty view when the - // client did not send a usable one. SSL_get_servername() cannot be used - // this early because the servername callback has not run yet, so this - // applies the same constraints that callback's parser would: exactly one - // entry, of type host_name, of a sane length and free of NULs. Anything - // else reads as absent here and is rejected outright by the TLS stack a - // moment later, once it parses extensions for itself. - inline std::string_view servername() const { - auto ext = extension(TLSEXT_TYPE_server_name); - if (!ext.has_value()) return {}; - // RFC 6066: a 16-bit list length, then one entry of an 8-bit name type, - // a 16-bit name length and that many name bytes. - auto list = ReadVector16(*ext); - if (!list.has_value() || list->size() < 3) return {}; - if ((*list)[0] != TLSEXT_NAMETYPE_host_name) return {}; - auto name = ReadVector16(list->subspan(1)); - if (!name.has_value()) return {}; - // The entry must be the only one, and must be a plausible host name. - if (name->size() + 3 != list->size()) return {}; - if (name->empty() || name->size() > TLSEXT_MAXLEN_host_name) return {}; - if (std::find(name->begin(), name->end(), 0) != name->end()) return {}; - return {reinterpret_cast(name->data()), name->size()}; - } + // The host name to select an identity for, empty if the client sent no + // server_name. Nothing at all if it sent one that cannot be used, in which + // case the handshake should fail. SSL_get_servername() does not work this + // early as the servername callback has not run yet. + std::optional servername() const; - // The protocols the client offered, in ALPN wire format: each entry is a - // length byte followed by that many name bytes. Empty when the client did - // not offer ALPN. This is the format SelectNextProtocol() expects. - inline std::span alpn_protocols() const { - auto ext = extension(TLSEXT_TYPE_application_layer_protocol_negotiation); - if (!ext.has_value()) return {}; - // RFC 7301: a 16-bit list length, then the protocol names. - return ReadVector16(*ext).value_or(std::span()); - } + // ALPN protocols in wire format: each entry is a length byte followed by + // that many name bytes. + std::span alpn_protocols() const; - // The raw body of an extension the client sent, or nothing when the - // client did not send that extension. - inline std::optional> extension( - unsigned int type) const { - const uint8_t* data = nullptr; - size_t len = 0; -#ifdef OPENSSL_IS_BORINGSSL - if (!SSL_early_callback_ctx_extension_get(handle_, type, &data, &len)) { - return std::nullopt; - } -#else - if (SSL_client_hello_get0_ext(handle_, type, &data, &len) != 1) { - return std::nullopt; - } -#endif - return std::span(data, len); - } + std::optional> extension(unsigned int type) const; - // Sets the TLS alert to send if the callback fails. Ignored by TLS - // libraries that choose the alert themselves. - inline void set_alert(int alert) const { - if (alert_ != nullptr) *alert_ = alert; - } + // Ignored by TLS libraries that choose the alert themselves. + void set_alert(int alert) const; - static inline Result Encode(ClientHelloResult result) { -#ifdef OPENSSL_IS_BORINGSSL - switch (result) { - case ClientHelloResult::kContinue: - return ssl_select_cert_success; - case ClientHelloResult::kRetry: - return ssl_select_cert_retry; - case ClientHelloResult::kFail: - break; - } - return ssl_select_cert_error; -#else - switch (result) { - case ClientHelloResult::kContinue: - return SSL_CLIENT_HELLO_SUCCESS; - case ClientHelloResult::kRetry: - return SSL_CLIENT_HELLO_RETRY; - case ClientHelloResult::kFail: - break; - } - return SSL_CLIENT_HELLO_ERROR; -#endif - } + static Result Encode(ClientHelloResult result); private: // Reads a 16-bit length prefixed vector from the front of data. Returns // nothing if the length does not fit within data. - static inline std::optional> ReadVector16( - std::span data) { - if (data.size() < 2) return std::nullopt; - const size_t len = (static_cast(data[0]) << 8) | data[1]; - if (data.size() < 2 + len) return std::nullopt; - return data.subspan(2, len); - } + static std::optional> ReadVector16( + std::span data); Handle handle_; int* alert_; }; -// Adapts a portable callback into the one the linked TLS library expects, -// e.g. SSL_CTX_set_client_hello_cb(ctx, ClientHelloCallback::Invoke, -// nullptr). +// Adapts a portable callback into the one the linked TLS library expects. template struct ClientHelloCallback final { #ifdef OPENSSL_IS_BORINGSSL @@ -198,48 +106,17 @@ inline void SetClientHelloCallback(SSL_CTX* ctx) { #endif } -// Whether protocols, a list in ALPN wire format, contains protocol. -inline bool AlpnListContains(std::span protocols, - std::string_view protocol) { - for (size_t n = 0; n < protocols.size();) { - const size_t len = protocols[n]; - if (len == 0 || n + 1 + len > protocols.size()) return false; - if (std::string_view( - reinterpret_cast(protocols.data() + n + 1), len) == - protocol) { - return true; - } - n += 1 + len; - } - return false; -} +bool AlpnListContains(std::span protocols, + std::string_view protocol); -inline void SetSelectedProtocol(const unsigned char** out, - unsigned char* outlen, - std::string_view protocol) { - *out = reinterpret_cast(protocol.data()); - *outlen = static_cast(protocol.size()); -} +void SetSelectedProtocol(const unsigned char** out, + unsigned char* outlen, + std::string_view protocol); // Selects the protocol to use from the offered and supported lists, both in // ALPN wire format. Returns nothing when the two lists do not overlap. -inline std::optional SelectNextProtocol( - std::span supported, std::span offered) { - if (supported.empty() || offered.empty()) return std::nullopt; - uint8_t* selected = nullptr; - uint8_t selected_len = 0; - if (SSL_select_next_proto(&selected, - &selected_len, - supported.data(), - static_cast(supported.size()), - offered.data(), - static_cast(offered.size())) != - OPENSSL_NPN_NEGOTIATED) { - return std::nullopt; - } - return std::string_view(reinterpret_cast(selected), - selected_len); -} +std::optional SelectNextProtocol( + std::span supported, std::span offered); } // namespace node::crypto diff --git a/src/crypto/crypto_tls.cc b/src/crypto/crypto_tls.cc index 68af8b938722..85dd7e0deca3 100644 --- a/src/crypto/crypto_tls.cc +++ b/src/crypto/crypto_tls.cc @@ -259,8 +259,10 @@ int SelectALPNCallback( if (alpn_protos.empty()) return SSL_TLSEXT_ERR_NOACK; - auto selected = - SelectNextProtocol({alpn_protos.data(), alpn_protos.size()}, {in, inlen}); + const std::span supported{alpn_protos.data(), + alpn_protos.size()}; + const std::span offered{in, inlen}; + auto selected = SelectNextProtocol(supported, offered); // Previous versions of Node.js returned SSL_TLSEXT_ERR_NOACK if no protocol // match was found. This would neither cause a fatal alert nor would it result diff --git a/src/quic/application.cc b/src/quic/application.cc index 6b2e8450e592..56dbfce6c020 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -195,9 +195,8 @@ void Session::Application::CollectSessionTicketAppData( SessionTicket::AppData::Status Session::Application::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { - // CollectSessionTicketAppData writes just the application type byte, so by - // default all there is to check is that the ticket was issued by a session - // that negotiated this same application. + // CollectSessionTicketAppData writes just the type byte, so all this can + // check is that the ticket came from the same application type. auto data = app_data.Get(); if (!data || data->len != 1 || static_cast(data->base[0]) != static_cast(type())) { diff --git a/src/quic/session.cc b/src/quic/session.cc index 778807511d5f..4a35666e34ad 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -2824,7 +2824,7 @@ bool Session::ReadPacket(const uint8_t* data, return AfterNgtcp2Read(err); } -bool Session::ResumeHandshake() { +void Session::ResumeHandshake() { DCHECK(!is_destroyed()); DCHECK(is_server()); // From here on the ClientHello callback is a no-op, so the handshake @@ -2836,8 +2836,8 @@ bool Session::ResumeHandshake() { NgTcp2CallbackScope callback_scope(this); err = ngtcp2_conn_continue_handshake(*this, uv_hrtime()); } - if (is_destroyed()) return false; - return AfterNgtcp2Read(err); + if (is_destroyed()) return; + AfterNgtcp2Read(err); } bool Session::AfterNgtcp2Read(int err) { @@ -2859,7 +2859,7 @@ bool Session::AfterNgtcp2Read(int err) { if (is_server() && tls_session().early_selection() == TLSSession::EarlySelection::kSelected) { endpoint().EmitNewSession(BaseObjectPtr(this)); - return !is_destroyed() && ResumeHandshake(); + if (!is_destroyed()) ResumeHandshake(); } } return true; @@ -3411,8 +3411,9 @@ void Session::CollectSessionTicketAppData( SessionTicket::AppData::Status Session::ExtractSessionTicketAppData( const SessionTicket::AppData& app_data, Flag flag) { DCHECK(!is_destroyed()); + // Renew, so the client stops offering a ticket that is never accepted. if (!has_application()) [[unlikely]] { - return SessionTicket::AppData::Status::TICKET_IGNORE; + return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW; } return application().ExtractSessionTicketAppData(app_data, flag); } diff --git a/src/quic/session.h b/src/quic/session.h index 7c55407fb21c..6cc3db4c86f4 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -621,7 +621,7 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { // defined there to manage it. void set_wrapped(); - bool ResumeHandshake(); + void ResumeHandshake(); void FlushPendingQlog(); diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index 37859c2145b3..fe4cd9ee1c5e 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -347,9 +347,10 @@ crypto::ClientHelloResult TLSContext::OnClientHello( // Selected already, but the Session has not been surfaced yet, so // the handshake must stay where it is. return crypto::ClientHelloResult::kRetry; - case EarlySelection::kComplete: + case EarlySelection::kComplete: { // Don't let HelloRetryRequests change SNI: - if (hello.servername() != tls_session.servername()) { + auto name = hello.servername(); + if (!name.has_value() || *name != tls_session.servername()) { Debug(&session, "ClientHello changed the requested servername"); hello.set_alert(SSL_AD_ILLEGAL_PARAMETER); return crypto::ClientHelloResult::kFail; @@ -357,9 +358,17 @@ crypto::ClientHelloResult TLSContext::OnClientHello( // Everything else the TLS library needs from here on is cached on the // TLSSession, so just let the handshake run. return crypto::ClientHelloResult::kContinue; + } + } + + auto requested = hello.servername(); + if (!requested.has_value()) { + Debug(&session, "Unusable servername in ClientHello"); + hello.set_alert(SSL_AD_DECODE_ERROR); + return crypto::ClientHelloResult::kFail; } + const std::string_view servername = *requested; - auto servername = hello.servername(); auto* selected = tls_session.context().SelectSNIContext(servername); if (selected == nullptr) { Debug(&session, "No TLS context for servername %s", servername); From 151ec9d759cec0af922ac8e0321d72c79e5f2af1 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 25 Aug 2026 19:13:45 +0100 Subject: [PATCH 3/5] quic: don't allow servers to configure an empty ALPN list --- lib/internal/quic/quic.js | 6 ++++++ src/quic/tlscontext.cc | 7 ------- test/parallel/test-quic-alpn.mjs | 9 ++++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 11152f070add..d55603daf1f4 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -5144,6 +5144,12 @@ function processTlsOptions(tls, forServer) { if (!forServer) { validateString(alpn, 'options.alpn'); } + // QUIC has no default application protocol: a server that offers none + // cannot complete a handshake with anyone. + if (protocols.length === 0) { + throw new ERR_INVALID_ARG_VALUE('options.alpn', alpn, + 'must offer at least one protocol'); + } let totalLen = 0; for (let i = 0; i < protocols.length; i++) { validateString(protocols[i], `options.alpn[${i}]`); diff --git a/src/quic/tlscontext.cc b/src/quic/tlscontext.cc index fe4cd9ee1c5e..e68380fd0424 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -387,13 +387,6 @@ crypto::ClientHelloResult TLSContext::OnClientHello( if (params.has_value()) session.SetEarlyRemoteTransportParams(*params); const auto& supported = selected->options().alpn; - if (supported.empty()) { - // The server has no ALPN preference, so there is nothing to select and - // no Application to install. QUIC requires ALPN so this will fail later. - tls_session.set_early_selection(EarlySelection::kComplete); - return crypto::ClientHelloResult::kContinue; - } - auto negotiated = crypto::SelectNextProtocol( {reinterpret_cast(supported.data()), supported.size()}, hello.alpn_protocols()); diff --git a/test/parallel/test-quic-alpn.mjs b/test/parallel/test-quic-alpn.mjs index a7680dd05a5a..9ca3d40e4fef 100644 --- a/test/parallel/test-quic-alpn.mjs +++ b/test/parallel/test-quic-alpn.mjs @@ -1,6 +1,6 @@ // Flags: --experimental-quic --no-warnings -import { hasQuic, skip, mustCall } from '../common/index.mjs'; +import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs'; import assert from 'node:assert'; import * as fixtures from '../common/fixtures.mjs'; @@ -44,3 +44,10 @@ const clientSession = await connect(serverEndpoint.address, { await Promise.all([serverOpened.promise, checkSession(clientSession)]); await clientSession.close(); await serverEndpoint.close(); + +// QUIC requires an application protocol, so a server that offers none is +// rejected when the endpoint is configured rather than at handshake time. +await assert.rejects(listen(mustNotCall(), { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: [], +}), { code: 'ERR_INVALID_ARG_VALUE' }); From 0eb48bfe0fb53e2f186719dd932987faae708046 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 25 Aug 2026 19:39:22 +0100 Subject: [PATCH 4/5] Update docs to mention that ALPN must be non-empty --- doc/api/quic.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index d40827f57e32..5e6b0e724591 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -2954,10 +2954,10 @@ The ALPN (Application-Layer Protocol Negotiation) identifier(s). For **client** sessions, this is a single string specifying the protocol the client wants to use (e.g. `'h3'`). -For **server** sessions, this is an array of protocol names in preference -order that the server supports (e.g. `['h3', 'h3-29']`). During the TLS -handshake, the server selects the first protocol from its list that the -client also supports. +For **server** sessions, this is a non-empty array of protocol names in +preference order that the server supports (e.g. `['h3', 'h3-29']`). +During the TLS handshake, the server selects the first protocol from its +list that the client also supports. The negotiated ALPN determines which Application implementation is used for the session. `'h3'` and `'h3-*'` variants select the HTTP/3 From e641814b000aed9bdebced695416f31276e384a7 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Tue, 25 Aug 2026 19:35:56 +0100 Subject: [PATCH 5/5] quic: add benchmarks for single request & connection setup --- benchmark/quic/h3-request.js | 150 +++++++++++++++++++++++++++++++++++ benchmark/quic/handshake.js | 74 +++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 benchmark/quic/h3-request.js create mode 100644 benchmark/quic/handshake.js diff --git a/benchmark/quic/h3-request.js b/benchmark/quic/h3-request.js new file mode 100644 index 000000000000..f96a18407ae1 --- /dev/null +++ b/benchmark/quic/h3-request.js @@ -0,0 +1,150 @@ +'use strict'; + +// Measures a complete HTTP/3 exchange: establish a session, send one request +// and read the whole response. Run in two modes, so the cost of a resumed +// 0-RTT session can be compared against a full handshake. +// +// The 0-RTT mode needs a session ticket, which can only come from an earlier +// connection. That first connection is made during warmup, outside the +// measured region, so what is timed is only the resumed exchange. + +const common = require('../common.js'); +const fixtures = require('../../test/common/fixtures'); +const { createPrivateKey } = require('crypto'); + +const bench = common.createBenchmark(main, { + // '0rtt' resumes from a ticket and sends the request in the very first + // flight; '1rtt' is a fresh session each time. 0-RTT is listed first so + // that it is the mode the benchmark CI test exercises. + mode: ['0rtt', '1rtt'], + n: [500], +}, { flags: ['--experimental-quic', '--experimental-stream-iter', + '--no-warnings'] }); + +async function main({ mode, n }) { + const { listen, connect } = require('node:quic'); + const { bytes } = require('stream/iter'); + + const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); + const cert = fixtures.readKey('agent1-cert.pem'); + const body = new TextEncoder().encode('x'.repeat(256)); + const decoder = new TextDecoder(); + + const request = { + ':method': 'GET', + ':path': '/', + ':scheme': 'https', + ':authority': 'localhost', + }; + + const endpoint = await listen((session) => { + session.opened.catch(() => {}); + session.closed.catch(() => {}); + session.onstream = (stream) => { stream.closed.catch(() => {}); }; + }, { + sni: { '*': { keys: [key], certs: [cert] } }, + onheaders() { + this.sendHeaders({ ':status': '200' }); + this.writer.writeSync(body); + this.writer.endSync(); + }, + endpoint: { + maxConnectionsPerHost: 0xFFFF, + maxConnectionsTotal: 0xFFFF, + sessionCreationRate: 1_000_000, + sessionCreationBurst: 1_000_000, + }, + }); + + const address = endpoint.address; + let received = 0; + const onheaders = () => { received++; }; + + // A full handshake, one request, one response. When resume is supplied the + // request goes out in the first flight, before the handshake completes. + async function exchange(resume) { + const session = await connect(address, { + servername: 'localhost', + verifyPeer: 'manual', + alpn: 'h3', + ...resume, + }); + const stream = await session.createBidirectionalStream({ + headers: request, + onheaders, + }); + if (resume === undefined) await session.opened; + const response = decoder.decode(await bytes(stream)); + if (response.length !== body.length) { + throw new Error(`short response: ${response.length}`); + } + session.close(); + await session.closed.catch(() => {}); + return session; + } + + // Collect a ticket for the 0-RTT mode from a connection that is not timed. + let resume; + if (mode === '0rtt') { + const { promise, resolve } = Promise.withResolvers(); + let ticket; + let token; + const session = await connect(address, { + servername: 'localhost', + verifyPeer: 'manual', + alpn: 'h3', + onsessionticket(value) { + ticket ??= value; + if (token !== undefined) resolve(); + }, + onnewtoken(value) { + token ??= value; + if (ticket !== undefined) resolve(); + }, + }); + await session.opened; + await promise; + session.close(); + await session.closed.catch(() => {}); + resume = { sessionTicket: ticket, token }; + } + + // The timed 0-RTT exchanges deliberately never await session.opened, since + // waiting for the handshake is exactly what 0-RTT avoids. That leaves no + // opportunity to notice early data being refused, so check separately - + // otherwise a ticket the server stopped accepting would quietly turn this + // into a measurement of the 1-RTT path. + async function checkEarlyDataAccepted() { + const session = await connect(address, { + servername: 'localhost', + verifyPeer: 'manual', + alpn: 'h3', + ...resume, + }); + const stream = await session.createBidirectionalStream({ + headers: request, + onheaders, + }); + const info = await session.opened; + await bytes(stream); + session.close(); + await session.closed.catch(() => {}); + if (!info.earlyDataAccepted) { + throw new Error('0-RTT was not accepted, benchmark would be invalid'); + } + } + + for (let i = 0; i < 20; i++) await exchange(resume); + if (mode === '0rtt') await checkEarlyDataAccepted(); + + received = 0; + bench.start(); + for (let i = 0; i < n; i++) await exchange(resume); + bench.end(n); + + if (received !== n) throw new Error(`missing responses: ${received}/${n}`); + // The ticket is reused for every iteration, so confirm it was still being + // accepted at the end of the run and not just at the start. + if (mode === '0rtt') await checkEarlyDataAccepted(); + await endpoint.close(); +} diff --git a/benchmark/quic/handshake.js b/benchmark/quic/handshake.js new file mode 100644 index 000000000000..9f0404008e03 --- /dev/null +++ b/benchmark/quic/handshake.js @@ -0,0 +1,74 @@ +'use strict'; + +// Measures the cost of establishing QUIC sessions: how many complete +// handshakes per second a single endpoint can serve, for raw QUIC and for +// HTTP/3. Nothing is sent on the session beyond what the protocol itself +// requires, so this isolates connection setup rather than data transfer. + +const common = require('../common.js'); +const fixtures = require('../../test/common/fixtures'); +const { createPrivateKey } = require('crypto'); + +const bench = common.createBenchmark(main, { + // 'raw' negotiates a non-HTTP ALPN and does no application work. + // 'h3' negotiates HTTP/3, so the server also builds an nghttp3 connection + // and its control/QPACK streams for every session. + protocol: ['raw', 'h3'], + concurrency: [1, 10], + n: [1000], +}, { flags: ['--experimental-quic', '--no-warnings'] }); + +async function main({ protocol, concurrency, n }) { + const { listen, connect } = require('node:quic'); + + const key = createPrivateKey(fixtures.readKey('agent1-key.pem')); + const cert = fixtures.readKey('agent1-cert.pem'); + const alpn = protocol === 'h3' ? 'h3' : 'quic-bench'; + + const endpoint = await listen((session) => { + // A benchmark peer never reads these; swallow so a torn-down session + // cannot produce an unhandled rejection. + session.opened.catch(() => {}); + session.closed.catch(() => {}); + }, { + sni: { '*': { keys: [key], certs: [cert] } }, + alpn: [alpn], + // The defaults rate-limit session creation per host, which a benchmark + // hammering a single address would otherwise trip. + endpoint: { + maxConnectionsPerHost: 0xFFFF, + maxConnectionsTotal: 0xFFFF, + sessionCreationRate: 1_000_000, + sessionCreationBurst: 1_000_000, + }, + }); + + const address = endpoint.address; + + async function handshake() { + const session = await connect(address, { + servername: 'localhost', + verifyPeer: 'manual', + alpn, + }); + await session.opened; + session.close(); + await session.closed.catch(() => {}); + } + + async function run(count) { + for (let i = 0; i < count; i += concurrency) { + const batch = Math.min(concurrency, count - i); + await Promise.all(Array.from({ length: batch }, handshake)); + } + } + + // Warm up the TLS and QUIC machinery before measuring. + await run(Math.min(100, n)); + + bench.start(); + await run(n); + bench.end(n); + + await endpoint.close(); +}