// Integration-test exemptions (same posture as the tidaldb integration tests): // unwrap on known-good fixtures and short-lived read guards are idiomatic here. #![allow(clippy::unwrap_used, clippy::significant_drop_tightening)] //! Tests for mutual TLS configuration. use std::{collections::HashMap, net::SocketAddr, thread, time::Duration}; use tidal_net::{ GrpcTransport, config::{GrpcTransportConfig, TlsConfig}, }; use tidaldb::replication::{ WalSegmentId, shard::{RegionId, ShardId}, transport::{Transport, WalSegmentPayload}, }; fn free_addr() -> SocketAddr { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.local_addr().unwrap() } /// Generate a self-signed CA and server/client certificates using rcgen. fn generate_certs(dir: &std::path::Path) -> TlsConfig { use rcgen::{CertificateParams, KeyPair}; // Generate CA. let ca_key = KeyPair::generate().unwrap(); let mut ca_params = CertificateParams::new(vec!["tidaldb-ca".to_string()]).unwrap(); ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); let ca_cert = ca_params.self_signed(&ca_key).unwrap(); // Generate server cert signed by CA. let server_key = KeyPair::generate().unwrap(); let server_params = CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]).unwrap(); let server_cert = server_params .signed_by(&server_key, &ca_cert, &ca_key) .unwrap(); // Generate client cert signed by CA. let client_key = KeyPair::generate().unwrap(); let client_params = CertificateParams::new(vec!["tidaldb-client".to_string()]).unwrap(); let client_cert = client_params .signed_by(&client_key, &ca_cert, &ca_key) .unwrap(); // Write to files. let ca_cert_path = dir.join("ca.pem"); let server_cert_path = dir.join("server.pem"); let server_key_path = dir.join("server-key.pem"); let client_cert_path = dir.join("client.pem"); let client_key_path = dir.join("client-key.pem"); std::fs::write(&ca_cert_path, ca_cert.pem()).unwrap(); std::fs::write(&server_cert_path, server_cert.pem()).unwrap(); std::fs::write(&server_key_path, server_key.serialize_pem()).unwrap(); std::fs::write(&client_cert_path, client_cert.pem()).unwrap(); std::fs::write(&client_key_path, client_key.serialize_pem()).unwrap(); TlsConfig { ca_cert: ca_cert_path, server_cert: server_cert_path, server_key: server_key_path, client_cert: Some(client_cert_path), client_key: Some(client_key_path), } } /// Generate a SECOND, independent CA and a client cert/key signed by it, /// written alongside the legit material. The returned `TlsConfig` carries the /// legit server-trusted CA (so the client still trusts the server) but a client /// identity the server's `client_ca_root` does NOT trust — i.e. an untrusted /// client certificate. Used to prove the mTLS boundary actually rejects /// foreign client certs. fn generate_untrusted_client(dir: &std::path::Path, legit: &TlsConfig) -> TlsConfig { use rcgen::{CertificateParams, KeyPair}; // A rogue CA the server has never heard of. let rogue_ca_key = KeyPair::generate().unwrap(); let mut rogue_ca_params = CertificateParams::new(vec!["rogue-ca".to_string()]).unwrap(); rogue_ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); let rogue_ca_cert = rogue_ca_params.self_signed(&rogue_ca_key).unwrap(); // A client cert signed by the rogue CA (NOT the server-trusted CA). let rogue_client_key = KeyPair::generate().unwrap(); let rogue_client_params = CertificateParams::new(vec!["rogue-client".to_string()]).unwrap(); let rogue_client_cert = rogue_client_params .signed_by(&rogue_client_key, &rogue_ca_cert, &rogue_ca_key) .unwrap(); let rogue_client_cert_path = dir.join("rogue-client.pem"); let rogue_client_key_path = dir.join("rogue-client-key.pem"); std::fs::write(&rogue_client_cert_path, rogue_client_cert.pem()).unwrap(); std::fs::write(&rogue_client_key_path, rogue_client_key.serialize_pem()).unwrap(); TlsConfig { // Keep the legit CA so the CLIENT still trusts the server's cert; only // the client's OWN identity is untrusted by the server. ca_cert: legit.ca_cert.clone(), server_cert: legit.server_cert.clone(), server_key: legit.server_key.clone(), client_cert: Some(rogue_client_cert_path), client_key: Some(rogue_client_key_path), } } /// A server config with the legit (trusted) CA and short timeouts so a rejected /// handshake fails fast in tests. fn server_config(shard: ShardId, listen: SocketAddr, tls: TlsConfig) -> GrpcTransportConfig { GrpcTransportConfig { local_shard: shard, listen_addr: listen, peers: HashMap::new(), tls: Some(tls), insecure: false, connect_timeout: Duration::from_millis(500), request_timeout: Duration::from_millis(500), ..Default::default() } } /// SECURITY BOUNDARY: a client whose certificate is signed by a CA the server /// does not trust must be REJECTED — the mTLS handshake fails and no segment is /// ever accepted. This is the negative counterpart to `mtls_send_and_receive`; /// without it the happy path alone could pass even if the server accepted ANY /// client cert (or none). #[test] fn untrusted_client_cert_is_rejected() { let tmp = tempfile::tempdir().unwrap(); let legit = generate_certs(tmp.path()); let rogue = generate_untrusted_client(tmp.path(), &legit); let addr_server = free_addr(); let addr_client = free_addr(); // Server (shard 1) trusts only the legit CA for client auth. let server = GrpcTransport::new(server_config(ShardId(1), addr_server, legit)) .expect("server with trusted CA"); // Client (shard 0) presents a cert signed by a rogue CA. let client_cfg = GrpcTransportConfig { peers: HashMap::from([(ShardId(1), addr_server)]), ..server_config(ShardId(0), addr_client, rogue) }; let client = GrpcTransport::new(client_cfg).expect("client with rogue cert"); thread::sleep(Duration::from_millis(200)); // The mTLS handshake must fail, so the send must error. Retry a couple of // times to defeat any single-shot connect race; every attempt must fail. let mut last_ok = false; for seq in 0..3u64 { let payload = WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), seq), bytes: vec![0x11; 32], event_count: 1, leader_last_seq: seq, }; if client.send_segment(ShardId(1), payload).is_ok() { last_ok = true; break; } thread::sleep(Duration::from_millis(100)); } assert!( !last_ok, "server must REJECT a client cert signed by an untrusted CA" ); // Keep the server alive until assertions run. drop(server); } /// SECURITY BOUNDARY: a client that presents NO certificate (absent client /// identity) must be REJECTED by a server that requires client auth /// (`client_ca_root`). Verifies the server demands mutual auth rather than /// accepting one-way TLS. #[test] fn absent_client_cert_is_rejected() { let tmp = tempfile::tempdir().unwrap(); let legit = generate_certs(tmp.path()); let addr_server = free_addr(); let addr_client = free_addr(); let server = GrpcTransport::new(server_config(ShardId(1), addr_server, legit.clone())) .expect("server requiring client auth"); // Client trusts the server's CA but presents NO client identity. let no_client_id = TlsConfig { ca_cert: legit.ca_cert.clone(), server_cert: legit.server_cert.clone(), server_key: legit.server_key, client_cert: None, client_key: None, }; let client_cfg = GrpcTransportConfig { peers: HashMap::from([(ShardId(1), addr_server)]), ..server_config(ShardId(0), addr_client, no_client_id) }; let client = GrpcTransport::new(client_cfg).expect("client without identity"); thread::sleep(Duration::from_millis(200)); let mut last_ok = false; for seq in 0..3u64 { let payload = WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), seq), bytes: vec![0x22; 32], event_count: 1, leader_last_seq: seq, }; if client.send_segment(ShardId(1), payload).is_ok() { last_ok = true; break; } thread::sleep(Duration::from_millis(100)); } assert!( !last_ok, "server requiring client_ca_root must REJECT a client with no certificate" ); // Keep the server alive until assertions run. drop(server); } #[test] fn mtls_send_and_receive() { let tmp = tempfile::tempdir().unwrap(); let tls = generate_certs(tmp.path()); let addr0 = free_addr(); let addr1 = free_addr(); let config0 = GrpcTransportConfig { local_shard: ShardId(0), listen_addr: addr0, peers: HashMap::from([(ShardId(1), addr1)]), tls: Some(tls.clone()), insecure: false, ..Default::default() }; let config1 = GrpcTransportConfig { local_shard: ShardId(1), listen_addr: addr1, peers: HashMap::from([(ShardId(0), addr0)]), tls: Some(tls), insecure: false, ..Default::default() }; let t0 = GrpcTransport::new(config0).expect("transport 0 with TLS"); let t1 = GrpcTransport::new(config1).expect("transport 1 with TLS"); thread::sleep(Duration::from_millis(200)); let payload = WalSegmentPayload { id: WalSegmentId::new(RegionId::SINGLE, ShardId(0), 99), bytes: vec![0xEF; 64], event_count: 2, leader_last_seq: 99, }; t0.send_segment(ShardId(1), payload).unwrap(); let received = t1.recv_segment().unwrap(); assert_eq!(received.id.seqno, 99); assert_eq!(received.event_count, 2); } #[test] fn plaintext_rejected_when_not_insecure() { let addr = free_addr(); let config = GrpcTransportConfig { local_shard: ShardId(0), listen_addr: addr, peers: HashMap::new(), tls: None, insecure: false, // Should reject — no TLS and not insecure. ..Default::default() }; let result = GrpcTransport::new(config); assert!(result.is_err(), "should reject plaintext when not insecure"); }