//! Integration tests for the application-layer TLS certificate capture and //! TOFU (Trust On First Use) pinning helpers. //! //! Each test spawns a local TLS server with a freshly generated self-signed //! certificate and exercises the capture/verify helpers against it. use clustri::tls::{ capture_fingerprint, fetch_certificate_info, verify_pin, verify_server_certificate, }; use clustri::Error; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::ServerConfig; use sha2::{Digest, Sha256}; use std::sync::Arc; use tokio_rustls::TlsAcceptor; /// A plausible but wrong fingerprint (SHA-256 size, hex-uppercase, colon-separated). const WRONG_PIN: &str = "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"; /// Computes the expected `AA:BB:CC:...` uppercase fingerprint from raw DER. fn fingerprint_of(der: &[u8]) -> String { let digest = Sha256::digest(der); let hex = hex::encode_upper(digest); hex.as_bytes() .chunks(2) .map(|pair| std::str::from_utf8(pair).expect("hex digits are ASCII")) .collect::>() .join(":") } /// Generates a self-signed certificate and key for `127.0.0.1`. fn generate_self_signed() -> (CertificateDer<'static>, PrivateKeyDer<'static>) { let certified = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]) .expect("self-signed cert generation should succeed"); let cert_der = certified.cert.der().clone(); let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(certified.key_pair.serialize_der())); (cert_der, key_der) } /// Spawns a TLS server on an ephemeral port serving `cert_der`/`key_der` and /// returns the `https://` URL and the expected certificate fingerprint. async fn spawn_tls_server( cert_der: CertificateDer<'static>, key_der: PrivateKeyDer<'static>, ) -> (String, String) { // rustls cannot auto-select a process-level CryptoProvider when the // dependency graph enables both `ring` (reqwest/tokio-rustls) and the // rustls default `aws-lc-rs`. The production capture helpers install ring // explicitly; the spawned server below must do the same or it races with // (and loses to) the client's install before building its config. let _ = rustls::crypto::ring::default_provider().install_default(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind should succeed"); let address = listener.local_addr().expect("local addr should exist"); let config = ServerConfig::builder() .with_no_client_auth() .with_single_cert(vec![cert_der.clone()], key_der) .expect("server config should build"); let acceptor = Arc::new(TlsAcceptor::from(Arc::new(config))); tokio::spawn(async move { loop { let (stream, _) = match listener.accept().await { Ok(accepted) => accepted, Err(_) => continue, }; let acceptor = Arc::clone(&acceptor); tokio::spawn(async move { // Complete the handshake, then drop the connection. The client // captures the certificate as soon as the handshake finishes. let _ = acceptor.accept(stream).await; }); } }); let expected = fingerprint_of(cert_der.as_ref()); (format!("https://{}", address), expected) } #[tokio::test(flavor = "multi_thread")] async fn fetch_certificate_info_returns_real_certificate_data() { let (cert_der, key_der) = generate_self_signed(); let (url, expected) = spawn_tls_server(cert_der, key_der).await; let info = fetch_certificate_info(&url) .await .expect("certificate info should be fetched"); assert_eq!( info.fingerprint, expected, "fingerprint must match the SHA-256 of the served certificate" ); assert!(info.self_signed, "generated certificate is self-signed"); } #[tokio::test(flavor = "multi_thread")] async fn capture_fingerprint_matches_served_certificate() { let (cert_der, key_der) = generate_self_signed(); let (url, expected) = spawn_tls_server(cert_der, key_der).await; let fingerprint = capture_fingerprint(&url) .await .expect("fingerprint should be captured"); assert_eq!(fingerprint, expected); } #[tokio::test(flavor = "multi_thread")] async fn verify_server_certificate_with_correct_pin_succeeds() { let (cert_der, key_der) = generate_self_signed(); let (url, expected) = spawn_tls_server(cert_der, key_der).await; let fingerprint = verify_server_certificate(&url, Some(&expected), false) .await .expect("matching pin should verify"); assert_eq!(fingerprint, expected); } #[tokio::test(flavor = "multi_thread")] async fn verify_server_certificate_with_wrong_pin_fails() { let (cert_der, key_der) = generate_self_signed(); let (url, _expected) = spawn_tls_server(cert_der, key_der).await; let error = verify_server_certificate(&url, Some(WRONG_PIN), false) .await .expect_err("mismatched pin should fail"); assert!( matches!(error, Error::CertificateError(ref message) if message.contains("fingerprint")), "expected CertificateError, got: {}", error ); } #[tokio::test(flavor = "multi_thread")] async fn verify_server_certificate_accepts_untrusted_on_mismatch() { let (cert_der, key_der) = generate_self_signed(); let (url, expected) = spawn_tls_server(cert_der, key_der).await; let fingerprint = verify_server_certificate(&url, Some(WRONG_PIN), true) .await .expect("accept_untrusted should bypass the pin mismatch"); assert_eq!(fingerprint, expected); } #[tokio::test(flavor = "multi_thread")] async fn verify_server_certificate_without_pin_returns_fingerprint() { let (cert_der, key_der) = generate_self_signed(); let (url, expected) = spawn_tls_server(cert_der, key_der).await; let fingerprint = verify_server_certificate(&url, None, false) .await .expect("first use should return the fingerprint"); assert_eq!(fingerprint, expected); } #[tokio::test(flavor = "multi_thread")] async fn fetch_certificate_info_rejects_non_https_urls() { let error = fetch_certificate_info("http://127.0.0.1:8006") .await .expect_err("http URL must be rejected"); assert!( matches!(error, Error::InvalidUrl(_)), "expected InvalidUrl, got: {}", error ); } #[test] fn verify_pin_is_case_insensitive_and_separator_agnostic() { let canonical = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90"; assert!(verify_pin(canonical, canonical)); assert!(verify_pin(canonical, &canonical.to_lowercase())); assert!(verify_pin(&canonical.replace(':', ""), canonical)); assert!(verify_pin(canonical, &canonical.replace(':', " "))); assert!(!verify_pin(canonical, WRONG_PIN)); }