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 ca_key_pem = ca_key.serialize_pem();
146    let ca_issuer = rcgen::Issuer::from_params(&ca_params, ca_key);
147    let node_cert = node_params
148        .signed_by(&node_key, &ca_issuer)
149        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;
150
151    Ok(GeneratedCredentials {
152        ca_cert_pem: ca_cert.pem(),
153        ca_key_pem,
154        node_cert_pem: node_cert.pem(),
155        node_key_pem: node_key.serialize_pem(),
156    })
157}
158
159/// Credentials produced by [`sign_node_cert`] -- no CA key material, just what the clinic node needs.
160pub struct SignedNodeCredentials {
161    /// CA certificate in PEM format. The clinic copies this to `tls/ca.crt`.
162    pub ca_cert_pem: String,
163    /// Node certificate signed by the hub CA. Written to `tls/node.crt`.
164    pub node_cert_pem: String,
165    /// Node private key (stays on this clinic node only). Written to `tls/node.key`.
166    pub node_key_pem: String,
167}
168
169/// Sign a new node certificate with an existing CA.
170///
171/// Used by `zamsync sign <clinic-dir> --ca <hub-dir>`:
172/// - The hub CA key is read from `<hub-dir>/tls/ca.key`.
173/// - A fresh ECDSA P-256 keypair is generated for the clinic node.
174/// - The clinic cert is signed by the hub CA, so the clinic joins the same mTLS deployment
175///   without the hub generating a new CA.
176///
177/// The CA cert PEM is passed through unchanged into `SignedNodeCredentials.ca_cert_pem` so
178/// the clinic always distributes the original CA cert to its own TLS config.
179pub fn sign_node_cert(ca_cert_pem: &str, ca_key_pem: &str) -> ZamResult<SignedNodeCredentials> {
180    let ca_key = rcgen::KeyPair::from_pem(ca_key_pem)
181        .map_err(|e| ZamError::Config(format!("parse CA key: {e}")))?;
182
183    // Reconstruct CA CertificateParams from its known structure (generated by `keygen`).
184    // We only need the CA's key pair and subject name to sign new node certs -- the
185    // subject key identifier is derived from the public key so it matches the original cert.
186    let mut ca_params = rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()])
187        .map_err(|e| ZamError::Config(format!("CA params: {e}")))?;
188    ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
189
190    let node_key = rcgen::KeyPair::generate()
191        .map_err(|e| ZamError::Config(format!("node key generation failed: {e}")))?;
192
193    let node_params = rcgen::CertificateParams::new(vec!["zamsync.local".to_string()])
194        .map_err(|e| ZamError::Config(format!("node params: {e}")))?;
195
196    let ca_issuer = rcgen::Issuer::from_params(&ca_params, ca_key);
197    let node_cert = node_params
198        .signed_by(&node_key, &ca_issuer)
199        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;
200
201    Ok(SignedNodeCredentials {
202        ca_cert_pem: ca_cert_pem.to_owned(),
203        node_cert_pem: node_cert.pem(),
204        node_key_pem: node_key.serialize_pem(),
205    })
206}
207
208/// Install the ring crypto provider for rustls. Safe to call multiple times.
209pub fn install_crypto_provider() {
210    let _ = rustls::crypto::ring::default_provider().install_default();
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn test_sign_node_cert_produces_valid_chain() {
219        install_crypto_provider();
220
221        let hub = generate_credentials().expect("hub keygen failed");
222
223        // Sign two separate clinic certs from the same CA
224        let clinic_a =
225            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_a signing failed");
226        let clinic_b =
227            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_b signing failed");
228
229        // Both clinics share the same CA cert
230        assert_eq!(clinic_a.ca_cert_pem, hub.ca_cert_pem);
231        assert_eq!(clinic_b.ca_cert_pem, hub.ca_cert_pem);
232
233        // Clinic certs are distinct
234        assert_ne!(clinic_a.node_cert_pem, clinic_b.node_cert_pem);
235        assert_ne!(clinic_a.node_key_pem, clinic_b.node_key_pem);
236
237        // Verify the signed cert can be loaded and used by rustls (hub server + clinic client)
238        let hub_tls = TlsConfig::from_pem(
239            hub.node_cert_pem.clone(),
240            hub.node_key_pem.clone(),
241            hub.ca_cert_pem.clone(),
242        );
243        let clinic_a_tls = TlsConfig::from_pem(
244            clinic_a.node_cert_pem.clone(),
245            clinic_a.node_key_pem.clone(),
246            clinic_a.ca_cert_pem.clone(),
247        );
248
249        // Both sides should produce valid TLS configs without error
250        hub_tls.server_config().expect("hub server_config failed");
251        clinic_a_tls
252            .client_config()
253            .expect("clinic_a client_config failed");
254    }
255
256    #[test]
257    fn test_rogue_node_with_own_ca_rejected() {
258        install_crypto_provider();
259
260        let hub = generate_credentials().expect("hub keygen");
261        let rogue = generate_credentials().expect("rogue keygen");
262
263        // Hub's server verifies against its own CA
264        let hub_tls = TlsConfig::from_pem(
265            hub.node_cert_pem.clone(),
266            hub.node_key_pem.clone(),
267            hub.ca_cert_pem.clone(),
268        );
269
270        // Rogue node has its own CA -- its cert is NOT signed by hub CA
271        let rogue_tls = TlsConfig::from_pem(
272            rogue.node_cert_pem.clone(),
273            rogue.node_key_pem.clone(),
274            hub.ca_cert_pem.clone(), // rogue pretends to trust hub CA
275        );
276
277        // Hub server config should build fine (hub's own cert is valid)
278        hub_tls.server_config().expect("hub server_config failed");
279
280        // Rogue client config builds fine locally -- but its cert was NOT signed by hub CA.
281        // At TLS handshake time the hub would reject it. We verify here that the rogue cert
282        // is NOT verifiable with the hub CA root store.
283        let hub_ca_der = rustls_pemfile::certs(&mut hub.ca_cert_pem.as_bytes())
284            .collect::<Result<Vec<_>, _>>()
285            .expect("parse hub CA");
286        let rogue_cert_der = rustls_pemfile::certs(&mut rogue.node_cert_pem.as_bytes())
287            .collect::<Result<Vec<_>, _>>()
288            .expect("parse rogue cert");
289
290        let mut root_store = rustls::RootCertStore::empty();
291        root_store.add(hub_ca_der[0].clone()).expect("add hub CA");
292
293        // The rogue cert was issued by its own CA, not the hub CA -- should not verify
294        let verifier =
295            rustls::server::WebPkiClientVerifier::builder(std::sync::Arc::new(root_store))
296                .build()
297                .expect("build verifier");
298
299        // rustls WebPkiClientVerifier::verify_client_cert returns an error for unknown issuers
300        let now = rustls::pki_types::UnixTime::now();
301        let result = verifier.verify_client_cert(&rogue_cert_der[0], &[], now);
302        assert!(
303            result.is_err(),
304            "rogue cert must be rejected by hub CA verifier"
305        );
306
307        // Confirm the rogue client config itself cannot be built with the hub CA as server root
308        // (rogue's cert will fail at the server-side verification)
309        rogue_tls
310            .client_config()
311            .expect("client config builds -- rejection happens at handshake");
312    }
313
314    /// A node cert whose `not_after` is in the past must be rejected by the
315    /// WebPki verifier -- this mirrors what rustls does at the TLS handshake.
316    #[test]
317    fn test_expired_cert_rejected_at_handshake() {
318        install_crypto_provider();
319
320        let ca_key = rcgen::KeyPair::generate().expect("CA key");
321        let mut ca_params =
322            rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()]).expect("CA params");
323        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
324        let ca_cert = ca_params.self_signed(&ca_key).expect("CA self-sign");
325
326        // Build a node cert whose validity window is entirely in the past (1970-01-01..1970-01-02).
327        let node_key = rcgen::KeyPair::generate().expect("node key");
328        let mut node_params =
329            rcgen::CertificateParams::new(vec!["zamsync.local".to_string()]).expect("node params");
330        node_params.not_before = time::OffsetDateTime::from_unix_timestamp(0).expect("epoch start");
331        node_params.not_after =
332            time::OffsetDateTime::from_unix_timestamp(86400).expect("epoch + 1 day");
333        let ca_issuer = rcgen::Issuer::from_params(&ca_params, ca_key);
334        let expired_cert = node_params
335            .signed_by(&node_key, &ca_issuer)
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}