Skip to main content

zamsync_network/
tls.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use rustls::pki_types::{CertificateDer, PrivateKeyDer};
5use zamsync_core::{ZamError, ZamResult};
6
7/// TLS credentials for a ZamSync node.
8///
9/// All nodes in a deployment share the same CA certificate. Each node has its
10/// own certificate and private key signed by the CA. Mutual TLS (mTLS) is used:
11/// both sides present a certificate, ensuring only authorized nodes can connect.
12pub struct TlsConfig {
13    cert_pem: Vec<u8>,
14    key_pem: Vec<u8>,
15    ca_pem: Vec<u8>,
16}
17
18impl TlsConfig {
19    /// Load TLS credentials from PEM files.
20    ///
21    /// * `cert_path` -- this node's certificate (`node.crt`)
22    /// * `key_path`  -- this node's private key  (`node.key`)
23    /// * `ca_path`   -- the deployment CA cert   (`ca.crt`)
24    pub fn from_files(
25        cert_path: impl AsRef<Path>,
26        key_path: impl AsRef<Path>,
27        ca_path: impl AsRef<Path>,
28    ) -> ZamResult<Self> {
29        Ok(Self {
30            cert_pem: std::fs::read(cert_path)?,
31            key_pem: std::fs::read(key_path)?,
32            ca_pem: std::fs::read(ca_path)?,
33        })
34    }
35
36    /// Construct directly from PEM strings (useful in tests).
37    pub fn from_pem(cert_pem: String, key_pem: String, ca_pem: String) -> Self {
38        Self {
39            cert_pem: cert_pem.into_bytes(),
40            key_pem: key_pem.into_bytes(),
41            ca_pem: ca_pem.into_bytes(),
42        }
43    }
44
45    fn load_cert(&self) -> ZamResult<CertificateDer<'static>> {
46        rustls_pemfile::certs(&mut self.cert_pem.as_slice())
47            .collect::<Result<Vec<_>, _>>()?
48            .into_iter()
49            .next()
50            .ok_or_else(|| ZamError::Config("no certificate in cert file".into()))
51    }
52
53    fn load_key(&self) -> ZamResult<PrivateKeyDer<'static>> {
54        rustls_pemfile::private_key(&mut self.key_pem.as_slice())?
55            .ok_or_else(|| ZamError::Config("no private key in key file".into()))
56    }
57
58    fn load_ca(&self) -> ZamResult<CertificateDer<'static>> {
59        rustls_pemfile::certs(&mut self.ca_pem.as_slice())
60            .collect::<Result<Vec<_>, _>>()?
61            .into_iter()
62            .next()
63            .ok_or_else(|| ZamError::Config("no certificate in CA file".into()))
64    }
65
66    /// Build a rustls ServerConfig (mTLS: requires client to present a cert from the same CA).
67    pub(crate) fn server_config(&self) -> ZamResult<Arc<rustls::ServerConfig>> {
68        let cert = self.load_cert()?;
69        let key = self.load_key()?;
70        let ca = self.load_ca()?;
71
72        let mut client_roots = rustls::RootCertStore::empty();
73        client_roots
74            .add(ca)
75            .map_err(|e| ZamError::Config(format!("invalid CA cert: {e}")))?;
76
77        let client_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(client_roots))
78            .build()
79            .map_err(|e| ZamError::Config(format!("client verifier: {e}")))?;
80
81        let config = rustls::ServerConfig::builder()
82            .with_client_cert_verifier(client_verifier)
83            .with_single_cert(vec![cert], key)
84            .map_err(|e| ZamError::Config(format!("server TLS config: {e}")))?;
85
86        Ok(Arc::new(config))
87    }
88
89    /// Build a rustls ClientConfig (mTLS: presents this node's cert to the server).
90    pub(crate) fn client_config(&self) -> ZamResult<Arc<rustls::ClientConfig>> {
91        let cert = self.load_cert()?;
92        let key = self.load_key()?;
93        let ca = self.load_ca()?;
94
95        let mut server_roots = rustls::RootCertStore::empty();
96        server_roots
97            .add(ca)
98            .map_err(|e| ZamError::Config(format!("invalid CA cert: {e}")))?;
99
100        let config = rustls::ClientConfig::builder()
101            .with_root_certificates(server_roots)
102            .with_client_auth_cert(vec![cert], key)
103            .map_err(|e| ZamError::Config(format!("client TLS config: {e}")))?;
104
105        Ok(Arc::new(config))
106    }
107}
108
109/// Credentials generated by [`generate_credentials`].
110pub struct GeneratedCredentials {
111    /// CA certificate in PEM format. Distribute to every node in the deployment.
112    pub ca_cert_pem: String,
113    /// CA private key in PEM format. Keep secret; only needed to sign new node certs.
114    pub ca_key_pem: String,
115    /// Node certificate in PEM format. Install on this node only.
116    pub node_cert_pem: String,
117    /// Node private key in PEM format. Keep secret; never leave this node.
118    pub node_key_pem: String,
119}
120
121/// Generate a deployment CA + node certificate pair using ECDSA P-256.
122///
123/// Typical per-deployment workflow:
124/// 1. Run `zamsync keygen <data-dir>` on each node.
125/// 2. Copy `ca.crt` from node A to all other nodes.
126/// 3. Keep `node.crt` and `node.key` local to each node.
127pub fn generate_credentials() -> ZamResult<GeneratedCredentials> {
128    let ca_key = rcgen::KeyPair::generate()
129        .map_err(|e| ZamError::Config(format!("CA key generation failed: {e}")))?;
130
131    let mut ca_params = rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()])
132        .map_err(|e| ZamError::Config(format!("CA params: {e}")))?;
133    ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
134
135    let ca_cert = ca_params
136        .self_signed(&ca_key)
137        .map_err(|e| ZamError::Config(format!("CA self-sign failed: {e}")))?;
138
139    let node_key = rcgen::KeyPair::generate()
140        .map_err(|e| ZamError::Config(format!("node key generation failed: {e}")))?;
141
142    let node_params = rcgen::CertificateParams::new(vec!["zamsync.local".to_string()])
143        .map_err(|e| ZamError::Config(format!("node params: {e}")))?;
144
145    let node_cert = node_params
146        .signed_by(&node_key, &ca_cert, &ca_key)
147        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;
148
149    Ok(GeneratedCredentials {
150        ca_cert_pem: ca_cert.pem(),
151        ca_key_pem: ca_key.serialize_pem(),
152        node_cert_pem: node_cert.pem(),
153        node_key_pem: node_key.serialize_pem(),
154    })
155}
156
157/// Credentials produced by [`sign_node_cert`] -- no CA key material, just what the clinic node needs.
158pub struct SignedNodeCredentials {
159    /// CA certificate in PEM format. The clinic copies this to `tls/ca.crt`.
160    pub ca_cert_pem: String,
161    /// Node certificate signed by the hub CA. Written to `tls/node.crt`.
162    pub node_cert_pem: String,
163    /// Node private key (stays on this clinic node only). Written to `tls/node.key`.
164    pub node_key_pem: String,
165}
166
167/// Sign a new node certificate with an existing CA.
168///
169/// Used by `zamsync sign <clinic-dir> --ca <hub-dir>`:
170/// - The hub CA key is read from `<hub-dir>/tls/ca.key`.
171/// - A fresh ECDSA P-256 keypair is generated for the clinic node.
172/// - The clinic cert is signed by the hub CA, so the clinic joins the same mTLS deployment
173///   without the hub generating a new CA.
174///
175/// The CA cert PEM is passed through unchanged into `SignedNodeCredentials.ca_cert_pem` so
176/// the clinic always distributes the original CA cert to its own TLS config.
177pub fn sign_node_cert(ca_cert_pem: &str, ca_key_pem: &str) -> ZamResult<SignedNodeCredentials> {
178    let ca_key = rcgen::KeyPair::from_pem(ca_key_pem)
179        .map_err(|e| ZamError::Config(format!("parse CA key: {e}")))?;
180
181    // Reconstruct CA CertificateParams from its known structure (generated by `keygen`).
182    // We only need the CA's key pair and subject name to sign new node certs -- the
183    // subject key identifier is derived from the public key so it matches the original cert.
184    let mut ca_params = rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()])
185        .map_err(|e| ZamError::Config(format!("CA params: {e}")))?;
186    ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
187
188    let ca_cert = ca_params
189        .self_signed(&ca_key)
190        .map_err(|e| ZamError::Config(format!("reconstruct CA cert: {e}")))?;
191
192    let node_key = rcgen::KeyPair::generate()
193        .map_err(|e| ZamError::Config(format!("node key generation failed: {e}")))?;
194
195    let node_params = rcgen::CertificateParams::new(vec!["zamsync.local".to_string()])
196        .map_err(|e| ZamError::Config(format!("node params: {e}")))?;
197
198    let node_cert = node_params
199        .signed_by(&node_key, &ca_cert, &ca_key)
200        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;
201
202    Ok(SignedNodeCredentials {
203        ca_cert_pem: ca_cert_pem.to_owned(),
204        node_cert_pem: node_cert.pem(),
205        node_key_pem: node_key.serialize_pem(),
206    })
207}
208
209/// Install the ring crypto provider for rustls. Safe to call multiple times.
210pub fn install_crypto_provider() {
211    let _ = rustls::crypto::ring::default_provider().install_default();
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_sign_node_cert_produces_valid_chain() {
220        install_crypto_provider();
221
222        let hub = generate_credentials().expect("hub keygen failed");
223
224        // Sign two separate clinic certs from the same CA
225        let clinic_a =
226            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_a signing failed");
227        let clinic_b =
228            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_b signing failed");
229
230        // Both clinics share the same CA cert
231        assert_eq!(clinic_a.ca_cert_pem, hub.ca_cert_pem);
232        assert_eq!(clinic_b.ca_cert_pem, hub.ca_cert_pem);
233
234        // Clinic certs are distinct
235        assert_ne!(clinic_a.node_cert_pem, clinic_b.node_cert_pem);
236        assert_ne!(clinic_a.node_key_pem, clinic_b.node_key_pem);
237
238        // Verify the signed cert can be loaded and used by rustls (hub server + clinic client)
239        let hub_tls = TlsConfig::from_pem(
240            hub.node_cert_pem.clone(),
241            hub.node_key_pem.clone(),
242            hub.ca_cert_pem.clone(),
243        );
244        let clinic_a_tls = TlsConfig::from_pem(
245            clinic_a.node_cert_pem.clone(),
246            clinic_a.node_key_pem.clone(),
247            clinic_a.ca_cert_pem.clone(),
248        );
249
250        // Both sides should produce valid TLS configs without error
251        hub_tls.server_config().expect("hub server_config failed");
252        clinic_a_tls
253            .client_config()
254            .expect("clinic_a client_config failed");
255    }
256
257    #[test]
258    fn test_rogue_node_with_own_ca_rejected() {
259        install_crypto_provider();
260
261        let hub = generate_credentials().expect("hub keygen");
262        let rogue = generate_credentials().expect("rogue keygen");
263
264        // Hub's server verifies against its own CA
265        let hub_tls = TlsConfig::from_pem(
266            hub.node_cert_pem.clone(),
267            hub.node_key_pem.clone(),
268            hub.ca_cert_pem.clone(),
269        );
270
271        // Rogue node has its own CA -- its cert is NOT signed by hub CA
272        let rogue_tls = TlsConfig::from_pem(
273            rogue.node_cert_pem.clone(),
274            rogue.node_key_pem.clone(),
275            hub.ca_cert_pem.clone(), // rogue pretends to trust hub CA
276        );
277
278        // Hub server config should build fine (hub's own cert is valid)
279        hub_tls.server_config().expect("hub server_config failed");
280
281        // Rogue client config builds fine locally -- but its cert was NOT signed by hub CA.
282        // At TLS handshake time the hub would reject it. We verify here that the rogue cert
283        // is NOT verifiable with the hub CA root store.
284        let hub_ca_der = rustls_pemfile::certs(&mut hub.ca_cert_pem.as_bytes())
285            .collect::<Result<Vec<_>, _>>()
286            .expect("parse hub CA");
287        let rogue_cert_der = rustls_pemfile::certs(&mut rogue.node_cert_pem.as_bytes())
288            .collect::<Result<Vec<_>, _>>()
289            .expect("parse rogue cert");
290
291        let mut root_store = rustls::RootCertStore::empty();
292        root_store.add(hub_ca_der[0].clone()).expect("add hub CA");
293
294        // The rogue cert was issued by its own CA, not the hub CA -- should not verify
295        let verifier =
296            rustls::server::WebPkiClientVerifier::builder(std::sync::Arc::new(root_store))
297                .build()
298                .expect("build verifier");
299
300        // rustls WebPkiClientVerifier::verify_client_cert returns an error for unknown issuers
301        let now = rustls::pki_types::UnixTime::now();
302        let result = verifier.verify_client_cert(&rogue_cert_der[0], &[], now);
303        assert!(
304            result.is_err(),
305            "rogue cert must be rejected by hub CA verifier"
306        );
307
308        // Confirm the rogue client config itself cannot be built with the hub CA as server root
309        // (rogue's cert will fail at the server-side verification)
310        rogue_tls
311            .client_config()
312            .expect("client config builds -- rejection happens at handshake");
313    }
314
315    /// A node cert whose `not_after` is in the past must be rejected by the
316    /// WebPki verifier -- this mirrors what rustls does at the TLS handshake.
317    #[test]
318    fn test_expired_cert_rejected_at_handshake() {
319        install_crypto_provider();
320
321        let ca_key = rcgen::KeyPair::generate().expect("CA key");
322        let mut ca_params =
323            rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()]).expect("CA params");
324        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
325        let ca_cert = ca_params.self_signed(&ca_key).expect("CA self-sign");
326
327        // Build a node cert whose validity window is entirely in the past (1970-01-01..1970-01-02).
328        let node_key = rcgen::KeyPair::generate().expect("node key");
329        let mut node_params =
330            rcgen::CertificateParams::new(vec!["zamsync.local".to_string()]).expect("node params");
331        node_params.not_before = time::OffsetDateTime::from_unix_timestamp(0).expect("epoch start");
332        node_params.not_after =
333            time::OffsetDateTime::from_unix_timestamp(86400).expect("epoch + 1 day");
334        let expired_cert = node_params
335            .signed_by(&node_key, &ca_cert, &ca_key)
336            .expect("sign expired cert");
337
338        let ca_pem = ca_cert.pem();
339        let expired_pem = expired_cert.pem();
340
341        let ca_der: Vec<_> = rustls_pemfile::certs(&mut ca_pem.as_bytes())
342            .collect::<Result<Vec<_>, _>>()
343            .expect("parse CA DER");
344        let expired_der: Vec<_> = rustls_pemfile::certs(&mut expired_pem.as_bytes())
345            .collect::<Result<Vec<_>, _>>()
346            .expect("parse expired cert DER");
347
348        let mut root_store = rustls::RootCertStore::empty();
349        root_store
350            .add(ca_der[0].clone())
351            .expect("add CA to root store");
352
353        let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
354            .build()
355            .expect("build verifier");
356
357        // Verify against current wall-clock time: the cert expired in 1970 so it must fail.
358        let now = rustls::pki_types::UnixTime::now();
359        let result = verifier.verify_client_cert(&expired_der[0], &[], now);
360        assert!(
361            result.is_err(),
362            "expired certificate must be rejected; got Ok instead"
363        );
364    }
365}