diff --git a/docs/source/topics/security/tls.md b/docs/source/topics/security/tls.md index a81c5dd63..c1c5dc4b2 100644 --- a/docs/source/topics/security/tls.md +++ b/docs/source/topics/security/tls.md @@ -12,20 +12,11 @@ Some notes on this guide: ### Generating the ScyllaDB/Cassandra Public and Private Keys -The most secure method of setting up TLS is to verify that DNS or IP address used to connect to the server matches identity information found in the TLS certificate. This helps to prevent man-in-the-middle attacks. ScyllaDB/Cassandra uses IP addresses internally so those can be used directly for verification or a domain name can be used via reverse DNS (PTR record). That means that the IP address or domain name of the ScyllaDB/Cassandra server where the certficate is installed needs to be present in either the certficate's common name (CN) or one of its subject alternative names (SANs). It's possible to create the certficate without either, but then it will not be possible to verify the server's identity. Although this is not as secure, it eases the deployment of TLS by allowing the same certficate to be deployed across the entire ScyllaDB/Cassandra cluster. +The most secure method of setting up TLS is to verify that DNS or IP address used to connect to the server matches identity information found in the TLS certificate. This helps to prevent man-in-the-middle attacks. ScyllaDB/Cassandra uses IP addresses internally so those can be used directly for verification (a domain name currently cannot be used via reverse DNS - PTR record). That means that the IP address of the ScyllaDB/Cassandra server where the certificate is installed needs to be present in one of the certificate's subject alternative names (SANs). It's possible to create the certificate without them, but then it will not be possible to verify the server's identity. Although this is not as secure, it eases the deployment of TLS by allowing the same certificate to be deployed across the entire ScyllaDB/Cassandra cluster. -To generate a public/private key pair with the IP address in the CN field use the following: +**NOTE:** this driver verifies the identity against subject alternative names of type `iPAddress` only; unlike the CPP driver, it does not fall back to the common name (CN). Prefer the SAN recipe below. A CN-only certificate can still be used, but only with identity verification relaxed to `CASS_SSL_VERIFY_PEER_CERT` or disabled with `CASS_SSL_VERIFY_NONE`. -```bash -keytool -genkeypair -noprompt -keyalg RSA -validity 36500 \ - -alias node \ - -keystore keystore.jks \ - -storepass \ - -keypass \ - -dname "CN=, OU=Drivers and Tools, O=DataStax Inc., L=Santa Clara, ST=California, C=US" -``` - -If SAN is preferred use this command: +To generate a public/private key pair with the IP address in the SAN field use the following: ```bash keytool -genkeypair -noprompt -keyalg RSA -validity 36500 \ @@ -37,8 +28,6 @@ keytool -genkeypair -noprompt -keyalg RSA -validity 36500 \ -dname "CN=node1.datastax.com, OU=Drivers and Tools, O=DataStax Inc., L=Santa Clara, ST=California, C=US" ``` -**NOTE:** If an IP address SAN is present then it overrides checking the CN. - ### Enabling `client-to-node` Encryption on ScyllaDB/Cassandra The generated keystore from the previous step will need to be copied to all ScyllaDB/Cassandra node(s) and an update of the `cassandra.yaml` configuration file will need to be performed. @@ -163,38 +152,40 @@ cass_ssl_set_verify_flags(ssl, CASS_SSL_VERIFY_NONE); cass_ssl_free(ssl); ``` -#### Enabling ScyllaDB/Cassandra identity verification +#### ScyllaDB/Cassandra identity verification -If a unique certificate has been generated for each ScyllaDB/Cassandra node with the IP address or domain name in the CN or SAN fields, you also need to enable identity verification. +If a unique certificate has been generated for each ScyllaDB/Cassandra node with +the IP address in the SAN field, the driver verifies that the node it connected +to is the one the certificate was issued for. -**NOTE:** This is disabled by default. +**NOTE:** This is disabled by default. This is part of `CASS_SSL_VERIFY_PEER_IDENTITY`. +The flags form a bitmask, so it can be requested explicitly on its own or combined with `CASS_SSL_VERIFY_PEER_CERT`: ```c CassSsl* ssl = cass_ssl_new(); -// Add identity verification flag: CASS_SSL_VERIFY_PEER_IDENTITY (IP address) +// Verify the certificate chain and the peer's identity (IP address). cass_ssl_set_verify_flags(ssl, CASS_SSL_VERIFY_PEER_CERT | CASS_SSL_VERIFY_PEER_IDENTITY); - -// Or use: CASS_SSL_VERIFY_PEER_IDENTITY_DNS (domain name) -cass_ssl_set_verify_flags(ssl, CASS_SSL_VERIFY_PEER_CERT | CASS_SSL_VERIFY_PEER_IDENTITY_DNS); ``` -If using a domain name to verify the peer's identity then hostname resolution -(reverse DNS) needs to be enabled: +**NOTE:** the identity is matched against the certificate's subject alternative +names of type `iPAddress` only. Unlike the C/C++ driver, this driver does not +fall back to the subject common name (CN), so a certificate that identifies a +node only by CN is rejected. -**NOTE:** This is also disabled by default. +To validate the certificate chain without checking who the peer claims to be — +useful with a single certificate shared by all nodes — ask for +`CASS_SSL_VERIFY_PEER_CERT` alone: ```c -CassCluster* cluster = cass_cluster_new(); - -// Enable reverse DNS -cass_cluster_set_use_hostname_resolution(cluster, cass_true); - -/* ... */ - -cass_cluster_free(cluster); +// Verify the certificate chain only; the peer's identity is not checked. +cass_ssl_set_verify_flags(ssl, CASS_SSL_VERIFY_PEER_CERT); ``` +Verifying the identity against a domain name rather than an IP address +(`CASS_SSL_VERIFY_PEER_IDENTITY_DNS`) is **not supported**; it is accepted, but +treated as `CASS_SSL_VERIFY_PEER_IDENTITY`. + ### Using ScyllaDB/Cassandra and the C/C++ driver with client-side certificates Client-side certificates allow ScyllaDB/Cassandra to authenticate the client using public key cryptography and chains of trust. This is same process as above but in reverse. The client has a public and private key and the ScyllaDB/Cassandra node has a copy of the private key or the CA chain used to generate the pair. diff --git a/include/cassandra.h b/include/cassandra.h index d2ae07239..7856a405e 100644 --- a/include/cassandra.h +++ b/include/cassandra.h @@ -4302,14 +4302,21 @@ cass_ssl_add_trusted_cert_n(CassSsl* ssl, * * CASS_SSL_VERIFY_NONE - No verification is performed * CASS_SSL_VERIFY_PEER_CERT - Certificate is present and valid - * CASS_SSL_VERIFY_PEER_IDENTITY - IP address matches the certificate's - * common name or one of its subject alternative names. This implies the - * certificate is also present. + * CASS_SSL_VERIFY_PEER_IDENTITY - IP address matches one of the certificate's + * subject alternative names of type iPAddress. This implies the certificate + * is also present. + * NOTE: unlike the C/C++ driver, the subject common name (CN) is NOT + * consulted. A certificate that identifies the node only by CN, with no + * iPAddress subject alternative name (SAN), is rejected. This follows from + * OpenSSL's X509_VERIFY_PARAM_set1_ip() (called by Rust Driver), which + * never falls back to the subject for IP address checks. Note that modern + * practices advocate for using SAN, and consider CN obsolete. * CASS_SSL_VERIFY_PEER_IDENTITY_DNS - Hostname matches the certificate's * common name or one of its subject alternative names. This implies the * certificate is also present. Hostname resolution must also be enabled. + * NOTE: not supported; treated as CASS_SSL_VERIFY_PEER_IDENTITY. * - * Default: CASS_SSL_VERIFY_NONE + * Default: CASS_SSL_VERIFY_PEER_CERT * * @public @memberof CassSsl * diff --git a/scylla-rust-wrapper/build.rs b/scylla-rust-wrapper/build.rs index 1645cfe56..2bc5ae005 100644 --- a/scylla-rust-wrapper/build.rs +++ b/scylla-rust-wrapper/build.rs @@ -172,6 +172,7 @@ fn main() { ], &out_path, ); + prepare_cppdriver_data("cppdriver_ssl_types.rs", &["CassSslVerifyFlags"], &out_path); prepare_cppdriver_data( "cppdriver_host_listener_types.rs", &["CassHostListenerEvent", "CassHostListenerCallback"], diff --git a/scylla-rust-wrapper/src/lib.rs b/scylla-rust-wrapper/src/lib.rs index e604e35f5..1b208173c 100644 --- a/scylla-rust-wrapper/src/lib.rs +++ b/scylla-rust-wrapper/src/lib.rs @@ -159,6 +159,15 @@ pub(crate) mod cass_authenticator_types { include_bindgen_generated!("cppdriver_authenticator_types.rs"); } +/// CassSsl +pub(crate) mod cass_ssl_types { + #![allow(unused)] + #![allow(non_camel_case_types, non_snake_case)] + #![allow(unreachable_pub, unnameable_types)] + + include_bindgen_generated!("cppdriver_ssl_types.rs"); +} + /// CassHostListenerEvent, CassHostListenerCallback pub(crate) mod cass_host_listener_types { #![allow(unused)] diff --git a/scylla-rust-wrapper/src/ssl.rs b/scylla-rust-wrapper/src/ssl.rs index c964f779d..2f370563d 100644 --- a/scylla-rust-wrapper/src/ssl.rs +++ b/scylla-rust-wrapper/src/ssl.rs @@ -3,6 +3,7 @@ use crate::argconv::{ CassStrNulTerminated, FFI, FromArc, }; use crate::cass_error::CassError; +use crate::cass_ssl_types::CassSslVerifyFlags; use crate::types::size_t; use libc::{c_int, strlen}; use openssl::ssl::SslVerifyMode; @@ -10,7 +11,9 @@ use openssl_sys::{ BIO, BIO_free_all, BIO_new_mem_buf, EVP_PKEY_free, PEM_read_bio_PrivateKey, PEM_read_bio_X509, SSL_CTX, SSL_CTX_add_extra_chain_cert, SSL_CTX_free, SSL_CTX_new, SSL_CTX_set_cert_store, SSL_CTX_set_verify, SSL_CTX_use_PrivateKey, SSL_CTX_use_certificate, TLS_method, X509_STORE, - X509_STORE_add_cert, X509_STORE_new, X509_free, + X509_STORE_CTX, X509_STORE_CTX_get_error, X509_STORE_CTX_set_error, X509_STORE_add_cert, + X509_STORE_new, X509_V_ERR_EMAIL_MISMATCH, X509_V_ERR_HOSTNAME_MISMATCH, + X509_V_ERR_IP_ADDRESS_MISMATCH, X509_V_OK, X509_free, }; use std::convert::TryInto; use std::os::raw::c_char; @@ -26,10 +29,14 @@ impl FFI for CassSsl { type Origin = FromArc; } -pub(crate) const CASS_SSL_VERIFY_NONE: i32 = 0x00; -pub(crate) const CASS_SSL_VERIFY_PEER_CERT: i32 = 0x01; -pub(crate) const CASS_SSL_VERIFY_PEER_IDENTITY: i32 = 0x02; -pub(crate) const CASS_SSL_VERIFY_PEER_IDENTITY_DNS: i32 = 0x04; +/// Type of the verification callback accepted by `SSL_CTX_set_verify()`. +type VerifyCallback = Option c_int>; + +/// All bits recognised by [`cass_ssl_set_verify_flags`]. +const CASS_SSL_VERIFY_KNOWN_FLAGS: i32 = (CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_CERT.0 + | CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY.0 + | CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY_DNS.0) + as i32; #[unsafe(no_mangle)] pub unsafe extern "C" fn cass_ssl_new() -> CassOwnedSharedPtr { @@ -44,7 +51,11 @@ pub unsafe extern "C" fn cass_ssl_new_no_lib_init() -> CassOwnedSharedPtr) { ArcFFI::free(ssl); } +/// Verification callback used to implement [`CASS_SSL_VERIFY_PEER_CERT`], +/// i.e. chain-only verification. +/// +/// The Rust driver unconditionally pins the expected peer identity to the +/// node's IP address (`ssl.param_mut().set_ip(node_address.ip())`) before every +/// handshake, so OpenSSL always checks the peer identity on top of the chain. +/// There is no way to undo that from the `SSL_CTX` we hand over. Instead, we +/// install this callback, which lets the chain validation proceed as usual but +/// tolerates the identity mismatch errors, leaving `CASS_SSL_VERIFY_PEER_CERT` +/// with exactly the semantics the C API promises: "certificate is present and +/// valid", without requiring it to match the peer's address. +/// +/// Declared as a safe `extern "C" fn`, because that is the callback type +/// `SSL_CTX_set_verify()` expects; `x509_ctx` is only ever supplied by OpenSSL. +extern "C" fn verify_peer_cert_callback( + preverify_ok: c_int, + x509_ctx: *mut X509_STORE_CTX, +) -> c_int { + // Anything that already passed is accepted as-is. + if preverify_ok == 1 { + return 1; + } + + let error = unsafe { X509_STORE_CTX_get_error(x509_ctx) }; + match error { + X509_V_ERR_HOSTNAME_MISMATCH + | X509_V_ERR_EMAIL_MISMATCH + | X509_V_ERR_IP_ADDRESS_MISMATCH => { + // Reset the error, so that the handshake does not merely continue + // but `SSL_get_verify_result()` also reports success afterwards. + unsafe { X509_STORE_CTX_set_error(x509_ctx, X509_V_OK) }; + 1 + } + // Every other failure - an untrusted issuer, an expired certificate, + // a broken chain - is still fatal. + _ => 0, + } +} + unsafe extern "C" fn pem_password_callback( buf: *mut c_char, size: c_int, @@ -178,29 +228,55 @@ pub unsafe extern "C" fn cass_ssl_set_verify_flags( return; }; - match flags { - CASS_SSL_VERIFY_NONE => unsafe { - SSL_CTX_set_verify(ssl.ssl_context, SslVerifyMode::NONE.bits(), None) - }, - CASS_SSL_VERIFY_PEER_CERT => unsafe { - SSL_CTX_set_verify(ssl.ssl_context, SslVerifyMode::PEER.bits(), None) - }, - _ => { - if flags & CASS_SSL_VERIFY_PEER_IDENTITY != 0 { - eprintln!( - "The CASS_SSL_VERIFY_PEER_CERT_IDENTITY is not supported, CASS_SSL_VERIFY_PEER_CERT is set in SSL context." - ); - } + // `CassSslVerifyFlags` is a bitmask: the values are disjoint bits, meant to + // be combined, e.g. `CASS_SSL_VERIFY_PEER_CERT | CASS_SSL_VERIFY_PEER_IDENTITY`. + // Matching on the value as a whole would reject every such combination. + // + // Bits outside the mask are still unrecognised, so - as before - they make + // the whole value unhonourable and we fall back to the strictest setting. + let flags = if flags & !CASS_SSL_VERIFY_KNOWN_FLAGS != 0 { + tracing::error!( + "Provided unknown CASS_SSL_VERIFY flags: {flags:#x}. \ + Enforcing the strictest verification (peer certificate and peer identity) instead." + ); + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY.0 as i32 + } else { + flags + }; - if flags & CASS_SSL_VERIFY_PEER_IDENTITY_DNS != 0 { - eprintln!( - "The CASS_SSL_VERIFY_PEER_CERT_IDENTITY_DNS is not supported, CASS_SSL_VERIFY_PEER_CERT is set in SSL context." - ); - } + if flags & CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY_DNS.0 as i32 != 0 { + tracing::warn!( + "The CASS_SSL_VERIFY_PEER_IDENTITY_DNS is not supported, CASS_SSL_VERIFY_PEER_IDENTITY is set in SSL context instead." + ); + } - unsafe { SSL_CTX_set_verify(ssl.ssl_context, SslVerifyMode::PEER.bits(), None) }; + // Verifying the peer's identity implies verifying its certificate, so any + // recognised bit turns peer verification on; `CASS_SSL_VERIFY_NONE` is + // their absence. + let verify_identity = flags + & (CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY.0 as i32 + | CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY_DNS.0 as i32) + != 0; + let verify_peer = + flags & CASS_SSL_VERIFY_KNOWN_FLAGS != CassSslVerifyFlags::CASS_SSL_VERIFY_NONE.0 as i32; + + let (mode, callback): (SslVerifyMode, VerifyCallback) = match (verify_peer, verify_identity) { + // Verifying the peer's identity implies verifying its certificate. + (_, true) => { + // Rust Driver verifies identity by default (and provides no lever to turn this verification off) + // by expecting particular IP address to be present in the SAN field. + // This means that once we enable SslVerifyMode::PEER, we get certificate + identity verification. + (SslVerifyMode::PEER, None) } - } + (true, false) => { + // Chain-only verification. The driver pins the peer's identity for us, + // so the mismatches that pinning produces have to be tolerated. + (SslVerifyMode::PEER, Some(verify_peer_cert_callback)) + } + (false, false) => (SslVerifyMode::NONE, None), + }; + + unsafe { SSL_CTX_set_verify(ssl.ssl_context, mode.bits(), callback) }; } #[unsafe(no_mangle)] @@ -375,3 +451,96 @@ pub unsafe extern "C" fn cass_ssl_set_private_key_n( CassError::CASS_OK } + +#[cfg(test)] +mod tests { + use openssl_sys::SSL_CTX_get_verify_mode; + + use super::*; + + /// Reads the verification mode currently configured in the `SSL_CTX`. + fn verify_mode(ssl: &CassBorrowedSharedPtr<'_, CassSsl, CMut>) -> c_int { + let ssl = ArcFFI::as_ref(ssl.borrow()).unwrap(); + unsafe { SSL_CTX_get_verify_mode(ssl.ssl_context) } + } + + #[test] + fn verify_flags_are_a_bitmask() { + unsafe { + let ssl = cass_ssl_new(); + + cass_ssl_set_verify_flags( + ssl.borrow(), + CassSslVerifyFlags::CASS_SSL_VERIFY_NONE.0 as i32, + ); + assert_eq!(verify_mode(&ssl.borrow()), SslVerifyMode::NONE.bits()); + + // Each flag on its own enables peer verification... + for flags in [ + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_CERT, + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY, + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY_DNS, + ] + .map(|flag| flag.0 as i32) + { + cass_ssl_set_verify_flags( + ssl.borrow(), + CassSslVerifyFlags::CASS_SSL_VERIFY_NONE.0 as i32, + ); + cass_ssl_set_verify_flags(ssl.borrow(), flags); + assert_eq!(verify_mode(&ssl.borrow()), SslVerifyMode::PEER.bits()); + } + + // ...and so does any combination of them, as documented in the + // TLS guide and accepted by the C/C++ driver. + for flags in [ + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_CERT.0 as i32 + | CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY.0 as i32, + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_CERT.0 as i32 + | CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY_DNS.0 as i32, + CASS_SSL_VERIFY_KNOWN_FLAGS, + ] { + cass_ssl_set_verify_flags( + ssl.borrow(), + CassSslVerifyFlags::CASS_SSL_VERIFY_NONE.0 as i32, + ); + cass_ssl_set_verify_flags(ssl.borrow(), flags); + assert_eq!(verify_mode(&ssl.borrow()), SslVerifyMode::PEER.bits()); + } + + cass_ssl_free(ssl); + } + } + + #[test] + fn unknown_verify_flags_enforce_strictest_verification() { + unsafe { + let ssl = cass_ssl_new(); + + // An unrecognised bit makes the whole value unhonourable, whether it + // comes on its own or smuggled in next to known flags. Verification + // must then be turned on, and in particular a previously requested + // `CASS_SSL_VERIFY_NONE` must not be left in effect. + for flags in [ + 0x08, + -1, + CassSslVerifyFlags::CASS_SSL_VERIFY_PEER_IDENTITY.0 as i32 | 0x10, + ] { + cass_ssl_set_verify_flags( + ssl.borrow(), + CassSslVerifyFlags::CASS_SSL_VERIFY_NONE.0 as i32, + ); + assert_eq!(verify_mode(&ssl.borrow()), SslVerifyMode::NONE.bits()); + + cass_ssl_set_verify_flags(ssl.borrow(), flags); + assert_eq!( + verify_mode(&ssl.borrow()), + SslVerifyMode::PEER.bits(), + "unknown flags {flags:#x} did not enable peer verification" + ); + } + + cass_ssl_free(ssl); + } + } +} diff --git a/scylla-rust-wrapper/tests/integration/ccm/tls.rs b/scylla-rust-wrapper/tests/integration/ccm/tls.rs index ca0c41726..9ef3ab753 100644 --- a/scylla-rust-wrapper/tests/integration/ccm/tls.rs +++ b/scylla-rust-wrapper/tests/integration/ccm/tls.rs @@ -348,20 +348,26 @@ async fn connect_tls_no_client_auth() { try_tls_connect(cluster, Some(CASS_SSL_VERIFY_NONE), None, None).await, ); - // default: verification disabled -> connects even without a trusted CA. - assert_cass_error_eq( - CassError::CASS_OK, - try_tls_connect(cluster, None, None, None).await, - ); + let assert_conn_fails = async |verify_flags| { + let err = try_tls_connect(cluster, verify_flags, None, None).await; + assert_ne!( + err, + CassError::CASS_OK, + "expected connection to fail when the server CA is not trusted" + ); + }; + + // default: PEER_CERT without a trusted CA -> certificate chain validation + // fails, so the connection is rejected. + assert_conn_fails(None).await; + + // PEER_CERT without a trusted CA -> certificate chain validation + // fails, so the connection is rejected. + assert_conn_fails(Some(CASS_SSL_VERIFY_PEER_CERT)).await; // PEER_IDENTITY without a trusted CA -> certificate chain validation // fails, so the connection is rejected. - let err = try_tls_connect(cluster, Some(CASS_SSL_VERIFY_PEER_IDENTITY), None, None).await; - assert_ne!( - err, - CassError::CASS_OK, - "expected connection to fail when the server CA is not trusted" - ); + assert_conn_fails(Some(CASS_SSL_VERIFY_PEER_IDENTITY)).await; } run_ccm_tls_test(prepare_cert, async |c| c, test).await @@ -403,6 +409,22 @@ async fn tls_verifies_hostname() { "expected connection to fail: certificate SAN does not match the node IP" ); + // The flags are a bitmask, so the combination the TLS guide documents + // must behave exactly like PEER_IDENTITY on its own, rather than + // falling through to some default. + let err = try_tls_connect( + cluster, + Some(CASS_SSL_VERIFY_PEER_CERT | CASS_SSL_VERIFY_PEER_IDENTITY), + Some(ca_pem.clone()), + None, + ) + .await; + assert_ne!( + err, + CassError::CASS_OK, + "expected PEER_CERT | PEER_IDENTITY to verify the identity too" + ); + // NONE: verification disabled -> the SAN mismatch is ignored and the // connection succeeds. assert_cass_error_eq( @@ -480,15 +502,13 @@ async fn connect_tls_with_client_auth() { run_ccm_tls_test(prepare_cert, require_client_auth, test).await } -/// Documents the *desired* semantics of `CASS_SSL_VERIFY_PEER_CERT`: it should -/// validate the certificate chain only, without checking the peer identity -/// (IP SAN). This is not implemented yet — currently `PEER_CERT` is equivalent -/// to `PEER_IDENTITY` because the underlying Rust driver always verifies the -/// node IP. The test is therefore `#[ignore]`d; un-ignore it once chain-only -/// `PEER_CERT` support lands. +/// Checks the semantics of `CASS_SSL_VERIFY_PEER_CERT`: it validates the +/// certificate chain only, without checking the peer identity (IP SAN). +/// +/// The Rust driver always pins the expected identity to the node's IP, so this +/// relies on the wrapper installing a verification callback that tolerates the +/// identity mismatch while keeping the rest of the chain validation intact. #[tokio::test] -#[ignore = "PEER_CERT currently behaves like PEER_IDENTITY (verifies the IP SAN); \ - chain-only PEER_CERT support is not implemented yet"] async fn tls_peer_cert_verifies_chain_only() { setup_tracing();