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


##########
src/iocore/net/SSLClientUtils.cc:
##########
@@ -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

Review Comment:
   On BoringSSL builds, SSL_set_custom_verify() replaces the existing 
SSL_set_verify()/verify_callback path for the entire connection. Installing it 
unconditionally here means a next hop with only client_rpk_enabled (offer-only) 
will also switch to the manual X.509 verification path even though it will 
never negotiate an RPK server identity, risking behavioral drift. Limit 
custom-verify to cases where we actually accept/pin an RPK server key (i.e. 
trusted_key_file is non-empty).



##########
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 <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;
+  }
+
+  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();

Review Comment:
   loadTrustedKeys() determines EOF by inspecting the OpenSSL error queue after 
PEM_read_bio() fails, but it never clears any pre-existing errors before 
calling PEM_read_bio(). If the thread already has an unrelated error queued, 
ERR_peek_last_error() may not report PEM_R_NO_START_LINE and a valid file could 
be treated as malformed.



##########
src/iocore/net/SSLUtils.cc:
##########
@@ -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<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", 
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);

Review Comment:
   The BoringSSL custom-verify X.509 fallback path verifies against 
SSL_CTX_get_cert_store(ctx), but ATS can override the verify store per 
connection via SSL_set0_verify_cert_store (e.g. 
SNIActionPerformer/setClientCertCACerts). Using the SSL_CTX store here can 
silently ignore per-SNI CA configuration when falling back to X.509.



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

Review Comment:
   This TestRun depends on parent_rpk being up to accept the upstream TLS 
connection, but it is not started in this run. Since the previous run's 
StillRunningAfter list doesn't include parent_rpk, it may have been stopped 
before this run begins, leading to a failing/flaky test.
   
   This issue also appears on line 199 of the same file.



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