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 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/node.gyp b/node.gyp index b99755575020..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', @@ -420,6 +421,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.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 new file mode 100644 index 000000000000..37b8124e8cc7 --- /dev/null +++ b/src/crypto/crypto_client_hello.h @@ -0,0 +1,125 @@ +#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 + +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. + +enum class ClientHelloResult { + 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; + 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) {} + + 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. + std::span session_id() const; + + bool has_session_ticket() const; + + // 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; + + // ALPN protocols in wire format: each entry is a length byte followed by + // that many name bytes. + std::span alpn_protocols() const; + + std::optional> extension(unsigned int type) const; + + // Ignored by TLS libraries that choose the alert themselves. + void set_alert(int alert) const; + + 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 std::optional> ReadVector16( + std::span data); + + Handle handle_; + int* alert_; +}; + +// Adapts a portable callback into the one the linked TLS library expects. +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 +} + +bool AlpnListContains(std::span protocols, + std::string_view protocol); + +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. +std::optional SelectNextProtocol( + std::span supported, std::span offered); + +} // 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..85dd7e0deca3 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,20 @@ 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); + 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 // 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 +391,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..56dbfce6c020 100644 --- a/src/quic/application.cc +++ b/src/quic/application.cc @@ -195,47 +195,18 @@ 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 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())) { + 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 +268,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..4a35666e34ad 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); +} + +void 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; + 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)); + if (!is_destroyed()) ResumeHandshake(); } } return true; @@ -3389,42 +3411,11 @@ 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"); + // Renew, so the client stops offering a ticket that is never accepted. + if (!has_application()) [[unlikely]] { 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; + return application().ExtractSessionTicketAppData(app_data, flag); } void Session::MemoryInfo(MemoryTracker* tracker) const { @@ -3528,33 +3519,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 +4047,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 +4062,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 +4077,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 +4238,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 +4308,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 +4366,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 +4392,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..6cc3db4c86f4 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); + void 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..e68380fd0424 100644 --- a/src/quic/tlscontext.cc +++ b/src/quic/tlscontext.cc @@ -334,52 +334,100 @@ 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: + 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; + } + // 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* 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; + 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 +534,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 +555,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 +704,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 +736,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 +838,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 +1039,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-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' }); 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(); +}