Copilot commented on code in PR #13548:
URL: https://github.com/apache/trafficserver/pull/13548#discussion_r4064738214


##########
src/iocore/net/SSLClientUtils.cc:
##########
@@ -155,6 +252,199 @@ 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 raw public key attempt.
+    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 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;
+    }
+  }
+
+  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) &&
+                           // Sets param->purpose and param->trust, which gate 
X509_check_purpose().
+                           // The library's own path does this; without it a 
clientAuth-only leaf
+                           // from a trusted CA authenticates the next hop.
+                           X509_STORE_CTX_set_default(store_ctx, "ssl_server") 
&&
+                           // Carries the connection's verify params (depth 
included) over the
+                           // store's, as the library path does.
+                           
X509_VERIFY_PARAM_set1(X509_STORE_CTX_get0_param(store_ctx), 
SSL_get0_param(ssl)) &&

Review Comment:
   The same ordering issue occurs for outbound fallback verification: 
`X509_VERIFY_PARAM_set1` runs after `set_default(\"ssl_server\")` and can 
overwrite the server-purpose parameters. That removes the EKU/purpose check 
this hand-rolled path is intended to reproduce, allowing a clientAuth-only 
certificate to authenticate as the origin. Copy the connection parameters 
first, then apply the `ssl_server` default while retaining the required 
per-connection settings.



