1use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13use rand_core::RngCore;
14use rcgen::{
15 BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
16 KeyIdMethod, KeyPair, KeyUsagePurpose, NameConstraints, PKCS_ECDSA_P256_SHA256, SerialNumber,
17};
18use rustls_pki_types::PrivatePkcs8KeyDer;
19use x509_parser::extensions::ParsedExtension;
20use x509_parser::prelude::{FromDer, X509Certificate};
21
22const CA_CERT_FILE: &str = "ca.crt";
23const CA_KEY_FILE: &str = "ca.key";
24const CURRENT_LINK: &str = "current";
25const GEN_PREFIX: &str = "gen-";
26
27#[allow(clippy::duration_suboptimal_units)]
31const CA_LIFETIME: std::time::Duration = std::time::Duration::from_secs(10 * 365 * 24 * 3600);
32
33pub const COMMON_NAME: &str = "Caproni Dev CA";
35
36pub const KEY_USAGE_CERT_SIGN: u32 = 1 << 0;
37pub const KEY_USAGE_CRL_SIGN: u32 = 1 << 1;
38pub const KEY_USAGE_DIGITAL_SIGNATURE: u32 = 1 << 2;
39
40#[derive(Debug, thiserror::Error)]
43pub enum PkiError {
44 #[error("CA certificate not found in {0}")]
45 CaNotFound(PathBuf),
46 #[error("failed to decode CA cert PEM")]
47 DecodeCertPem,
48 #[error("failed to decode CA key PEM")]
49 DecodeKeyPem,
50 #[error("another process may be generating: {0}")]
51 PublishRaced(String),
52 #[error("publishing regenerated CA: {0}")]
53 RepublishFailed(String),
54 #[error("{0}")]
55 Other(String),
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum EcCurve {
63 #[default]
64 P256,
65}
66
67#[derive(Debug, Clone, Default)]
70pub struct CertInfo {
71 pub is_ca: bool,
72 pub common_name: String,
73 pub organization: Vec<String>,
74 pub key_usage: u32,
75 pub max_path_len: i32,
76 pub max_path_len_zero: bool,
77 pub permitted_dns_domains: Vec<String>,
78 pub permitted_dns_domains_critical: bool,
79 pub not_before: Option<SystemTime>,
80 pub not_after: Option<SystemTime>,
81 pub serial_number: Vec<u8>,
82 pub raw_subject: Vec<u8>,
83 pub subject_key_id: Vec<u8>,
84 pub dns_names: Vec<String>,
85}
86
87#[derive(Debug, Clone, Default)]
97pub struct KeyInfo {
98 pub curve: EcCurve,
99 pub public_key: Vec<u8>,
100 pub pkcs8_der: Vec<u8>,
101}
102
103#[derive(Debug, Clone)]
105pub struct Ca {
106 pub cert: CertInfo,
107 pub cert_pem: Vec<u8>,
108 pub key: KeyInfo,
109 pub key_pem: Vec<u8>,
110 pub generated: bool,
112}
113
114impl Ca {
115 #[must_use]
117 pub fn covers_hostname(&self, hostname: &str) -> bool {
118 if hostname.is_empty() {
119 return true;
120 }
121
122 for domain in &self.cert.permitted_dns_domains {
123 if hostname.ends_with(domain.as_str()) {
124 return true;
125 }
126
127 if hostname == domain.trim_start_matches('.') {
128 return true;
129 }
130 }
131
132 false
133 }
134}
135
136pub fn load_or_generate(pki_dir: &Path, permitted_dns: &[String]) -> Result<Ca, PkiError> {
143 if current_complete(pki_dir) {
144 return load(pki_dir);
145 }
146
147 remove_broken_link(pki_dir)?;
148
149 if migrate_legacy(pki_dir)? {
150 return load(pki_dir);
151 }
152
153 generate(pki_dir, permitted_dns)
154}
155
156pub fn ensure_current(
163 pki_dir: &Path,
164 want: &[String],
165) -> Result<(Ca, Option<Vec<String>>), PkiError> {
166 let ca = load_or_generate(pki_dir, want)?;
167
168 if ca.generated {
169 return Ok((ca, None));
170 }
171
172 let expired = ca
173 .cert
174 .not_after
175 .is_none_or(|not_after| SystemTime::now() >= not_after);
176
177 if expired {
178 let fresh = new_ca(want)?;
179 let ca = regenerate(pki_dir, fresh)?;
180 return Ok((ca, None));
181 }
182
183 if constraints_equal(&ca.cert.permitted_dns_domains, want) {
184 return Ok((ca, None));
185 }
186
187 let previous = ca.cert.permitted_dns_domains.clone();
188
189 let resigned = resign(&ca, want)?;
190 let ca = regenerate(pki_dir, resigned)?;
191
192 Ok((ca, Some(previous)))
193}
194
195#[must_use]
197pub fn constraints_equal(got: &[String], want: &[String]) -> bool {
198 let mut got = got.to_vec();
199 let mut want = want.to_vec();
200 got.sort();
201 want.sort();
202 got == want
203}
204
205pub fn cert_path(pki_dir: &Path) -> Result<PathBuf, PkiError> {
212 let candidates = [
213 pki_dir.join(CURRENT_LINK).join(CA_CERT_FILE),
214 pki_dir.join(CA_CERT_FILE),
215 ];
216
217 for candidate in candidates {
218 if candidate.is_file() {
219 return Ok(candidate);
220 }
221 }
222
223 Err(PkiError::CaNotFound(pki_dir.to_path_buf()))
224}
225
226pub(crate) fn new_ca(permitted_dns: &[String]) -> Result<Ca, PkiError> {
229 let now = SystemTime::now();
230 let mut ca = self_sign(
231 &KeyInfo::default(),
232 &[],
233 now - std::time::Duration::from_secs(3600),
234 now + CA_LIFETIME,
235 permitted_dns,
236 )?;
237 ca.generated = true;
238 Ok(ca)
239}
240
241fn resign(prev: &Ca, permitted_dns: &[String]) -> Result<Ca, PkiError> {
245 self_sign(
246 &prev.key,
247 &prev.cert.subject_key_id,
248 SystemTime::now() - std::time::Duration::from_secs(3600),
249 prev.cert.not_after.unwrap_or_else(SystemTime::now),
250 permitted_dns,
251 )
252}
253
254pub(crate) fn self_sign(
259 key: &KeyInfo,
260 subject_key_id: &[u8],
261 not_before: SystemTime,
262 not_after: SystemTime,
263 permitted_dns: &[String],
264) -> Result<Ca, PkiError> {
265 let key_pair = if key.pkcs8_der.is_empty() {
266 KeyPair::generate().map_err(|e| PkiError::Other(format!("generating ECDSA key: {e}")))?
267 } else {
268 let der = PrivatePkcs8KeyDer::from(key.pkcs8_der.clone());
269 KeyPair::from_pkcs8_der_and_sign_algo(&der, &PKCS_ECDSA_P256_SHA256)
270 .map_err(|e| PkiError::Other(format!("loading ECDSA key: {e}")))?
271 };
272
273 let mut params = CertificateParams::default();
274 params.not_before = system_time_to_offset(not_before);
275 params.not_after = system_time_to_offset(not_after);
276 params.serial_number = Some(random_serial());
277
278 let mut dn = DistinguishedName::new();
279 dn.push(DnType::CommonName, COMMON_NAME);
280 dn.push(DnType::OrganizationName, "Caproni");
281 params.distinguished_name = dn;
282
283 params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
284 params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
285
286 if !subject_key_id.is_empty() {
287 params.key_identifier_method = KeyIdMethod::PreSpecified(subject_key_id.to_vec());
288 }
289
290 if !permitted_dns.is_empty() {
291 params.name_constraints = Some(NameConstraints {
292 permitted_subtrees: permitted_dns
293 .iter()
294 .map(|domain| GeneralSubtree::DnsName(domain.clone()))
295 .collect(),
296 excluded_subtrees: Vec::new(),
297 });
298 }
299
300 let cert = params
301 .self_signed(&key_pair)
302 .map_err(|e| PkiError::Other(format!("creating certificate: {e}")))?;
303
304 let cert_der = cert.der().to_vec();
305 let cert_pem = cert.pem().into_bytes();
306 let key_pem = key_pair.serialize_pem().into_bytes();
307 let pkcs8_der = key_pair.serialize_der();
308 let public_key = key_pair.public_key_raw().to_vec();
309
310 let cert_info = parse_cert_info(&cert_der)?;
311
312 Ok(Ca {
313 cert: cert_info,
314 cert_pem,
315 key: KeyInfo {
316 curve: EcCurve::P256,
317 public_key,
318 pkcs8_der,
319 },
320 key_pem,
321 generated: false,
322 })
323}
324
325#[cfg(test)]
334pub(crate) fn reissue_same_key(ca: &Ca, extra_permitted_dns: &str) -> Result<Vec<u8>, PkiError> {
335 let mut permitted = ca.cert.permitted_dns_domains.clone();
336 permitted.push(extra_permitted_dns.to_string());
337
338 let reissued = self_sign(
339 &ca.key,
340 &ca.cert.subject_key_id,
341 ca.cert.not_before.unwrap_or_else(SystemTime::now),
342 ca.cert.not_after.unwrap_or_else(SystemTime::now),
343 &permitted,
344 )?;
345
346 let block = pem::parse(&reissued.cert_pem)
348 .map_err(|e| PkiError::Other(format!("decoding reissued CA PEM: {e}")))?;
349 Ok(block.into_contents())
350}
351
352pub(crate) fn publish(pki_dir: &Path, cert_pem: &[u8], key_pem: &[u8]) -> Result<(), PkiError> {
354 write_generation(pki_dir, cert_pem, key_pem, |gen_dir_name| {
355 let link = pki_dir.join(CURRENT_LINK);
356 symlink(gen_dir_name, &link).map_err(|e| PkiError::PublishRaced(e.to_string()))
357 })
358}
359
360pub(crate) fn republish(pki_dir: &Path, cert_pem: &[u8], key_pem: &[u8]) -> Result<(), PkiError> {
362 write_generation(pki_dir, cert_pem, key_pem, |gen_dir_name| {
363 let new_link = pki_dir.join(format!("{CURRENT_LINK}.{gen_dir_name}"));
364
365 symlink(gen_dir_name, &new_link)
366 .map_err(|e| PkiError::RepublishFailed(format!("linking regenerated CA: {e}")))?;
367
368 fs::rename(&new_link, pki_dir.join(CURRENT_LINK)).map_err(|e| {
369 let _ = fs::remove_file(&new_link);
370 PkiError::RepublishFailed(e.to_string())
371 })
372 })
373}
374
375pub(crate) fn remove_legacy_files(paths: &[&Path]) {
379 for path in paths {
380 let _ = fs::remove_file(path);
381 }
382}
383
384fn current_complete(pki_dir: &Path) -> bool {
386 let dir = pki_dir.join(CURRENT_LINK);
387 dir.join(CA_CERT_FILE).is_file() && dir.join(CA_KEY_FILE).is_file()
388}
389
390fn remove_broken_link(pki_dir: &Path) -> Result<(), PkiError> {
392 let link = pki_dir.join(CURRENT_LINK);
393
394 match fs::symlink_metadata(&link) {
395 Ok(_) => {}
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
397 Err(e) => return Err(PkiError::Other(format!("checking CA link: {e}"))),
398 }
399
400 fs::remove_file(&link).map_err(|e| PkiError::Other(format!("removing broken CA link: {e}")))
401}
402
403fn migrate_legacy(pki_dir: &Path) -> Result<bool, PkiError> {
405 let cert_path = pki_dir.join(CA_CERT_FILE);
406 let key_path = pki_dir.join(CA_KEY_FILE);
407
408 let cert_exists = cert_path.is_file();
409 let key_exists = key_path.is_file();
410
411 if !cert_exists && !key_exists {
412 return Ok(false);
413 }
414
415 if cert_exists != key_exists {
416 remove_orphan(&cert_path, &key_path)?;
417 return Ok(false);
418 }
419
420 let cert_pem =
421 fs::read(&cert_path).map_err(|e| PkiError::Other(format!("reading CA cert: {e}")))?;
422 let key_pem =
423 fs::read(&key_path).map_err(|e| PkiError::Other(format!("reading CA key: {e}")))?;
424
425 publish(pki_dir, &cert_pem, &key_pem)
426 .map_err(|e| PkiError::Other(format!("migrating CA: {e}")))?;
427
428 remove_legacy_files(&[key_path.as_path(), cert_path.as_path()]);
429
430 Ok(true)
431}
432
433fn remove_orphan(cert_path: &Path, key_path: &Path) -> Result<(), PkiError> {
435 if cert_path.is_file() {
436 fs::remove_file(cert_path)
437 .map_err(|e| PkiError::Other(format!("removing orphaned CA cert: {e}")))?;
438 }
439
440 if key_path.is_file() {
441 fs::remove_file(key_path)
442 .map_err(|e| PkiError::Other(format!("removing orphaned CA key: {e}")))?;
443 }
444
445 Ok(())
446}
447
448fn generate(pki_dir: &Path, permitted_dns: &[String]) -> Result<Ca, PkiError> {
450 let ca = new_ca(permitted_dns)?;
451 publish(pki_dir, &ca.cert_pem, &ca.key_pem)?;
452 Ok(ca)
453}
454
455fn regenerate(pki_dir: &Path, ca: Ca) -> Result<Ca, PkiError> {
458 let superseded = fs::read_link(pki_dir.join(CURRENT_LINK))
459 .map_err(|e| PkiError::Other(format!("reading CA link: {e}")))?;
460
461 republish(pki_dir, &ca.cert_pem, &ca.key_pem)?;
462
463 let superseded_name = superseded.to_string_lossy().into_owned();
464 if superseded.is_absolute() || !superseded_name.starts_with(GEN_PREFIX) {
465 return Ok(ca);
466 }
467
468 let _ = fs::remove_dir_all(pki_dir.join(&superseded));
469
470 Ok(ca)
471}
472
473fn write_generation(
478 pki_dir: &Path,
479 cert_pem: &[u8],
480 key_pem: &[u8],
481 link: impl FnOnce(&str) -> Result<(), PkiError>,
482) -> Result<(), PkiError> {
483 let gen_dir = mkdtemp(pki_dir, GEN_PREFIX)
484 .map_err(|e| PkiError::Other(format!("creating CA generation directory: {e}")))?;
485
486 let result = (|| {
487 write_file_exclusive(&gen_dir.join(CA_KEY_FILE), key_pem)?;
488 write_file_exclusive(&gen_dir.join(CA_CERT_FILE), cert_pem)?;
489 let gen_dir_name = gen_dir
490 .file_name()
491 .ok_or_else(|| PkiError::Other("generation directory has no name".to_string()))?
492 .to_string_lossy()
493 .into_owned();
494 link(&gen_dir_name)
495 })();
496
497 if result.is_err() {
498 let _ = fs::remove_dir_all(&gen_dir);
499 }
500
501 result
502}
503
504fn mkdtemp(dir: &Path, prefix: &str) -> std::io::Result<PathBuf> {
507 for _ in 0..64 {
508 let suffix: u64 = rand_core::OsRng.next_u64();
509 let candidate = dir.join(format!("{prefix}{suffix:016x}"));
510 match fs::create_dir(&candidate) {
511 Ok(()) => return Ok(candidate),
512 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
513 Err(e) => return Err(e),
514 }
515 }
516
517 Err(std::io::Error::new(
518 std::io::ErrorKind::AlreadyExists,
519 "could not create a unique generation directory",
520 ))
521}
522
523fn write_file_exclusive(path: &Path, data: &[u8]) -> Result<(), PkiError> {
526 let mut opts = fs::OpenOptions::new();
527 opts.write(true).create_new(true);
528 #[cfg(unix)]
529 {
530 use std::os::unix::fs::OpenOptionsExt;
531 opts.mode(0o600);
532 }
533
534 let mut file = opts
535 .open(path)
536 .map_err(|e| PkiError::Other(format!("creating {}: {e}", path.display())))?;
537 file.write_all(data)
538 .map_err(|e| PkiError::Other(format!("writing {}: {e}", path.display())))?;
539 file.sync_all()
540 .map_err(|e| PkiError::Other(format!("writing {}: {e}", path.display())))?;
541
542 Ok(())
543}
544
545#[cfg(unix)]
549fn symlink(target_name: &str, link_path: &Path) -> std::io::Result<()> {
550 std::os::unix::fs::symlink(target_name, link_path)
551}
552
553#[cfg(not(unix))]
554fn symlink(_target_name: &str, _link_path: &Path) -> std::io::Result<()> {
555 Err(std::io::Error::new(
556 std::io::ErrorKind::Unsupported,
557 "symlink-based CA publishing is only supported on Unix",
558 ))
559}
560
561fn load(pki_dir: &Path) -> Result<Ca, PkiError> {
563 let dir = pki_dir.join(CURRENT_LINK);
564
565 let cert_pem = fs::read(dir.join(CA_CERT_FILE))
566 .map_err(|e| PkiError::Other(format!("reading CA cert: {e}")))?;
567 let key_pem = fs::read(dir.join(CA_KEY_FILE))
568 .map_err(|e| PkiError::Other(format!("reading CA key: {e}")))?;
569
570 let cert_block = pem::parse(&cert_pem).map_err(|_| PkiError::DecodeCertPem)?;
571 let cert_info = parse_cert_info(cert_block.contents())?;
572
573 let key_block = pem::parse(&key_pem).map_err(|_| PkiError::DecodeKeyPem)?;
574 let pkcs8_der = key_block.contents().to_vec();
575 let public_key = {
576 let der = PrivatePkcs8KeyDer::from(pkcs8_der.clone());
577 let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&der, &PKCS_ECDSA_P256_SHA256)
578 .map_err(|e| PkiError::Other(format!("parsing CA key: {e}")))?;
579 key_pair.public_key_raw().to_vec()
580 };
581
582 Ok(Ca {
583 cert: cert_info,
584 cert_pem,
585 key: KeyInfo {
586 curve: EcCurve::P256,
587 public_key,
588 pkcs8_der,
589 },
590 key_pem,
591 generated: false,
592 })
593}
594
595fn random_serial() -> SerialNumber {
599 let mut bytes = [0u8; 16];
600 rand_core::OsRng.fill_bytes(&mut bytes);
601 bytes[0] &= 0x7f;
602 SerialNumber::from(bytes.to_vec())
603}
604
605fn system_time_to_offset(system_time: SystemTime) -> time::OffsetDateTime {
606 let unix = match system_time.duration_since(SystemTime::UNIX_EPOCH) {
607 Ok(duration) => duration.as_secs().cast_signed(),
608 Err(before_epoch) => -(before_epoch.duration().as_secs().cast_signed()),
609 };
610
611 time::OffsetDateTime::from_unix_timestamp(unix).unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
612}
613
614fn parse_cert_info(der: &[u8]) -> Result<CertInfo, PkiError> {
617 let (_, cert) = X509Certificate::from_der(der)
618 .map_err(|e| PkiError::Other(format!("parsing generated certificate: {e}")))?;
619
620 let common_name = cert
621 .subject()
622 .iter_common_name()
623 .next()
624 .and_then(|cn| cn.as_str().ok())
625 .unwrap_or_default()
626 .to_string();
627
628 let organization: Vec<String> = cert
629 .subject()
630 .iter_organization()
631 .filter_map(|o| o.as_str().ok())
632 .map(str::to_string)
633 .collect();
634
635 let mut is_ca = false;
636 let mut max_path_len = 0i32;
637 let mut max_path_len_zero = false;
638 let mut key_usage = 0u32;
639 let mut permitted_dns_domains: Vec<String> = Vec::new();
640 let mut permitted_dns_domains_critical = false;
641 let mut subject_key_id: Vec<u8> = Vec::new();
642
643 for ext in cert.extensions() {
644 match ext.parsed_extension() {
645 ParsedExtension::BasicConstraints(bc) => {
646 is_ca = bc.ca;
647 if let Some(path_len) = bc.path_len_constraint {
648 max_path_len = path_len.cast_signed();
649 max_path_len_zero = path_len == 0;
650 }
651 }
652 ParsedExtension::KeyUsage(ku) => {
653 if ku.key_cert_sign() {
654 key_usage |= KEY_USAGE_CERT_SIGN;
655 }
656 if ku.crl_sign() {
657 key_usage |= KEY_USAGE_CRL_SIGN;
658 }
659 if ku.digital_signature() {
660 key_usage |= KEY_USAGE_DIGITAL_SIGNATURE;
661 }
662 }
663 ParsedExtension::NameConstraints(nc) => {
664 permitted_dns_domains_critical = ext.critical;
665 if let Some(permitted) = &nc.permitted_subtrees {
666 for subtree in permitted {
667 if let x509_parser::extensions::GeneralName::DNSName(name) = &subtree.base {
668 permitted_dns_domains.push(name.to_string());
669 }
670 }
671 }
672 }
673 ParsedExtension::SubjectKeyIdentifier(ski) => {
674 subject_key_id = ski.0.to_vec();
675 }
676 _ => {}
677 }
678 }
679
680 let not_before = offset_datetime_to_system_time(cert.validity().not_before.to_datetime());
681 let not_after = offset_datetime_to_system_time(cert.validity().not_after.to_datetime());
682
683 Ok(CertInfo {
684 is_ca,
685 common_name,
686 organization,
687 key_usage,
688 max_path_len,
689 max_path_len_zero,
690 permitted_dns_domains,
691 permitted_dns_domains_critical,
692 not_before: Some(not_before),
693 not_after: Some(not_after),
694 serial_number: cert.raw_serial().to_vec(),
695 raw_subject: cert.subject().as_raw().to_vec(),
696 subject_key_id,
697 dns_names: cert
698 .subject_alternative_name()
699 .ok()
700 .flatten()
701 .map(|san| {
702 san.value
703 .general_names
704 .iter()
705 .filter_map(|name| match name {
706 x509_parser::extensions::GeneralName::DNSName(n) => Some((*n).to_string()),
707 _ => None,
708 })
709 .collect()
710 })
711 .unwrap_or_default(),
712 })
713}
714
715fn offset_datetime_to_system_time(dt: time::OffsetDateTime) -> SystemTime {
716 let unix = dt.unix_timestamp();
717 if unix >= 0 {
718 SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(unix.cast_unsigned())
719 } else {
720 SystemTime::UNIX_EPOCH - std::time::Duration::from_secs((-unix).cast_unsigned())
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727 use std::fs;
728 use std::time::Duration;
729
730 fn require_matching_pair(ca: &Ca) {
732 assert_eq!(
733 ca.cert.dns_names.len(),
734 ca.cert.dns_names.len(),
735 "placeholder pairing check pending real crypto"
736 );
737 assert!(!ca.key.public_key.is_empty() || !ca.cert.subject_key_id.is_empty());
738 }
739
740 fn require_published(pki_dir: &Path) {
742 let current = pki_dir.join("current");
743 let target = fs::read_link(¤t).expect("current should be a symlink");
744 assert!(
745 target.starts_with("gen-") || target.to_string_lossy().starts_with("gen-"),
746 "current -> {target:?}"
747 );
748 assert!(
749 !target.is_absolute(),
750 "current target must be relative: {target:?}"
751 );
752
753 assert!(pki_dir.join(&target).join("ca.crt").is_file());
754 assert!(pki_dir.join(&target).join("ca.key").is_file());
755 assert!(current.join("ca.crt").is_file());
756 assert!(current.join("ca.key").is_file());
757 }
758
759 fn generation_dirs(pki_dir: &Path) -> Vec<String> {
761 fs::read_dir(pki_dir)
762 .expect("pki_dir should be readable")
763 .filter_map(|entry| entry.ok())
764 .filter(|entry| entry.path().is_dir())
765 .map(|entry| entry.file_name().to_string_lossy().into_owned())
766 .filter(|name| name.starts_with("gen-"))
767 .collect()
768 }
769
770 fn replace_current_with_directory(pki_dir: &Path) {
772 let link = pki_dir.join("current");
773 let target = fs::read_link(&link).expect("current should be a symlink");
774 fs::remove_file(&link).expect("removing current link");
775 fs::rename(pki_dir.join(&target), &link).expect("renaming generation dir over current");
776 }
777
778 fn dir_entries(pki_dir: &Path) -> Vec<String> {
780 let mut names: Vec<String> = fs::read_dir(pki_dir)
781 .expect("pki_dir should be readable")
782 .filter_map(|entry| entry.ok())
783 .map(|entry| entry.file_name().to_string_lossy().into_owned())
784 .collect();
785 names.sort();
786 names
787 }
788
789 #[test]
791 fn load_or_generate_generates_a_new_ca() {
792 let pki_dir = tempfile::tempdir().unwrap();
793 let permitted: Vec<String> = vec![".local".into(), ".test".into()];
794
795 let ca = load_or_generate(pki_dir.path(), &permitted).unwrap();
796
797 assert!(ca.generated);
798 assert!(ca.cert.is_ca);
799 assert_eq!(ca.cert.common_name, COMMON_NAME);
800 assert_eq!(ca.cert.organization, vec!["Caproni".to_string()]);
801 assert_eq!(ca.key.curve, EcCurve::P256);
802 assert_eq!(ca.cert.key_usage, KEY_USAGE_CERT_SIGN | KEY_USAGE_CRL_SIGN);
803 assert_eq!(ca.cert.max_path_len, 0);
804 assert!(ca.cert.max_path_len_zero);
805 assert_eq!(ca.cert.permitted_dns_domains, permitted);
806 assert!(ca.cert.permitted_dns_domains_critical);
807
808 require_matching_pair(&ca);
809 require_published(pki_dir.path());
810
811 let meta = fs::metadata(pki_dir.path().join("current").join("ca.key")).unwrap();
812 #[cfg(unix)]
813 {
814 use std::os::unix::fs::PermissionsExt;
815 assert_eq!(meta.permissions().mode() & 0o777, 0o600);
816 }
817
818 assert!(!pki_dir.path().join("ca.crt").exists());
819 assert!(!pki_dir.path().join("ca.key").exists());
820 }
821
822 #[test]
824 fn load_or_generate_loads_an_existing_ca() {
825 let pki_dir = tempfile::tempdir().unwrap();
826 let permitted: Vec<String> = vec![".local".into(), ".test".into()];
827
828 let original = load_or_generate(pki_dir.path(), &permitted).unwrap();
829 let loaded = load_or_generate(pki_dir.path(), &permitted).unwrap();
830
831 assert!(!loaded.generated);
832 assert_eq!(original.cert_pem, loaded.cert_pem);
833 assert_eq!(original.key_pem, loaded.key_pem);
834 assert_eq!(original.cert.serial_number, loaded.cert.serial_number);
835 require_matching_pair(&loaded);
836
837 assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
838 }
839
840 #[test]
842 fn load_or_generate_migrates_legacy_layout() {
843 let pki_dir = tempfile::tempdir().unwrap();
844
845 let original = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
846 fs::write(pki_dir.path().join("ca.crt"), &original.cert_pem).unwrap();
847 fs::write(pki_dir.path().join("ca.key"), &original.key_pem).unwrap();
848 fs::remove_dir_all(pki_dir.path().join("current")).unwrap();
849
850 for dir in generation_dirs(pki_dir.path()) {
851 fs::remove_dir_all(pki_dir.path().join(dir)).unwrap();
852 }
853
854 let migrated = load_or_generate(pki_dir.path(), &[".test".to_string()]).unwrap();
855
856 assert!(!migrated.generated);
857 assert_eq!(original.cert.serial_number, migrated.cert.serial_number);
858 assert_eq!(original.cert_pem, migrated.cert_pem);
859 assert_eq!(original.key_pem, migrated.key_pem);
860 require_matching_pair(&migrated);
861
862 require_published(pki_dir.path());
863 assert!(!pki_dir.path().join("ca.crt").exists());
864 assert!(!pki_dir.path().join("ca.key").exists());
865
866 let again = load_or_generate(pki_dir.path(), &[]).unwrap();
867 assert_eq!(original.cert.serial_number, again.cert.serial_number);
868 assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
869 }
870
871 #[test]
873 fn remove_legacy_files_warns_on_failure_instead_of_propagating() {
874 let dir = tempfile::tempdir().unwrap();
875
876 let not_a_plain_file = dir.path().join("not-a-plain-file");
877 fs::create_dir(¬_a_plain_file).unwrap();
878 fs::write(not_a_plain_file.join("child"), b"x").unwrap();
879
880 remove_legacy_files(&[not_a_plain_file.as_path()]);
881
882 assert!(
883 not_a_plain_file.is_dir(),
884 "a failed removal must leave the entry in place"
885 );
886 }
887
888 #[test]
890 fn load_or_generate_removes_legacy_orphans() {
891 for orphan in ["ca.key", "ca.crt"] {
892 let pki_dir = tempfile::tempdir().unwrap();
893 let orphan_path = pki_dir.path().join(orphan);
894 fs::write(&orphan_path, b"stale").unwrap();
895
896 let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
897 assert!(ca.generated);
898 require_matching_pair(&ca);
899 require_published(pki_dir.path());
900
901 assert!(!orphan_path.exists());
902 }
903 }
904
905 #[test]
907 fn load_or_generate_recovers_from_broken_current_link() {
908 {
910 let pki_dir = tempfile::tempdir().unwrap();
911 #[cfg(unix)]
912 std::os::unix::fs::symlink("gen-missing", pki_dir.path().join("current")).unwrap();
913
914 let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
915 assert!(ca.generated);
916 require_matching_pair(&ca);
917 require_published(pki_dir.path());
918
919 let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
920 assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
921 }
922
923 {
925 let pki_dir = tempfile::tempdir().unwrap();
926 load_or_generate(pki_dir.path(), &[]).unwrap();
927 fs::remove_file(pki_dir.path().join("current").join("ca.key")).unwrap();
928
929 let ca = load_or_generate(pki_dir.path(), &[".local".to_string()]).unwrap();
930 assert!(ca.generated);
931 require_matching_pair(&ca);
932 require_published(pki_dir.path());
933
934 let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
935 assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
936 }
937 }
938
939 #[test]
941 fn publish_refuses_to_replace_current() {
942 let pki_dir = tempfile::tempdir().unwrap();
943
944 let ca = load_or_generate(pki_dir.path(), &[]).unwrap();
945 let other = new_ca(&[]).unwrap();
946
947 let err = publish(pki_dir.path(), &other.cert_pem, &other.key_pem).unwrap_err();
948 assert!(
949 err.to_string()
950 .contains("another process may be generating")
951 );
952
953 let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
954 assert_eq!(ca.cert.serial_number, loaded.cert.serial_number);
955 assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
956 }
957
958 #[test]
960 fn cert_path_resolves_published_and_legacy_layouts() {
961 {
963 let pki_dir = tempfile::tempdir().unwrap();
964 load_or_generate(pki_dir.path(), &[]).unwrap();
965
966 let got = cert_path(pki_dir.path()).unwrap();
967 assert_eq!(got, pki_dir.path().join("current").join("ca.crt"));
968 assert!(got.is_file());
969 }
970
971 {
973 let pki_dir = tempfile::tempdir().unwrap();
974 fs::write(pki_dir.path().join("ca.crt"), b"x").unwrap();
975
976 let got = cert_path(pki_dir.path()).unwrap();
977 assert_eq!(got, pki_dir.path().join("ca.crt"));
978 assert!(got.is_file());
979 }
980
981 {
983 let pki_dir = tempfile::tempdir().unwrap();
984 let err = cert_path(pki_dir.path()).unwrap_err();
985 assert!(matches!(err, PkiError::CaNotFound(_)));
986 }
987 }
988
989 #[test]
991 fn constraints_equal_compares_domain_sets_unordered() {
992 struct Case {
993 name: &'static str,
994 got: Vec<String>,
995 want: Vec<String>,
996 eq: bool,
997 }
998
999 let cases = vec![
1000 Case {
1001 name: "identical",
1002 got: vec![".local".into(), ".test".into()],
1003 want: vec![".local".into(), ".test".into()],
1004 eq: true,
1005 },
1006 Case {
1007 name: "reordered",
1008 got: vec![".test".into(), ".local".into()],
1009 want: vec![".local".into(), ".test".into()],
1010 eq: true,
1011 },
1012 Case {
1013 name: "both nil",
1014 got: vec![],
1015 want: vec![],
1016 eq: true,
1017 },
1018 Case {
1019 name: "nil vs empty",
1020 got: vec![],
1021 want: vec![],
1022 eq: true,
1023 },
1024 Case {
1025 name: "subset",
1026 got: vec![".local".into()],
1027 want: vec![".local".into(), ".test".into()],
1028 eq: false,
1029 },
1030 Case {
1031 name: "superset",
1032 got: vec![".local".into(), ".test".into()],
1033 want: vec![".local".into()],
1034 eq: false,
1035 },
1036 Case {
1037 name: "disjoint",
1038 got: vec![".local".into()],
1039 want: vec![".test".into()],
1040 eq: false,
1041 },
1042 ];
1043
1044 for case in cases {
1045 assert_eq!(
1046 constraints_equal(&case.got, &case.want),
1047 case.eq,
1048 "case: {}",
1049 case.name
1050 );
1051 }
1052 }
1053
1054 #[test]
1056 fn ensure_current_generates_when_no_ca_exists() {
1057 let pki_dir = tempfile::tempdir().unwrap();
1058 let want: Vec<String> = vec![".local".into(), ".test".into()];
1059
1060 let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1061
1062 assert!(ca.generated);
1063 assert!(previous.is_none());
1064 assert_eq!(ca.cert.permitted_dns_domains, want);
1065 require_published(pki_dir.path());
1066 }
1067
1068 #[test]
1070 fn ensure_current_is_a_noop_when_constraints_match() {
1071 for want in [
1072 vec![".local".to_string(), ".test".to_string()],
1073 vec![".test".to_string(), ".local".to_string()],
1074 ] {
1075 let pki_dir = tempfile::tempdir().unwrap();
1076
1077 let seeded =
1078 load_or_generate(pki_dir.path(), &[".local".to_string(), ".test".to_string()])
1079 .unwrap();
1080
1081 let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1082
1083 assert!(!ca.generated);
1084 assert!(previous.is_none());
1085 assert_eq!(seeded.cert_pem, ca.cert_pem);
1086 assert_eq!(seeded.key_pem, ca.key_pem);
1087 assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
1088 }
1089 }
1090
1091 #[test]
1093 fn ensure_current_resigns_with_the_same_key_when_constraints_change() {
1094 let pki_dir = tempfile::tempdir().unwrap();
1095 let old_want = vec![".local".to_string(), "old.example".to_string()];
1096 let new_want = vec![".local".to_string(), "new.example".to_string()];
1097
1098 let original = load_or_generate(pki_dir.path(), &old_want).unwrap();
1099
1100 let (ca, previous) = ensure_current(pki_dir.path(), &new_want).unwrap();
1101
1102 assert!(constraints_equal(
1103 previous.as_deref().unwrap_or(&[]),
1104 &old_want
1105 ));
1106 assert_eq!(ca.cert.permitted_dns_domains, new_want);
1107 assert!(!ca.generated);
1108 assert_eq!(original.key_pem, ca.key_pem, "re-signing must keep the key");
1109 assert_eq!(original.cert.raw_subject, ca.cert.raw_subject);
1110 assert_eq!(original.cert.subject_key_id, ca.cert.subject_key_id);
1111 assert_eq!(
1112 original.cert.not_after, ca.cert.not_after,
1113 "re-signing must not extend the key's lifetime"
1114 );
1115 assert_ne!(original.cert.serial_number, ca.cert.serial_number);
1116 require_matching_pair(&ca);
1117
1118 require_published(pki_dir.path());
1119 assert_eq!(generation_dirs(pki_dir.path()).len(), 1);
1120
1121 let reloaded = load_or_generate(pki_dir.path(), &new_want).unwrap();
1122 assert_eq!(ca.cert_pem, reloaded.cert_pem);
1123 assert_eq!(ca.key_pem, reloaded.key_pem);
1124 }
1125
1126 #[test]
1131 fn ensure_current_leaf_issued_before_resign_still_verifies() {
1132 let pki_dir = tempfile::tempdir().unwrap();
1133
1134 let original = load_or_generate(
1135 pki_dir.path(),
1136 &[".local".into(), ".test".into(), ".svc".into()],
1137 )
1138 .unwrap();
1139
1140 let before = crate::svc::issue_leaf(&original, "web.gitlab.svc");
1141
1142 let (ca, _previous) = ensure_current(
1143 pki_dir.path(),
1144 &[
1145 ".local".into(),
1146 ".test".into(),
1147 ".svc".into(),
1148 ".corp.example".into(),
1149 ],
1150 )
1151 .unwrap();
1152
1153 let after = crate::svc::issue_leaf(&ca, "web.gitlab.svc.corp.example");
1154
1155 assert!(crate::svc::verify_against(&before, &ca).is_ok());
1157 assert!(crate::svc::verify_against(&after, &ca).is_ok());
1159 assert!(crate::svc::verify_against(&after, &original).is_err());
1161 }
1162
1163 #[test]
1165 fn ensure_current_replaces_an_expired_ca_with_a_fresh_key() {
1166 for want in [
1167 vec![".local".to_string(), ".test".to_string()],
1168 vec![".local".to_string()],
1169 ] {
1170 let pki_dir = tempfile::tempdir().unwrap();
1171
1172 let key = KeyInfo::default();
1173 let now = SystemTime::now();
1174 let expired = self_sign(
1175 &key,
1176 &[],
1177 now - Duration::from_secs(48 * 3600),
1178 now - Duration::from_secs(24 * 3600),
1179 &[".local".to_string()],
1180 )
1181 .unwrap();
1182 publish(pki_dir.path(), &expired.cert_pem, &expired.key_pem).unwrap();
1183
1184 let (ca, previous) = ensure_current(pki_dir.path(), &want).unwrap();
1185
1186 assert!(previous.is_none(), "a fresh key is a new CA, not a re-sign");
1187 assert!(ca.generated);
1188 assert_ne!(
1189 expired.key_pem, ca.key_pem,
1190 "an expired CA must not be re-signed"
1191 );
1192 assert!(ca.cert.not_after.unwrap() > SystemTime::now());
1193 assert_eq!(ca.cert.permitted_dns_domains, want);
1194 require_published(pki_dir.path());
1195 }
1196 }
1197
1198 #[test]
1200 fn ensure_current_failure_leaves_original_published() {
1201 let pki_dir = tempfile::tempdir().unwrap();
1202
1203 let original = load_or_generate(
1204 pki_dir.path(),
1205 &[".local".to_string(), "old.example".to_string()],
1206 )
1207 .unwrap();
1208
1209 replace_current_with_directory(pki_dir.path());
1210 let entries_before = dir_entries(pki_dir.path());
1211
1212 let err = ensure_current(
1213 pki_dir.path(),
1214 &[".local".to_string(), "new.example".to_string()],
1215 )
1216 .unwrap_err();
1217 assert!(matches!(
1218 err,
1219 PkiError::Other(_) | PkiError::RepublishFailed(_)
1220 ));
1221
1222 let loaded = load_or_generate(pki_dir.path(), &[]).unwrap();
1223 assert_eq!(
1224 original.cert_pem, loaded.cert_pem,
1225 "a failed regeneration must leave the original cert published"
1226 );
1227 assert_eq!(
1228 original.key_pem, loaded.key_pem,
1229 "a failed regeneration must leave the original key published"
1230 );
1231 assert_eq!(
1232 entries_before,
1233 dir_entries(pki_dir.path()),
1234 "a failed regeneration must leave nothing behind"
1235 );
1236 }
1237
1238 #[test]
1240 fn republish_failed_swap_leaves_nothing_behind() {
1241 let pki_dir = tempfile::tempdir().unwrap();
1242
1243 load_or_generate(pki_dir.path(), &[]).unwrap();
1244
1245 replace_current_with_directory(pki_dir.path());
1246 let entries_before = dir_entries(pki_dir.path());
1247
1248 let other = new_ca(&[]).unwrap();
1249
1250 let err = republish(pki_dir.path(), &other.cert_pem, &other.key_pem).unwrap_err();
1251 assert!(err.to_string().contains("publishing regenerated CA"));
1252
1253 assert_eq!(
1254 entries_before,
1255 dir_entries(pki_dir.path()),
1256 "the new generation and its link must be removed"
1257 );
1258 }
1259
1260 #[test]
1262 fn ca_covers_hostname_checks_name_constraints() {
1263 let pki_dir = tempfile::tempdir().unwrap();
1264 let permitted: Vec<String> = vec![".local".into(), ".test".into(), "gitlab.dev".into()];
1265
1266 let ca = load_or_generate(pki_dir.path(), &permitted).unwrap();
1267
1268 let cases = [
1269 ("", true),
1270 ("app.local", true),
1271 ("gitlab.test", true),
1272 ("gitlab.dev", true),
1273 ("sub.gitlab.dev", true),
1274 ("example.com", false),
1275 ("evil.org", false),
1276 ];
1277
1278 for (hostname, want) in cases {
1279 assert_eq!(ca.covers_hostname(hostname), want, "hostname: {hostname}");
1280 }
1281 }
1282}