Skip to main content

sidedns_core/certs/
ca.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result, anyhow};
4use rcgen::{
5    BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
6    KeyUsagePurpose,
7};
8use tokio_rustls::rustls::{
9    crypto::ring::sign,
10    pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _},
11    sign::CertifiedKey,
12};
13
14pub const ROOT_CERT_FILENAME: &str = "SideDNS-CA.crt";
15pub const ROOT_KEY_FILENAME: &str = "SideDNS-CA.key";
16
17#[macro_export]
18macro_rules! cert_path {
19    () => {
20        $crate::ROOT_CERTIFICATE_DIR.join($crate::certs::ca::ROOT_CERT_FILENAME)
21    };
22    ($dir:expr) => {
23        $dir.join($crate::certs::ca::ROOT_CERT_FILENAME)
24    };
25}
26
27#[macro_export]
28macro_rules! cert_key_path {
29    () => {
30        $crate::ROOT_CERTIFICATE_DIR.join($crate::certs::ca::ROOT_KEY_FILENAME)
31    };
32    ($dir:expr) => {
33        $dir.join($crate::certs::ca::ROOT_KEY_FILENAME)
34    };
35}
36
37#[derive(Debug)]
38pub struct Ca {
39    issuer: Issuer<'static, KeyPair>,
40}
41
42pub struct CaPem {
43    cert: String,
44    key: String,
45}
46
47impl Ca {
48    pub fn load_or_generate(path: PathBuf) -> Result<Self> {
49        let res = Self::load(path.to_path_buf());
50        if let Ok(ca) = res {
51            return Ok(ca);
52        }
53
54        let (ca_pem, ca) = Self::generate()?;
55
56        Self::save(path, ca_pem)?;
57
58        Ok(ca)
59    }
60
61    pub fn sign(&self, domains: &[&str]) -> Result<CertifiedKey> {
62        anyhow::ensure!(!domains.is_empty(), "At least one domain required");
63
64        let domain_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
65
66        let mut params =
67            CertificateParams::new(domains.iter().map(ToString::to_string).collect::<Vec<_>>())?;
68
69        domains
70            .iter()
71            .for_each(|d| params.distinguished_name.push(DnType::CommonName, *d));
72
73        params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
74        params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
75
76        let cert = params.signed_by(&domain_key, &self.issuer)?;
77
78        let cert_der = cert.der().clone().into_owned();
79        let key_der =
80            PrivateKeyDer::try_from(domain_key.serialize_der()).map_err(|err| anyhow!(err))?;
81
82        let signing_key = sign::any_supported_type(&key_der)
83            .map_err(|e| anyhow::anyhow!("rustls signing key error: {e}"))?;
84
85        Ok(CertifiedKey::new(vec![cert_der], signing_key))
86    }
87
88    pub fn is_installed() -> bool {
89        Self::load(crate::ROOT_CERTIFICATE_DIR.to_path_buf()).is_ok()
90    }
91
92    pub fn load(cert_root_dir: PathBuf) -> Result<Self> {
93        let cert_path = cert_path!(cert_root_dir);
94        let key_path = cert_key_path!(cert_root_dir);
95
96        if cert_path.exists() && key_path.exists() {
97            let ca_cert_der =
98                CertificateDer::from_pem_file(cert_path).context("Failed to read CA cert")?;
99            let ca_key_der =
100                PrivateKeyDer::from_pem_file(key_path).context("Failed to read CA key")?;
101
102            let ca_key =
103                KeyPair::from_der_and_sign_algo(&ca_key_der, &rcgen::PKCS_ECDSA_P256_SHA256)?;
104            let issuer = Issuer::from_ca_cert_der(&ca_cert_der, ca_key)?;
105
106            return Ok(Self { issuer });
107        }
108        anyhow::bail!(
109            "Failed to load CA from {}. Did you install it with 'sidedns cert install'?",
110            cert_path.display()
111        );
112    }
113
114    pub fn generate() -> Result<(CaPem, Self)> {
115        let ca_key = KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256)?;
116
117        let mut params = CertificateParams::new(vec![])?;
118
119        params
120            .distinguished_name
121            .push(DnType::CommonName, crate::CERT_NAME);
122        params
123            .distinguished_name
124            .push(DnType::OrganizationName, "SideDNS");
125        params.distinguished_name.push(
126            DnType::OrganizationalUnitName,
127            Self::get_user_and_hostname(),
128        );
129
130        params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
131        params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
132
133        let ca_cert = params.self_signed(&ca_key)?;
134        let ca_pem = ca_cert.pem();
135        let key_pem = ca_key.serialize_pem();
136
137        let issuer = Issuer::from_ca_cert_pem(&ca_pem, ca_key)?;
138
139        Ok((
140            CaPem {
141                cert: ca_pem,
142                key: key_pem,
143            },
144            Self { issuer },
145        ))
146    }
147
148    fn save(cert_root_dir: PathBuf, ca_pem: CaPem) -> Result<()> {
149        std::fs::create_dir_all(cert_root_dir.as_path())?;
150        std::fs::write(cert_path!(cert_root_dir), &ca_pem.cert)?;
151        std::fs::write(cert_key_path!(cert_root_dir), &ca_pem.key)?;
152        Ok(())
153    }
154
155    pub fn uninstall(cert_root_dir: PathBuf) -> Result<()> {
156        std::fs::remove_file(cert_path!(cert_root_dir))?;
157        std::fs::remove_file(cert_key_path!(cert_root_dir))?;
158        Ok(())
159    }
160
161    fn get_user_and_hostname() -> String {
162        let user = std::env::var("USER")
163            .or_else(|_| std::env::var("USERNAME"))
164            .map(|s| s + "@")
165            .unwrap_or_default();
166        let host = std::env::var("HOSTNAME")
167            .or_else(|_| std::env::var("COMPUTERNAME"))
168            .unwrap_or_default();
169
170        format!("{user}{host}")
171    }
172}
173
174pub fn load_or_generate() -> Result<Ca> {
175    Ca::load_or_generate(crate::ROOT_CERTIFICATE_DIR.to_path_buf())
176}
177
178pub fn load() -> Result<Ca> {
179    Ca::load(crate::ROOT_CERTIFICATE_DIR.to_path_buf())
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn generate_and_sign() {
188        let (_, ca) = Ca::generate().unwrap();
189        let cert = ca.sign(&["api.local"]).unwrap();
190        assert!(!cert.cert.is_empty());
191    }
192
193    #[test]
194    fn generate_sign_multiple_sans() {
195        let (_, ca) = Ca::generate().unwrap();
196        let cert = ca
197            .sign(&["api.local", "auth.local", "*.dev.local"])
198            .unwrap();
199        assert!(!cert.cert.is_empty());
200    }
201
202    #[test]
203    fn save_and_load_produces_valid_issuer() {
204        let dir = tempfile::tempdir().unwrap();
205        let (ca_pem, _) = Ca::generate().unwrap();
206        Ca::save(dir.path().into(), ca_pem).unwrap();
207
208        let loaded = Ca::load(dir.path().into()).unwrap();
209
210        let cert = loaded.sign(&["api.local"]).unwrap();
211        assert!(!cert.cert.is_empty());
212    }
213
214    #[test]
215    fn load_or_generate_creates_files_when_missing() {
216        let dir = tempfile::tempdir().unwrap();
217
218        let _ = Ca::load_or_generate(dir.path().into()).unwrap();
219        assert!(cert_path!(dir.path()).exists());
220        assert!(cert_key_path!(dir.path()).exists());
221    }
222
223    #[test]
224    fn load_or_generate_reuses_existing() {
225        let dir = tempfile::tempdir().unwrap();
226
227        let ca1 = Ca::load_or_generate(dir.path().into()).unwrap();
228        let ca2 = Ca::load_or_generate(dir.path().into()).unwrap();
229
230        assert_eq!(
231            ca1.issuer.key().public_key_pem(),
232            ca2.issuer.key().public_key_pem()
233        );
234    }
235
236    #[test]
237    fn generate_produces_non_empty_pems() {
238        let (pem, _) = Ca::generate().unwrap();
239        assert!(!pem.cert.is_empty());
240        assert!(!pem.key.is_empty());
241    }
242
243    #[test]
244    fn generate_cert_pem_has_correct_markers() {
245        let (pem, _) = Ca::generate().unwrap();
246        assert!(pem.cert.contains("-----BEGIN CERTIFICATE-----"));
247        assert!(pem.cert.contains("-----END CERTIFICATE-----"));
248    }
249
250    #[test]
251    fn generate_key_pem_has_correct_markers() {
252        let (pem, _) = Ca::generate().unwrap();
253        assert!(pem.key.contains("-----BEGIN"));
254    }
255
256    #[test]
257    fn two_generates_produce_different_keys() {
258        let (pem1, _) = Ca::generate().unwrap();
259        let (pem2, _) = Ca::generate().unwrap();
260        assert_ne!(pem1.key, pem2.key);
261        assert_ne!(pem1.cert, pem2.cert);
262    }
263
264    #[test]
265    fn sign_single_domain() {
266        let (_, ca) = Ca::generate().unwrap();
267        let ck = ca.sign(&["api.local"]).unwrap();
268        assert!(!ck.cert.is_empty());
269    }
270
271    #[test]
272    fn sign_empty_domains_errors() {
273        let (_, ca) = Ca::generate().unwrap();
274        assert!(ca.sign(&[]).is_err());
275    }
276
277    #[test]
278    fn sign_wildcard_domain() {
279        let (_, ca) = Ca::generate().unwrap();
280        let ck = ca.sign(&["*.local"]).unwrap();
281        assert!(!ck.cert.is_empty());
282    }
283
284    #[test]
285    fn sign_multiple_sans() {
286        let (_, ca) = Ca::generate().unwrap();
287        let ck = ca
288            .sign(&["api.local", "auth.local", "*.dev.local"])
289            .unwrap();
290        assert!(!ck.cert.is_empty());
291    }
292
293    #[test]
294    fn sign_localhost() {
295        let (_, ca) = Ca::generate().unwrap();
296        let ck = ca.sign(&["localhost"]).unwrap();
297        assert!(!ck.cert.is_empty());
298    }
299
300    #[test]
301    fn two_signs_produce_different_certs() {
302        let (_, ca) = Ca::generate().unwrap();
303        let ck1 = ca.sign(&["api.local"]).unwrap();
304        let ck2 = ca.sign(&["api.local"]).unwrap();
305        assert_ne!(ck1.cert, ck2.cert);
306    }
307
308    #[test]
309    fn save_creates_cert_and_key_files() {
310        let dir = tempfile::tempdir().unwrap();
311        let (pem, _) = Ca::generate().unwrap();
312        Ca::save(dir.path().into(), pem).unwrap();
313        assert!(cert_path!(dir.path()).exists(), "cert file must exist");
314        assert!(cert_key_path!(dir.path()).exists(), "key file must exist");
315    }
316
317    #[test]
318    fn load_after_save_can_sign() {
319        let dir = tempfile::tempdir().unwrap();
320        let (pem, _) = Ca::generate().unwrap();
321        Ca::save(dir.path().into(), pem).unwrap();
322
323        let ca = Ca::load(dir.path().into()).unwrap();
324        let ck = ca.sign(&["api.local"]).unwrap();
325        assert!(!ck.cert.is_empty());
326    }
327
328    #[test]
329    fn load_preserves_cert_pem_content() {
330        let dir = tempfile::tempdir().unwrap();
331        let (original_pem, _) = Ca::generate().unwrap();
332        let expected_cert = original_pem.cert.clone();
333        Ca::save(dir.path().into(), original_pem).unwrap();
334
335        let on_disk = std::fs::read_to_string(cert_path!(dir.path())).unwrap();
336        assert_eq!(on_disk.trim(), expected_cert.trim());
337    }
338
339    #[test]
340    fn load_nonexistent_dir_returns_error() {
341        let result = Ca::load("/tmp/sidedns-definitely-does-not-exist-xyz".into());
342        assert!(result.is_err());
343    }
344
345    #[test]
346    fn load_or_generate_creates_files_when_absent() {
347        let dir = tempfile::tempdir().unwrap();
348        Ca::load_or_generate(dir.path().into()).unwrap();
349        assert!(cert_path!(dir.path()).exists());
350        assert!(cert_key_path!(dir.path()).exists());
351    }
352
353    #[test]
354    fn load_or_generate_reuses_without_regenerating() {
355        let dir = tempfile::tempdir().unwrap();
356
357        let (pem1, _) = Ca::generate().unwrap();
358        let cert1 = pem1.cert.clone();
359        Ca::save(dir.path().into(), pem1).unwrap();
360
361        Ca::load_or_generate(dir.path().into()).unwrap();
362
363        let cert_on_disk = std::fs::read_to_string(cert_path!(dir.path())).unwrap();
364        assert_eq!(cert_on_disk.trim(), cert1.trim());
365    }
366
367    #[test]
368    fn load_or_generate_can_sign_after_reload() {
369        let dir = tempfile::tempdir().unwrap();
370        Ca::load_or_generate(dir.path().into()).unwrap();
371        let ca = Ca::load_or_generate(dir.path().into()).unwrap();
372        assert!(ca.sign(&["api.local"]).is_ok());
373    }
374}