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


##########
src/iocore/net/SSLUtils.cc:
##########
@@ -205,6 +253,120 @@ 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)");
+  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)) &&
+                     X509_STORE_CTX_set_ex_data(store_ctx, 
SSL_get_ex_data_X509_STORE_CTX_idx(), ssl);

Review Comment:
   `SSL_get_peer_full_cert_chain()` returns a chain that includes the leaf 
cert, but `X509_STORE_CTX_init(store, leaf, chain)` expects `chain` to be the 
*untrusted intermediates* (typically excluding the leaf). Passing the full 
chain (leaf included) can lead to redundant/incorrect path building. Consider 
building a separate `STACK_OF(X509)` containing only entries `[1..N-1]` as 
intermediates (or otherwise ensuring the stack passed to `X509_STORE_CTX_init` 
excludes the leaf) before calling `X509_verify_cert()`.



##########
include/iocore/net/SSLSNIConfig.h:
##########
@@ -49,8 +50,15 @@
 
 // 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
+  // Parsed contents of server_rpk_ca_file (SSLRPKUtils::TrustedKeySet, i.e. a 
set of DER
+  // SubjectPublicKeyInfo blobs), loaded once at config load rather than 
re-parsed from disk on
+  // every outbound handshake to this next hop.
+  std::shared_ptr<const std::vector<std::vector<unsigned char>>> server_rpk_ca;

Review Comment:
   `server_rpk_ca` uses a raw `std::vector<std::vector<unsigned char>>` type 
even though the comment describes it as `SSLRPKUtils::TrustedKeySet`. This 
makes the API harder to read and easier to drift if `TrustedKeySet` changes. 
Prefer using `std::shared_ptr<const SSLRPKUtils::TrustedKeySet>` (either by 
including `SSLRPKUtils.h` here, or by introducing a shared type alias exposed 
in a header that both can include).



##########
src/iocore/net/SSLRPKUtils.cc:
##########
@@ -0,0 +1,158 @@
+/** @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);
+      }
+    }
+
+    OPENSSL_free(name);
+    OPENSSL_free(header);
+
+    if (pkey == nullptr) {
+      OPENSSL_free(data);
+      return false;
+    }
+    OPENSSL_free(data);
+
+    int canonical_len = i2d_PUBKEY(pkey, nullptr);
+    if (canonical_len <= 0) {
+      SSLError("SSLRPKUtils: failed to re-encode a raw public key from %s", 
path);
+      EVP_PKEY_free(pkey);
+      return false;
+    }
+    TrustedKey     canonical(canonical_len);
+    unsigned char *cp      = canonical.data();
+    int const      written = i2d_PUBKEY(pkey, &cp);
+    EVP_PKEY_free(pkey);
+    if (written != canonical_len) {
+      // A short write would leave the pin zero padded, so it could never 
match a peer's key --
+      // presenting as a mismatch against the peer rather than as a bad pin 
file.
+      SSLError("SSLRPKUtils: inconsistent raw public key encoding from %s", 
path);
+      return false;
+    }
+
+    out.push_back(std::move(canonical));
+  }
+
+  return true;

Review Comment:
   `loadTrustedKeys()` appends into `out` as it parses, but returns `false` on 
mid-file parse/read errors without rolling back. That can leave callers with a 
partially-updated trust set (depending on how they handle the `false` return). 
A safer pattern is to load into a local `TrustedKeySet tmp` and only `out = 
std::move(tmp)` / `out.swap(tmp)` upon success (or explicitly clear `out` on 
any failure).



##########
src/iocore/net/unit_tests/test_YamlSNIConfig.cc:
##########
@@ -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());

Review Comment:
   These sections hard-code `conf.items[13]` / `[12]`, which couples the test 
to the exact ordering and count of entries in `sni_conf_test.yaml` (making 
unrelated additions/removals reorder-sensitive). Consider locating the target 
item by a stable attribute (e.g., matching `fqdn == "rpk.com"` for the enabled 
case, and another known fqdn for the defaults case) rather than by index.



-- 
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