Skip to content
This repository has been archived by the owner on Nov 20, 2024. It is now read-only.

[backport] tls: SNI-based cert selection during TLS handshake #59

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions api/envoy/extensions/transport_sockets/tls/v3/tls.proto
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ message UpstreamTlsContext {
google.protobuf.UInt32Value max_session_keys = 4;
}

// [#next-free-field: 9]
// [#next-free-field: 10]
message DownstreamTlsContext {
option (udpa.annotations.versioning).previous_message_type =
"envoy.api.v2.auth.DownstreamTlsContext";
Expand Down Expand Up @@ -123,6 +123,11 @@ message DownstreamTlsContext {
// an accompanying OCSP response or if the response expires at runtime.
// Defaults to LENIENT_STAPLING
OcspStaplePolicy ocsp_staple_policy = 8 [(validate.rules).enum = {defined_only: true}];

// Multiple certificates are allowed in Downstream transport socket to serve different SNI.
// If the client provides SNI but no such cert matched, it will decide to full scan certificates or not based on this config.
// Defaults to false. See more details in :ref:`Multiple TLS certificates <arch_overview_ssl_cert_select>`.
google.protobuf.BoolValue full_scan_certs_on_sni_mismatch = 9;
}

// TLS key log configuration.
Expand Down Expand Up @@ -227,12 +232,9 @@ message CommonTlsContext {
// TLS protocol versions, cipher suites etc.
TlsParameters tls_params = 1;

// Only a single TLS certificate is supported in client contexts. In server contexts,
// :ref:`Multiple TLS certificates <arch_overview_ssl_cert_select>` can be associated with the
// same context to allow both RSA and ECDSA certificates.
//
// Only a single TLS certificate is supported in client contexts. In server contexts, the first
// RSA certificate is used for clients that only support RSA and the first ECDSA certificate is
// used for clients that support ECDSA.
// same context to allow both RSA and ECDSA certificates and support SNI-based selection.
//
// Only one of ``tls_certificates``, ``tls_certificate_sds_secret_configs``,
// and ``tls_certificate_provider_instance`` may be used.
Expand Down
6 changes: 6 additions & 0 deletions changelogs/current.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@ removed_config_or_runtime:
# *Normally occurs at the end of the* :ref:`deprecation period <deprecated>`

new_features:
- area: tls
change: |
added support for SNI-based cert selection in tls downstream transport socket. Detailed documentation is available :ref:`cert selection<arch_overview_ssl_cert_select>`.
New config option :ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`
is introduced to disable or enable full scan when no cert matches to SNI, defaults to false.
New runtime flag ``envoy.reloadable_features.no_full_scan_certs_on_sni_mismatch`` can be used for override the default value.

deprecated:
51 changes: 40 additions & 11 deletions docs/root/intro/arch_overview/security/ssl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,50 @@ Certificate selection
---------------------

:ref:`DownstreamTlsContexts <envoy_v3_api_msg_extensions.transport_sockets.tls.v3.DownstreamTlsContext>` support multiple TLS
certificates. These may be a mix of RSA and P-256 ECDSA certificates. The following rules apply:
certificates. These may be a mix of RSA and P-256 ECDSA certificates for multiple server name patterns.

* Only one certificate of a particular type (RSA or ECDSA) may be specified.
Certificate config/loading rules:

* DNS SANs or Subject Common Name is extracted as server name pattern to match SNI during handshake. Subject Common Name is not used if DNS SANs are present in the certificate.
* FQDN like "test.example.com" and wildcard like "\*.example.com" are valid at the same time, which will be loaded
as two different server name patterns.
* Only one certificate of a particular type (RSA or ECDSA) may be specified for each server name pattern.
* Non-P-256 server ECDSA certificates are rejected.
* If the client supports P-256 ECDSA, a P-256 ECDSA certificate will be selected if one is present in the
:ref:`DownstreamTlsContext <envoy_v3_api_msg_extensions.transport_sockets.tls.v3.DownstreamTlsContext>`
and it is in compliance with the OCSP policy.
* If the client only supports RSA certificates, a RSA certificate will be selected if present in the
:ref:`DownstreamTlsContext <envoy_v3_api_msg_extensions.transport_sockets.tls.v3.DownstreamTlsContext>`.
* Otherwise, the first certificate listed is used. This will result in a failed handshake if the
client only supports RSA certificates and the server only has ECDSA certificates.
* Static and SDS certificates may not be mixed in a given :ref:`DownstreamTlsContext
<envoy_v3_api_msg_extensions.transport_sockets.tls.v3.DownstreamTlsContext>`.
* The selected certificate must adhere to the OCSP policy. If no
such certificate is found, the connection is refused.

Certificate selection rules:

* If the client supports SNI, e.g. SNI is "test.example.com", it looks for a cert that exactly matches to the SNI.
If the certificate adheres to the OCSP policy and matches to key type, it is selected for handshake.
If the certificate adheres to the OCSP policy, but key type is RSA while client is ECDSA capable, it is marked as
as the candidate and continues searching until a cert is selected with perfect match or certs exhausted.
Candidate will be selected for handshake if there is no perfect match.
* If the client supports SNI, but no cert is selected from certs that exactly matches to SNI, it matches on wildcard server name.
e.g. if SNI is "test.example.com", a certificate with "test.example.com" will be preferred over "\*.example.com". And wildcard
matching only works for 1 level of depth, so "\*.com" will not be a match for "test.example.com".
Afterwards, it execuates OCSP and key type checking on each cert which is the same as what happens after exact SNI matching.
* If no cert is selected from certs that matches wildcard name, the candidate cert is selected for handshake if it is present.
If there is no candidate, check :ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`,
go to full scan all certificates if it is enabled, otherwise pick the first certificate for handshake.
* If the client does not provide SNI at all, go to full scan no matter :ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`
is false or true.
* Full scan execuates OCSP and key type checking on each cert which is the same as described above in exact SNI matching.
It falls back to the first cert in the whole list if there is no cert selected.
* Currently only two kinds of key type are supported, RSA or ECDSA. If the client supports P-256 ECDSA, the P-256 ECDSA certificate
is preferred over RSA. The certificate that it falls back to might result in a failed handshake. For instance, a client only supports
RSA certificates and the certificate only support ECDSA.
* The final selected certificate must adhere to the OCSP policy. If no such certificate is found, the connection is refused.

.. note::
With the support of SNI-based certificate selection, it allows configuring large number of certificates for multiple hostnames.
:ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`
is introduced to determine if we continue full scan on SNI mismatch when the client provides SNI. SNI mismatch contains two cases in this context, one is there is no cert that matches to SNI,
another one is there are certs matches to SNI while OCSP policy fails on those certs. The :ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`
defaults to false, so full scan is disabled by default. The runtime flag ``envoy.reloadable_features.no_full_scan_certs_on_sni_mismatch``
can be used to override the default value of :ref:`full_scan_certs_on_sni_mismatch <envoy_v3_api_field_extensions.transport_sockets.tls.v3.DownstreamTlsContext.full_scan_certs_on_sni_mismatch>`.
If full scan is enabled, it will look for the cert from the whole cert list on SNI mismatch, this could be a problem for a potential DoS attack because of O(n) complexity.


Only a single TLS certificate is supported today for :ref:`UpstreamTlsContexts
<envoy_v3_api_msg_extensions.transport_sockets.tls.v3.UpstreamTlsContext>`.
Expand Down
6 changes: 6 additions & 0 deletions envoy/ssl/context_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ class ServerContextConfig : public virtual ContextConfig {
* @return True if stateless TLS session resumption is disabled, false otherwise.
*/
virtual bool disableStatelessSessionResumption() const PURE;

/**
* @return True if we allow full scan certificates when there is no cert matching SNI during
* downstream TLS handshake, false otherwise.
*/
virtual bool fullScanCertsOnSNIMismatch() const PURE;
};

using ServerContextConfigPtr = std::unique_ptr<ServerContextConfig>;
Expand Down
1 change: 1 addition & 0 deletions source/common/runtime/runtime_features.cc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ RUNTIME_GUARD(envoy_reloadable_features_local_ratelimit_match_all_descriptors);
RUNTIME_GUARD(envoy_reloadable_features_lua_respond_with_send_local_reply);
RUNTIME_GUARD(envoy_reloadable_features_no_delay_close_for_upgrades);
RUNTIME_GUARD(envoy_reloadable_features_no_extension_lookup_by_name);
RUNTIME_GUARD(envoy_reloadable_features_no_full_scan_certs_on_sni_mismatch);
RUNTIME_GUARD(envoy_reloadable_features_original_dst_rely_on_idle_timeout);
RUNTIME_GUARD(envoy_reloadable_features_override_request_timeout_by_gateway_timeout);
RUNTIME_GUARD(envoy_reloadable_features_postpone_h3_client_connect_to_next_loop);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,11 @@ ServerContextConfigImpl::ServerContextConfigImpl(
PROTOBUF_GET_WRAPPED_OR_DEFAULT(config, require_client_certificate, false)),
ocsp_staple_policy_(ocspStaplePolicyFromProto(config.ocsp_staple_policy())),
session_ticket_keys_provider_(getTlsSessionTicketKeysConfigProvider(factory_context, config)),
disable_stateless_session_resumption_(getStatelessSessionResumptionDisabled(config)) {
disable_stateless_session_resumption_(getStatelessSessionResumptionDisabled(config)),
full_scan_certs_on_sni_mismatch_(PROTOBUF_GET_WRAPPED_OR_DEFAULT(
config, full_scan_certs_on_sni_mismatch,
!Runtime::runtimeFeatureEnabled(
"envoy.reloadable_features.no_full_scan_certs_on_sni_mismatch"))) {

if (session_ticket_keys_provider_ != nullptr) {
// Validate tls session ticket keys early to reject bad sds updates.
Expand Down
3 changes: 3 additions & 0 deletions source/extensions/transport_sockets/tls/context_config_impl.h
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ class ServerContextConfigImpl : public ContextConfigImpl, public Envoy::Ssl::Ser
return disable_stateless_session_resumption_;
}

bool fullScanCertsOnSNIMismatch() const override { return full_scan_certs_on_sni_mismatch_; }

private:
static const unsigned DEFAULT_MIN_VERSION;
static const unsigned DEFAULT_MAX_VERSION;
Expand All @@ -191,6 +193,7 @@ class ServerContextConfigImpl : public ContextConfigImpl, public Envoy::Ssl::Ser

absl::optional<std::chrono::seconds> session_timeout_;
const bool disable_stateless_session_resumption_;
bool full_scan_certs_on_sni_mismatch_;
};

} // namespace Tls
Expand Down
168 changes: 150 additions & 18 deletions source/extensions/transport_sockets/tls/context_impl.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
#include <openssl/ssl.h>

#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <string>
#include <utility>
#include <vector>

#include "envoy/admin/v3/certs.pb.h"
Expand Down Expand Up @@ -189,7 +191,6 @@ ContextImpl::ContextImpl(Stats::Scope& scope, const Envoy::Ssl::ContextConfig& c
}
#endif

absl::node_hash_set<int> cert_pkey_ids;
if (!capabilities_.provides_certificates) {
for (uint32_t i = 0; i < tls_certificates.size(); ++i) {
auto& ctx = tls_contexts_[i];
Expand All @@ -214,11 +215,6 @@ ContextImpl::ContextImpl(Stats::Scope& scope, const Envoy::Ssl::ContextConfig& c

bssl::UniquePtr<EVP_PKEY> public_key(X509_get_pubkey(ctx.cert_chain_.get()));
const int pkey_id = EVP_PKEY_id(public_key.get());
if (!cert_pkey_ids.insert(pkey_id).second) {
throw EnvoyException(fmt::format("Failed to load certificate chain from {}, at most one "
"certificate of a given type may be specified",
ctx.cert_chain_file_path_));
}
ctx.is_ecdsa_ = pkey_id == EVP_PKEY_EC;
switch (pkey_id) {
case EVP_PKEY_EC: {
Expand Down Expand Up @@ -782,11 +778,21 @@ ServerContextImpl::ServerContextImpl(Stats::Scope& scope,
const std::vector<std::string>& server_names,
TimeSource& time_source)
: ContextImpl(scope, config, time_source), session_ticket_keys_(config.sessionTicketKeys()),
ocsp_staple_policy_(config.ocspStaplePolicy()) {
ocsp_staple_policy_(config.ocspStaplePolicy()), has_rsa_(false),
full_scan_certs_on_sni_mismatch_(config.fullScanCertsOnSNIMismatch()) {
if (config.tlsCertificates().empty() && !config.capabilities().provides_certificates) {
throw EnvoyException("Server TlsCertificates must have a certificate specified");
}

for (auto& ctx : tls_contexts_) {
bssl::UniquePtr<EVP_PKEY> public_key(X509_get_pubkey(ctx.cert_chain_.get()));
const int pkey_id = EVP_PKEY_id(public_key.get());
// Load DNS SAN entries and Subject Common Name as server name patterns after certificate
// chain loaded, and populate ServerNamesMap which will be used to match SNI.
has_rsa_ |= (pkey_id == EVP_PKEY_RSA);
populateServerNamesMap(ctx, pkey_id);
}

// Compute the session context ID hash. We use all the certificate identities,
// since we should have a common ID for session resumption no matter what cert
// is used. We do this early because it can throw an EnvoyException.
Expand Down Expand Up @@ -869,6 +875,61 @@ ServerContextImpl::ServerContextImpl(Stats::Scope& scope,
}
}

void ServerContextImpl::populateServerNamesMap(TlsContext& ctx, int pkey_id) {
if (ctx.cert_chain_ == nullptr) {
return;
}

auto populate = [&](const std::string& sn) {
std::string sn_pattern = sn;
if (absl::StartsWith(sn, "*.")) {
sn_pattern = sn.substr(1);
}
PkeyTypesMap pkey_types_map;
// Multiple certs with different key type are allowed for one server name pattern.
auto sn_match = server_names_map_.try_emplace(sn_pattern, pkey_types_map).first;
auto pt_match = sn_match->second.find(pkey_id);
if (pt_match != sn_match->second.end()) {
throw EnvoyException(fmt::format(
"Failed to load certificate chain from {}, at most one "
"certificate of a given type may be specified for each DNS SAN entry or Subject CN: {}",
ctx.cert_chain_file_path_, sn_match->first));
}
sn_match->second.emplace(std::pair<int, std::reference_wrapper<TlsContext>>(pkey_id, ctx));
};

bssl::UniquePtr<GENERAL_NAMES> san_names(static_cast<GENERAL_NAMES*>(
X509_get_ext_d2i(ctx.cert_chain_.get(), NID_subject_alt_name, nullptr, nullptr)));
if (san_names != nullptr) {
auto dns_sans = Utility::getSubjectAltNames(*ctx.cert_chain_, GEN_DNS);
// https://www.rfc-editor.org/rfc/rfc6066#section-3
// Currently, the only server names supported are DNS hostnames, so we
// only save dns san entries to match SNI.
for (const auto& san : dns_sans) {
populate(san);
}
} else {
// https://www.rfc-editor.org/rfc/rfc6125#section-6.4.4
// As noted, a client MUST NOT seek a match for a reference identifier
// of CN-ID if the presented identifiers include a DNS-ID, SRV-ID,
// URI-ID, or any application-specific identifier types supported by the
// client.
X509_NAME* cert_subject = X509_get_subject_name(ctx.cert_chain_.get());
const int cn_index = X509_NAME_get_index_by_NID(cert_subject, NID_commonName, -1);
if (cn_index >= 0) {
X509_NAME_ENTRY* cn_entry = X509_NAME_get_entry(cert_subject, cn_index);
if (cn_entry) {
ASN1_STRING* cn_asn1 = X509_NAME_ENTRY_get_data(cn_entry);
if (ASN1_STRING_length(cn_asn1) > 0) {
std::string subject_cn(reinterpret_cast<const char*>(ASN1_STRING_data(cn_asn1)),
ASN1_STRING_length(cn_asn1));
populate(subject_cn);
}
}
}
}
}

ServerContextImpl::SessionContextID
ServerContextImpl::generateHashForSessionContextId(const std::vector<std::string>& server_names) {
uint8_t hash_buffer[EVP_MAX_MD_SIZE];
Expand Down Expand Up @@ -1160,23 +1221,94 @@ enum ssl_select_cert_result_t
ServerContextImpl::selectTlsContext(const SSL_CLIENT_HELLO* ssl_client_hello) {
const bool client_ecdsa_capable = isClientEcdsaCapable(ssl_client_hello);
const bool client_ocsp_capable = isClientOcspCapable(ssl_client_hello);
absl::string_view sni = absl::NullSafeStringView(
SSL_get_servername(ssl_client_hello->ssl, TLSEXT_NAMETYPE_host_name));

// Fallback on first certificate.
const TlsContext* selected_ctx = &tls_contexts_[0];
auto ocsp_staple_action = ocspStapleAction(*selected_ctx, client_ocsp_capable);
for (const auto& ctx : tls_contexts_) {
if (client_ecdsa_capable != ctx.is_ecdsa_) {
continue;
}
// selected_ctx represents the final selected certificate, it should meet all requirements or pick
// a candidate
const TlsContext* selected_ctx = nullptr;
const TlsContext* candicate_ctx = nullptr;
OcspStapleAction ocsp_staple_action;

auto selected = [&](const TlsContext& ctx) -> bool {
auto action = ocspStapleAction(ctx, client_ocsp_capable);
if (action == OcspStapleAction::Fail) {
continue;
// The selected ctx must adhere to OCSP policy
return false;
}

selected_ctx = &ctx;
ocsp_staple_action = action;
break;
if (client_ecdsa_capable == ctx.is_ecdsa_) {
selected_ctx = &ctx;
ocsp_staple_action = action;
return true;
}

if (client_ecdsa_capable && !ctx.is_ecdsa_ && candicate_ctx == nullptr) {
// ECDSA cert is preferred if client is ECDSA capable, so RSA cert is marked as a candidate,
// searching will continue until exhausting all certs or find a exact match.
candicate_ctx = &ctx;
ocsp_staple_action = action;
return false;
}

return false;
};

auto select_from_map = [this, &selected](absl::string_view server_name) -> void {
auto it = server_names_map_.find(server_name);
if (it == server_names_map_.end()) {
return;
}
const auto& pkey_types_map = it->second;
for (const auto& entry : pkey_types_map) {
if (selected(entry.second.get())) {
break;
}
}
};

auto tail_select = [&](bool go_to_next_phase) {
if (selected_ctx == nullptr) {
selected_ctx = candicate_ctx;
}

if (selected_ctx == nullptr && !go_to_next_phase) {
selected_ctx = &tls_contexts_[0];
ocsp_staple_action = ocspStapleAction(*selected_ctx, client_ocsp_capable);
}
};

// Select cert based on SNI if SNI is provided by client.
if (!sni.empty()) {
// Match on exact server name, i.e. "www.example.com" for "www.example.com".
select_from_map(sni);
tail_select(true);

if (selected_ctx == nullptr) {
// Match on wildcard domain, i.e. ".example.com" for "www.example.com".
// https://datatracker.ietf.org/doc/html/rfc6125#section-6.4
size_t pos = sni.find('.', 1);
if (pos < sni.size() - 1 && pos != std::string::npos) {
absl::string_view wildcard = sni.substr(pos);
select_from_map(wildcard);
}
}
tail_select(full_scan_certs_on_sni_mismatch_);
}
// Full scan certs if SNI is not provided by client;
// Full scan certs if client provides SNI but no cert matches to it,
// it requires full_scan_certs_on_sni_mismatch is enabled.
if (selected_ctx == nullptr) {
candicate_ctx = nullptr;
// Skip loop when there is no cert compatible to key type
if (client_ecdsa_capable || (!client_ecdsa_capable && has_rsa_)) {
for (const auto& ctx : tls_contexts_) {
if (selected(ctx)) {
break;
}
}
}
tail_select(false);
}

// Apply the selected context. This must be done before OCSP stapling below
Expand Down
Loading