zamsync-network 1.1.1

TCP and mTLS transport for the ZamSync distributed sync engine
Documentation
use std::path::Path;
use std::sync::Arc;

use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use zamsync_core::{ZamError, ZamResult};

/// TLS credentials for a ZamSync node.
///
/// All nodes in a deployment share the same CA certificate. Each node has its
/// own certificate and private key signed by the CA. Mutual TLS (mTLS) is used:
/// both sides present a certificate, ensuring only authorized nodes can connect.
pub struct TlsConfig {
    cert_pem: Vec<u8>,
    key_pem: Vec<u8>,
    ca_pem: Vec<u8>,
}

impl TlsConfig {
    /// Load TLS credentials from PEM files.
    ///
    /// * `cert_path` -- this node's certificate (`node.crt`)
    /// * `key_path`  -- this node's private key  (`node.key`)
    /// * `ca_path`   -- the deployment CA cert   (`ca.crt`)
    pub fn from_files(
        cert_path: impl AsRef<Path>,
        key_path: impl AsRef<Path>,
        ca_path: impl AsRef<Path>,
    ) -> ZamResult<Self> {
        Ok(Self {
            cert_pem: std::fs::read(cert_path)?,
            key_pem: std::fs::read(key_path)?,
            ca_pem: std::fs::read(ca_path)?,
        })
    }

    /// Construct directly from PEM strings (useful in tests).
    pub fn from_pem(cert_pem: String, key_pem: String, ca_pem: String) -> Self {
        Self {
            cert_pem: cert_pem.into_bytes(),
            key_pem: key_pem.into_bytes(),
            ca_pem: ca_pem.into_bytes(),
        }
    }

    fn load_cert(&self) -> ZamResult<CertificateDer<'static>> {
        rustls_pemfile::certs(&mut self.cert_pem.as_slice())
            .collect::<Result<Vec<_>, _>>()?
            .into_iter()
            .next()
            .ok_or_else(|| ZamError::Config("no certificate in cert file".into()))
    }

    fn load_key(&self) -> ZamResult<PrivateKeyDer<'static>> {
        rustls_pemfile::private_key(&mut self.key_pem.as_slice())?
            .ok_or_else(|| ZamError::Config("no private key in key file".into()))
    }

    fn load_ca(&self) -> ZamResult<CertificateDer<'static>> {
        rustls_pemfile::certs(&mut self.ca_pem.as_slice())
            .collect::<Result<Vec<_>, _>>()?
            .into_iter()
            .next()
            .ok_or_else(|| ZamError::Config("no certificate in CA file".into()))
    }

    /// Build a rustls ServerConfig (mTLS: requires client to present a cert from the same CA).
    pub(crate) fn server_config(&self) -> ZamResult<Arc<rustls::ServerConfig>> {
        let cert = self.load_cert()?;
        let key = self.load_key()?;
        let ca = self.load_ca()?;

        let mut client_roots = rustls::RootCertStore::empty();
        client_roots
            .add(ca)
            .map_err(|e| ZamError::Config(format!("invalid CA cert: {e}")))?;

        let client_verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(client_roots))
            .build()
            .map_err(|e| ZamError::Config(format!("client verifier: {e}")))?;

        let config = rustls::ServerConfig::builder()
            .with_client_cert_verifier(client_verifier)
            .with_single_cert(vec![cert], key)
            .map_err(|e| ZamError::Config(format!("server TLS config: {e}")))?;

        Ok(Arc::new(config))
    }

    /// Build a rustls ClientConfig (mTLS: presents this node's cert to the server).
    pub(crate) fn client_config(&self) -> ZamResult<Arc<rustls::ClientConfig>> {
        let cert = self.load_cert()?;
        let key = self.load_key()?;
        let ca = self.load_ca()?;

        let mut server_roots = rustls::RootCertStore::empty();
        server_roots
            .add(ca)
            .map_err(|e| ZamError::Config(format!("invalid CA cert: {e}")))?;

        let config = rustls::ClientConfig::builder()
            .with_root_certificates(server_roots)
            .with_client_auth_cert(vec![cert], key)
            .map_err(|e| ZamError::Config(format!("client TLS config: {e}")))?;

        Ok(Arc::new(config))
    }
}

/// Credentials generated by [`generate_credentials`].
pub struct GeneratedCredentials {
    /// CA certificate in PEM format. Distribute to every node in the deployment.
    pub ca_cert_pem: String,
    /// CA private key in PEM format. Keep secret; only needed to sign new node certs.
    pub ca_key_pem: String,
    /// Node certificate in PEM format. Install on this node only.
    pub node_cert_pem: String,
    /// Node private key in PEM format. Keep secret; never leave this node.
    pub node_key_pem: String,
}

