From d65ae9f6784fe1c51d72d95e745e3c99bf818ea8 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Mon, 27 Mar 2023 23:19:27 -0700 Subject: [PATCH] quic: add additional quic implementation utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add TokenSecret, StatelessResetToken, RetryToken, and RegularToken * add SessionTicket implementation PR-URL: https://github.com/nodejs/node/pull/47289 Reviewed-By: Yagiz Nizipli Reviewed-By: Tobias Nießen Reviewed-By: Stephen Belanger --- node.gyp | 5 + src/quic/data.cc | 9 ++ src/quic/data.h | 3 + src/quic/sessionticket.cc | 177 ++++++++++++++++++++++ src/quic/sessionticket.h | 112 ++++++++++++++ src/quic/tokens.cc | 255 ++++++++++++++++++++++++++++++++ src/quic/tokens.h | 245 ++++++++++++++++++++++++++++++ test/cctest/test_quic_tokens.cc | 154 +++++++++++++++++++ 8 files changed, 960 insertions(+) create mode 100644 src/quic/sessionticket.cc create mode 100644 src/quic/sessionticket.h create mode 100644 src/quic/tokens.cc create mode 100644 src/quic/tokens.h create mode 100644 test/cctest/test_quic_tokens.cc diff --git a/node.gyp b/node.gyp index 45304452e74f33..1f053394540888 100644 --- a/node.gyp +++ b/node.gyp @@ -339,9 +339,13 @@ 'src/quic/cid.cc', 'src/quic/data.cc', 'src/quic/preferredaddress.cc', + 'src/quic/sessionticket.cc', + 'src/quic/tokens.cc', 'src/quic/cid.h', 'src/quic/data.h', 'src/quic/preferredaddress.h', + 'src/quic/sessionticket.h', + 'src/quic/tokens.h', ], 'node_mksnapshot_exec': '<(PRODUCT_DIR)/<(EXECUTABLE_PREFIX)node_mksnapshot<(EXECUTABLE_SUFFIX)', 'conditions': [ @@ -1033,6 +1037,7 @@ 'test/cctest/test_crypto_clienthello.cc', 'test/cctest/test_node_crypto.cc', 'test/cctest/test_quic_cid.cc', + 'test/cctest/test_quic_tokens.cc', ] }], ['v8_enable_inspector==1', { diff --git a/src/quic/data.cc b/src/quic/data.cc index e3428141f1ec6b..c5cba75a5c9374 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -15,6 +15,7 @@ using v8::BigInt; using v8::Integer; using v8::Local; using v8::MaybeLocal; +using v8::Uint8Array; using v8::Undefined; using v8::Value; @@ -66,6 +67,14 @@ Store::Store(v8::Local view, Option option) } } +v8::Local Store::ToUint8Array(Environment* env) const { + return !store_ + ? Uint8Array::New(v8::ArrayBuffer::New(env->isolate(), 0), 0, 0) + : Uint8Array::New(v8::ArrayBuffer::New(env->isolate(), store_), + offset_, + length_); +} + Store::operator bool() const { return store_ != nullptr; } diff --git a/src/quic/data.h b/src/quic/data.h index 14a613df69196b..f6a741922ad6c5 100644 --- a/src/quic/data.h +++ b/src/quic/data.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS #if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC +#include #include #include #include @@ -41,6 +42,8 @@ class Store final : public MemoryRetainer { Store(v8::Local buffer, Option option = Option::NONE); Store(v8::Local view, Option option = Option::NONE); + v8::Local ToUint8Array(Environment* env) const; + operator uv_buf_t() const; operator ngtcp2_vec() const; operator nghttp3_vec() const; diff --git a/src/quic/sessionticket.cc b/src/quic/sessionticket.cc new file mode 100644 index 00000000000000..6353b0f2949877 --- /dev/null +++ b/src/quic/sessionticket.cc @@ -0,0 +1,177 @@ +#if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC + +#include "sessionticket.h" +#include +#include +#include +#include +#include + +namespace node { + +using v8::ArrayBufferView; +using v8::Just; +using v8::Local; +using v8::Maybe; +using v8::MaybeLocal; +using v8::Nothing; +using v8::Object; +using v8::Value; +using v8::ValueDeserializer; +using v8::ValueSerializer; + +namespace quic { + +namespace { +SessionTicket::AppData::Source* GetAppDataSource(SSL* ssl) { + ngtcp2_crypto_conn_ref* ref = + static_cast(SSL_get_app_data(ssl)); + if (ref != nullptr && ref->user_data != nullptr) { + return static_cast(ref->user_data); + } + return nullptr; +} +} // namespace + +SessionTicket::SessionTicket(Store&& ticket, Store&& transport_params) + : ticket_(std::move(ticket)), + transport_params_(std::move(transport_params)) {} + +Maybe SessionTicket::FromV8Value(Environment* env, + v8::Local value) { + if (!value->IsArrayBufferView()) { + THROW_ERR_INVALID_ARG_TYPE(env, "The ticket must be an ArrayBufferView."); + return Nothing(); + } + + Store content(value.As()); + ngtcp2_vec vec = content; + + ValueDeserializer des(env->isolate(), vec.base, vec.len); + + if (des.ReadHeader(env->context()).IsNothing()) { + THROW_ERR_INVALID_ARG_VALUE(env, "The ticket format is invalid."); + return Nothing(); + } + + Local ticket; + Local transport_params; + + errors::TryCatchScope tryCatch(env); + + if (!des.ReadValue(env->context()).ToLocal(&ticket) || + !des.ReadValue(env->context()).ToLocal(&transport_params) || + !ticket->IsArrayBufferView() || !transport_params->IsArrayBufferView()) { + if (tryCatch.HasCaught()) { + // Any errors thrown we want to catch and supress. The only + // error we want to expose to the user is that the ticket format + // is invalid. + if (!tryCatch.HasTerminated()) { + THROW_ERR_INVALID_ARG_VALUE(env, "The ticket format is invalid."); + tryCatch.ReThrow(); + } + return Nothing(); + } + THROW_ERR_INVALID_ARG_VALUE(env, "The ticket format is invalid."); + return Nothing(); + } + + return Just(SessionTicket(Store(ticket.As()), + Store(transport_params.As()))); +} + +MaybeLocal SessionTicket::encode(Environment* env) const { + auto context = env->context(); + ValueSerializer ser(env->isolate()); + ser.WriteHeader(); + + if (ser.WriteValue(context, ticket_.ToUint8Array(env)).IsNothing() || + ser.WriteValue(context, transport_params_.ToUint8Array(env)) + .IsNothing()) { + return MaybeLocal(); + } + + auto result = ser.Release(); + + return Buffer::New(env, reinterpret_cast(result.first), result.second); +} + +const uv_buf_t SessionTicket::ticket() const { + return ticket_; +} + +const ngtcp2_vec SessionTicket::transport_params() const { + return transport_params_; +} + +void SessionTicket::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("ticket", ticket_); + tracker->TrackField("transport_params", transport_params_); +} + +int SessionTicket::GenerateCallback(SSL* ssl, void* arg) { + SessionTicket::AppData::Collect(ssl); + return 1; +} + +SSL_TICKET_RETURN SessionTicket::DecryptedCallback(SSL* ssl, + SSL_SESSION* session, + const unsigned char* keyname, + size_t keyname_len, + SSL_TICKET_STATUS status, + void* arg) { + switch (status) { + default: + return SSL_TICKET_RETURN_IGNORE; + case SSL_TICKET_EMPTY: + [[fallthrough]]; + case SSL_TICKET_NO_DECRYPT: + return SSL_TICKET_RETURN_IGNORE_RENEW; + case SSL_TICKET_SUCCESS_RENEW: + [[fallthrough]]; + case SSL_TICKET_SUCCESS: + return static_cast( + SessionTicket::AppData::Extract(ssl)); + } +} + +SessionTicket::AppData::AppData(SSL* ssl) : ssl_(ssl) {} + +bool SessionTicket::AppData::Set(const uv_buf_t& data) { + if (set_ || data.base == nullptr || data.len == 0) return false; + set_ = true; + SSL_SESSION_set1_ticket_appdata(SSL_get0_session(ssl_), data.base, data.len); + return set_; +} + +std::optional SessionTicket::AppData::Get() const { + uv_buf_t buf; + int ret = + SSL_SESSION_get0_ticket_appdata(SSL_get0_session(ssl_), + reinterpret_cast(&buf.base), + reinterpret_cast(&buf.len)); + if (ret != 1) return std::nullopt; + return buf; +} + +void SessionTicket::AppData::Collect(SSL* ssl) { + auto source = GetAppDataSource(ssl); + if (source != nullptr) { + SessionTicket::AppData app_data(ssl); + source->CollectSessionTicketAppData(&app_data); + } +} + +SessionTicket::AppData::Status SessionTicket::AppData::Extract(SSL* ssl) { + auto source = GetAppDataSource(ssl); + if (source != nullptr) { + SessionTicket::AppData app_data(ssl); + return source->ExtractSessionTicketAppData(app_data); + } + return Status::TICKET_IGNORE; +} + +} // namespace quic +} // namespace node + +#endif // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC diff --git a/src/quic/sessionticket.h b/src/quic/sessionticket.h new file mode 100644 index 00000000000000..4700af5743954e --- /dev/null +++ b/src/quic/sessionticket.h @@ -0,0 +1,112 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC + +#include +#include +#include +#include +#include +#include "data.h" + +namespace node { +namespace quic { + +// A TLS 1.3 Session resumption ticket. Encapsulates both the TLS +// ticket and the encoded QUIC transport parameters. The encoded +// structure should be considered to be opaque for end users. +// In JavaScript, the ticket will be represented as a Buffer +// instance with opaque data. To resume a session, the user code +// would pass that Buffer back into to client connection API. +class SessionTicket final : public MemoryRetainer { + public: + static v8::Maybe FromV8Value(Environment* env, + v8::Local value); + + SessionTicket() = default; + SessionTicket(Store&& ticket, Store&& transport_params); + + const uv_buf_t ticket() const; + + const ngtcp2_vec transport_params() const; + + v8::MaybeLocal encode(Environment* env) const; + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(SessionTicket) + SET_SELF_SIZE(SessionTicket) + + class AppData; + + // The callback that OpenSSL will call when generating the session ticket + // and it needs to collect additional application specific data. + static int GenerateCallback(SSL* ssl, void* arg); + + // The callback that OpenSSL will call when consuming the session ticket + // and it needs to pass embedded application data back into the app. + static SSL_TICKET_RETURN DecryptedCallback(SSL* ssl, + SSL_SESSION* session, + const unsigned char* keyname, + size_t keyname_len, + SSL_TICKET_STATUS status, + void* arg); + + private: + Store ticket_; + Store transport_params_; +}; + +// SessionTicket::AppData is a utility class that is used only during the +// generation or access of TLS stateless sesson tickets. It exists solely to +// provide a easier way for Session::Application instances to set relevant +// metadata in the session ticket when it is created, and the exract and +// subsequently verify that data when a ticket is received and is being +// validated. The app data is completely opaque to anything other than the +// server-side of the Session::Application that sets it. +class SessionTicket::AppData final { + public: + enum class Status { + TICKET_IGNORE = SSL_TICKET_RETURN_IGNORE, + TICKET_IGNORE_RENEW = SSL_TICKET_RETURN_IGNORE_RENEW, + TICKET_USE = SSL_TICKET_RETURN_USE, + TICKET_USE_RENEW = SSL_TICKET_RETURN_USE_RENEW, + }; + + explicit AppData(SSL* session); + AppData(const AppData&) = delete; + AppData(AppData&&) = delete; + AppData& operator=(const AppData&) = delete; + AppData& operator=(AppData&&) = delete; + + bool Set(const uv_buf_t& data); + std::optional Get() const; + + // A source of application data collected during the creation of the + // session ticket. This interface will be implemented by the QUIC + // Session. + class Source { + public: + enum class Flag { STATUS_NONE, STATUS_RENEW }; + + // Collect application data into the given AppData instance. + virtual void CollectSessionTicketAppData(AppData* app_data) const = 0; + + // Extract application data from the given AppData instance. + virtual Status ExtractSessionTicketAppData( + const AppData& app_data, Flag flag = Flag::STATUS_NONE) = 0; + }; + + static void Collect(SSL* ssl); + static Status Extract(SSL* ssl); + + private: + bool set_ = false; + SSL* ssl_; +}; + +} // namespace quic +} // namespace node + +#endif // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/src/quic/tokens.cc b/src/quic/tokens.cc new file mode 100644 index 00000000000000..f47aa45c25414f --- /dev/null +++ b/src/quic/tokens.cc @@ -0,0 +1,255 @@ +#if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC + +#include "tokens.h" +#include +#include +#include +#include +#include +#include "util.h" + +namespace node { +namespace quic { + +// ============================================================================ +// TokenSecret + +TokenSecret::TokenSecret() : buf_() { + Reset(); +} + +TokenSecret::TokenSecret(const uint8_t* secret) : buf_() { + *this = secret; +} + +TokenSecret& TokenSecret::operator=(const uint8_t* other) { + CHECK_NOT_NULL(other); + memcpy(buf_, other, QUIC_TOKENSECRET_LEN); + return *this; +} + +TokenSecret::operator const uint8_t*() const { + return buf_; +} + +void TokenSecret::Reset() { + // As a performance optimization later, we could consider creating an entropy + // cache here similar to what we use for random CIDs so that we do not have + // to engage CSPRNG on every call. That, however, is suboptimal for secrets. + // If someone manages to get visibility into that cache then they would know + // the secrets for a larger number of tokens, which could be bad. For now, + // generating on each call is safer, even if less performant. + CHECK(crypto::CSPRNG(buf_, QUIC_TOKENSECRET_LEN).is_ok()); +} + +// ============================================================================ +// StatelessResetToken + +StatelessResetToken::StatelessResetToken() : ptr_(nullptr), buf_() {} + +StatelessResetToken::StatelessResetToken(const uint8_t* token) : ptr_(token) {} + +StatelessResetToken::StatelessResetToken(const TokenSecret& secret, + const CID& cid) + : ptr_(buf_) { + CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token( + buf_, secret, kStatelessTokenLen, cid), + 0); +} + +StatelessResetToken::StatelessResetToken(uint8_t* token, + const TokenSecret& secret, + const CID& cid) + : ptr_(token) { + CHECK_EQ(ngtcp2_crypto_generate_stateless_reset_token( + token, secret, kStatelessTokenLen, cid), + 0); +} + +StatelessResetToken::StatelessResetToken(const StatelessResetToken& other) + : ptr_(buf_) { + if (other) { + memcpy(buf_, other.ptr_, kStatelessTokenLen); + } else { + ptr_ = nullptr; + } +} + +StatelessResetToken::operator const uint8_t*() const { + return ptr_ != nullptr ? ptr_ : buf_; +} + +StatelessResetToken::operator const char*() const { + return reinterpret_cast(ptr_ != nullptr ? ptr_ : buf_); +} + +StatelessResetToken::operator bool() const { + return ptr_ != nullptr; +} + +bool StatelessResetToken::operator==(const StatelessResetToken& other) const { + if (ptr_ == other.ptr_) return true; + if ((ptr_ == nullptr && other.ptr_ != nullptr) || + (ptr_ != nullptr && other.ptr_ == nullptr)) { + return false; + } + return memcmp(ptr_, other.ptr_, kStatelessTokenLen) == 0; +} + +bool StatelessResetToken::operator!=(const StatelessResetToken& other) const { + return !(*this == other); +} + +std::string StatelessResetToken::ToString() const { + if (ptr_ == nullptr) return std::string(); + char dest[kStatelessTokenLen * 2]; + size_t written = + StringBytes::hex_encode(*this, kStatelessTokenLen, dest, arraysize(dest)); + DCHECK_EQ(written, arraysize(dest)); + return std::string(dest, written); +} + +size_t StatelessResetToken::Hash::operator()( + const StatelessResetToken& token) const { + size_t hash = 0; + if (token.ptr_ == nullptr) return hash; + for (size_t n = 0; n < kStatelessTokenLen; n++) + hash ^= std::hash{}(token.ptr_[n]) + 0x9e3779b9 + (hash << 6) + + (hash >> 2); + return hash; +} + +StatelessResetToken StatelessResetToken::kInvalid; + +// ============================================================================ +// RetryToken and RegularToken +namespace { +ngtcp2_vec GenerateRetryToken(uint8_t* buffer, + uint32_t version, + const SocketAddress& address, + const CID& retry_cid, + const CID& odcid, + const TokenSecret& token_secret) { + ssize_t ret = + ngtcp2_crypto_generate_retry_token(buffer, + token_secret, + TokenSecret::QUIC_TOKENSECRET_LEN, + version, + address.data(), + address.length(), + retry_cid, + odcid, + uv_hrtime()); + DCHECK_GE(ret, 0); + DCHECK_LE(ret, RetryToken::kRetryTokenLen); + DCHECK_EQ(buffer[0], RetryToken::kTokenMagic); + // This shouldn't be possible but we handle it anyway just to be safe. + if (ret == 0) return {nullptr, 0}; + return {buffer, static_cast(ret)}; +} + +ngtcp2_vec GenerateRegularToken(uint8_t* buffer, + uint32_t version, + const SocketAddress& address, + const TokenSecret& token_secret) { + ssize_t ret = + ngtcp2_crypto_generate_regular_token(buffer, + token_secret, + TokenSecret::QUIC_TOKENSECRET_LEN, + address.data(), + address.length(), + uv_hrtime()); + DCHECK_GE(ret, 0); + DCHECK_LE(ret, RegularToken::kRegularTokenLen); + DCHECK_EQ(buffer[0], RegularToken::kTokenMagic); + // This shouldn't be possible but we handle it anyway just to be safe. + if (ret == 0) return {nullptr, 0}; + return {buffer, static_cast(ret)}; +} +} // namespace + +RetryToken::RetryToken(uint32_t version, + const SocketAddress& address, + const CID& retry_cid, + const CID& odcid, + const TokenSecret& token_secret) + : buf_(), + ptr_(GenerateRetryToken( + buf_, version, address, retry_cid, odcid, token_secret)) {} + +RetryToken::RetryToken(const uint8_t* token, size_t size) + : ptr_(ngtcp2_vec{const_cast(token), size}) { + DCHECK_LE(size, RetryToken::kRetryTokenLen); + DCHECK_IMPLIES(token == nullptr, size = 0); +} + +std::optional RetryToken::Validate(uint32_t version, + const SocketAddress& addr, + const CID& dcid, + const TokenSecret& token_secret, + uint64_t verification_expiration) { + if (ptr_.base == nullptr || ptr_.len == 0) return std::nullopt; + ngtcp2_cid ocid; + int ret = ngtcp2_crypto_verify_retry_token( + &ocid, + ptr_.base, + ptr_.len, + token_secret, + TokenSecret::QUIC_TOKENSECRET_LEN, + version, + addr.data(), + addr.length(), + dcid, + std::min(verification_expiration, QUIC_MIN_RETRYTOKEN_EXPIRATION), + uv_hrtime()); + if (ret != 0) return std::nullopt; + return std::optional(ocid); +} + +RetryToken::operator const ngtcp2_vec&() const { + return ptr_; +} +RetryToken::operator const ngtcp2_vec*() const { + return &ptr_; +} + +RegularToken::RegularToken(uint32_t version, + const SocketAddress& address, + const TokenSecret& token_secret) + : buf_(), + ptr_(GenerateRegularToken(buf_, version, address, token_secret)) {} + +RegularToken::RegularToken(const uint8_t* token, size_t size) + : ptr_(ngtcp2_vec{const_cast(token), size}) { + DCHECK_LE(size, RegularToken::kRegularTokenLen); + DCHECK_IMPLIES(token == nullptr, size = 0); +} + +bool RegularToken::Validate(uint32_t version, + const SocketAddress& addr, + const TokenSecret& token_secret, + uint64_t verification_expiration) { + if (ptr_.base == nullptr || ptr_.len == 0) return false; + return ngtcp2_crypto_verify_regular_token( + ptr_.base, + ptr_.len, + token_secret, + TokenSecret::QUIC_TOKENSECRET_LEN, + addr.data(), + addr.length(), + std::min(verification_expiration, + QUIC_MIN_REGULARTOKEN_EXPIRATION), + uv_hrtime()) == 0; +} + +RegularToken::operator const ngtcp2_vec&() const { + return ptr_; +} +RegularToken::operator const ngtcp2_vec*() const { + return &ptr_; +} + +} // namespace quic +} // namespace node + +#endif // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC diff --git a/src/quic/tokens.h b/src/quic/tokens.h new file mode 100644 index 00000000000000..00c2bf81233e69 --- /dev/null +++ b/src/quic/tokens.h @@ -0,0 +1,245 @@ +#pragma once + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC + +#include +#include +#include +#include +#include "cid.h" + +namespace node { +namespace quic { + +// TokenSecrets are used to generate things like stateless reset tokens, +// retry tokens, and token packets. They are always QUIC_TOKENSECRET_LEN +// bytes in length. +// +// In the default case, token secrets will always be generated randomly. +// User code will be given the option to provide a secret directly +// however. +class TokenSecret final : public MemoryRetainer { + public: + static constexpr int QUIC_TOKENSECRET_LEN = 16; + + // Generate a random secret. + TokenSecret(); + + // Copy the given secret. The uint8_t* is assumed + // to be QUIC_TOKENSECRET_LEN in length. Note that + // the length is not verified so care must be taken + // when this constructor is used. + explicit TokenSecret(const uint8_t* secret); + + TokenSecret(const TokenSecret& other) = default; + TokenSecret& operator=(const TokenSecret& other) = default; + TokenSecret& operator=(const uint8_t* other); + + TokenSecret& operator=(TokenSecret&& other) = delete; + + operator const uint8_t*() const; + + // Resets the secret to a random value. + void Reset(); + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(TokenSecret) + SET_SELF_SIZE(TokenSecret) + + private: + uint8_t buf_[QUIC_TOKENSECRET_LEN]; +}; + +// A stateless reset token is used when a QUIC endpoint receives a QUIC packet +// with a short header but the associated connection ID cannot be matched to any +// known Session. In such cases, the receiver may choose to send a subtle opaque +// indication to the sending peer that state for the Session has apparently been +// lost. For any on- or off- path attacker, a stateless reset packet resembles +// any other QUIC packet with a short header. In order to be successfully +// handled as a stateless reset, the peer must have already seen a reset token +// issued to it associated with the given CID. The token itself is opaque to the +// peer that receives is but must be possible to statelessly recreate by the +// peer that originally created it. The actual implementation is Node.js +// specific but we currently defer to a utility function provided by ngtcp2. +// +// QUIC leaves the generation of stateless session tokens up to the +// implementation to figure out. The idea, however, is that it ought to be +// possible to generate a stateless reset token reliably even when all state +// for a connection has been lost. We use the cid as it is the only reliably +// consistent bit of data we have when a session is destroyed. +// +// StatlessResetTokens are always kStatelessTokenLen bytes, +// as are the secrets used to generate the token. +class StatelessResetToken final : public MemoryRetainer { + public: + static constexpr int kStatelessTokenLen = NGTCP2_STATELESS_RESET_TOKENLEN; + + // Generates a stateless reset token using HKDF with the cid and token secret + // as input. The token secret is either provided by user code when an Endpoint + // is created or is generated randomly. + StatelessResetToken(const TokenSecret& secret, const CID& cid); + + // Generates a stateless reset token using the given token storage. + // The StatelessResetToken wraps the token and does not take ownership. + // The token storage must be at least kStatelessTokenLen bytes in length. + // The length is not verified so care must be taken when using this + // constructor. + StatelessResetToken(uint8_t* token, + const TokenSecret& secret, + const CID& cid); + + // Wraps the given token. Does not take over ownership of the token storage. + // The token must be at least kStatelessTokenLen bytes in length. + // The length is not verified so care must be taken when using this + // constructor. + explicit StatelessResetToken(const uint8_t* token); + + StatelessResetToken(const StatelessResetToken& other); + StatelessResetToken(StatelessResetToken&&) = delete; + + std::string ToString() const; + + operator const uint8_t*() const; + operator bool() const; + + bool operator==(const StatelessResetToken& other) const; + bool operator!=(const StatelessResetToken& other) const; + + struct Hash { + size_t operator()(const StatelessResetToken& token) const; + }; + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(StatelessResetToken) + SET_SELF_SIZE(StatelessResetToken) + + template + using Map = + std::unordered_map; + + static StatelessResetToken kInvalid; + + private: + StatelessResetToken(); + operator const char*() const; + + const uint8_t* ptr_; + uint8_t buf_[NGTCP2_STATELESS_RESET_TOKENLEN]; +}; + +// A RETRY packet communicates a retry token to the client. Retry tokens are +// generated only by QUIC servers for the purpose of validating the network path +// between a client and server. The content payload of the RETRY packet is +// opaque to the clientand must not be guessable by on- or off-path attackers. +// +// A QUIC server sends a RETRY token as a way of initiating explicit path +// validation in response to an initial QUIC packet. The client, upon receiving +// a RETRY, must abandon the initial connection attempt and try again with the +// received retry token included with the new initial packet sent to the server. +// If the server is performing explicit validation, it will look for the +// presence of the retry token and attempt to validate it if found. The internal +// structure of the retry token must be meaningful to the server, and the server +// must be able to validate that the token is correct without relying on any +// state left over from the previous connection attempt. We use an +// implementation that is provided by ngtcp2. +// +// The token secret must be kept private on the QUIC server that generated the +// retry. When multiple QUIC servers are used in a cluster, it cannot be +// guaranteed that the same QUIC server instance will receive the subsequent new +// Initial packet. Therefore, all QUIC servers in the cluster should either +// share or be aware of the same token secret or a mechanism needs to be +// implemented to ensure that subsequent packets are routed to the same QUIC +// server instance. +class RetryToken final : public MemoryRetainer { + public: + // The token prefix that is used to differentiate between a retry token + // and a regular token. + static constexpr uint8_t kTokenMagic = NGTCP2_CRYPTO_TOKEN_MAGIC_RETRY; + static constexpr int kRetryTokenLen = NGTCP2_CRYPTO_MAX_RETRY_TOKENLEN; + + static constexpr uint64_t QUIC_DEFAULT_RETRYTOKEN_EXPIRATION = + 10 * NGTCP2_SECONDS; + static constexpr uint64_t QUIC_MIN_RETRYTOKEN_EXPIRATION = 1 * NGTCP2_SECONDS; + + // Generates a new retry token. + RetryToken(uint32_t version, + const SocketAddress& address, + const CID& retry_cid, + const CID& odcid, + const TokenSecret& token_secret); + + // Wraps the given retry token + RetryToken(const uint8_t* token, size_t length); + + // Validates the retry token given the input. If the token is valid, + // the embedded original CID will be extracted from the token an + // returned. If the token is invalid, std::nullopt will be returned. + std::optional Validate( + uint32_t version, + const SocketAddress& address, + const CID& cid, + const TokenSecret& token_secret, + uint64_t verification_expiration = QUIC_DEFAULT_RETRYTOKEN_EXPIRATION); + + operator const ngtcp2_vec&() const; + operator const ngtcp2_vec*() const; + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(RetryToken) + SET_SELF_SIZE(RetryToken) + + private: + uint8_t buf_[kRetryTokenLen]; + const ngtcp2_vec ptr_; +}; + +// A NEW_TOKEN packet communicates a regular token to a client that the server +// would like the client to send in the header of an initial packet for a +// future connection. It is similar to RETRY and used for the same purpose, +// except a NEW_TOKEN is used in advance of the client establishing a new +// connection and a RETRY is sent in response to the client trying to open +// a new connection. +class RegularToken final : public MemoryRetainer { + public: + // The token prefix that is used to differentiate between a retry token + // and a regular token. + static constexpr uint8_t kTokenMagic = NGTCP2_CRYPTO_TOKEN_MAGIC_REGULAR; + static constexpr int kRegularTokenLen = NGTCP2_CRYPTO_MAX_REGULAR_TOKENLEN; + static constexpr uint64_t QUIC_DEFAULT_REGULARTOKEN_EXPIRATION = + 10 * NGTCP2_SECONDS; + static constexpr uint64_t QUIC_MIN_REGULARTOKEN_EXPIRATION = + 1 * NGTCP2_SECONDS; + + // Generates a new retry token. + RegularToken(uint32_t version, + const SocketAddress& address, + const TokenSecret& token_secret); + + // Wraps the given retry token + RegularToken(const uint8_t* token, size_t length); + + // Validates the retry token given the input. + bool Validate( + uint32_t version, + const SocketAddress& address, + const TokenSecret& token_secret, + uint64_t verification_expiration = QUIC_DEFAULT_REGULARTOKEN_EXPIRATION); + + operator const ngtcp2_vec&() const; + operator const ngtcp2_vec*() const; + + SET_NO_MEMORY_INFO() + SET_MEMORY_INFO_NAME(RetryToken) + SET_SELF_SIZE(RetryToken) + + private: + uint8_t buf_[kRegularTokenLen]; + const ngtcp2_vec ptr_; +}; + +} // namespace quic +} // namespace node + +#endif // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS diff --git a/test/cctest/test_quic_tokens.cc b/test/cctest/test_quic_tokens.cc new file mode 100644 index 00000000000000..c02bab54646f7d --- /dev/null +++ b/test/cctest/test_quic_tokens.cc @@ -0,0 +1,154 @@ +#if HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC +#include +#include +#include +#include +#include +#include +#include +#include + +using node::quic::CID; +using node::quic::RegularToken; +using node::quic::RetryToken; +using node::quic::StatelessResetToken; +using node::quic::TokenSecret; + +TEST(StatelessResetToken, Basic) { + ngtcp2_cid cid_; + uint8_t secret[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6}; + uint8_t nothing[StatelessResetToken::kStatelessTokenLen]{}; + uint8_t cid_data[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0}; + ngtcp2_cid_init(&cid_, cid_data, 10); + + TokenSecret fixed_secret(secret); + + CID cid(cid_); + + CHECK(!StatelessResetToken::kInvalid); + const uint8_t* zeroed = StatelessResetToken::kInvalid; + CHECK_EQ(memcmp(zeroed, nothing, StatelessResetToken::kStatelessTokenLen), 0); + CHECK_EQ(StatelessResetToken::kInvalid.ToString(), ""); + + StatelessResetToken token(fixed_secret, cid); + CHECK(token); + CHECK_EQ(token.ToString(), "e21ea22bb78cae0ab8c7daa422240857"); + + // Token generation should be deterministic + StatelessResetToken token2(fixed_secret, cid); + + CHECK_EQ(token, token2); + + // Let's pretend out secret is also a token just for the sake + // of the test. That's ok because they're the same length. + StatelessResetToken token3(secret); + + CHECK_NE(token, token3); + + // Copy constructor works. + StatelessResetToken token4 = token3; + CHECK_EQ(token3, token4); + + uint8_t wrapped[StatelessResetToken::kStatelessTokenLen]; + StatelessResetToken token5(wrapped, fixed_secret, cid); + CHECK_EQ(token5, token); + + // StatelessResetTokens will be used as keys in a map... + StatelessResetToken::Map map; + map[token] = "abc"; + map[token3] = "xyz"; + CHECK_EQ(map[token], "abc"); + CHECK_EQ(map[token4], "xyz"); + + // And as values in a CID::Map... + CID::Map tokens; + tokens.emplace(cid, token); + auto found = tokens.find(cid); + CHECK_NE(found, tokens.end()); + CHECK_EQ(found->second, token); +} + +TEST(RetryToken, Basic) { + auto& random = CID::Factory::random(); + TokenSecret secret; + node::SocketAddress address; + CHECK(node::SocketAddress::New(AF_INET, "123.123.123.123", 1234, &address)); + auto retry_cid = random.Generate(); + auto odcid = random.Generate(); + RetryToken token(NGTCP2_PROTO_VER_MAX, address, retry_cid, odcid, secret); + auto result = token.Validate(NGTCP2_PROTO_VER_MAX, + address, + retry_cid, + secret, + // Set a large expiration just to be safe + 10000000000); + CHECK_NE(result, std::nullopt); + CHECK_EQ(result.value(), odcid); + + // We can pass the data into a new instance... + ngtcp2_vec token_data = token; + RetryToken token2(token_data.base, token_data.len); + auto result2 = token.Validate(NGTCP2_PROTO_VER_MAX, + address, + retry_cid, + secret, + // Set a large expiration just to be safe + 10000000000); + CHECK_NE(result2, std::nullopt); + CHECK_EQ(result2.value(), odcid); + + auto noresult = token.Validate(NGTCP2_PROTO_VER_MAX, + address, + retry_cid, + secret, + // Use a very small expiration that is + // guaranteed to fail + 0); + CHECK_EQ(noresult, std::nullopt); + + // Fails if we change the retry_cid... + auto noresult2 = token.Validate( + NGTCP2_PROTO_VER_MAX, address, random.Generate(), secret, 10000000000); + CHECK_EQ(noresult2, std::nullopt); + + // Also fails if we change the address.... + CHECK(node::SocketAddress::New(AF_INET, "123.123.123.124", 1234, &address)); + + auto noresult3 = token.Validate( + NGTCP2_PROTO_VER_MAX, address, retry_cid, secret, 10000000000); + CHECK_EQ(noresult3, std::nullopt); +} + +TEST(RegularToken, Basic) { + TokenSecret secret; + node::SocketAddress address; + CHECK(node::SocketAddress::New(AF_INET, "123.123.123.123", 1234, &address)); + RegularToken token(NGTCP2_PROTO_VER_MAX, address, secret); + CHECK(token.Validate(NGTCP2_PROTO_VER_MAX, + address, + secret, + // Set a large expiration just to be safe + 10000000000)); + + // We can pass the data into a new instance... + ngtcp2_vec token_data = token; + RegularToken token2(token_data.base, token_data.len); + CHECK(token.Validate(NGTCP2_PROTO_VER_MAX, + address, + secret, + // Set a large expiration just to be safe + 10000000000)); + + CHECK(!token.Validate(NGTCP2_PROTO_VER_MAX, + address, + secret, + // Use a very small expiration that is + // guaranteed to fail + 0)); + + // Also fails if we change the address.... + CHECK(node::SocketAddress::New(AF_INET, "123.123.123.124", 1234, &address)); + + CHECK(!token.Validate(NGTCP2_PROTO_VER_MAX, address, secret, 10000000000)); +} +#endif // HAVE_OPENSSL && NODE_OPENSSL_HAS_QUIC