Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across the engine, server, net, and CLI surfaces: - WAL/session-journal durability, checkpoint format, and crash-recovery hardening - Replication shipper/receiver, tenant isolation, and migration paths - Cluster scatter-gather, router, standalone server + health/offload endpoints - tidalctl refactored into command modules with JSON output and WAL-state tooling - Cohort, governance, signal-ledger, and vector-registry correctness fixes - Expanded UAT/integration/durability test coverage across all milestones
287 lines
10 KiB
Rust
287 lines
10 KiB
Rust
// 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");
|
|
}
|