diff --git a/bindings/rust/standard/s2n-tls-metrics-subscriber/src/compatibility.rs b/bindings/rust/standard/s2n-tls-metrics-subscriber/src/compatibility.rs index 883722c815b..d2d82dcbb05 100644 --- a/bindings/rust/standard/s2n-tls-metrics-subscriber/src/compatibility.rs +++ b/bindings/rust/standard/s2n-tls-metrics-subscriber/src/compatibility.rs @@ -5,7 +5,7 @@ //! particular TLS Profile. use crate::parsing::ClientHelloSupportedParameters; -use s2n_tls_metrics_schema::static_lists::{Cipher, Group, Signature, Version}; +use s2n_tls_metrics_schema::static_lists::{CertKeyType, Cipher, Group, Signature, Version}; pub(crate) trait TlsProfile { const ALLOWED_VERSIONS: &[Version]; @@ -13,8 +13,34 @@ pub(crate) trait TlsProfile { const ALLOWED_GROUPS: &[Group]; const ALLOWED_SIGNATURES: &[Signature]; - /// returns true if a client could handshake with this [`TlsProfile`] - fn supported(client_hello: &ClientHelloSupportedParameters) -> bool { + /// The certificate key types the policy permits in a *locally presented* + /// certificate chain. + /// + /// `None` means the policy imposes no certificate-key restriction (its + /// underlying s2n security policy has no `certificate_key_preferences`, or + /// does not apply them locally), so any certificate is acceptable. + /// + /// `Some(list)` means the policy validates local certificates against + /// `certificate_key_preferences` (i.e. `certificate_preferences_apply_locally` + /// is set). A handshake is only compatible if *every* observed local cert + /// key is in `list`, because a single incompatible certificate breaks the + /// handshake under the real policy. + const ALLOWED_CERT_KEYS: Option<&[CertKeyType]>; + + /// returns true if a peer could handshake with this [`TlsProfile`], given + /// the parameters advertised in its `ClientHello` and the key types of the + /// certificate chain this endpoint presents locally. + /// + /// `local_cert_keys` are the [`CertKeyType`]s parsed from the local + /// (selected) certificate chain. This is required in addition to the + /// ClientHello parameters because policies like CNSA1 reject certificates + /// whose key is not in `certificate_key_preferences` (e.g. a P-256 cert + /// under a P-384-only policy), which the advertised parameters alone cannot + /// reveal. + fn supported( + client_hello: &ClientHelloSupportedParameters, + local_cert_keys: &[CertKeyType], + ) -> bool { let supported_version = client_hello .supported_versions() .iter() @@ -43,7 +69,25 @@ pub(crate) trait TlsProfile { }) .unwrap_or(false); - supported_version && supported_cipher && supported_group && supported_signature + let supported_cert = match Self::ALLOWED_CERT_KEYS { + // No certificate-key restriction: the cert dimension always passes. + None => true, + // The policy restricts cert keys, but we couldn't observe any local + // cert key (no local cert, or a parse failure). We cannot prove the + // migration is safe, so fail closed. + Some(_) if local_cert_keys.is_empty() => false, + // Every local cert key must be allowed: a single incompatible cert + // would be rejected by the real policy and break the handshake. + Some(allowed) => local_cert_keys + .iter() + .all(|cert_key| allowed.contains(cert_key)), + }; + + supported_version + && supported_cipher + && supported_group + && supported_signature + && supported_cert } } @@ -84,6 +128,10 @@ impl TlsProfile for General20251201 { Signature::ecdsa_secp384r1_sha384, Signature::ecdsa_secp521r1_sha512, ]; + + // The `default` policy has no `certificate_key_preferences`, so it imposes + // no restriction on the local certificate's key type. + const ALLOWED_CERT_KEYS: Option<&[CertKeyType]> = None; } pub(crate) struct Fips20251201; @@ -119,6 +167,11 @@ impl TlsProfile for Fips20251201 { Signature::ecdsa_secp384r1_sha384, Signature::ecdsa_secp521r1_sha512, ]; + + // The `default_fips` policy has `certificate_signature_preferences` but no + // `certificate_key_preferences`, and does not apply certificate preferences + // locally, so it imposes no restriction on the local certificate's key type. + const ALLOWED_CERT_KEYS: Option<&[CertKeyType]> = None; } pub(crate) struct Cnsa1; @@ -133,6 +186,18 @@ impl TlsProfile for Cnsa1 { const ALLOWED_GROUPS: &[Group] = &[Group::secp384r1]; const ALLOWED_SIGNATURES: &[Signature] = &[Signature::ecdsa_secp384r1_sha384]; + + // CNSA1 applies certificate preferences locally and only permits the key + // types in `s2n_certificate_key_preferences_20250429`: P-384 and RSA + // (rsae/pss) 3072/4096. In particular a P-256 certificate is rejected and + // would break the handshake, so it must not be counted as compatible. + const ALLOWED_CERT_KEYS: Option<&[CertKeyType]> = Some(&[ + CertKeyType::Secp384r1, + CertKeyType::Rsa3072, + CertKeyType::Rsa4096, + CertKeyType::RsaPss3072, + CertKeyType::RsaPss4096, + ]); } pub(crate) struct Cnsa2; @@ -144,6 +209,17 @@ impl TlsProfile for Cnsa2 { const ALLOWED_GROUPS: &[Group] = &[Group::MLKEM1024]; const ALLOWED_SIGNATURES: &[Signature] = &[Signature::mldsa87]; + + // CNSA2 permits only ML-DSA-87 certificates. The metrics cert parser does + // not yet recognize ML-DSA keys (they parse to `CertKeyType::Unknown`), so + // encoding a `Some([...])` restriction here would incorrectly reject a valid + // ML-DSA-87 cert. We leave the cert dimension unrestricted for now; CNSA2 is + // already tightly bounded by its `mldsa87` signature and `MLKEM1024` group + // requirements, which no classical (P-256/P-384/RSA) peer satisfies. + // + // TODO: add per-cert CNSA2 gating once the cert parser learns ML-DSA (would + // require an `MlDsa87` `CertKeyType` variant + OID mapping). + const ALLOWED_CERT_KEYS: Option<&[CertKeyType]> = None; } #[cfg(test)] @@ -193,6 +269,36 @@ mod tests { pair.server } + /// Extract the key types of the connection's local (selected) certificate + /// chain, mirroring how `record.rs` feeds `local_cert_keys` into the + /// compatibility check. On the server side of these tests `selected_cert()` + /// is the server's own chain. + fn local_cert_keys(conn: &s2n_tls::connection::Connection) -> Vec { + let mut keys = Vec::new(); + if let Some(chain) = conn.selected_cert() { + for cert in chain.iter().flatten() { + if let Ok(der) = cert.der() { + if let Ok(parsed) = crate::parsing::cert::parse(der) { + keys.push(parsed.key_type); + } + } + } + } + keys + } + + /// Build `ClientHelloSupportedParameters` that satisfy CNSA1's non-certificate + /// dimensions (TLS 1.2/1.3, AES-256-GCM-SHA384, secp384r1, + /// ecdsa_secp384r1_sha384) so tests can isolate the certificate dimension. + fn cnsa1_compatible_client_hello() -> ClientHelloSupportedParameters { + ClientHelloSupportedParameters::from_parts( + vec![Version::TLS_1_2, Version::TLS_1_3], + vec![Cipher::TLS_AES_256_GCM_SHA384], + Some(vec![Group::secp384r1]), + Some(vec![Signature::ecdsa_secp384r1_sha384]), + ) + } + fn default_cert() -> CertKeyPair { CertKeyPair::default() } @@ -206,6 +312,15 @@ mod tests { ) } + fn ecdsa_p256_cert() -> CertKeyPair { + CertKeyPair::from_path( + "permutations/ec_ecdsa_p256_sha256/", + "server-chain", + "server-key", + "ca-cert", + ) + } + /// ML-DSA files don't use .pem extension, so we build configs directly /// instead of using CertKeyPair. fn mldsa87_configs(policy_name: &str) -> (s2n_tls::config::Config, s2n_tls::config::Config) { @@ -242,7 +357,8 @@ mod tests { let server = handshake_with_policy("default", &default_cert()); let ch = server.client_hello().unwrap(); assert!(General20251201::supported( - &ClientHelloSupportedParameters::new(ch).unwrap() + &ClientHelloSupportedParameters::new(ch).unwrap(), + &local_cert_keys(&server), )); } @@ -251,7 +367,8 @@ mod tests { let server = handshake_with_policy("default_fips", &default_cert()); let ch = server.client_hello().unwrap(); assert!(Fips20251201::supported( - &ClientHelloSupportedParameters::new(ch).unwrap() + &ClientHelloSupportedParameters::new(ch).unwrap(), + &local_cert_keys(&server), )); } @@ -260,7 +377,8 @@ mod tests { let server = handshake_with_policy("cnsa_1", &ecdsa_p384_cert()); let ch = server.client_hello().unwrap(); assert!(Cnsa1::supported( - &ClientHelloSupportedParameters::new(ch).unwrap() + &ClientHelloSupportedParameters::new(ch).unwrap(), + &local_cert_keys(&server), )); } @@ -271,14 +389,18 @@ mod tests { pair.handshake().unwrap(); let ch = pair.server.client_hello().unwrap(); let supported_parameters = ClientHelloSupportedParameters::new(ch).unwrap(); + let cert_keys = local_cert_keys(&pair.server); - assert!(Cnsa2::supported(&supported_parameters)); + assert!(Cnsa2::supported(&supported_parameters, &cert_keys)); // doesn't support required groups/signatures - assert!(!Cnsa1::supported(&supported_parameters)); + assert!(!Cnsa1::supported(&supported_parameters, &cert_keys)); // doesn't support required groups - assert!(!Fips20251201::supported(&supported_parameters)); + assert!(!Fips20251201::supported(&supported_parameters, &cert_keys)); // doesn't support required groups - assert!(!General20251201::supported(&supported_parameters)); + assert!(!General20251201::supported( + &supported_parameters, + &cert_keys + )); } #[test] @@ -288,7 +410,104 @@ mod tests { let server = handshake_with_policy("cnsa_1_2_interop", &cert); let ch = server.client_hello().unwrap(); let supported_parameters = ClientHelloSupportedParameters::new(ch).unwrap(); - assert!(Cnsa1::supported(&supported_parameters)); - assert!(Cnsa2::supported(&supported_parameters)); + let cert_keys = local_cert_keys(&server); + assert!(Cnsa1::supported(&supported_parameters, &cert_keys)); + assert!(Cnsa2::supported(&supported_parameters, &cert_keys)); + } + + /// Regression test for issue #5982: a peer whose ClientHello satisfies all + /// of CNSA1's advertised requirements is NOT CNSA1-compatible when the + /// local certificate uses a P-256 key, because the real CNSA1 policy rejects + /// P-256 certificates. + #[test] + fn cnsa1_incompatible_with_p256_cert() { + let params = cnsa1_compatible_client_hello(); + // sanity: the ClientHello parameters alone satisfy CNSA1 when the cert + // key is compatible. + assert!(Cnsa1::supported(¶ms, &[CertKeyType::Secp384r1])); + // but a P-256 cert must disqualify CNSA1 compatibility. + assert!(!Cnsa1::supported(¶ms, &[CertKeyType::Secp256r1])); + } + + /// A P-384 local certificate keeps a CNSA1-satisfying ClientHello compatible. + #[test] + fn cnsa1_compatible_with_p384_cert() { + let params = cnsa1_compatible_client_hello(); + assert!(Cnsa1::supported(¶ms, &[CertKeyType::Secp384r1])); + } + + /// CNSA1 also permits RSA-3072/4096 certificates, but not RSA-2048 or P-256. + #[test] + fn cnsa1_cert_key_allow_list() { + let params = cnsa1_compatible_client_hello(); + assert!(Cnsa1::supported(¶ms, &[CertKeyType::Rsa3072])); + assert!(Cnsa1::supported(¶ms, &[CertKeyType::Rsa4096])); + assert!(Cnsa1::supported(¶ms, &[CertKeyType::RsaPss3072])); + assert!(!Cnsa1::supported(¶ms, &[CertKeyType::Rsa2048])); + assert!(!Cnsa1::supported(¶ms, &[CertKeyType::Rsa1024])); + } + + /// A single incompatible cert anywhere in the local chain disqualifies + /// compatibility, because the real policy validates every cert it presents. + #[test] + fn cnsa1_incompatible_when_any_chain_cert_incompatible() { + let params = cnsa1_compatible_client_hello(); + // leaf P-384 (ok) but an intermediate is P-256 (not ok) + assert!(!Cnsa1::supported( + ¶ms, + &[CertKeyType::Secp384r1, CertKeyType::Secp256r1], + )); + } + + /// When the cert is a policy-restricted profile (CNSA1) but no local cert + /// key could be observed, we fail closed (cannot prove migration is safe). + #[test] + fn cnsa1_incompatible_when_no_local_cert() { + let params = cnsa1_compatible_client_hello(); + assert!(!Cnsa1::supported(¶ms, &[])); + } + + /// Profiles with no certificate-key restriction (General/Fips) are + /// unaffected by the local cert key types, including when none are observed. + #[test] + fn unrestricted_profiles_ignore_cert_keys() { + // General/Fips advertised requirements satisfied by a broad ClientHello. + let params = ClientHelloSupportedParameters::from_parts( + vec![Version::TLS_1_2, Version::TLS_1_3], + vec![Cipher::TLS_AES_128_GCM_SHA256], + Some(vec![Group::secp256r1]), + Some(vec![Signature::ecdsa_secp256r1_sha256]), + ); + // A P-256 cert (or no cert at all) is fine for the unrestricted profiles. + assert!(General20251201::supported( + ¶ms, + &[CertKeyType::Secp256r1] + )); + assert!(General20251201::supported(¶ms, &[])); + assert!(Fips20251201::supported(¶ms, &[CertKeyType::Secp256r1])); + assert!(Fips20251201::supported(¶ms, &[])); + } + + /// End-to-end analogue of the regression test using a real handshake: a + /// P-256 server cert must not be counted CNSA1-compatible, while a P-384 + /// server cert is. The client uses a permissive policy so the handshake + /// succeeds regardless (we are measuring the profile, not enforcing it). + #[test] + fn cnsa1_p256_vs_p384_end_to_end() { + let p256_server = handshake_with_policy("test_all", &ecdsa_p256_cert()); + let p256_ch = p256_server.client_hello().unwrap(); + let p256_params = ClientHelloSupportedParameters::new(p256_ch).unwrap(); + assert!(!Cnsa1::supported( + &p256_params, + &local_cert_keys(&p256_server), + )); + + let p384_server = handshake_with_policy("cnsa_1", &ecdsa_p384_cert()); + let p384_ch = p384_server.client_hello().unwrap(); + let p384_params = ClientHelloSupportedParameters::new(p384_ch).unwrap(); + assert!(Cnsa1::supported( + &p384_params, + &local_cert_keys(&p384_server), + )); } } diff --git a/bindings/rust/standard/s2n-tls-metrics-subscriber/src/record.rs b/bindings/rust/standard/s2n-tls-metrics-subscriber/src/record.rs index 1c673515b6f..1aefb0ccc93 100644 --- a/bindings/rust/standard/s2n-tls-metrics-subscriber/src/record.rs +++ b/bindings/rust/standard/s2n-tls-metrics-subscriber/src/record.rs @@ -268,6 +268,99 @@ impl HandshakeRecordInProgress { self.negotiated_signatures.increment(&sig); } + // populate cert metrics + // + // We parse the cert chains before evaluating policy compatibility below + // because the compatibility check needs the local (selected) chain's + // key types: policies like CNSA1 reject certificates whose key is not in + // their `certificate_key_preferences` (e.g. a P-256 cert), which the + // ClientHello-advertised parameters alone cannot reveal. + let local_cert_keys = { + fn record_chain_metrics<'a>( + mut certs: impl Iterator< + Item = Result, s2n_tls::error::Error>, + >, + leaf_key: &Counter, + leaf_sig: &Counter, + chain_key: &Counter, + chain_sig: &Counter, + parse_failures: &AtomicU64, + ) -> Vec { + // The key types parsed from this chain (leaf first), returned so + // the caller can feed the local chain into the compatibility check. + let mut key_types = Vec::new(); + if let Some(Ok(cert)) = certs.next() { + if let Ok(der) = cert.der() { + match parsing::cert::parse(der) { + Ok(c) => { + leaf_key.increment(&c.key_type); + leaf_sig.increment(&c.signature); + key_types.push(c.key_type); + } + Err(_) => { + parse_failures.fetch_add(1, Ordering::Relaxed); + } + } + } + } + for cert in certs.flatten() { + if let Ok(der) = cert.der() { + match parsing::cert::parse(der) { + Ok(c) => { + chain_key.increment(&c.key_type); + chain_sig.increment(&c.signature); + key_types.push(c.key_type); + } + Err(_) => { + parse_failures.fetch_add(1, Ordering::Relaxed); + } + } + } + } + key_types + } + + // selected_cert() is the local cert; peer_cert_chain() is the remote cert. + // Map to server/client labels based on connection mode. + let (server_cert, client_cert) = match conn.mode() { + s2n_tls::enums::Mode::Server => (conn.selected_cert(), conn.peer_cert_chain().ok()), + s2n_tls::enums::Mode::Client => (conn.peer_cert_chain().ok(), conn.selected_cert()), + }; + + let server_cert_keys = if let Some(cert) = server_cert { + record_chain_metrics( + cert.iter(), + &self.server_leaf_cert_key, + &self.server_leaf_cert_sig, + &self.server_chain_cert_key, + &self.server_chain_cert_sig, + &self.server_cert_parsing_failure, + ) + } else { + Vec::new() + }; + + let client_cert_keys = if let Some(cert) = client_cert { + record_chain_metrics( + cert.iter(), + &self.client_leaf_cert_key, + &self.client_leaf_cert_sig, + &self.client_chain_cert_key, + &self.client_chain_cert_sig, + &self.client_cert_parsing_failure, + ) + } else { + Vec::new() + }; + + // The local (selected) chain is the server chain on the server side + // and the client chain on the client side, matching the mapping above. + match conn.mode() { + s2n_tls::enums::Mode::Server => server_cert_keys, + s2n_tls::enums::Mode::Client => client_cert_keys, + } + }; + let supported_parameters = if let Some(client_hello) = client_hello { match ( conn.client_hello_is_sslv2(), @@ -302,18 +395,18 @@ impl HandshakeRecordInProgress { .flat_map(|sigs| sigs.iter()) .for_each(|signature| self.supported_signatures.increment(signature)); - if General20251201::supported(&supported_parameter) { + if General20251201::supported(&supported_parameter, &local_cert_keys) { self.compatibility_general20251201 .fetch_add(1, Ordering::Relaxed); } - if Fips20251201::supported(&supported_parameter) { + if Fips20251201::supported(&supported_parameter, &local_cert_keys) { self.compatibility_fips20251201 .fetch_add(1, Ordering::Relaxed); } - if Cnsa1::supported(&supported_parameter) { + if Cnsa1::supported(&supported_parameter, &local_cert_keys) { self.compatibility_cnsa1.fetch_add(1, Ordering::Relaxed); } - if Cnsa2::supported(&supported_parameter) { + if Cnsa2::supported(&supported_parameter, &local_cert_keys) { self.compatibility_cnsa2.fetch_add(1, Ordering::Relaxed); } Some(supported_parameter) @@ -347,76 +440,6 @@ impl HandshakeRecordInProgress { .for_each(|issue| self.client_issue.increment(&issue)); } - // populate cert metrics - { - fn record_chain_metrics<'a>( - mut certs: impl Iterator< - Item = Result, s2n_tls::error::Error>, - >, - leaf_key: &Counter, - leaf_sig: &Counter, - chain_key: &Counter, - chain_sig: &Counter, - parse_failures: &AtomicU64, - ) { - if let Some(Ok(cert)) = certs.next() { - if let Ok(der) = cert.der() { - match parsing::cert::parse(der) { - Ok(c) => { - leaf_key.increment(&c.key_type); - leaf_sig.increment(&c.signature); - } - Err(_) => { - parse_failures.fetch_add(1, Ordering::Relaxed); - } - } - } - } - for cert in certs.flatten() { - if let Ok(der) = cert.der() { - match parsing::cert::parse(der) { - Ok(c) => { - chain_key.increment(&c.key_type); - chain_sig.increment(&c.signature); - } - Err(_) => { - parse_failures.fetch_add(1, Ordering::Relaxed); - } - } - } - } - } - - // selected_cert() is the local cert; peer_cert_chain() is the remote cert. - // Map to server/client labels based on connection mode. - let (server_cert, client_cert) = match conn.mode() { - s2n_tls::enums::Mode::Server => (conn.selected_cert(), conn.peer_cert_chain().ok()), - s2n_tls::enums::Mode::Client => (conn.peer_cert_chain().ok(), conn.selected_cert()), - }; - - if let Some(cert) = server_cert { - record_chain_metrics( - cert.iter(), - &self.server_leaf_cert_key, - &self.server_leaf_cert_sig, - &self.server_chain_cert_key, - &self.server_chain_cert_sig, - &self.server_cert_parsing_failure, - ); - } - - if let Some(cert) = client_cert { - record_chain_metrics( - cert.iter(), - &self.client_leaf_cert_key, - &self.client_leaf_cert_sig, - &self.client_chain_cert_key, - &self.client_chain_cert_sig, - &self.client_cert_parsing_failure, - ); - } - } - // accuracy: as long as the handshake took less than 500,000 years // this cast will not truncate. We prefer truncation/less accurate metrics // over a panic.