/// Generate a deployment CA + node certificate pair using ECDSA P-256.
///
/// Typical per-deployment workflow:
/// 1. Run `zamsync keygen <data-dir>` on each node.
/// 2. Copy `ca.crt` from node A to all other nodes.
/// 3. Keep `node.crt` and `node.key` local to each node.
pub fn generate_credentials() -> ZamResult<GeneratedCredentials> {
    let ca_key = rcgen::KeyPair::generate()
        .map_err(|e| ZamError::Config(format!("CA key generation failed: {e}")))?;

    let mut ca_params = rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()])
        .map_err(|e| ZamError::Config(format!("CA params: {e}")))?;
    ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);

    let ca_cert = ca_params
        .self_signed(&ca_key)
        .map_err(|e| ZamError::Config(format!("CA self-sign failed: {e}")))?;

    let node_key = rcgen::KeyPair::generate()
        .map_err(|e| ZamError::Config(format!("node key generation failed: {e}")))?;

    let node_params = rcgen::CertificateParams::new(vec!["zamsync.local".to_string()])
        .map_err(|e| ZamError::Config(format!("node params: {e}")))?;

    let node_cert = node_params
        .signed_by(&node_key, &ca_cert, &ca_key)
        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;

    Ok(GeneratedCredentials {
        ca_cert_pem: ca_cert.pem(),
        ca_key_pem: ca_key.serialize_pem(),
        node_cert_pem: node_cert.pem(),
        node_key_pem: node_key.serialize_pem(),
    })
}

/// Credentials produced by [`sign_node_cert`] -- no CA key material, just what the clinic node needs.
pub struct SignedNodeCredentials {
    /// CA certificate in PEM format. The clinic copies this to `tls/ca.crt`.
    pub ca_cert_pem: String,
    /// Node certificate signed by the hub CA. Written to `tls/node.crt`.
    pub node_cert_pem: String,
    /// Node private key (stays on this clinic node only). Written to `tls/node.key`.
    pub node_key_pem: String,
}

/// Sign a new node certificate with an existing CA.
///
/// Used by `zamsync sign <clinic-dir> --ca <hub-dir>`:
/// - The hub CA key is read from `<hub-dir>/tls/ca.key`.
/// - A fresh ECDSA P-256 keypair is generated for the clinic node.
/// - The clinic cert is signed by the hub CA, so the clinic joins the same mTLS deployment
///   without the hub generating a new CA.
///
/// The CA cert PEM is passed through unchanged into `SignedNodeCredentials.ca_cert_pem` so
/// the clinic always distributes the original CA cert to its own TLS config.
pub fn sign_node_cert(ca_cert_pem: &str, ca_key_pem: &str) -> ZamResult<SignedNodeCredentials> {
    let ca_key = rcgen::KeyPair::from_pem(ca_key_pem)
        .map_err(|e| ZamError::Config(format!("parse CA key: {e}")))?;

    // Reconstruct CA CertificateParams from its known structure (generated by `keygen`).
    // We only need the CA's key pair and subject name to sign new node certs -- the
    // subject key identifier is derived from the public key so it matches the original cert.
    let mut ca_params = rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()])
        .map_err(|e| ZamError::Config(format!("CA params: {e}")))?;
    ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);

    let ca_cert = ca_params
        .self_signed(&ca_key)
        .map_err(|e| ZamError::Config(format!("reconstruct CA cert: {e}")))?;

    let node_key = rcgen::KeyPair::generate()
        .map_err(|e| ZamError::Config(format!("node key generation failed: {e}")))?;

    let node_params = rcgen::CertificateParams::new(vec!["zamsync.local".to_string()])
        .map_err(|e| ZamError::Config(format!("node params: {e}")))?;

    let node_cert = node_params
        .signed_by(&node_key, &ca_cert, &ca_key)
        .map_err(|e| ZamError::Config(format!("node cert signing failed: {e}")))?;

    Ok(SignedNodeCredentials {
        ca_cert_pem: ca_cert_pem.to_owned(),
        node_cert_pem: node_cert.pem(),
        node_key_pem: node_key.serialize_pem(),
    })
}

