local-ssl 0.1.0

Local HTTPS certificate generation for development — pair with local-dns
Documentation
use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use x509_parser::prelude::*;

use crate::util;

pub struct CaStore {
    pub dir: PathBuf,
    pub key_path: PathBuf,
    pub cert_path: PathBuf,
}

impl CaStore {
    pub fn new() -> Self {
        let dir = if cfg!(target_os = "windows") {
            PathBuf::from(std::env::var("PROGRAMDATA").unwrap_or_else(|_| r"C:\ProgramData".into()))
                .join("local-ssl")
        } else {
            PathBuf::from("/etc/local-ssl")
        };
        CaStore {
            key_path: dir.join("ca-key.pem"),
            cert_path: dir.join("ca-cert.pem"),
            dir,
        }
    }

    pub fn exists(&self) -> bool {
        self.key_path.exists() && self.cert_path.exists()
    }

    pub fn ca_params() -> CertificateParams {
        let mut params = CertificateParams::new(vec!["local-ssl Development CA".to_string()])
            .expect("Cannot create CA params");
        params
            .distinguished_name
            .push(DnType::CommonName, "local-ssl Development CA");
        params
            .distinguished_name
            .push(DnType::OrganizationName, "local-ssl");
        params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
        params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
        params
    }

    pub fn init(&self) -> Result<(), String> {
        fs::create_dir_all(&self.dir)
            .map_err(|e| format!("Cannot create {}: {e}", self.dir.display()))?;

        let key_pair = KeyPair::generate().map_err(|e| format!("Cannot generate CA key: {e}"))?;

        let mut params = Self::ca_params();
        params.not_before = ::time::OffsetDateTime::now_utc();
        params.not_after =
            ::time::OffsetDateTime::now_utc() + Duration::from_secs(10 * 365 * 86400);

        let cert = params
            .self_signed(&key_pair)
            .map_err(|e| format!("Cannot self-sign CA: {e}"))?;

        fs::write(&self.key_path, key_pair.serialize_pem())
            .map_err(|e| format!("Cannot write CA key: {e}"))?;
        fs::write(&self.cert_path, cert.pem()).map_err(|e| format!("Cannot write CA cert: {e}"))?;

        Ok(())
    }

    pub fn load_key(&self) -> Result<KeyPair, String> {
        let pem =
            fs::read_to_string(&self.key_path).map_err(|e| format!("Cannot read CA key: {e}"))?;
        KeyPair::from_pem(&pem).map_err(|e| format!("Cannot parse CA key: {e}"))
    }

    pub fn status(&self) -> Result<String, String> {
        if !self.exists() {
            return Ok("CA not initialized. Run `local-ssl init`.".to_string());
        }

        let pem = fs::read_to_string(&self.cert_path).map_err(|e| format!("{e}"))?;
        let der = util::pem_decode(&pem)?;
        let parsed = X509Certificate::from_der(&der)
            .map_err(|e| format!("Cannot parse CA cert: {e}"))?
            .1;

        let cn = parsed
            .subject()
            .iter_common_name()
            .next()
            .and_then(|a| a.as_str().ok())
            .unwrap_or("(unknown)");
        let not_before = parsed.validity().not_before.to_datetime();
        let not_after = parsed.validity().not_after.to_datetime();
        let issuer = parsed
            .issuer()
            .iter_common_name()
            .next()
            .and_then(|a| a.as_str().ok())
            .unwrap_or("(unknown)");
        let serial = &parsed.tbs_certificate.serial;

        let mut out = format!("Subject:       {cn}\n");
        out.push_str(&format!("Issuer:        {issuer}\n"));
        out.push_str(&format!("Serial:        {serial}\n"));
        out.push_str(&format!("Valid from:    {not_before}\n"));
        out.push_str(&format!("Valid until:   {not_after}\n"));
        out.push_str(&format!("Location:      {}\n", self.dir.display()));

        Ok(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rcgen::{BasicConstraints, IsCa};
    use tempfile::tempdir;

    fn temp_store() -> (CaStore, tempfile::TempDir) {
        let dir = tempdir().unwrap();
        let store = CaStore {
            dir: dir.path().to_path_buf(),
            key_path: dir.path().join("ca-key.pem"),
            cert_path: dir.path().join("ca-cert.pem"),
        };
        (store, dir)
    }

    #[test]
    fn test_exists_returns_false_for_nonexistent() {
        let (store, _dir) = temp_store();
        assert!(!store.exists());
    }

    #[test]
    fn test_init_creates_files() {
        let (store, _dir) = temp_store();
        store.init().unwrap();
        assert!(store.exists());
        assert!(store.key_path.exists());
        assert!(store.cert_path.exists());
    }

    #[test]
    fn test_load_key_after_init() {
        let (store, _dir) = temp_store();
        store.init().unwrap();
        let key = store.load_key().unwrap();
        let pem = key.serialize_pem();
        assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
    }

    #[test]
    fn test_status_before_init() {
        let (store, _dir) = temp_store();
        let status = store.status().unwrap();
        assert_eq!(status, "CA not initialized. Run `local-ssl init`.");
    }

    #[test]
    fn test_status_after_init() {
        let (store, _dir) = temp_store();
        store.init().unwrap();
        let status = store.status().unwrap();
        assert!(status.contains("Subject:       local-ssl Development CA"));
        assert!(status.contains("Issuer:        local-ssl Development CA"));
        assert!(status.contains("Serial:"));
        assert!(status.contains("Valid from:"));
        assert!(status.contains("Valid until:"));
        assert!(status.contains("Location:"));
    }

    #[test]
    fn test_ca_params_is_ca() {
        let params = CaStore::ca_params();
        assert!(matches!(
            params.is_ca,
            IsCa::Ca(BasicConstraints::Unconstrained)
        ));
    }

    #[test]
    fn test_ca_params_key_usages() {
        let params = CaStore::ca_params();
        assert_eq!(
            params.key_usages,
            vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]
        );
    }
}