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}