From 4a3e82e174835b20955d5f32156ef1b0c6dc160e Mon Sep 17 00:00:00 2001 From: Masakazu Kitajo Date: Thu, 13 Aug 2026 14:25:39 -0600 Subject: [PATCH 1/4] iocore/net: add RFC 7250 raw public key (RPK) support for TLS hops Layered-cache deployments have TLS connections between nodes under the same operator's control, where the CA/hostname-verification machinery that X.509 exists for isn't needed -- a raw public key, pinned per hop, is sufficient. RPK is negotiated alongside X.509 rather than replacing it, so a hop between nodes at different points in a rolling upgrade falls back to a normal certificate exchange rather than failing. Adds build-time detection of OpenSSL 3.2+'s SSL_CTX_set1_server_cert_type and BoringSSL's SSL_CREDENTIAL_new_raw_public_key, and a library-agnostic SSLRPKUtils helper for loading and pinning trusted keys. New settings: - ssl_multicert.yaml: ssl_rpk_enabled, ssl_client_rpk_ca_name - sni.yaml: client_rpk_enabled, server_rpk_ca On BoringSSL, accepting an RPK client cert requires switching to SSL_CTX_set_custom_verify, which disables automatic X.509 chain verification for the whole connection, so the X.509 fallback path is reimplemented manually there on both client and server sides. ssl_client_rpk_ca_name now resolves its filename against proxy.config.ssl.CA.cert.path, matching every sibling ssl_multicert field, and both RPK-offer paths (client and server) now report a clear error when no certificate/key is configured to derive a raw public key from, on OpenSSL as well as BoringSSL. Test coverage adds two mTLS scenarios exercising ssl_client_rpk_ca_name end to end. --- CMakeLists.txt | 8 + doc/admin-guide/files/sni.yaml.en.rst | 51 +++ .../files/ssl_multicert.yaml.en.rst | 41 +++ include/config/ssl_multicert.h | 22 +- include/iocore/net/SSLSNIConfig.h | 7 +- include/iocore/net/YamlSNIConfig.h | 4 + include/tscore/ink_config.h.cmake.in | 3 + src/config/ssl_multicert.cc | 43 ++- src/iocore/net/CMakeLists.txt | 2 + src/iocore/net/P_SSLCertLookup.h | 25 +- src/iocore/net/P_SSLClientUtils.h | 15 + src/iocore/net/SSLClientUtils.cc | 332 ++++++++++++++++++ src/iocore/net/SSLNetVConnection.cc | 12 + src/iocore/net/SSLRPKUtils.cc | 134 +++++++ src/iocore/net/SSLRPKUtils.h | 57 +++ src/iocore/net/SSLSNIConfig.cc | 12 + src/iocore/net/SSLUtils.cc | 230 +++++++++++- src/iocore/net/YamlSNIConfig.cc | 15 + src/iocore/net/unit_tests/rpk_malformed.pem | 3 + src/iocore/net/unit_tests/rpk_multi.pem | 17 + src/iocore/net/unit_tests/rpk_other.pem | 4 + src/iocore/net/unit_tests/rpk_single.pem | 4 + src/iocore/net/unit_tests/sni_conf_test.yaml | 3 + src/iocore/net/unit_tests/test_SSLRPKUtils.cc | 150 ++++++++ .../net/unit_tests/test_YamlSNIConfig.cc | 18 +- src/traffic_layout/info.cc | 1 + tests/gold_tests/tls/ssl/server.pubkey.pem | 14 + .../gold_tests/tls/ssl/server.wrongpubkey.pem | 9 + tests/gold_tests/tls/tls_rpk_hop.test.py | 230 ++++++++++++ 29 files changed, 1428 insertions(+), 38 deletions(-) create mode 100644 src/iocore/net/SSLRPKUtils.cc create mode 100644 src/iocore/net/SSLRPKUtils.h create mode 100644 src/iocore/net/unit_tests/rpk_malformed.pem create mode 100644 src/iocore/net/unit_tests/rpk_multi.pem create mode 100644 src/iocore/net/unit_tests/rpk_other.pem create mode 100644 src/iocore/net/unit_tests/rpk_single.pem create mode 100644 src/iocore/net/unit_tests/test_SSLRPKUtils.cc create mode 100644 tests/gold_tests/tls/ssl/server.pubkey.pem create mode 100644 tests/gold_tests/tls/ssl/server.wrongpubkey.pem create mode 100644 tests/gold_tests/tls/tls_rpk_hop.test.py diff --git a/CMakeLists.txt b/CMakeLists.txt index f91b3546861..4d60e94aa7c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -657,6 +657,8 @@ check_symbol_exists(DH_get_2048_256 "openssl/dh.h" TS_USE_GET_DH_2048_256) check_symbol_exists(OPENSSL_NO_TLS_3 "openssl/ssl.h" TS_NO_USE_TLS12) check_symbol_exists(SSL_CTX_set_client_hello_cb "openssl/ssl.h" HAVE_SSL_CTX_SET_CLIENT_HELLO_CB) check_symbol_exists(SSL_CTX_set_select_certificate_cb "openssl/ssl.h" HAVE_SSL_CTX_SET_SELECT_CERTIFICATE_CB) +check_symbol_exists(SSL_CTX_set1_server_cert_type "openssl/ssl.h" HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE) +check_symbol_exists(SSL_CREDENTIAL_new_raw_public_key "openssl/ssl.h" HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY) check_symbol_exists(SSL_set1_verify_cert_store "openssl/ssl.h" TS_HAS_VERIFY_CERT_STORE) check_symbol_exists(SSL_get_shared_curve "openssl/ssl.h" HAVE_SSL_GET_SHARED_CURVE) check_symbol_exists(SSL_get_curve_name "openssl/ssl.h" HAVE_SSL_GET_CURVE_NAME) @@ -696,6 +698,12 @@ else() set(TS_USE_HELLO_CB FALSE) endif() +if(HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE OR HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY) + set(TS_USE_RPK TRUE) +else() + set(TS_USE_RPK FALSE) +endif() + if(HAVE_SSL_SET_MAX_EARLY_DATA OR HAVE_SSL_READ_EARL_DATA OR HAVE_SSL_WRITE_EARLY_DATA diff --git a/doc/admin-guide/files/sni.yaml.en.rst b/doc/admin-guide/files/sni.yaml.en.rst index 4a4f2c48080..0304226baad 100644 --- a/doc/admin-guide/files/sni.yaml.en.rst +++ b/doc/admin-guide/files/sni.yaml.en.rst @@ -247,6 +247,35 @@ client_sni_policy Outbound Policy of SNI on outbound con If not specified, the value of :ts:cv:`proxy.config.ssl.client.sni_policy` is used. +client_rpk_enabled Outbound Set to :code:`true` to offer a raw public key (`RFC 7250 + `_), derived from + ``client_cert``/``client_key`` (or the global + :ts:cv:`proxy.config.ssl.client.cert.filename` if those are not set), as an alternative + to an X.509 certificate for this outbound connection. The raw public key is offered + alongside the X.509 certificate, not instead of it: if the next hop does not support + raw public keys, the connection negotiates a normal certificate exchange instead. + + Only available in builds linked against a TLS library with RFC 7250 support + (OpenSSL 3.2 or later, or a sufficiently recent BoringSSL). If this key is set on a + build without that support, |TS| logs a warning and ignores it. + +server_rpk_ca Outbound The file containing the raw public key(s) (RFC 7250) that this next hop is trusted + to present, PEM-encoded. The file may contain more than one key concatenated together, + which allows an "old" and "new" key to both be trusted during a planned key rotation. + + If this is relative, it is relative to the path in + :ts:cv:`proxy.config.ssl.client.CA.cert.path`. + + Raw public keys have no certificate chain, issuer, or subject alternative name, so + when the next hop authenticates with one, |TS| checks the offered key against this + trusted set instead of the usual certificate chain and hostname checks -- + ``verify_server_properties``'s :code:`NAME` check has nothing to act on for a raw + public key. ``verify_server_policy`` still governs whether a key that does not match + this trusted set is fatal (:code:`ENFORCED`) or only logged (:code:`PERMISSIVE`). + + Only available in builds linked against a TLS library with RFC 7250 support; see + ``client_rpk_enabled`` above. + http2 Inbound Indicates whether the H2 protocol should be added to or removed from the protocol negotiation list. The valid values are :code:`on` or :code:`off`. @@ -415,6 +444,15 @@ In addition ``verify_server_properties`` specifies what |TS| will check when per Verify both the signature and the SNI in the origin certificate. +If ``client_rpk_enabled`` is set and the next hop authenticates with a raw public key rather than +a certificate, ``verify_server_properties``'s :code:`NAME` check has nothing to act on -- a raw +public key has no subject alternative name -- so ``server_rpk_ca`` (matching the offered key +against a configured trusted set) takes its place. ``verify_server_policy`` still governs whether +a key that is not in the trusted set is fatal, exactly as for a failed certificate check. Since +raw public keys are negotiated alongside X.509 rather than instead of it, this only applies when +the next hop actually offers one; a next hop that does not support RFC 7250 falls back to a normal +certificate exchange, and the usual certificate checks apply. + If ``tunnel_route`` is specified, none of the certificate verification will be done because the TLS negotiation will be tunneled to the upstream target, making those values irrelevant for that configuration item. This option is explained in more detail in :ref:`sni-routing`. @@ -430,6 +468,19 @@ Disable HTTP/2 for ``no-http2.example.com``. - fqdn: no-http2.example.com http2: off +Offer a raw public key (RFC 7250) alongside the certificate when connecting to +``parent.example.com``, and pin the exact key it must present. The connection falls back to a +normal certificate exchange if ``parent.example.com`` does not (yet) support raw public keys -- +the expected state throughout a rolling upgrade of that next hop, not a failure. + +.. code-block:: yaml + + sni: + - fqdn: parent.example.com + client_rpk_enabled: true + server_rpk_ca: parent-rpk-trusted.pem + verify_server_policy: ENFORCED + Require client certificate verification for ``foo.com`` and any server name ending with ``.yahoo.com``. Therefore, client request for a server name ending with yahoo.com (e.g., def.yahoo.com, abc.yahoo.com etc.) will cause |TS| require and verify the client certificate. diff --git a/doc/admin-guide/files/ssl_multicert.yaml.en.rst b/doc/admin-guide/files/ssl_multicert.yaml.en.rst index 2c27d7e4a98..99166d01e8a 100644 --- a/doc/admin-guide/files/ssl_multicert.yaml.en.rst +++ b/doc/admin-guide/files/ssl_multicert.yaml.en.rst @@ -129,6 +129,33 @@ ssl_key_dialog: builtin|"exec:/path/to/program [args]" (optional) program runs a security check to ensure that the system is not compromised by an attacker before providing the pass phrase. +ssl_rpk_enabled: 1|0 (optional) + Set to `1` to offer a raw public key (`RFC 7250 + `_), derived from this entry's ``ssl_cert_name``/ + ``ssl_key_name``, as an alternative to the X.509 certificate for inbound connections matching + this entry. The raw public key is offered alongside the certificate, not instead of it: a + client that does not support RFC 7250 negotiates a normal certificate exchange instead. + Defaults to `0`. + + Only available in builds linked against a TLS library with RFC 7250 support (OpenSSL 3.2 or + later, or a sufficiently recent BoringSSL). If this key is set on a build without that support, + |TS| logs a warning and ignores it. + +ssl_client_rpk_ca_name: FILENAME (optional) + The name of the file containing the raw public key(s) (RFC 7250) that a client is trusted to + present for mutual TLS on this entry, PEM-encoded. *FILENAME* is resolved relative to the + :ts:cv:`proxy.config.ssl.CA.cert.path` configuration variable. The file may contain more than + one key concatenated together, which allows an "old" and "new" key to both be trusted during a + planned key rotation. + + A client authenticating with a raw public key has no certificate chain to verify, so this + trusted set takes the place of the usual client-certificate verification for that connection; + whether the client certificate level configuration is set to fail closed or only log applies + here in the same way it does for a failed certificate check. + + Only available in builds linked against a TLS library with RFC 7250 support; see + ``ssl_rpk_enabled`` above. + action: tunnel (optional) If set to ``tunnel``, Traffic Server will not participate in the TLS handshake and will blind tunnel the connection instead. @@ -253,6 +280,20 @@ pass phrase to decrypt the keys. - ssl_cert_name: server2.pem ssl_key_dialog: "exec:/usr/bin/mypass foo 'ba r'" +The following example configures Traffic Server to use the SSL certificate +``server.pem`` for all requests, and offers a raw public key (RFC 7250) +alongside it. Clients presenting a raw public key for mutual TLS are checked +against ``client-rpk-trusted.pem`` instead of the usual certificate chain. + +.. code-block:: yaml + + ssl_multicert: + - dest_ip: "*" + ssl_cert_name: server.pem + ssl_key_name: server.key + ssl_rpk_enabled: 1 + ssl_client_rpk_ca_name: client-rpk-trusted.pem + Migration from ssl_multicert.config =================================== diff --git a/include/config/ssl_multicert.h b/include/config/ssl_multicert.h index b7984eba301..a1c016c131e 100644 --- a/include/config/ssl_multicert.h +++ b/include/config/ssl_multicert.h @@ -37,16 +37,18 @@ namespace config * Represents a single certificate entry in ssl_multicert configuration. */ struct SSLMultiCertEntry { - std::string ssl_cert_name; ///< Certificate file name (required unless action is tunnel). - std::string dest_ip{"*"}; ///< IP address to match (default "*"). - std::string ssl_key_name; ///< Private key file name (optional). - std::string ssl_ca_name; ///< CA certificate file name (optional). - std::string ssl_ocsp_name; ///< OCSP response file name (optional). - std::string ssl_key_dialog; ///< Passphrase dialog method (optional). - std::string dest_fqdn; ///< Destination FQDN (optional). - std::string action; ///< Action (e.g., "tunnel"). - std::optional ssl_ticket_enabled; ///< Session ticket enabled (optional). - std::optional ssl_ticket_number; ///< Number of session tickets (optional). + std::string ssl_cert_name; ///< Certificate file name (required unless action is tunnel). + std::string dest_ip{"*"}; ///< IP address to match (default "*"). + std::string ssl_key_name; ///< Private key file name (optional). + std::string ssl_ca_name; ///< CA certificate file name (optional). + std::string ssl_ocsp_name; ///< OCSP response file name (optional). + std::string ssl_key_dialog; ///< Passphrase dialog method (optional). + std::string dest_fqdn; ///< Destination FQDN (optional). + std::string action; ///< Action (e.g., "tunnel"). + std::optional ssl_ticket_enabled; ///< Session ticket enabled (optional). + std::optional ssl_ticket_number; ///< Number of session tickets (optional). + std::optional ssl_rpk_enabled; ///< Offer a RFC 7250 raw public key alongside X.509 (optional). + std::string ssl_client_rpk_ca_name; ///< Trusted client raw public keys file name (optional). }; /// A configuration is a vector of certificate entries. diff --git a/include/iocore/net/SSLSNIConfig.h b/include/iocore/net/SSLSNIConfig.h index 64dd23a7c5d..bcd5196f672 100644 --- a/include/iocore/net/SSLSNIConfig.h +++ b/include/iocore/net/SSLSNIConfig.h @@ -49,8 +49,11 @@ // Properties for the next hop server struct NextHopProperty { - std::string client_cert_file; // full path to client cert file for lookup - std::string client_key_file; // full path to client key file for lookup + std::string client_cert_file; // full path to client cert file for lookup + std::string client_key_file; // full path to client key file for lookup + bool client_rpk_enabled = false; // offer a RFC 7250 raw public key (derived from the configured client + // cert/key) alongside X.509 when connecting to this next hop + std::string server_rpk_ca_file; // full path to the PEM of trusted next-hop raw public keys to pin against YamlSNIConfig::Policy verify_server_policy = YamlSNIConfig::Policy::UNSET; // whether to verify the next hop YamlSNIConfig::Property verify_server_properties = YamlSNIConfig::Property::UNSET; // what to verify on the next hop }; diff --git a/include/iocore/net/YamlSNIConfig.h b/include/iocore/net/YamlSNIConfig.h index 91d0683f8f3..0687651e296 100644 --- a/include/iocore/net/YamlSNIConfig.h +++ b/include/iocore/net/YamlSNIConfig.h @@ -55,6 +55,8 @@ TSDECL(verify_server_properties); TSDECL(verify_origin_server); TSDECL(client_cert); TSDECL(client_key); +TSDECL(client_rpk_enabled); +TSDECL(server_rpk_ca); TSDECL(client_sni_policy); TSDECL(server_cipher_suite); TSDECL(server_TLSv1_3_cipher_suites); @@ -105,6 +107,8 @@ struct YamlSNIConfig { Property verify_server_properties = Property::UNSET; std::string client_cert; std::string client_key; + bool client_rpk_enabled = false; + std::string server_rpk_ca; std::string client_sni_policy; std::string server_cipher_suite; std::string server_TLSv1_3_cipher_suites; diff --git a/include/tscore/ink_config.h.cmake.in b/include/tscore/ink_config.h.cmake.in index fe19d38e484..6492b48f505 100644 --- a/include/tscore/ink_config.h.cmake.in +++ b/include/tscore/ink_config.h.cmake.in @@ -166,6 +166,7 @@ const int DEFAULT_STACKSIZE = @DEFAULT_STACK_SIZE@; #cmakedefine01 TS_USE_QUIC #cmakedefine01 TS_USE_QMUX #cmakedefine01 TS_USE_REMOTE_UNWINDING +#cmakedefine01 TS_USE_RPK #cmakedefine01 TS_USE_TLS13 #cmakedefine01 TS_USE_TLS_ASYNC #cmakedefine01 TS_USE_TPROXY @@ -178,6 +179,8 @@ const int DEFAULT_STACKSIZE = @DEFAULT_STACK_SIZE@; #cmakedefine HAVE_SSL_SET_MAX_EARLY_DATA #cmakedefine01 HAVE_SSL_CTX_SET_CLIENT_HELLO_CB #cmakedefine01 HAVE_SSL_CTX_SET_SELECT_CERTIFICATE_CB +#cmakedefine01 HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE +#cmakedefine01 HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY #cmakedefine01 HAVE_SSL_GET_SHARED_CURVE #cmakedefine01 HAVE_SSL_GET_CURVE_NAME #cmakedefine01 HAVE_SSL_GET0_GROUP_NAME diff --git a/src/config/ssl_multicert.cc b/src/config/ssl_multicert.cc index 873068df112..98e1d5add86 100644 --- a/src/config/ssl_multicert.cc +++ b/src/config/ssl_multicert.cc @@ -43,21 +43,24 @@ constexpr swoc::Errata::Severity ERRATA_WARN_SEV{static_cast(DL_Error)}; // YAML key names. -constexpr char KEY_SSL_CERT_NAME[] = "ssl_cert_name"; -constexpr char KEY_DEST_IP[] = "dest_ip"; -constexpr char KEY_SSL_KEY_NAME[] = "ssl_key_name"; -constexpr char KEY_SSL_CA_NAME[] = "ssl_ca_name"; -constexpr char KEY_SSL_OCSP_NAME[] = "ssl_ocsp_name"; -constexpr char KEY_SSL_KEY_DIALOG[] = "ssl_key_dialog"; -constexpr char KEY_DEST_FQDN[] = "dest_fqdn"; -constexpr char KEY_SSL_TICKET_ENABLED[] = "ssl_ticket_enabled"; -constexpr char KEY_SSL_TICKET_NUMBER[] = "ssl_ticket_number"; -constexpr char KEY_ACTION[] = "action"; -constexpr char KEY_SSL_MULTICERT[] = "ssl_multicert"; +constexpr char KEY_SSL_CERT_NAME[] = "ssl_cert_name"; +constexpr char KEY_DEST_IP[] = "dest_ip"; +constexpr char KEY_SSL_KEY_NAME[] = "ssl_key_name"; +constexpr char KEY_SSL_CA_NAME[] = "ssl_ca_name"; +constexpr char KEY_SSL_OCSP_NAME[] = "ssl_ocsp_name"; +constexpr char KEY_SSL_KEY_DIALOG[] = "ssl_key_dialog"; +constexpr char KEY_DEST_FQDN[] = "dest_fqdn"; +constexpr char KEY_SSL_TICKET_ENABLED[] = "ssl_ticket_enabled"; +constexpr char KEY_SSL_TICKET_NUMBER[] = "ssl_ticket_number"; +constexpr char KEY_SSL_RPK_ENABLED[] = "ssl_rpk_enabled"; +constexpr char KEY_SSL_CLIENT_RPK_CA_NAME[] = "ssl_client_rpk_ca_name"; +constexpr char KEY_ACTION[] = "action"; +constexpr char KEY_SSL_MULTICERT[] = "ssl_multicert"; std::set const valid_keys = { - KEY_SSL_CERT_NAME, KEY_DEST_IP, KEY_SSL_KEY_NAME, KEY_SSL_CA_NAME, KEY_SSL_OCSP_NAME, - KEY_SSL_KEY_DIALOG, KEY_DEST_FQDN, KEY_SSL_TICKET_ENABLED, KEY_SSL_TICKET_NUMBER, KEY_ACTION, + KEY_SSL_CERT_NAME, KEY_DEST_IP, KEY_SSL_KEY_NAME, KEY_SSL_CA_NAME, + KEY_SSL_OCSP_NAME, KEY_SSL_KEY_DIALOG, KEY_DEST_FQDN, KEY_SSL_TICKET_ENABLED, + KEY_SSL_TICKET_NUMBER, KEY_ACTION, KEY_SSL_RPK_ENABLED, KEY_SSL_CLIENT_RPK_CA_NAME, }; /** @@ -128,6 +131,8 @@ emit_entry(YAML::Emitter &emitter, config::SSLMultiCertEntry const &entry) write_field(KEY_ACTION, entry.action); write_int_field(KEY_SSL_TICKET_ENABLED, entry.ssl_ticket_enabled); write_int_field(KEY_SSL_TICKET_NUMBER, entry.ssl_ticket_number); + write_int_field(KEY_SSL_RPK_ENABLED, entry.ssl_rpk_enabled); + write_field(KEY_SSL_CLIENT_RPK_CA_NAME, entry.ssl_client_rpk_ca_name); emitter << YAML::EndMap; } @@ -176,6 +181,14 @@ template <> struct convert { entry.ssl_ticket_number = node[KEY_SSL_TICKET_NUMBER].as(); } + if (node[KEY_SSL_RPK_ENABLED]) { + entry.ssl_rpk_enabled = node[KEY_SSL_RPK_ENABLED].as(); + } + + if (node[KEY_SSL_CLIENT_RPK_CA_NAME]) { + entry.ssl_client_rpk_ca_name = node[KEY_SSL_CLIENT_RPK_CA_NAME].as(); + } + if (node[KEY_ACTION]) { entry.action = node[KEY_ACTION].as(); } @@ -330,6 +343,10 @@ SSLMultiCertParser::parse_legacy(std::string_view content) entry.ssl_ticket_enabled = swoc::svtoi(value); } else if (key == KEY_SSL_TICKET_NUMBER) { entry.ssl_ticket_number = swoc::svtoi(value); + } else if (key == KEY_SSL_RPK_ENABLED) { + entry.ssl_rpk_enabled = swoc::svtoi(value); + } else if (key == KEY_SSL_CLIENT_RPK_CA_NAME) { + entry.ssl_client_rpk_ca_name = value; } else if (unknown_keys.insert(key).second) { errata.note(ERRATA_NOTE_SEV, "Ignoring unknown ssl_multicert key '{}' in legacy format", key); } diff --git a/src/iocore/net/CMakeLists.txt b/src/iocore/net/CMakeLists.txt index b317e26b3ea..3451a22a681 100644 --- a/src/iocore/net/CMakeLists.txt +++ b/src/iocore/net/CMakeLists.txt @@ -47,6 +47,7 @@ add_library( SSLNetVConnection.cc SSLNextProtocolAccept.cc SSLNextProtocolSet.cc + SSLRPKUtils.cc SSLSNIConfig.cc SSLStats.cc SSLSessionCache.cc @@ -147,6 +148,7 @@ if(BUILD_TESTING) unit_tests/test_ProxyProtocol.cc unit_tests/test_SSLCertLookup.cc unit_tests/test_SSLNetVConnectionAsyncEp.cc + unit_tests/test_SSLRPKUtils.cc unit_tests/test_SSLSNIConfig.cc unit_tests/test_YamlSNIConfig.cc unit_tests/test_OCSPStapling.cc diff --git a/src/iocore/net/P_SSLCertLookup.h b/src/iocore/net/P_SSLCertLookup.h index 6367818ece3..f7195cb4fa0 100644 --- a/src/iocore/net/P_SSLCertLookup.h +++ b/src/iocore/net/P_SSLCertLookup.h @@ -57,17 +57,20 @@ struct SSLMultiCertConfigParams { session_ticket_number = RecGetRecordInt("proxy.config.ssl.server.session_ticket.number").value_or(0); } - int session_ticket_enabled; ///< session ticket enabled - int session_ticket_number; ///< amount of session tickets to issue for new TLSv1.3 connections - ats_scoped_str addr; ///< IPv[64] address to match - ats_scoped_str cert; ///< certificate - ats_scoped_str first_cert; ///< the first certificate name when multiple cert files are in 'ssl_cert_name' - ats_scoped_str ca; ///< CA public certificate - ats_scoped_str key; ///< Private key - ats_scoped_str ocsp_response; ///< prefetched OCSP response - ats_scoped_str dialog; ///< Private key dialog - ats_scoped_str servername; ///< Destination server - SSLCertContextOption opt; ///< SSLCertContext special handling option + int session_ticket_enabled; ///< session ticket enabled + int session_ticket_number; ///< amount of session tickets to issue for new TLSv1.3 connections + ats_scoped_str addr; ///< IPv[64] address to match + ats_scoped_str cert; ///< certificate + ats_scoped_str first_cert; ///< the first certificate name when multiple cert files are in 'ssl_cert_name' + ats_scoped_str ca; ///< CA public certificate + ats_scoped_str key; ///< Private key + ats_scoped_str ocsp_response; ///< prefetched OCSP response + ats_scoped_str dialog; ///< Private key dialog + ats_scoped_str servername; ///< Destination server + bool rpk_enabled = false; ///< Offer RFC 7250 raw public keys (using this entry's existing cert/key as the + ///< identity) alongside X.509, negotiated per connection + ats_scoped_str client_rpk_ca; ///< Trusted client raw public keys (PEM, may contain more than one) for inbound mTLS pinning + SSLCertContextOption opt; ///< SSLCertContext special handling option }; struct ssl_ticket_key_t { diff --git a/src/iocore/net/P_SSLClientUtils.h b/src/iocore/net/P_SSLClientUtils.h index 6be333ccac2..53541599e6f 100644 --- a/src/iocore/net/P_SSLClientUtils.h +++ b/src/iocore/net/P_SSLClientUtils.h @@ -24,6 +24,7 @@ #include "P_SSLConfig.h" #include +#include #include // BoringSSL does not have this include file @@ -40,3 +41,17 @@ SSL_CTX *SSLCreateClientContext(const struct SSLConfigParams *params, const char int verify_callback(int preverify_ok, X509_STORE_CTX *ctx); bool validate_server_certificate_hostname(NetVConnection *netvc, std::string_view hostname); + +#if TS_USE_RPK +/** Configure @a ssl to offer and/or pin RFC 7250 raw public keys for an outbound connection. + + @a trusted_key_file, when non-empty, is a PEM of the next hop's acceptable raw public keys; + the loaded set is attached to @a ssl for the verify callback to pin against. @a offer_rpk + advertises a raw public key (derived from the client certificate/key already configured on + the context) as an alternative to X.509 for our own identity. + + Both are advertised alongside X.509 rather than replacing it, so a next hop that doesn't + support RPK negotiates down to a normal certificate exchange. + */ +bool ssl_client_setup_rpk(SSL *ssl, bool offer_rpk, const std::string &trusted_key_file); +#endif diff --git a/src/iocore/net/SSLClientUtils.cc b/src/iocore/net/SSLClientUtils.cc index 0f65c29f53c..5f38fa1db3f 100644 --- a/src/iocore/net/SSLClientUtils.cc +++ b/src/iocore/net/SSLClientUtils.cc @@ -23,6 +23,7 @@ #include "P_SSLConfig.h" #include "P_SSLNetVConnection.h" #include "P_TLSKeyLogger.h" +#include "SSLRPKUtils.h" #include "SSLSessionCache.h" #include "TLSCertCompression.h" #include "iocore/net/TLSBasicSupport.h" @@ -36,6 +37,8 @@ #include #include +#include + SSLOriginSessionCache *origin_sess_cache; namespace @@ -43,6 +46,29 @@ namespace DbgCtl dbg_ctl_ssl_verify{"ssl_verify"}; DbgCtl dbg_ctl_ssl_origin_session_cache{"ssl.origin_session_cache"}; +#if TS_USE_RPK +// SSL-level (not SSL_CTX-level, unlike the inbound side) ex_data index holding the trusted +// next-hop raw public keys for this connection. Outbound cert selection is already per-connection +// here, and SSLConfigParams::getCTX() caches contexts by (cert, key, CA) -- attaching pins to the +// shared context would leak one next hop's pin set onto every other hop sharing that cache entry. +int ssl_server_rpk_index = -1; + +void +ssl_server_rpk_ex_free(void * /*parent*/, void *ptr, CRYPTO_EX_DATA * /*ad*/, int /*idx*/, long /*argl*/, void * /*argp*/) +{ + delete static_cast(ptr); +} + +const SSLRPKUtils::TrustedKeySet * +ssl_get_trusted_rpk(const SSL *ssl) +{ + if (ssl_server_rpk_index < 0) { + return nullptr; + } + return static_cast(SSL_get_ex_data(ssl, ssl_server_rpk_index)); +} +#endif + } // end anonymous namespace int @@ -79,6 +105,50 @@ verify_callback(int signature_ok, X509_STORE_CTX *ctx) bool check_sig = static_cast(netvc->options.verifyServerProperties) & static_cast(YamlSNIConfig::Property::SIGNATURE_MASK); +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + if (EVP_PKEY *peer_rpk = X509_STORE_CTX_get0_rpk(ctx); peer_rpk != nullptr) { + // The next hop authenticated with a raw public key. There is no chain to walk and no SAN to + // match, so the configured pin set replaces both the signature and name checks below; the + // SIGNATURE_MASK/NAME_MASK properties have nothing to act on. verifyServerPolicy still + // decides whether a mismatch is fatal, matching the X.509 paths. + // + // Note `signature_ok` is always 0 here: without DANE enabled, OpenSSL presets + // X509_V_ERR_RPK_UNTRUSTED and invokes this callback with `ctx->error == X509_V_OK` being + // false (see verify_rpk() in crypto/x509/x509_vfy.c). Our pin match is what decides the + // outcome, so on success the preset error has to be cleared -- otherwise it survives into + // SSL_get_verify_result() and marks a properly pinned connection as unverified. + const SSLRPKUtils::TrustedKeySet *trusted = ssl_get_trusted_rpk(ssl); + bool pin_ok = trusted != nullptr && SSLRPKUtils::pinnedKeyMatches(peer_rpk, *trusted); + Dbg(dbg_ctl_ssl_verify, "Origin authenticated with a raw public key (RFC 7250), pin match=%s", pin_ok ? "yes" : "no"); + if (pin_ok) { + X509_STORE_CTX_set_error(ctx, X509_V_OK); + } else { + char buff[INET6_ADDRSTRLEN]; + ats_ip_ntop(netvc->get_effective_remote_addr(), buff, INET6_ADDRSTRLEN); + Warning("Origin raw public key did not match any trusted key. Action=%s server=%s(%s)", + enforce_mode ? "Terminate" : "Continue", netvc->options.ssl_servername.get(), buff); + if (!enforce_mode) { + // Permissive mode continues the handshake, and the X.509 paths likewise leave the + // recorded error in place for a failure that is only warned about. + X509_STORE_CTX_set_error(ctx, X509_V_ERR_RPK_UNTRUSTED); + } + } + // The hook always runs, as on the X.509 path below: plugins observe every attempt and may add + // rejection, but cannot turn a failed pin match into acceptance. + TLSBasicSupport *tbs = TLSBasicSupport::getInstance(ssl); + if (tbs == nullptr) { + Dbg(dbg_ctl_ssl_verify, "call back on stale netvc"); + return false; + } + if (tbs->verify_certificate(ctx) == 1) { + Warning("TS_EVENT_SSL_VERIFY_SERVER plugin failed the origin raw public key check for %s. Action=%s", + netvc->options.ssl_servername.get(), enforce_mode ? "Terminate" : "Continue"); + return !enforce_mode; + } + return pin_ok || !enforce_mode; + } +#endif + if (check_sig) { if (!signature_ok) { Dbg(dbg_ctl_ssl_verify, "verification error:num=%d:%s:depth=%d", err, X509_verify_cert_error_string(err), depth); @@ -155,6 +225,171 @@ verify_callback(int signature_ok, X509_STORE_CTX *ctx) return true; } +#if HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY +// BoringSSL rejects raw public keys outright unless a custom verify callback is installed, and +// SSL_set_custom_verify() displaces SSL_set_verify() (and with it BoringSSL's automatic chain +// verification) for the whole connection. So this callback owns both cases: pin the peer's raw +// public key, or -- when the next hop negotiated X.509 after all, the normal state mid-rollout -- +// rebuild and verify the chain by hand before deferring to the usual policy/name/hook logic. +static enum ssl_verify_result_t +ssl_client_custom_verify_callback(SSL *ssl, uint8_t *out_alert) +{ + SSLNetVConnection *netvc = SSLNetVCAccess(ssl); + if (netvc == nullptr) { + Dbg(dbg_ctl_ssl_verify, "WARNING, NetVC is NULL in custom cert verify callback"); + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + if (netvc->options.verifyServerPolicy == YamlSNIConfig::Policy::DISABLED) { + return ssl_verify_ok; + } + + bool const enforce_mode = netvc->options.verifyServerPolicy == YamlSNIConfig::Policy::ENFORCED; + + TLSBasicSupport *tbs = TLSBasicSupport::getInstance(ssl); + if (tbs == nullptr) { + Dbg(dbg_ctl_ssl_verify, "custom verify callback on stale netvc"); + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + + if (SSL_get_peer_cert_type(ssl) == TLSEXT_cert_type_rpk) { + EVP_PKEY *peer_rpk = SSL_get0_peer_rpk(ssl); + const SSLRPKUtils::TrustedKeySet *trusted = ssl_get_trusted_rpk(ssl); + bool pin_ok = trusted != nullptr && SSLRPKUtils::pinnedKeyMatches(peer_rpk, *trusted); + Dbg(dbg_ctl_ssl_verify, "Origin authenticated with a raw public key (RFC 7250), pin match=%s", pin_ok ? "yes" : "no"); + if (!pin_ok) { + char buff[INET6_ADDRSTRLEN]; + ats_ip_ntop(netvc->get_effective_remote_addr(), buff, INET6_ADDRSTRLEN); + Warning("Origin raw public key did not match any trusted key. Action=%s server=%s(%s)", + enforce_mode ? "Terminate" : "Continue", netvc->options.ssl_servername.get(), buff); + } + + // There is no X509_STORE_CTX to hand the hook for a raw public key, but the hook still runs + // on every attempt, as on the X.509 paths. + if (tbs->verify_certificate(nullptr) == 1) { + Warning("TS_EVENT_SSL_VERIFY_SERVER plugin failed the origin raw public key check for %s. Action=%s", + netvc->options.ssl_servername.get(), enforce_mode ? "Terminate" : "Continue"); + if (enforce_mode) { + *out_alert = SSL_AD_CERTIFICATE_UNKNOWN; + return ssl_verify_invalid; + } + return ssl_verify_ok; + } + if (!pin_ok && enforce_mode) { + *out_alert = SSL_AD_CERTIFICATE_UNKNOWN; + return ssl_verify_invalid; + } + return ssl_verify_ok; + } + + // X.509 fallback. Rebuild the chain BoringSSL hands back as CRYPTO_BUFFERs so the shared + // verify_callback() logic (signature/name/policy/hook) can run against a real X509_STORE_CTX. + const STACK_OF(CRYPTO_BUFFER) *chain = SSL_get0_peer_certificates(ssl); + if (chain == nullptr || sk_CRYPTO_BUFFER_num(chain) == 0) { + if (enforce_mode) { + *out_alert = SSL_AD_CERTIFICATE_REQUIRED; + return ssl_verify_invalid; + } + return ssl_verify_ok; + } + + X509 *leaf = nullptr; + STACK_OF(X509) *intermediates = sk_X509_new_null(); + if (intermediates == nullptr) { + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + for (size_t i = 0; i < sk_CRYPTO_BUFFER_num(chain); i++) { + const CRYPTO_BUFFER *buf = sk_CRYPTO_BUFFER_value(chain, i); + const uint8_t *data = CRYPTO_BUFFER_data(buf); + X509 *cert = d2i_X509(nullptr, &data, CRYPTO_BUFFER_len(buf)); + if (cert == nullptr) { + SSLError("failed to parse an origin certificate on a RPK-enabled connection"); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + *out_alert = SSL_AD_BAD_CERTIFICATE; + return ssl_verify_invalid; + } + if (i == 0) { + leaf = cert; + } else { + sk_X509_push(intermediates, cert); + } + } + + X509_STORE_CTX *store_ctx = X509_STORE_CTX_new(); + bool const initialized = + store_ctx != nullptr && X509_STORE_CTX_init(store_ctx, SSL_CTX_get_cert_store(SSL_get_SSL_CTX(ssl)), leaf, intermediates); + bool accepted = false; + if (initialized) { + X509_STORE_CTX_set_depth(store_ctx, SSL_CTX_get_verify_depth(SSL_get_SSL_CTX(ssl))); + + bool const signature_ok = X509_verify_cert(store_ctx) == 1; + bool const check_sig = + static_cast(netvc->options.verifyServerProperties) & static_cast(YamlSNIConfig::Property::SIGNATURE_MASK); + bool const check_name = + static_cast(netvc->options.verifyServerProperties) & static_cast(YamlSNIConfig::Property::NAME_MASK); + + char buff[INET6_ADDRSTRLEN]; + ats_ip_ntop(netvc->get_effective_remote_addr(), buff, INET6_ADDRSTRLEN); + std::string_view sni_name = netvc->options.sni_servername ? netvc->options.sni_servername.get() : buff; + + // This mirrors verify_callback()'s terminal-certificate logic rather than delegating to it. + // OpenSSL drives that callback once per chain depth and it bails out at the depth that + // failed; here X509_verify_cert() has already collapsed the whole chain into one verdict, so + // running the remaining checks inline is what keeps permissive mode behaving the same -- + // a chain failure must still fall through to the name check and the hook, not return early. + accepted = true; + if (check_sig && !signature_ok) { + int const err = X509_STORE_CTX_get_error(store_ctx); + Dbg(dbg_ctl_ssl_verify, "verification error:num=%d:%s", err, X509_verify_cert_error_string(err)); + Warning("Core server certificate verification failed for (%.*s). Action=%s Error=%s server=%s(%s)", + static_cast(sni_name.length()), sni_name.data(), enforce_mode ? "Terminate" : "Continue", + X509_verify_cert_error_string(err), netvc->options.ssl_servername.get(), buff); + accepted = !enforce_mode; + } + + if (accepted && check_name) { + char *matched_name = nullptr; + if (validate_hostname(leaf, sni_name, false, &matched_name)) { + Dbg(dbg_ctl_ssl_verify, "Hostname %.*s verified OK, matched %s", static_cast(sni_name.length()), sni_name.data(), + matched_name); + ats_free(matched_name); + } else { + Warning("SNI (%.*s) not in certificate. Action=%s server=%s(%s)", static_cast(sni_name.length()), sni_name.data(), + enforce_mode ? "Terminate" : "Continue", netvc->options.ssl_servername.get(), buff); + accepted = !enforce_mode; + } + } + + // As on the other paths, the hook always runs and may only add rejection. + if (tbs->verify_certificate(store_ctx) == 1) { + Warning("TS_EVENT_SSL_VERIFY_SERVER plugin failed the origin certificate check for %s. Action=%s SNI=%.*s", + netvc->options.ssl_servername.get(), enforce_mode ? "Terminate" : "Continue", static_cast(sni_name.length()), + sni_name.data()); + accepted = !enforce_mode; + } + } else { + SSLError("failed to initialize X509_STORE_CTX for origin certificate verification"); + } + + X509_STORE_CTX_free(store_ctx); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + + if (!initialized) { + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + if (!accepted) { + *out_alert = SSL_AD_CERTIFICATE_UNKNOWN; + return ssl_verify_invalid; + } + return ssl_verify_ok; +} +#endif + bool validate_server_certificate_hostname(NetVConnection *netvc, std::string_view hostname) { @@ -168,6 +403,17 @@ validate_server_certificate_hostname(NetVConnection *netvc, std::string_view hos return true; } +#if TS_USE_RPK + // A resumed session that originally authenticated with a raw public key has no certificate and + // no SAN to match a hostname against; the pin check done during the original handshake stands. + // The live connection's negotiation state is gone by now, so this has to consult the session + // rather than SSL_get0_peer_rpk(). + if (SSL_SESSION *session = SSL_get_session(ssl); session != nullptr && SSL_SESSION_get0_peer_rpk(session) != nullptr) { + Dbg(dbg_ctl_ssl_verify, "Skipping hostname validation for session reuse: peer authenticated with a raw public key"); + return true; + } +#endif + bool check_name = static_cast(netvc->options.verifyServerProperties) & static_cast(YamlSNIConfig::Property::NAME_MASK); if (!check_name) { @@ -236,6 +482,92 @@ ssl_new_session_callback(SSL *ssl, SSL_SESSION *sess) return 0; } +#if TS_USE_RPK +bool +ssl_client_setup_rpk(SSL *ssl, bool offer_rpk, const std::string &trusted_key_file) +{ + if (!offer_rpk && trusted_key_file.empty()) { + return true; + } + + static std::once_flag rpk_index_once; + std::call_once(rpk_index_once, []() { + ssl_server_rpk_index = SSL_get_ex_new_index(0, (void *)"Trusted next-hop RPK keys", nullptr, nullptr, ssl_server_rpk_ex_free); + }); + if (ssl_server_rpk_index < 0) { + SSLError("failed to reserve an ex_data index for next-hop raw public keys"); + return false; + } + + if (!trusted_key_file.empty()) { + auto *trusted = new SSLRPKUtils::TrustedKeySet(); + if (!SSLRPKUtils::loadTrustedKeys(trusted_key_file.c_str(), *trusted)) { + delete trusted; + return false; + } + // ssl_server_rpk_ex_free() releases `trusted` when ssl is freed. + if (!SSL_set_ex_data(ssl, ssl_server_rpk_index, trusted)) { + delete trusted; + SSLError("failed to attach trusted next-hop raw public keys to the connection"); + return false; + } + + // Accept a raw public key from the next hop, still preferring it over X.509 only when the + // peer also supports it. + static const unsigned char server_types[] = {TLSEXT_cert_type_rpk, TLSEXT_cert_type_x509}; +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + if (!SSL_set1_server_cert_type(ssl, server_types, sizeof(server_types))) { +#else + if (!SSL_set1_accepted_peer_cert_types(ssl, server_types, sizeof(server_types))) { +#endif + SSLError("failed to enable RPK server cert type negotiation for the outbound connection"); + return false; + } + } + + if (offer_rpk) { + static const unsigned char client_types[] = {TLSEXT_cert_type_rpk, TLSEXT_cert_type_x509}; + // Both libraries derive/wrap the offered raw public key from the client certificate/key + // already configured on the context -- there is nothing to offer if that's unset. + if (SSL_CTX_get0_privatekey(SSL_get_SSL_CTX(ssl)) == nullptr) { + SSLError("client_rpk_enabled requires a client certificate/key configured for this next hop"); + return false; + } +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + // OpenSSL derives the offered raw public key from the certificate/key already on the context. + if (!SSL_set1_client_cert_type(ssl, client_types, sizeof(client_types))) { + SSLError("failed to enable RPK client cert type negotiation for the outbound connection"); + return false; + } +#else + // BoringSSL needs an explicit credential, wrapping that same already-configured key. + EVP_PKEY *pkey = SSL_CTX_get0_privatekey(SSL_get_SSL_CTX(ssl)); + SSL_CREDENTIAL *cred = SSL_CREDENTIAL_new_raw_public_key(pkey); + if (cred == nullptr || !SSL_add1_credential(ssl, cred)) { + SSLError("failed to add the outbound RPK credential"); + SSL_CREDENTIAL_free(cred); + return false; + } + SSL_CREDENTIAL_free(cred); + if (!SSL_set1_available_client_cert_types(ssl, client_types, sizeof(client_types))) { + SSLError("failed to advertise RPK client cert types for the outbound connection"); + return false; + } +#endif + } + +#if HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY + // BoringSSL rejects raw public keys unless a custom verify callback is installed, and this + // displaces the SSL_set_verify()/verify_callback() pair the caller already set for this + // connection. Only RPK-configured next hops take this path; every other outbound connection + // keeps the classic callback untouched. + SSL_set_custom_verify(ssl, SSL_VERIFY_PEER, ssl_client_custom_verify_callback); +#endif + + return true; +} +#endif + SSL_CTX * SSLInitClientContext(const SSLConfigParams *params) { diff --git a/src/iocore/net/SSLNetVConnection.cc b/src/iocore/net/SSLNetVConnection.cc index 8d2abaf7af0..519e906135c 100644 --- a/src/iocore/net/SSLNetVConnection.cc +++ b/src/iocore/net/SSLNetVConnection.cc @@ -1283,6 +1283,18 @@ SSLNetVConnection::_sslStartHandShake(int event, int &err) SSL_set_verify(this->ssl, SSL_VERIFY_PEER, verify_callback); +#if TS_USE_RPK + // Offer and/or pin RFC 7250 raw public keys when this next hop is configured for them. + // Both are advertised alongside X.509, so a next hop that doesn't (yet) support RPK -- a + // normal state during a rolling upgrade -- negotiates down to a certificate exchange. + if (nps && (nps->client_rpk_enabled || !nps->server_rpk_ca_file.empty())) { + if (!ssl_client_setup_rpk(this->ssl, nps->client_rpk_enabled, nps->server_rpk_ca_file)) { + SSLErrorVC(this, "failed to configure raw public keys for the outbound connection"); + return EVENT_ERROR; + } + } +#endif + // SNI ats_scoped_str &tlsext_host_name = this->options.sni_hostname ? this->options.sni_hostname : this->options.sni_servername; if (tlsext_host_name) { diff --git a/src/iocore/net/SSLRPKUtils.cc b/src/iocore/net/SSLRPKUtils.cc new file mode 100644 index 00000000000..1c9ebc1e217 --- /dev/null +++ b/src/iocore/net/SSLRPKUtils.cc @@ -0,0 +1,134 @@ +/** @file + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#include "SSLRPKUtils.h" + +#include "P_SSLUtils.h" +#include "iocore/net/SSLDiags.h" + +#include +#include +#include + +#include + +namespace SSLRPKUtils +{ +bool +loadTrustedKeys(const char *path, TrustedKeySet &out) +{ + scoped_BIO bio(BIO_new_file(path, "r")); + if (!bio) { + SSLError("SSLRPKUtils: failed to open trusted RPK key file %s", path); + return false; + } + + for (;;) { + // Read the PEM envelope first and decode it separately. Asking PEM_read_bio_PUBKEY() to do + // both makes end-of-file indistinguishable from a decode failure: on OpenSSL 3 it runs the + // decoder framework, so the error left on the queue at EOF is the decoder's "unsupported" + // rather than PEM_R_NO_START_LINE, and a "no more keys" stop looks exactly like a malformed + // key. PEM_read_bio() reports EOF unambiguously via PEM_R_NO_START_LINE. + char *name = nullptr; + char *header = nullptr; + unsigned char *data = nullptr; + long len = 0; + + if (PEM_read_bio(bio.get(), &name, &header, &data, &len) != 1) { + unsigned long err = ERR_peek_last_error(); + bool const at_eof = ERR_GET_REASON(err) == PEM_R_NO_START_LINE; + ERR_clear_error(); + if (at_eof && !out.empty()) { + break; + } + SSLError("SSLRPKUtils: failed to read a PEM block from %s", path); + return false; + } + + bool const is_pubkey = name != nullptr && strcmp(name, PEM_STRING_PUBLIC) == 0; + if (!is_pubkey) { + SSLError("SSLRPKUtils: %s contains a '%s' block; only bare public keys are supported", path, + name != nullptr ? name : "(unnamed)"); + } + + EVP_PKEY *pkey = nullptr; + if (is_pubkey) { + const unsigned char *p = data; + // The PEM payload for a PUBLIC KEY block is already a DER SubjectPublicKeyInfo, which is + // exactly what gets pinned -- but decode it anyway so a corrupt key is rejected at config + // load rather than silently pinned as opaque bytes. + pkey = d2i_PUBKEY(nullptr, &p, len); + if (pkey == nullptr) { + SSLError("SSLRPKUtils: failed to parse a raw public key from %s", path); + } + } + + OPENSSL_free(name); + OPENSSL_free(header); + + if (pkey == nullptr) { + OPENSSL_free(data); + return false; + } + EVP_PKEY_free(pkey); + + out.emplace_back(data, data + len); + OPENSSL_free(data); + } + + return true; +} + +bool +pinnedKeyMatches(const unsigned char *peer_spki_der, int peer_spki_len, const TrustedKeySet &trusted) +{ + if (peer_spki_der == nullptr || peer_spki_len <= 0) { + return false; + } + + for (auto const &key : trusted) { + if (key.size() == static_cast(peer_spki_len) && memcmp(key.data(), peer_spki_der, key.size()) == 0) { + return true; + } + } + + return false; +} + +bool +pinnedKeyMatches(EVP_PKEY *pkey, const TrustedKeySet &trusted) +{ + if (pkey == nullptr) { + return false; + } + + unsigned char *der = nullptr; + int der_len = i2d_PUBKEY(pkey, &der); + if (der_len <= 0) { + return false; + } + + bool matched = pinnedKeyMatches(der, der_len, trusted); + OPENSSL_free(der); + return matched; +} + +} // namespace SSLRPKUtils diff --git a/src/iocore/net/SSLRPKUtils.h b/src/iocore/net/SSLRPKUtils.h new file mode 100644 index 00000000000..1f3cfd22e12 --- /dev/null +++ b/src/iocore/net/SSLRPKUtils.h @@ -0,0 +1,57 @@ +/** @file + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#pragma once + +#include + +#include + +/** + Shared helpers for RFC 7250 Raw Public Key (RPK) support. + + These are used identically regardless of whether ATS is built against + OpenSSL or BoringSSL, so that RPK pinning behaves the same way no matter + which TLS library a given build links. Only the code that calls into these + helpers (extracting the peer's offered key from a `X509_STORE_CTX` on + OpenSSL vs. a `SSL *` on BoringSSL) is library-specific. + */ +namespace SSLRPKUtils +{ +/// One trusted peer key, DER-encoded as a SubjectPublicKeyInfo. +using TrustedKey = std::vector; +using TrustedKeySet = std::vector; + +/** Load a PEM file containing one or more concatenated SubjectPublicKeyInfo + blocks (bare public keys, not certificates) as a set of trusted/pinned + peer keys. Supporting more than one key in the same file allows an + operator to roll an "old" and "new" key during rotation. + @return true if at least one key was loaded, false on I/O or parse failure. +*/ +bool loadTrustedKeys(const char *path, TrustedKeySet &out); + +/// Compare a peer-offered raw public key (DER-encoded SubjectPublicKeyInfo) against @a trusted. +bool pinnedKeyMatches(const unsigned char *peer_spki_der, int peer_spki_len, const TrustedKeySet &trusted); + +/// Convenience wrapper: DER-encode @a pkey as a SubjectPublicKeyInfo and check it against @a trusted. +bool pinnedKeyMatches(EVP_PKEY *pkey, const TrustedKeySet &trusted); + +} // namespace SSLRPKUtils diff --git a/src/iocore/net/SSLSNIConfig.cc b/src/iocore/net/SSLSNIConfig.cc index 12ab464d483..c2626f939d8 100644 --- a/src/iocore/net/SSLSNIConfig.cc +++ b/src/iocore/net/SSLSNIConfig.cc @@ -31,6 +31,7 @@ #include "P_SSLUtils.h" #include "P_SSLConfig.h" +#include "SSLRPKUtils.h" #include "iocore/net/SSLSNIConfig.h" #include "iocore/net/SNIActionItem.h" #include "mgmt/config/ConfigContextDiags.h" @@ -173,6 +174,7 @@ SNIConfigParams::set_next_hop_properties(YamlSNIConfig::Item const &item) nps.set_glob_name(item.fqdn); nps.prop.verify_server_policy = item.verify_server_policy; nps.prop.verify_server_properties = item.verify_server_properties; + nps.prop.client_rpk_enabled = item.client_rpk_enabled; return true; } @@ -194,6 +196,16 @@ SNIConfigParams::load_certs_if_client_cert_specified(YamlSNIConfig::Item const & } } + if (!item.server_rpk_ca.empty()) { + SSLConfig::scoped_config params; + nps.prop.server_rpk_ca_file = Layout::get()->relative_to(params->clientCACertPath, item.server_rpk_ca.data()); + // Fail the config load now rather than at handshake time if the pinned keys are unreadable. + SSLRPKUtils::TrustedKeySet probe; + if (!SSLRPKUtils::loadTrustedKeys(nps.prop.server_rpk_ca_file.c_str(), probe)) { + return false; + } + } + return true; } diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index 2a1e8cf1e96..84941840e20 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -27,6 +27,7 @@ #include "P_SSLNetVConnection.h" #include "P_TLSKeyLogger.h" #include "SSLKeyUtils.h" +#include "SSLRPKUtils.h" #include "SSLStats.h" #include "SSLSessionCache.h" #include "SSLSessionTicket.h" @@ -94,6 +95,19 @@ static constexpr char SSL_CERT_SEPARATE_DELIM = ','; static int ssl_vc_index = -1; +#if TS_USE_RPK +// SSL_CTX-level ex_data index holding this context's trusted client RPK keys (a heap-allocated +// SSLRPKUtils::TrustedKeySet*), so ssl_verify_client_callback() can reach it without needing a +// per-connection back-reference to the SSLMultiCertConfigParams that built the context. +static int ssl_client_rpk_ca_index = -1; + +static void +ssl_client_rpk_ca_ex_free(void * /*parent*/, void *ptr, CRYPTO_EX_DATA * /*ad*/, int /*idx*/, long /*argl*/, void * /*argp*/) +{ + delete static_cast(ptr); +} +#endif + static ink_mutex *mutex_buf = nullptr; static bool open_ssl_initialized = false; @@ -197,6 +211,33 @@ ssl_verify_client_callback(int preverify_ok, X509_STORE_CTX *ctx) return false; } +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + if (EVP_PKEY *peer_rpk = X509_STORE_CTX_get0_rpk(ctx); peer_rpk != nullptr) { + // The client presented a raw public key instead of a certificate: there's no chain and no + // hostname to check, so pinning against the configured trusted keys stands in for preverify_ok. + // The hook still always runs, mirroring the X.509 path below: plugins see every attempt, and + // may add further rejection, but can't turn a failed pin match into acceptance. + // + // `preverify_ok` is always 0 here: with no DANE configured, OpenSSL presets + // X509_V_ERR_RPK_UNTRUSTED before invoking this callback (see verify_rpk() in + // crypto/x509/x509_vfy.c). Clear it on a successful pin match so the preset error doesn't + // survive into SSL_get_verify_result() for a connection we actually accepted. + auto *trusted = static_cast(SSL_CTX_get_ex_data(SSL_get_SSL_CTX(ssl), ssl_client_rpk_ca_index)); + bool pin_ok = trusted != nullptr && SSLRPKUtils::pinnedKeyMatches(peer_rpk, *trusted); + Dbg(dbg_ctl_ssl_verify, "Client authenticated with a raw public key (RFC 7250), pin match=%s", pin_ok ? "yes" : "no"); + if (pin_ok) { + X509_STORE_CTX_set_error(ctx, X509_V_OK); + } else { + Warning("client raw public key did not match any trusted key for %s", netvc->options.sni_servername.get()); + } + if (tbs->verify_certificate(ctx) == 1) { + Warning("TS_EVENT_SSL_VERIFY_CLIENT plugin failed the client certificate check for %s.", netvc->options.sni_servername.get()); + return false; + } + return pin_ok; + } +#endif + if (tbs->verify_certificate(ctx) == 1) { // hook moved the handshake state to terminal Warning("TS_EVENT_SSL_VERIFY_CLIENT plugin failed the client certificate check for %s.", netvc->options.sni_servername.get()); return false; @@ -205,6 +246,107 @@ ssl_verify_client_callback(int preverify_ok, X509_STORE_CTX *ctx) return preverify_ok; } +#if HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY +// BoringSSL's SSL_CTX_set_custom_verify(), required to accept RPK client certs, replaces its +// automatic X.509 chain verification entirely -- unlike OpenSSL's classic SSL_CTX_set_verify(), +// which only lets ssl_verify_client_callback() observe/override a chain BoringSSL already +// validated. For the X.509 fallback case (the client didn't offer an RPK this time), this +// callback must therefore redo that validation manually via the legacy X509_STORE_CTX API, +// against the same certificate store _setup_client_cert_verification() configured on this ctx. +static enum ssl_verify_result_t +ssl_custom_verify_client_callback(SSL *ssl, uint8_t *out_alert) +{ + Dbg(dbg_ctl_ssl_verify, "Callback: custom verify client cert (RPK-enabled ctx)"); + SSLNetVConnection *netvc = SSLNetVCAccess(ssl); + TLSBasicSupport *tbs = TLSBasicSupport::getInstance(ssl); + if (tbs == nullptr) { + Dbg(dbg_ctl_ssl_verify, "ssl_custom_verify_client_callback call back on stale netvc"); + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + + SSL_CTX *ctx = SSL_get_SSL_CTX(ssl); + + if (SSL_get_peer_cert_type(ssl) == TLSEXT_cert_type_rpk) { + EVP_PKEY *peer_rpk = SSL_get0_peer_rpk(ssl); + auto *trusted = static_cast(SSL_CTX_get_ex_data(ctx, ssl_client_rpk_ca_index)); + bool pin_ok = trusted != nullptr && SSLRPKUtils::pinnedKeyMatches(peer_rpk, *trusted); + if (!pin_ok) { + Warning("client raw public key did not match any trusted key for %s", netvc->options.sni_servername.get()); + } + // As above: the hook always runs, and can add rejection but not override a failed pin match. + if (tbs->verify_certificate(nullptr) == 1 || !pin_ok) { + *out_alert = SSL_AD_CERTIFICATE_UNKNOWN; + return ssl_verify_invalid; + } + return ssl_verify_ok; + } + + // X.509 fallback: BoringSSL hands back the raw chain as CRYPTO_BUFFERs, not X509 objects, so + // rebuild it and run the same verification SSL_CTX_set_verify() would otherwise do for us. + const STACK_OF(CRYPTO_BUFFER) *chain = SSL_get0_peer_certificates(ssl); + if (chain == nullptr || sk_CRYPTO_BUFFER_num(chain) == 0) { + *out_alert = SSL_AD_CERTIFICATE_REQUIRED; + return ssl_verify_invalid; + } + + X509 *leaf = nullptr; + STACK_OF(X509) *intermediates = sk_X509_new_null(); + if (intermediates == nullptr) { + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + for (size_t i = 0; i < sk_CRYPTO_BUFFER_num(chain); i++) { + const CRYPTO_BUFFER *buf = sk_CRYPTO_BUFFER_value(chain, i); + const uint8_t *data = CRYPTO_BUFFER_data(buf); + X509 *cert = d2i_X509(nullptr, &data, CRYPTO_BUFFER_len(buf)); + if (cert == nullptr) { + SSLError("failed to parse a client certificate offered to a RPK-enabled context"); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + *out_alert = SSL_AD_BAD_CERTIFICATE; + return ssl_verify_invalid; + } + if (i == 0) { + leaf = cert; + } else { + sk_X509_push(intermediates, cert); + } + } + + X509_STORE_CTX *store_ctx = X509_STORE_CTX_new(); + bool initialized = store_ctx != nullptr && X509_STORE_CTX_init(store_ctx, SSL_CTX_get_cert_store(ctx), leaf, intermediates); + bool verified = false; + if (initialized) { + X509_STORE_CTX_set_depth(store_ctx, SSL_CTX_get_verify_depth(ctx)); + verified = X509_verify_cert(store_ctx) == 1; + if (!verified) { + Dbg(dbg_ctl_ssl_verify, "client certificate chain verification failed: %s", + X509_verify_cert_error_string(X509_STORE_CTX_get_error(store_ctx))); + } + } else { + SSLError("failed to initialize X509_STORE_CTX for client certificate verification"); + } + + // The hook always runs when we have a usable store_ctx to hand it -- even for a chain that + // already failed verification, mirroring ssl_verify_client_callback()'s contract -- but there's + // nothing useful to hand a plugin if we couldn't even build one (an internal error, not a + // normal verification outcome). + bool hook_ok = initialized && tbs->verify_certificate(store_ctx) == 0; + + X509_STORE_CTX_free(store_ctx); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + + if (!initialized || !verified || !hook_ok) { + *out_alert = initialized ? SSL_AD_CERTIFICATE_UNKNOWN : SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; + } + + return ssl_verify_ok; +} +#endif + #if HAVE_SSL_CTX_SET_CLIENT_HELLO_CB // Pausable callback static int @@ -878,6 +1020,11 @@ SSLInitializeLibrary() // the SSLNetVConnection to the SSL session. ssl_vc_index = SSL_get_ex_new_index(0, (void *)"NetVC index", nullptr, nullptr, nullptr); +#if TS_USE_RPK + ssl_client_rpk_ca_index = + SSL_CTX_get_ex_new_index(0, (void *)"Trusted client RPK keys", nullptr, nullptr, ssl_client_rpk_ca_ex_free); +#endif + TLSBasicSupport::initialize(); TLSEventSupport::initialize(); ALPNSupport::initialize(); @@ -1442,8 +1589,20 @@ SSLMultiCertConfigLoader::_setup_client_cert_verification(SSL_CTX *ctx) server_verify_client = SSL_VERIFY_NONE; Error("illegal client certification level %d in %s", server_verify_client, ts::filename::RECORDS); } - SSL_CTX_set_verify(ctx, server_verify_client, ssl_verify_client_callback); SSL_CTX_set_verify_depth(ctx, params->verify_depth); // might want to make configurable at some point. +#if HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY + // On BoringSSL, SSL_CTX_set_verify() and SSL_CTX_set_custom_verify() are mutually exclusive + // per SSL_CTX, and only the latter can see an RPK client cert at all. Only entries that + // configured ssl_client_rpk_ca_name (checked via the ex_data load_certs() attached) take the + // custom_verify path -- everything else keeps today's classic verify behavior unchanged. + if (SSL_CTX_get_ex_data(ctx, ssl_client_rpk_ca_index) != nullptr) { + SSL_CTX_set_custom_verify(ctx, server_verify_client, ssl_custom_verify_client_callback); + } else { + SSL_CTX_set_verify(ctx, server_verify_client, ssl_verify_client_callback); + } +#else + SSL_CTX_set_verify(ctx, server_verify_client, ssl_verify_client_callback); +#endif } return true; } @@ -1946,6 +2105,12 @@ SSLMultiCertConfigLoader::_load_items(SSLCertLookup *lookup, config::SSLMultiCer if (item.ssl_ticket_number.has_value()) { sslMultiCertSettings->session_ticket_number = item.ssl_ticket_number.value(); } + if (item.ssl_rpk_enabled.has_value()) { + sslMultiCertSettings->rpk_enabled = item.ssl_rpk_enabled.value() != 0; + } + if (!item.ssl_client_rpk_ca_name.empty()) { + sslMultiCertSettings->client_rpk_ca = ats_strdup(item.ssl_client_rpk_ca_name.c_str()); + } if (item.action == "tunnel") { sslMultiCertSettings->opt = SSLCertContextOption::OPT_TUNNEL; } @@ -2381,6 +2546,69 @@ SSLMultiCertConfigLoader::load_certs(SSL_CTX *ctx, const std::vectorrpk_enabled) { + // Both libraries derive/wrap the offered raw public key from whatever certificate/private + // key is already configured on this SSL_CTX -- there is nothing to offer if that's unset. + if (SSL_CTX_get0_privatekey(ctx) == nullptr) { + SSLError("ssl_rpk_enabled requires a certificate/key already configured on this entry"); + return false; + } +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + // OpenSSL derives the raw public key it offers from whatever certificate/private key is + // already configured on this SSL_CTX (see SSL_set1_server_cert_type(3)) -- there is no + // separate RPK key to load; enabling the extension is all that's needed here. + static const unsigned char cert_types[] = {TLSEXT_cert_type_rpk, TLSEXT_cert_type_x509}; + if (!SSL_CTX_set1_server_cert_type(ctx, cert_types, sizeof(cert_types))) { + SSLError("failed to enable RPK server cert type negotiation"); + return false; + } +#elif HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY + // BoringSSL's credential model needs an explicit RPK credential, but it can wrap the same + // key already loaded for the X.509 identity above -- no separate key file needed either. + EVP_PKEY *pkey = SSL_CTX_get0_privatekey(ctx); + SSL_CREDENTIAL *cred = SSL_CREDENTIAL_new_raw_public_key(pkey); + if (cred == nullptr || !SSL_CTX_add1_credential(ctx, cred)) { + SSLError("failed to add RPK credential to SSL_CTX"); + SSL_CREDENTIAL_free(cred); + return false; + } + SSL_CREDENTIAL_free(cred); +#else + Warning("ssl_rpk_enabled is set, but this build has no RFC 7250 raw public key support; ignoring"); +#endif + } + + if (sslMultCertSettings->client_rpk_ca) { +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE || HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY + std::string completeClientRPKCAPath(Layout::relative_to(params->serverCACertPath, sslMultCertSettings->client_rpk_ca.get())); + auto *trusted = new SSLRPKUtils::TrustedKeySet(); + if (!SSLRPKUtils::loadTrustedKeys(completeClientRPKCAPath.c_str(), *trusted)) { + delete trusted; + SSLError("failed to load trusted client RPK keys from %s", completeClientRPKCAPath.c_str()); + return false; + } + // ssl_client_rpk_ca_ex_free() releases `trusted` when ctx is freed. + SSL_CTX_set_ex_data(ctx, ssl_client_rpk_ca_index, trusted); + +#if HAVE_SSL_CTX_SET1_SERVER_CERT_TYPE + static const unsigned char client_cert_types[] = {TLSEXT_cert_type_rpk, TLSEXT_cert_type_x509}; + if (!SSL_CTX_set1_client_cert_type(ctx, client_cert_types, sizeof(client_cert_types))) { + SSLError("failed to enable RPK client cert type acceptance"); + return false; + } +#else + static const unsigned char accepted_types[] = {TLSEXT_cert_type_rpk, TLSEXT_cert_type_x509}; + if (!SSL_CTX_set1_accepted_peer_cert_types(ctx, accepted_types, sizeof(accepted_types))) { + SSLError("failed to enable RPK client cert type acceptance"); + return false; + } +#endif +#else + Warning("ssl_client_rpk_ca_name is set, but this build has no RFC 7250 raw public key support; ignoring"); +#endif + } + return true; } diff --git a/src/iocore/net/YamlSNIConfig.cc b/src/iocore/net/YamlSNIConfig.cc index ed1cd229162..bc7420d4345 100644 --- a/src/iocore/net/YamlSNIConfig.cc +++ b/src/iocore/net/YamlSNIConfig.cc @@ -236,6 +236,8 @@ std::set valid_sni_config_keys = {TS_fqdn, TS_verify_server_properties, TS_client_cert, TS_client_key, + TS_client_rpk_enabled, + TS_server_rpk_ca, TS_client_sni_policy, TS_server_cipher_suite, #if TS_USE_TLS_SET_CIPHERSUITES @@ -467,6 +469,19 @@ template <> struct convert { if (node[TS_client_key]) { item.client_key = node[TS_client_key].as(); } + if (node[TS_client_rpk_enabled]) { + item.client_rpk_enabled = node[TS_client_rpk_enabled].as(); + } + if (node[TS_server_rpk_ca]) { + item.server_rpk_ca = node[TS_server_rpk_ca].as(); + } +#if !TS_USE_RPK + if (item.client_rpk_enabled || !item.server_rpk_ca.empty()) { + Warning("sni.yaml: this build has no RFC 7250 raw public key support, so client_rpk_enabled and server_rpk_ca do not apply " + "for fqdn '%s'", + item.fqdn.empty() ? "*" : item.fqdn.c_str()); + } +#endif if (node[TS_client_sni_policy]) { item.client_sni_policy = node[TS_client_sni_policy].as(); } diff --git a/src/iocore/net/unit_tests/rpk_malformed.pem b/src/iocore/net/unit_tests/rpk_malformed.pem new file mode 100644 index 00000000000..a9853b70f13 --- /dev/null +++ b/src/iocore/net/unit_tests/rpk_malformed.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +not base64 at all!! +-----END PUBLIC KEY----- diff --git a/src/iocore/net/unit_tests/rpk_multi.pem b/src/iocore/net/unit_tests/rpk_multi.pem new file mode 100644 index 00000000000..f1a8428ac49 --- /dev/null +++ b/src/iocore/net/unit_tests/rpk_multi.pem @@ -0,0 +1,17 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZyzgDtRMmHNQM6X3I6aOeIEM1++A +IUFHoc190+dYQNWLKgibwBR9WK8GGFnlF90wPrC2vqToRD5SDDVhQcYl7Q== +-----END PUBLIC KEY----- +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJxOqyWrI2FU+teBXWr+ijO58LBLl +dvIwvqNWa7mgWu/vjkgtrU2hEjljlS8uK+MCYECv4HlZTFyMoY9vRowohA== +-----END PUBLIC KEY----- +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3Wqs3IMsV5sxlxgkP4BP +VgOZi3/7g010o7tgwAD5+C2yD+Yroj/v4oBlBtSRtruSbrDkQaqnOvo2Yt8/NMPr +hehHOGJV0NZu0vvfznbBnfsFCHJLKgsca2iOGmeJDoRGJhoN7IRf8y3+Z7u9fUlo +nxpatvBFe+ueiDtA4ydmnxGnxaghs+59PXqWY+fN4JWoHh6l65gXPUd5aw4rOI4f +YfaSp+GqToDFX98+4qsfjgJTNsxGnZ0M7Oo8oEd6xE6dwlJ0+21JcBQxW7epIEBE +Yn8ZhpLGTd91+BpgD/II6wBoP+0Vxpl/06t5N0cfJtcTmjmiy1czXxOL2/s/b9F0 +awIDAQAB +-----END PUBLIC KEY----- diff --git a/src/iocore/net/unit_tests/rpk_other.pem b/src/iocore/net/unit_tests/rpk_other.pem new file mode 100644 index 00000000000..fb2bd216348 --- /dev/null +++ b/src/iocore/net/unit_tests/rpk_other.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJxOqyWrI2FU+teBXWr+ijO58LBLl +dvIwvqNWa7mgWu/vjkgtrU2hEjljlS8uK+MCYECv4HlZTFyMoY9vRowohA== +-----END PUBLIC KEY----- diff --git a/src/iocore/net/unit_tests/rpk_single.pem b/src/iocore/net/unit_tests/rpk_single.pem new file mode 100644 index 00000000000..6d1f3ab8540 --- /dev/null +++ b/src/iocore/net/unit_tests/rpk_single.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZyzgDtRMmHNQM6X3I6aOeIEM1++A +IUFHoc190+dYQNWLKgibwBR9WK8GGFnlF90wPrC2vqToRD5SDDVhQcYl7Q== +-----END PUBLIC KEY----- diff --git a/src/iocore/net/unit_tests/sni_conf_test.yaml b/src/iocore/net/unit_tests/sni_conf_test.yaml index f0c0c789a47..b04dbe73a75 100644 --- a/src/iocore/net/unit_tests/sni_conf_test.yaml +++ b/src/iocore/net/unit_tests/sni_conf_test.yaml @@ -62,3 +62,6 @@ sni: # test entry with no ip_allow (only protocol settings) - fqdn: noipallow.example.com http2: off +# test raw public key (RFC 7250) settings +- fqdn: rpk.com + client_rpk_enabled: true diff --git a/src/iocore/net/unit_tests/test_SSLRPKUtils.cc b/src/iocore/net/unit_tests/test_SSLRPKUtils.cc new file mode 100644 index 00000000000..afee1e1d42e --- /dev/null +++ b/src/iocore/net/unit_tests/test_SSLRPKUtils.cc @@ -0,0 +1,150 @@ +/** @file + + Catch based unit tests for SSLRPKUtils + + @section license License + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +#ifndef LIBINKNET_UNIT_TEST_DIR +#error please set LIBINKNET_UNIT_TEST_DIR +#endif + +#define _STR(s) #s +#define _XSTR(s) _STR(s) + +#include + +#include + +#include "tscore/ink_config.h" + +#include +#include + +#include "../SSLRPKUtils.h" + +#if TS_USE_RPK + +namespace +{ +std::string +fixture(const char *name) +{ + return std::string{_XSTR(LIBINKNET_UNIT_TEST_DIR)} + "/" + name; +} + +/// Load a bare public key PEM the way a peer's offered key would arrive, for comparison. +EVP_PKEY * +load_pubkey(const char *name) +{ + BIO *bio = BIO_new_file(fixture(name).c_str(), "r"); + if (bio == nullptr) { + return nullptr; + } + EVP_PKEY *pkey = PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr); + BIO_free(bio); + return pkey; +} + +} // namespace + +TEST_CASE("SSLRPKUtils loads a single trusted key", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_single.pem").c_str(), keys)); + CHECK(keys.size() == 1); +} + +TEST_CASE("SSLRPKUtils loads every key in a multi-key file", "[rpk]") +{ + // Key rotation relies on more than one key being accepted from one file. + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_multi.pem").c_str(), keys)); + CHECK(keys.size() == 3); +} + +TEST_CASE("SSLRPKUtils reports a missing trusted key file", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + CHECK_FALSE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_does_not_exist.pem").c_str(), keys)); +} + +TEST_CASE("SSLRPKUtils reports a malformed trusted key file", "[rpk]") +{ + // A malformed file must fail rather than silently yielding an empty (accept-nothing) set. + SSLRPKUtils::TrustedKeySet keys; + CHECK_FALSE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_malformed.pem").c_str(), keys)); +} + +TEST_CASE("SSLRPKUtils matches a pinned key", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_single.pem").c_str(), keys)); + + EVP_PKEY *peer = load_pubkey("rpk_single.pem"); + REQUIRE(peer != nullptr); + CHECK(SSLRPKUtils::pinnedKeyMatches(peer, keys)); + EVP_PKEY_free(peer); +} + +TEST_CASE("SSLRPKUtils rejects a key that is not pinned", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_single.pem").c_str(), keys)); + + EVP_PKEY *peer = load_pubkey("rpk_other.pem"); + REQUIRE(peer != nullptr); + CHECK_FALSE(SSLRPKUtils::pinnedKeyMatches(peer, keys)); + EVP_PKEY_free(peer); +} + +TEST_CASE("SSLRPKUtils matches any key in a rotation set", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_multi.pem").c_str(), keys)); + + // Both the first and a later entry must match, including across key algorithms. + for (auto const *name : {"rpk_single.pem", "rpk_other.pem"}) { + EVP_PKEY *peer = load_pubkey(name); + REQUIRE(peer != nullptr); + CHECK(SSLRPKUtils::pinnedKeyMatches(peer, keys)); + EVP_PKEY_free(peer); + } +} + +TEST_CASE("SSLRPKUtils rejects a null or empty peer key", "[rpk]") +{ + SSLRPKUtils::TrustedKeySet keys; + REQUIRE(SSLRPKUtils::loadTrustedKeys(fixture("rpk_single.pem").c_str(), keys)); + + CHECK_FALSE(SSLRPKUtils::pinnedKeyMatches(static_cast(nullptr), keys)); + CHECK_FALSE(SSLRPKUtils::pinnedKeyMatches(nullptr, 0, keys)); +} + +TEST_CASE("SSLRPKUtils rejects everything when no keys are trusted", "[rpk]") +{ + // An empty trust set must never accept a peer -- this is the fail-closed case. + SSLRPKUtils::TrustedKeySet empty; + EVP_PKEY *peer = load_pubkey("rpk_single.pem"); + REQUIRE(peer != nullptr); + CHECK_FALSE(SSLRPKUtils::pinnedKeyMatches(peer, empty)); + EVP_PKEY_free(peer); +} + +#endif // TS_USE_RPK diff --git a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc index bb67951c4c3..ab0f1f796bb 100644 --- a/src/iocore/net/unit_tests/test_YamlSNIConfig.cc +++ b/src/iocore/net/unit_tests/test_YamlSNIConfig.cc @@ -56,7 +56,7 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") FAIL(errorstream.str()); } REQUIRE(zret.is_ok()); - REQUIRE(conf.items.size() == 13); + REQUIRE(conf.items.size() == 14); SECTION("If no ports were specified, port range should contain all ports.") { @@ -112,6 +112,22 @@ TEST_CASE("YamlSNIConfig sets port ranges appropriately") REQUIRE(item.ssl_ticket_number.has_value()); CHECK(item.ssl_ticket_number.value() == 3); } + + SECTION("Raw public key settings are parsed.") + { + // server_rpk_ca is deliberately not exercised here: SNIConfigParams resolves it against the + // configured CA directory and probe-loads it, which a unit test can't stage. The loader + // itself is covered directly in test_SSLRPKUtils.cc. + auto const &item{conf.items[13]}; + CHECK(item.client_rpk_enabled); + } + + SECTION("Raw public key settings default to off.") + { + auto const &item{conf.items[12]}; + CHECK_FALSE(item.client_rpk_enabled); + CHECK(item.server_rpk_ca.empty()); + } } TEST_CASE("YamlConfig handles bad ports appropriately.") diff --git a/src/traffic_layout/info.cc b/src/traffic_layout/info.cc index 91b0677e042..ba607bc5835 100644 --- a/src/traffic_layout/info.cc +++ b/src/traffic_layout/info.cc @@ -160,6 +160,7 @@ produce_features(bool json) print_feature("TS_HAS_IP_TOS", TS_HAS_IP_TOS, json); print_feature("TS_USE_HWLOC", TS_USE_HWLOC, json); print_feature("TS_USE_TLS13", TS_USE_TLS13, json); + print_feature("TS_USE_RPK", TS_USE_RPK, json); print_feature("TS_USE_QUIC", TS_USE_QUIC, json); print_feature("TS_USE_QMUX", TS_USE_QMUX, json); print_feature("TS_HAS_OPENSSL_QUIC", TS_HAS_OPENSSL_QUIC, json); diff --git a/tests/gold_tests/tls/ssl/server.pubkey.pem b/tests/gold_tests/tls/ssl/server.pubkey.pem new file mode 100644 index 00000000000..c44cf05094c --- /dev/null +++ b/tests/gold_tests/tls/ssl/server.pubkey.pem @@ -0,0 +1,14 @@ +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAqP5Byly+quJFCewdg9rY +gB3hSJS/mvHk+2VKP2iaa6YEiNRHn4T+14iVccfwAQObACZuPWOEnMdXLye5OOHB +nW3o/fxZmO6aLwrnfBcK3R7sacyST++IRpAhrXvn5TQsQBv69O2APqGgqBi6ECt4 +ed03/3Y86EpwjMeKyofUQbl3XTOu84MuBfQTshgy3urMQEZMYaqzEBV7FQz9Oyt2 ++fmI3ylivZaW7dTipa7vv0M1/O+oGh7SBSL5wdU2+j5Zt8VXxmOquzx/4AXrvOLV +7mtVUNQEcOjLZbYkHNz5eQWdFtHGDjrPX/lRDlb7DK63LkLbsEUFLCZijuyaBm63 +RoKhEKU8RVSRr2gy0CS5rRCSgeAWJKOG4AhcPaALf4FqraAZw4BPdgfcZbEK6WU4 +kK8fltAOtd/XegtF5zMYjUGnpC3fQ3isATdEAyyMu69K6iRsIEzeW75amrrAforx +SqtqGhO1Tu3ZnuDH/0jlaPN7KBKE5mAAWd2n19ZCrbRgnEZvmqvrEfGMuGrh0EEp +VJtObdJi0Ss4WDbIreG1ZzPWOZc2zLsoJ1H5RZl5A4MAZJ7KsdlU/Kw7SLEihQ1Y +dcPX/3ZUlvkh4MHKfT2fyV48skEHJDCiRe9Qieq/kY1r2IdSRfbPeA0YVp1fMXU/ +g9WcR58P7N61NqVd/7VFUkUCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/tests/gold_tests/tls/ssl/server.wrongpubkey.pem b/tests/gold_tests/tls/ssl/server.wrongpubkey.pem new file mode 100644 index 00000000000..65dbdeb9036 --- /dev/null +++ b/tests/gold_tests/tls/ssl/server.wrongpubkey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4Uim3ZOB1IfLWxpSjQ60 +dq2j7oVi5fW8idDg3zZOxBv2NlTmCa4uFtwW+Jhv4CSed/7ggoPvtuxHvTy4w2rw +xFpM29sInRjQdJJ/gftIIkaEqZ5cqleGBsaG5CLDFSPejJ2+rSY0FWg2/F9GxljV +6BNgO0ukv3AjeIGpRdZF3mJIozb3fU3/XOrgDfCt6IH9ZBPHhRA1DzkuBtBkStDg +XVYr3bzfhmVb9tMKZRJjLUPjKfa24ninFKXl/2S6/RHSRcLWde3U4IizmfepXiIF +i2j49UGiDzCq3sKCvMsAahwwx8fRFEMMwV6oWJM0rgzoz8YEPBC2oRO1FCIkHOYH +EQIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/gold_tests/tls/tls_rpk_hop.test.py b/tests/gold_tests/tls/tls_rpk_hop.test.py new file mode 100644 index 00000000000..0aea0a7e56d --- /dev/null +++ b/tests/gold_tests/tls/tls_rpk_hop.test.py @@ -0,0 +1,230 @@ +''' +Test RFC 7250 raw public key (RPK) TLS between two ATS instances. +''' +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +Test.Summary = ''' +Test raw public keys (RFC 7250) on ATS-to-ATS (layered cache) TLS hops. +''' + +# RPK is only compiled in when the linked TLS library supports it, so skip rather +# than fail where it is unavailable. +Test.SkipUnless(Condition.HasATSFeature('TS_USE_RPK')) + +server = Test.MakeOriginServer("server") +request_header = {'headers': 'GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n', 'timestamp': '1469733493.993', 'body': ''} +response_header = { + 'headers': 'HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n', + 'timestamp': '1469733493.993', + 'body': 'origin response' +} +server.addResponse("sessionlog.json", request_header, response_header) + + +def make_parent(name, rpk_enabled, client_rpk_ca_file=None, client_cert_level=0): + """An upstream (parent) ATS: terminates TLS from the edge, forwards to the origin. + + `client_rpk_ca_file`, if set, configures ssl_client_rpk_ca_name to pin the edge's raw public + key for mTLS; `client_cert_level` then requires/requests a client cert accordingly. + """ + ts = Test.MakeATSProcess(name, enable_tls=True) + ts.addSSLfile("ssl/server.pem") + ts.addSSLfile("ssl/server.key") + if client_rpk_ca_file is not None: + ts.addSSLfile("ssl/{0}".format(client_rpk_ca_file)) + ts.Disk.remap_config.AddLine('map / http://127.0.0.1:{0}'.format(server.Variables.Port)) + multicert_lines = [ + 'ssl_multicert:', + ' - dest_ip: "*"', + ' ssl_cert_name: server.pem', + ' ssl_key_name: server.key', + ] + if rpk_enabled: + multicert_lines.append(' ssl_rpk_enabled: 1') + if client_rpk_ca_file is not None: + # The file name is deliberately bare here (not ts.Variables.SSLDir-prefixed) to exercise + # that ssl_client_rpk_ca_name resolves against proxy.config.ssl.CA.cert.path, matching + # the equivalent resolution ssl_ca_name already gets. + multicert_lines.append(' ssl_client_rpk_ca_name: {0}'.format(client_rpk_ca_file)) + ts.Disk.ssl_multicert_yaml.AddLines(multicert_lines) + records = { + 'proxy.config.http.cache.http': 0, + 'proxy.config.ssl.server.cert.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.server.private_key.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'ssl_verify|ssl_load', + } + if client_cert_level: + records['proxy.config.ssl.client.certification_level'] = client_cert_level + records['proxy.config.ssl.CA.cert.path'] = '{0}'.format(ts.Variables.SSLDir) + ts.Disk.records_config.update(records) + return ts + + +def make_edge(name, parent, pin_file, policy='ENFORCED', offer_client_rpk=False): + """A downstream (edge) ATS: connects to `parent` over TLS, pinning its raw public key. + + `offer_client_rpk`, if set, also offers a raw public key (derived from ssl/server.pem/.key, + the same identity the edge uses inbound) as its own client cert toward `parent`, for `parent` + to pin via ssl_client_rpk_ca_name. + """ + ts = Test.MakeATSProcess(name, enable_tls=True) + ts.addSSLfile("ssl/server.pem") + ts.addSSLfile("ssl/server.key") + ts.addSSLfile("ssl/server.pubkey.pem") + ts.addSSLfile("ssl/server.wrongpubkey.pem") + ts.Disk.remap_config.AddLine('map / https://127.0.0.1:{0}'.format(parent.Variables.ssl_port)) + ts.Disk.ssl_multicert_yaml.AddLines( + [ + 'ssl_multicert:', + ' - dest_ip: "*"', + ' ssl_cert_name: server.pem', + ' ssl_key_name: server.key', + ]) + ts.Disk.records_config.update( + { + 'proxy.config.http.cache.http': 0, + 'proxy.config.ssl.server.cert.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.server.private_key.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.client.cert.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.ssl.client.private_key.path': '{0}'.format(ts.Variables.SSLDir), + 'proxy.config.diags.debug.enabled': 1, + 'proxy.config.diags.debug.tags': 'ssl_verify', + 'proxy.config.ssl.client.verify.server.policy': policy, + # Pin the exact key instead of matching a name: a raw public key carries no SAN. + 'proxy.config.ssl.client.verify.server.properties': 'SIGNATURE', + }) + if pin_file is not None or offer_client_rpk: + sni_lines = [ + 'sni:', + '- fqdn: 127.0.0.1', + ] + if pin_file is not None: + sni_lines.append(' server_rpk_ca: {0}/{1}'.format(ts.Variables.SSLDir, pin_file)) + if offer_client_rpk: + sni_lines += [ + ' client_cert: server.pem', + ' client_key: server.key', + ' client_rpk_enabled: true', + ] + ts.Disk.sni_yaml.AddLines(sni_lines) + return ts + + +# 1. Both hops speak RPK and the pin matches -> RPK is negotiated and accepted. +parent_rpk = make_parent("parent_rpk", rpk_enabled=True) +edge_ok = make_edge("edge_ok", parent_rpk, "server.pubkey.pem") + +# 2. The parent has not been upgraded (no RPK), the edge is configured for it -> +# negotiation must fall back to X.509 rather than failing. This is the steady state +# for the whole duration of a rolling upgrade. +parent_x509 = make_parent("parent_x509", rpk_enabled=False) +edge_fallback = make_edge("edge_fallback", parent_x509, "server.pubkey.pem", policy='PERMISSIVE') + +# 3. The pin does not match the key the parent presents -> rejected under ENFORCED. +edge_badpin = make_edge("edge_badpin", parent_rpk, "server.wrongpubkey.pem") + +# 4. Same mismatch under PERMISSIVE -> warned about, but the request still succeeds. +edge_badpin_permissive = make_edge("edge_badpin_permissive", parent_rpk, "server.wrongpubkey.pem", policy='PERMISSIVE') + +# 5. mTLS: the parent requires and pins the edge's raw public key, and the pin matches. +parent_mtls = make_parent("parent_mtls", rpk_enabled=True, client_rpk_ca_file="server.pubkey.pem", client_cert_level=2) +edge_mtls = make_edge("edge_mtls", parent_mtls, "server.pubkey.pem", offer_client_rpk=True) + +# 6. mTLS: same setup, but the parent pins a different key than the edge actually offers -> +# a required client cert is always fatal, unlike verify_server_policy which has a +# PERMISSIVE mode -- there is no equivalent "warn only" mode for inbound mTLS. +parent_mtls_badpin = make_parent( + "parent_mtls_badpin", rpk_enabled=True, client_rpk_ca_file="server.wrongpubkey.pem", client_cert_level=2) +edge_mtls_badpin = make_edge("edge_mtls_badpin", parent_mtls_badpin, "server.pubkey.pem", offer_client_rpk=True) + +tr = Test.AddTestRun("RPK negotiated and pin matches") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_ok.Variables.ssl_port)) +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(server) +tr.Processes.Default.StartBefore(parent_rpk) +tr.Processes.Default.StartBefore(edge_ok) +tr.Processes.Default.Streams.All = Testers.ContainsExpression('origin response', 'the request should succeed end to end') +edge_ok.Disk.traffic_out.Content = Testers.ContainsExpression( + 'Origin authenticated with a raw public key .*pin match=yes', 'the hop should use RPK, not fall back to X.509') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_rpk +tr.StillRunningAfter += edge_ok + +tr = Test.AddTestRun("falls back to X.509 against a parent without RPK support") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_fallback.Variables.ssl_port)) +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(parent_x509) +tr.Processes.Default.StartBefore(edge_fallback) +tr.Processes.Default.Streams.All = Testers.ContainsExpression('origin response', 'the request should still succeed') +# No RPK was negotiated, so the RPK branch must never run for this hop. +edge_fallback.Disk.traffic_out.Content = Testers.ExcludesExpression( + 'Origin authenticated with a raw public key', 'the hop should quietly negotiate X.509 instead') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_x509 +tr.StillRunningAfter += edge_fallback + +tr = Test.AddTestRun("pin mismatch is fatal under ENFORCED") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_badpin.Variables.ssl_port)) +# curl sees a 5xx from the edge (upstream connect failed) rather than a transport error. +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(edge_badpin) +tr.Processes.Default.Streams.All = Testers.ExcludesExpression('origin response', 'the request must not be served') +edge_badpin.Disk.traffic_out.Content = Testers.ContainsExpression( + 'Origin authenticated with a raw public key .*pin match=no', 'the offered key should not match the pin') +# Warning() goes to diags.log, not traffic.out (which only carries Dbg() debug output). +edge_badpin.Disk.diags_log.Content = Testers.ContainsExpression( + 'Origin raw public key did not match any trusted key. Action=Terminate', + 'an unmatched pin must terminate the connection under ENFORCED') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_rpk + +tr = Test.AddTestRun("pin mismatch only warns under PERMISSIVE") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_badpin_permissive.Variables.ssl_port)) +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(edge_badpin_permissive) +tr.Processes.Default.Streams.All = Testers.ContainsExpression('origin response', 'the request should still be served') +edge_badpin_permissive.Disk.diags_log.Content = Testers.ContainsExpression( + 'Origin raw public key did not match any trusted key. Action=Continue', 'an unmatched pin must only warn under PERMISSIVE') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_rpk + +tr = Test.AddTestRun("mTLS: parent pins the edge's raw public key and it matches") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_mtls.Variables.ssl_port)) +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(parent_mtls) +tr.Processes.Default.StartBefore(edge_mtls) +tr.Processes.Default.Streams.All = Testers.ContainsExpression( + 'origin response', 'the request should succeed once the client cert pin matches') +parent_mtls.Disk.diags_log.Content = Testers.ExcludesExpression( + 'client raw public key did not match any trusted key', 'a matching pin must not warn') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_mtls +tr.StillRunningAfter += edge_mtls + +tr = Test.AddTestRun("mTLS: a required client cert pin mismatch is always fatal") +tr.MakeCurlCommand('-k https://127.0.0.1:{0}/'.format(edge_mtls_badpin.Variables.ssl_port)) +# curl sees a 5xx from the edge (upstream mTLS handshake failed) rather than a transport error. +tr.Processes.Default.ReturnCode = 0 +tr.Processes.Default.StartBefore(parent_mtls_badpin) +tr.Processes.Default.StartBefore(edge_mtls_badpin) +tr.Processes.Default.Streams.All = Testers.ExcludesExpression('origin response', 'the request must not be served') +parent_mtls_badpin.Disk.diags_log.Content = Testers.ContainsExpression( + 'client raw public key did not match any trusted key', 'the offered client key should not match the pin') +tr.StillRunningAfter = server +tr.StillRunningAfter += parent_mtls_badpin +tr.StillRunningAfter += edge_mtls_badpin From 19e63c0b113ffca17373c8789db82ffcea977bc0 Mon Sep 17 00:00:00 2001 From: Masakazu Kitajo Date: Thu, 13 Aug 2026 17:51:13 -0600 Subject: [PATCH 2/4] doc: fix undefined |TS| substitution in ssl_multicert.yaml.en.rst The RPK section added a |TS| reference, but this file never included common.defs (where |TS| is defined), unlike every other admin-guide doc file. Docs CI caught it as an undefined substitution error. --- doc/admin-guide/files/ssl_multicert.yaml.en.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/admin-guide/files/ssl_multicert.yaml.en.rst b/doc/admin-guide/files/ssl_multicert.yaml.en.rst index 99166d01e8a..1d924facaa9 100644 --- a/doc/admin-guide/files/ssl_multicert.yaml.en.rst +++ b/doc/admin-guide/files/ssl_multicert.yaml.en.rst @@ -15,6 +15,8 @@ specific language governing permissions and limitations under the License. +.. include:: ../../common.defs + ================== ssl_multicert.yaml ================== From 9124801c37eb9749a8564b3c75761ba89a106b5e Mon Sep 17 00:00:00 2001 From: Masakazu Kitajo Date: Thu, 13 Aug 2026 18:16:48 -0600 Subject: [PATCH 3/4] iocore/net: fix RPK verify-callback issues found by review Three fixes from PR review of the RFC 7250 RPK support: - Client-side ssl_client_setup_rpk() installed BoringSSL's SSL_set_custom_verify() unconditionally whenever any RPK config was present, including offer-only next hops (client_rpk_enabled with no server_rpk_ca). Since we never advertise RPK acceptance in that case, the peer always presents X.509 and the classic verify path is sufficient -- only install custom_verify when a trusted key file is actually configured to pin against. - The BoringSSL X.509 fallback in ssl_custom_verify_client_callback() (server-side mTLS) always verified against SSL_CTX_get_cert_store(), ignoring a per-connection CA override that VerifyClient::SNIAction may have set via setClientCertCACerts()/SSL_set0_verify_cert_store(). Since BoringSSL has no getter for that store, rebuild the same override from the netvc's stored ca_cert_file/ca_cert_dir when present, matching setClientCertCACerts()'s own construction. - SSLRPKUtils::loadTrustedKeys() now clears the error queue before its first PEM_read_bio() call, so its end-of-file detection can't be confused by an unrelated error already queued on the thread. --- src/iocore/net/SSLClientUtils.cc | 9 ++++++--- src/iocore/net/SSLRPKUtils.cc | 5 +++++ src/iocore/net/SSLUtils.cc | 34 +++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/iocore/net/SSLClientUtils.cc b/src/iocore/net/SSLClientUtils.cc index 5f38fa1db3f..ded0aeb5bba 100644 --- a/src/iocore/net/SSLClientUtils.cc +++ b/src/iocore/net/SSLClientUtils.cc @@ -559,9 +559,12 @@ ssl_client_setup_rpk(SSL *ssl, bool offer_rpk, const std::string &trusted_key_fi #if HAVE_SSL_CREDENTIAL_NEW_RAW_PUBLIC_KEY // BoringSSL rejects raw public keys unless a custom verify callback is installed, and this // displaces the SSL_set_verify()/verify_callback() pair the caller already set for this - // connection. Only RPK-configured next hops take this path; every other outbound connection - // keeps the classic callback untouched. - SSL_set_custom_verify(ssl, SSL_VERIFY_PEER, ssl_client_custom_verify_callback); + // connection. Only next hops actually prepared to accept/pin an RPK server key take this path + // -- an offer-only connection (client_rpk_enabled with no server_rpk_ca) never advertises RPK + // acceptance above, so the peer will always present X.509 and the classic callback suffices. + if (!trusted_key_file.empty()) { + SSL_set_custom_verify(ssl, SSL_VERIFY_PEER, ssl_client_custom_verify_callback); + } #endif return true; diff --git a/src/iocore/net/SSLRPKUtils.cc b/src/iocore/net/SSLRPKUtils.cc index 1c9ebc1e217..d75d32f3325 100644 --- a/src/iocore/net/SSLRPKUtils.cc +++ b/src/iocore/net/SSLRPKUtils.cc @@ -41,6 +41,11 @@ loadTrustedKeys(const char *path, TrustedKeySet &out) return false; } + // Discard anything already on this thread's error queue so the PEM_R_NO_START_LINE check + // below on the first iteration can't be confused by an unrelated error left over from earlier, + // unrelated OpenSSL/BoringSSL calls on this thread. + ERR_clear_error(); + for (;;) { // Read the PEM envelope first and decode it separately. Asking PEM_read_bio_PUBKEY() to do // both makes end-of-file indistinguishable from a decode failure: on OpenSSL 3 it runs the diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index 84941840e20..a52ec5086f1 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -314,9 +314,34 @@ ssl_custom_verify_client_callback(SSL *ssl, uint8_t *out_alert) } } - X509_STORE_CTX *store_ctx = X509_STORE_CTX_new(); - bool initialized = store_ctx != nullptr && X509_STORE_CTX_init(store_ctx, SSL_CTX_get_cert_store(ctx), leaf, intermediates); - bool verified = false; + // A per-SNI verify_client action (VerifyClient::SNIAction) may have pinned a CA file/dir onto + // this connection via setClientCertCACerts()/SSL_set0_verify_cert_store() -- that call only + // takes effect for the classic SSL_set_verify() path, since BoringSSL has no public getter for + // whatever store it attached. Rebuild the same override here rather than falling back to the + // SSL_CTX's default store and silently ignoring a per-connection CA that was configured for + // this exact SNI. + X509_STORE *verify_store = nullptr; + bool owns_store = false; + const char *ca_cert_file = netvc->get_ca_cert_file(); + const char *ca_cert_dir = netvc->get_ca_cert_dir(); + if ((ca_cert_file != nullptr && ca_cert_file[0] != '\0') || (ca_cert_dir != nullptr && ca_cert_dir[0] != '\0')) { + verify_store = X509_STORE_new(); + if (verify_store != nullptr && + X509_STORE_load_locations(verify_store, ca_cert_file != nullptr && ca_cert_file[0] != '\0' ? ca_cert_file : nullptr, + ca_cert_dir != nullptr && ca_cert_dir[0] != '\0' ? ca_cert_dir : nullptr)) { + owns_store = true; + } else { + X509_STORE_free(verify_store); + verify_store = nullptr; + } + } + if (verify_store == nullptr) { + verify_store = SSL_CTX_get_cert_store(ctx); + } + + X509_STORE_CTX *store_ctx = X509_STORE_CTX_new(); + bool initialized = store_ctx != nullptr && X509_STORE_CTX_init(store_ctx, verify_store, leaf, intermediates); + bool verified = false; if (initialized) { X509_STORE_CTX_set_depth(store_ctx, SSL_CTX_get_verify_depth(ctx)); verified = X509_verify_cert(store_ctx) == 1; @@ -335,6 +360,9 @@ ssl_custom_verify_client_callback(SSL *ssl, uint8_t *out_alert) bool hook_ok = initialized && tbs->verify_certificate(store_ctx) == 0; X509_STORE_CTX_free(store_ctx); + if (owns_store) { + X509_STORE_free(verify_store); + } X509_free(leaf); sk_X509_pop_free(intermediates, X509_free); From 9d4532cf0bbe9db80b6c3715a35ef2a19b39d317 Mon Sep 17 00:00:00 2001 From: Masakazu Kitajo Date: Thu, 13 Aug 2026 18:26:59 -0600 Subject: [PATCH 4/4] iocore/net: check sk_X509_push() return value in RPK X.509 fallback sk_X509_push() failure (allocation failure) was silently ignored on both the client and server BoringSSL custom-verify fallback paths, leaking the certificate and continuing verification against a truncated intermediate chain. Free the certificate and abort with an internal error instead. --- src/iocore/net/SSLClientUtils.cc | 9 +++++++-- src/iocore/net/SSLUtils.cc | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/iocore/net/SSLClientUtils.cc b/src/iocore/net/SSLClientUtils.cc index ded0aeb5bba..9c294fef839 100644 --- a/src/iocore/net/SSLClientUtils.cc +++ b/src/iocore/net/SSLClientUtils.cc @@ -313,8 +313,13 @@ ssl_client_custom_verify_callback(SSL *ssl, uint8_t *out_alert) } if (i == 0) { leaf = cert; - } else { - sk_X509_push(intermediates, cert); + } else if (!sk_X509_push(intermediates, cert)) { + SSLError("failed to append an intermediate certificate on a RPK-enabled connection"); + X509_free(cert); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; } } diff --git a/src/iocore/net/SSLUtils.cc b/src/iocore/net/SSLUtils.cc index a52ec5086f1..a93e4cc6b89 100644 --- a/src/iocore/net/SSLUtils.cc +++ b/src/iocore/net/SSLUtils.cc @@ -309,8 +309,13 @@ ssl_custom_verify_client_callback(SSL *ssl, uint8_t *out_alert) } if (i == 0) { leaf = cert; - } else { - sk_X509_push(intermediates, cert); + } else if (!sk_X509_push(intermediates, cert)) { + SSLError("failed to append an intermediate certificate offered to a RPK-enabled context"); + X509_free(cert); + X509_free(leaf); + sk_X509_pop_free(intermediates, X509_free); + *out_alert = SSL_AD_INTERNAL_ERROR; + return ssl_verify_invalid; } }