##########
src/iocore/net/SSLRPKUtils.cc:
##########
@@ -0,0 +1,210 @@
+/** @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 <openssl/err.h>
+#include <openssl/pem.h>
+#include <openssl/x509.h>
+
+#include <cstring>
+
+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;
+  }
+
+  // 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
+    // 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();
+      // Reason codes 100-255 are per-sub-library, so the library has to be 
checked too.
+      bool const at_eof = ERR_GET_LIB(err) == ERR_LIB_PEM && 
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;
+      // Decode the PEM payload so a corrupt key is rejected at config load 
rather than silently
+      // pinned as opaque bytes -- and so the pin is the canonical re-encoding 
of the key rather
+      // than the payload as received: d2i_PUBKEY() doesn't reject trailing 
bytes after a valid
+      // DER structure, so a non-canonically-encoded payload would otherwise 
get pinned including
+      // whatever garbage follows the key, and could never match a peer's 
cleanly-encoded SPKI.
+      pkey = d2i_PUBKEY(nullptr, &p, len);
+      if (pkey == nullptr) {
+        SSLError("SSLRPKUtils: failed to parse a raw public key from %s", 
path);

Review Comment:
   `d2i_PUBKEY` may successfully decode a valid key while leaving trailing 
bytes unread, so this accepts malformed PEM payloads despite the comment saying 
corrupt keys are rejected. Check that `p == data + len` after decoding and 
reject the block otherwise; otherwise operators can unknowingly pin a file 
containing extra data.



##########
src/iocore/net/SSLUtils.cc:
##########
@@ -188,23 +248,168 @@ static int
 ssl_verify_client_callback(int preverify_ok, X509_STORE_CTX *ctx)
 {
   Dbg(dbg_ctl_ssl_verify, "Callback: verify client cert");
-  auto              *ssl   = static_cast<SSL 
*>(X509_STORE_CTX_get_ex_data(ctx, SSL_get_ex_data_X509_STORE_CTX_idx()));
-  SSLNetVConnection *netvc = SSLNetVCAccess(ssl);
-  TLSBasicSupport   *tbs   = TLSBasicSupport::getInstance(ssl);
+  auto            *ssl = static_cast<SSL *>(X509_STORE_CTX_get_ex_data(ctx, 
SSL_get_ex_data_X509_STORE_CTX_idx()));
+  TLSBasicSupport *tbs = TLSBasicSupport::getInstance(ssl);
 
   if (tbs == nullptr) {
     Dbg(dbg_ctl_ssl_verify, "call back on stale netvc");
     return false;
   }
 
+  // QUIC binds TLSBasicSupport without ever calling SSLNetVCAttach(), so the 
netvc is null on an H3
+  // connection even though tbs is not. Take the name from the support class 
instead.
+  TLSSNISupport *snis       = TLSSNISupport::getInstance(ssl);
+  const char    *servername = snis != nullptr ? snis->get_sni_server_name() : 
"";
+
+#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<SSLRPKUtils::TrustedKeySet 
*>(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", 
servername);
+    }
+    if (tbs->verify_certificate(ctx) == 1) {
+      Warning("TS_EVENT_SSL_VERIFY_CLIENT plugin failed the client certificate 
check for %s.", servername);
+      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());
+    Warning("TS_EVENT_SSL_VERIFY_CLIENT plugin failed the client certificate 
check for %s.", servername);
     return false;
   }
 
   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)");
+  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;
+  }
+  // QUIC binds TLSBasicSupport without ever calling SSLNetVCAttach(), so this 
is null on an H3
+  // connection even though tbs is not. Only the per-connection CA override 
below needs it.
+  SSLNetVConnection *netvc      = SSLNetVCAccess(ssl);
+  TLSSNISupport     *snis       = TLSSNISupport::getInstance(ssl);
+  const char        *servername = snis != nullptr ? 
snis->get_sni_server_name() : "";
+
+  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<SSLRPKUtils::TrustedKeySet 
*>(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", 
servername);
+    }
+    // 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 parses the peer's certificate chain into X509 
objects as soon as it
+  // receives the Certificate message, regardless of whether a custom verify 
callback is
+  // installed -- SSL_get_peer_full_cert_chain() exposes that already-parsed 
chain (leaf included),
+  // so there's no need to redo the CRYPTO_BUFFER-to-X509 decoding 
SSL_get0_peer_certificates()
+  // would otherwise require here.
+  STACK_OF(X509) *chain = SSL_get_peer_full_cert_chain(ssl);
+  if (chain == nullptr || sk_X509_num(chain) == 0) {
+    // Defensive only: BoringSSL gates ssl_verify_peer_cert() on 
ssl_session_has_peer_cred(), so a
+    // peer that sent no certificate never reaches this callback, and 
SSL_VERIFY_FAIL_IF_NO_PEER_CERT
+    // is enforced before that point. Optional client certs are unaffected.
+    *out_alert = SSL_AD_CERTIFICATE_REQUIRED;
+    return ssl_verify_invalid;
+  }
+  X509 *leaf = sk_X509_value(chain, 0);
+
+  // A per-SNI verify_client action may have pinned a CA file/dir onto this 
connection via
+  // setClientCertCACerts(), which BoringSSL exposes no getter for. Use the 
store it stashed rather
+  // than opening and parsing the same files again on the event thread, once 
per connection.
+  const char *ca_cert_file = netvc != nullptr ? netvc->get_ca_cert_file() : 
nullptr;
+  const char *ca_cert_dir  = netvc != nullptr ? netvc->get_ca_cert_dir() : 
nullptr;
+  bool const  ca_configured =
+    (ca_cert_file != nullptr && ca_cert_file[0] != '\0') || (ca_cert_dir != 
nullptr && ca_cert_dir[0] != '\0');
+  X509_STORE *verify_store =
+    ssl_verify_store_index >= 0 ? static_cast<X509_STORE 
*>(SSL_get_ex_data(ssl, ssl_verify_store_index)) : nullptr;
+  if (verify_store == nullptr) {
+    if (ca_configured) {
+      // Configured but never materialized, so falling back to the SSL_CTX 
store here would widen
+      // trust past what the SNI action asked for.
+      SSLError("no per-connection client CA store for %s despite one being 
configured", servername);
+      *out_alert = SSL_AD_INTERNAL_ERROR;
+      return ssl_verify_invalid;
+    }
+    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, chain) &&
+                     // Sets param->purpose and param->trust, which gate 
X509_check_purpose(). The
+                     // library's own path does this; without it a 
serverAuth-only leaf from the
+                     // trusted client CA authenticates as a client.
+                     X509_STORE_CTX_set_default(store_ctx, "ssl_client") &&
+                     // Carries the connection's verify params (depth 
included) over the store's,
+                     // as the library path does. Anything set per-connection 
wins; purpose and
+                     // trust from set_default() above survive because ATS 
never sets them here.
+                     
X509_VERIFY_PARAM_set1(X509_STORE_CTX_get0_param(store_ctx), 
SSL_get0_param(ssl)) &&

Review Comment:
   `X509_VERIFY_PARAM_set1` copies the source verification parameters, 
including purpose/trust, after `X509_STORE_CTX_set_default` has set the 
`ssl_client` purpose. This can overwrite the purpose that the fallback relies 
on, allowing a serverAuth-only certificate to pass as a client certificate on 
BoringSSL. Copy the connection parameters before applying the `ssl_client` 
default, then explicitly preserve any per-connection settings that must take 
precedence.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to