/// Install the ring crypto provider for rustls. Safe to call multiple times.
pub fn install_crypto_provider() {
    let _ = rustls::crypto::ring::default_provider().install_default();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sign_node_cert_produces_valid_chain() {
        install_crypto_provider();

        let hub = generate_credentials().expect("hub keygen failed");

        // Sign two separate clinic certs from the same CA
        let clinic_a =
            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_a signing failed");
        let clinic_b =
            sign_node_cert(&hub.ca_cert_pem, &hub.ca_key_pem).expect("clinic_b signing failed");

        // Both clinics share the same CA cert
        assert_eq!(clinic_a.ca_cert_pem, hub.ca_cert_pem);
        assert_eq!(clinic_b.ca_cert_pem, hub.ca_cert_pem);

        // Clinic certs are distinct
        assert_ne!(clinic_a.node_cert_pem, clinic_b.node_cert_pem);
        assert_ne!(clinic_a.node_key_pem, clinic_b.node_key_pem);

        // Verify the signed cert can be loaded and used by rustls (hub server + clinic client)
        let hub_tls = TlsConfig::from_pem(
            hub.node_cert_pem.clone(),
            hub.node_key_pem.clone(),
            hub.ca_cert_pem.clone(),
        );
        let clinic_a_tls = TlsConfig::from_pem(
            clinic_a.node_cert_pem.clone(),
            clinic_a.node_key_pem.clone(),
            clinic_a.ca_cert_pem.clone(),
        );

        // Both sides should produce valid TLS configs without error
        hub_tls.server_config().expect("hub server_config failed");
        clinic_a_tls
            .client_config()
            .expect("clinic_a client_config failed");
    }

    #[test]
    fn test_rogue_node_with_own_ca_rejected() {
        install_crypto_provider();

        let hub = generate_credentials().expect("hub keygen");
        let rogue = generate_credentials().expect("rogue keygen");

        // Hub's server verifies against its own CA
        let hub_tls = TlsConfig::from_pem(
            hub.node_cert_pem.clone(),
            hub.node_key_pem.clone(),
            hub.ca_cert_pem.clone(),
        );

        // Rogue node has its own CA -- its cert is NOT signed by hub CA
        let rogue_tls = TlsConfig::from_pem(
            rogue.node_cert_pem.clone(),
            rogue.node_key_pem.clone(),
            hub.ca_cert_pem.clone(), // rogue pretends to trust hub CA
        );

        // Hub server config should build fine (hub's own cert is valid)
        hub_tls.server_config().expect("hub server_config failed");

        // Rogue client config builds fine locally -- but its cert was NOT signed by hub CA.
        // At TLS handshake time the hub would reject it. We verify here that the rogue cert
        // is NOT verifiable with the hub CA root store.
        let hub_ca_der = rustls_pemfile::certs(&mut hub.ca_cert_pem.as_bytes())
            .collect::<Result<Vec<_>, _>>()
            .expect("parse hub CA");
        let rogue_cert_der = rustls_pemfile::certs(&mut rogue.node_cert_pem.as_bytes())
            .collect::<Result<Vec<_>, _>>()
            .expect("parse rogue cert");

        let mut root_store = rustls::RootCertStore::empty();
        root_store.add(hub_ca_der[0].clone()).expect("add hub CA");

        // The rogue cert was issued by its own CA, not the hub CA -- should not verify
        let verifier =
            rustls::server::WebPkiClientVerifier::builder(std::sync::Arc::new(root_store))
                .build()
                .expect("build verifier");

        // rustls WebPkiClientVerifier::verify_client_cert returns an error for unknown issuers
        let now = rustls::pki_types::UnixTime::now();
        let result = verifier.verify_client_cert(&rogue_cert_der[0], &[], now);
        assert!(
            result.is_err(),
            "rogue cert must be rejected by hub CA verifier"
        );

        // Confirm the rogue client config itself cannot be built with the hub CA as server root
        // (rogue's cert will fail at the server-side verification)
        rogue_tls
            .client_config()
            .expect("client config builds -- rejection happens at handshake");
    }

    /// A node cert whose `not_after` is in the past must be rejected by the
    /// WebPki verifier -- this mirrors what rustls does at the TLS handshake.
    #[test]
    fn test_expired_cert_rejected_at_handshake() {
        install_crypto_provider();

        let ca_key = rcgen::KeyPair::generate().expect("CA key");
        let mut ca_params =
            rcgen::CertificateParams::new(vec!["ZamSync CA".to_string()]).expect("CA params");
        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
        let ca_cert = ca_params.self_signed(&ca_key).expect("CA self-sign");

        // Build a node cert whose validity window is entirely in the past (1970-01-01..1970-01-02).
        let node_key = rcgen::KeyPair::generate().expect("node key");
        let mut node_params =
            rcgen::CertificateParams::new(vec!["zamsync.local".to_string()]).expect("node params");
        node_params.not_before =
            time::OffsetDateTime::from_unix_timestamp(0).expect("epoch start");
        node_params.not_after =
            time::OffsetDateTime::from_unix_timestamp(86400).expect("epoch + 1 day");
        let expired_cert = node_params
            .signed_by(&node_key, &ca_cert, &ca_key)
            .expect("sign expired cert");

        let ca_pem = ca_cert.pem();
        let expired_pem = expired_cert.pem();

        let ca_der: Vec<_> = rustls_pemfile::certs(&mut ca_pem.as_bytes())
            .collect::<Result<Vec<_>, _>>()
            .expect("parse CA DER");
        let expired_der: Vec<_> = rustls_pemfile::certs(&mut expired_pem.as_bytes())
            .collect::<Result<Vec<_>, _>>()
            .expect("parse expired cert DER");

        let mut root_store = rustls::RootCertStore::empty();
        root_store.add(ca_der[0].clone()).expect("add CA to root store");

        let verifier =
            rustls::server::WebPkiClientVerifier::builder(Arc::new(root_store))
                .build()
                .expect("build verifier");

        // Verify against current wall-clock time: the cert expired in 1970 so it must fail.
        let now = rustls::pki_types::UnixTime::now();
        let result = verifier.verify_client_cert(&expired_der[0], &[], now);
        assert!(
            result.is_err(),
            "expired certificate must be rejected; got Ok instead"
        );
    }
}