1use std::path::{Path, PathBuf};
33use std::sync::Arc;
34
35use rcgen::{
36 BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
37 GeneralSubtree, IsCa, Issuer, KeyPair, KeyUsagePurpose, NameConstraints,
38};
39use rustls::pki_types::pem::PemObject;
40use rustls::pki_types::CertificateDer;
41use time::{Duration, OffsetDateTime};
42
43use crate::cert::{certified_key_from_pem, leaf_expiry, CertError};
44use crate::fleet::{FleetError, ZoneResolver};
45use crate::resolver::SwappableCert;
46use crate::storage::{CaStore, CertMeta, CertStore, StorageError};
47
48pub const LOCAL_SOURCE: &str = "local";
52
53pub const CA_COMMON_NAME: &str = "didbot local development authority";
56
57const PERMITTED_SUBTREE: &str = "localhost";
61
62pub const CA_VALIDITY: Duration = Duration::days(5 * 365);
69
70pub const LEAF_VALIDITY: Duration = Duration::days(90);
74
75pub const LEAF_REISSUE_BEFORE: Duration = Duration::days(30);
80
81const CLOCK_SKEW: Duration = Duration::hours(1);
84
85#[derive(Debug, thiserror::Error)]
87pub enum LocalError {
88 #[error("data directory: {0}")]
90 Storage(#[from] StorageError),
91 #[error("generating a local certificate: {0}")]
93 Generate(#[from] rcgen::Error),
94 #[error("the local certificate this run signed or loaded: {0}")]
97 Cert(#[from] CertError),
98 #[error("{0}")]
100 Fleet(#[from] FleetError),
101 #[error("{0}")]
104 Refused(String),
105}
106
107pub struct LocalAuthority {
110 store: CaStore,
111 issuer: Issuer<'static, KeyPair>,
112 ca_pem: String,
113 generated: bool,
114}
115
116impl std::fmt::Debug for LocalAuthority {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.debug_struct("LocalAuthority")
119 .field("certificate", &self.certificate_path())
120 .field("generated", &self.generated)
121 .finish_non_exhaustive()
122 }
123}
124
125impl LocalAuthority {
126 pub fn open(dir: &Path) -> Result<Self, LocalError> {
132 let store = CaStore::open(dir)?;
133 if let Some((ca_pem, key_pem)) = store.load()? {
134 match KeyPair::from_pem(&key_pem) {
135 Ok(key) => {
136 return Ok(Self {
137 issuer: Issuer::new(ca_params(OffsetDateTime::now_utc())?, key),
138 ca_pem,
139 store,
140 generated: false,
141 })
142 }
143 Err(error) => {
144 tracing::warn!(
150 %error,
151 path = %store.certificate_path().display(),
152 "the local authority's key does not parse; generating a new authority, \
153 which has to be trusted again"
154 );
155 }
156 }
157 }
158
159 let key = KeyPair::generate()?;
160 let params = ca_params(OffsetDateTime::now_utc())?;
161 let ca_pem = params.self_signed(&key)?.pem();
162 store.save(&ca_pem, &key.serialize_pem())?;
163 Ok(Self {
164 issuer: Issuer::new(params, key),
165 ca_pem,
166 store,
167 generated: true,
168 })
169 }
170
171 pub fn certificate_path(&self) -> PathBuf {
173 self.store.certificate_path()
174 }
175
176 pub fn was_generated(&self) -> bool {
178 self.generated
179 }
180
181 pub fn trust_instructions(&self) -> Vec<String> {
187 let path = self.certificate_path();
188 let path = path.display();
189 vec![
190 format!("sudo cp {path} /usr/local/share/ca-certificates/didbot-local.crt && sudo update-ca-certificates"),
191 format!("certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n didbot-local -i {path}"),
192 format!("export DIDBOT_EXTRA_CA_CERTS={path} NODE_EXTRA_CA_CERTS={path}"),
193 ]
194 }
195
196 pub fn issue(&self, zone: &str) -> Result<(String, String), LocalError> {
206 self.issue_names(&[zone.to_owned(), format!("*.{zone}")])
207 }
208
209 pub fn issue_names(&self, names: &[String]) -> Result<(String, String), LocalError> {
216 let mut params = CertificateParams::new(names.to_vec())?;
217 let now = OffsetDateTime::now_utc();
218 params.not_before = now - CLOCK_SKEW;
219 params.not_after = now + LEAF_VALIDITY;
220 params.distinguished_name = DistinguishedName::new();
221 params.distinguished_name.push(
222 DnType::CommonName,
223 names.first().cloned().unwrap_or_default(),
224 );
225 params.use_authority_key_identifier_extension = true;
226 params.key_usages = vec![
227 KeyUsagePurpose::DigitalSignature,
228 KeyUsagePurpose::KeyEncipherment,
229 ];
230 params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
231
232 let key = KeyPair::generate()?;
233 let leaf = params.signed_by(&key, &self.issuer)?;
234 Ok((
235 format!("{}{}", leaf.pem(), self.ca_pem),
236 key.serialize_pem(),
237 ))
238 }
239
240 fn certificate_for(
249 &self,
250 zone: &str,
251 leaves: Option<&Path>,
252 provider: &rustls::crypto::CryptoProvider,
253 ) -> Result<rustls::sign::CertifiedKey, LocalError> {
254 if let Some(certified) = leaves.and_then(|dir| self.stored_certificate(zone, dir, provider))
255 {
256 tracing::debug!(zone, "reusing the local certificate on disk");
257 return Ok(certified);
258 }
259 let (chain_pem, key_pem) = self.issue(zone)?;
260 let certified = certified_key_from_pem(&chain_pem, &key_pem, provider)?;
263 if let Some(dir) = leaves {
264 CertStore::open_zone(dir, zone)?.save_certificate(
265 &chain_pem,
266 &key_pem,
267 &CertMeta {
268 directory_url: LOCAL_SOURCE.to_owned(),
269 issued_at: OffsetDateTime::now_utc(),
270 },
271 )?;
272 }
273 tracing::info!(zone, "signed a local certificate for this zone and *.zone");
274 Ok(certified)
275 }
276
277 fn stored_certificate(
286 &self,
287 zone: &str,
288 leaves: &Path,
289 provider: &rustls::crypto::CryptoProvider,
290 ) -> Option<rustls::sign::CertifiedKey> {
291 let store = CertStore::open_zone(leaves, zone).ok()?;
292 let (chain_pem, key_pem, meta) = store.certificate().ok()??;
293 if meta.directory_url != LOCAL_SOURCE {
294 return None;
295 }
296 let certified = certified_key_from_pem(&chain_pem, &key_pem, provider).ok()?;
297 if certified.cert.last()? != &self.certificate_der().ok()? {
301 return None;
302 }
303 let expiry = leaf_expiry(&certified.cert).ok()?;
304 (expiry - OffsetDateTime::now_utc() > LEAF_REISSUE_BEFORE).then_some(certified)
305 }
306
307 fn certificate_der(&self) -> Result<CertificateDer<'static>, CertError> {
310 CertificateDer::pem_slice_iter(self.ca_pem.as_bytes())
311 .next()
312 .unwrap_or(Err(rustls::pki_types::pem::Error::NoItemsFound))
313 .map_err(CertError::Chain)
314 }
315}
316
317#[derive(Debug)]
324pub struct LocalFleet {
325 authority: LocalAuthority,
326 resolver: Arc<ZoneResolver>,
327}
328
329impl LocalFleet {
330 pub fn bootstrap(
340 ca_dir: &Path,
341 leaves: Option<&Path>,
342 zones: &[String],
343 ) -> Result<Self, LocalError> {
344 let _ = rustls::crypto::ring::default_provider().install_default();
345 let provider = rustls::crypto::ring::default_provider();
346
347 for zone in zones {
348 if let Some(refusal) = crate::source::CertificateSource::Local.refusal_for(zone) {
349 return Err(LocalError::Refused(refusal));
350 }
351 }
352
353 let authority = LocalAuthority::open(ca_dir)?;
354 let mut certificates = Vec::with_capacity(zones.len());
355 for zone in zones {
356 let certified = authority.certificate_for(zone, leaves, &provider)?;
357 certificates.push((zone.clone(), SwappableCert::new(certified)));
358 }
359 let resolver = ZoneResolver::new(certificates)?;
360 tracing::info!(
361 zones = ?resolver.zones(),
362 authority = %authority.certificate_path().display(),
363 "local tls certificates ready, one pair per zone"
364 );
365 Ok(Self {
366 authority,
367 resolver,
368 })
369 }
370
371 pub fn authority(&self) -> &LocalAuthority {
373 &self.authority
374 }
375
376 pub fn server_config(&self) -> Arc<rustls::ServerConfig> {
378 let mut config = rustls::ServerConfig::builder()
379 .with_no_client_auth()
380 .with_cert_resolver(self.resolver.clone());
381 config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
382 Arc::new(config)
383 }
384}
385
386fn ca_params(now: OffsetDateTime) -> Result<CertificateParams, rcgen::Error> {
389 let mut params = CertificateParams::new(Vec::<String>::new())?;
390 params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
391 params.not_before = now - CLOCK_SKEW;
392 params.not_after = now + CA_VALIDITY;
393 params.distinguished_name = DistinguishedName::new();
394 params
395 .distinguished_name
396 .push(DnType::CommonName, CA_COMMON_NAME);
397 params.key_usages = vec![
398 KeyUsagePurpose::KeyCertSign,
399 KeyUsagePurpose::CrlSign,
400 KeyUsagePurpose::DigitalSignature,
401 ];
402 params.name_constraints = Some(NameConstraints {
403 permitted_subtrees: vec![GeneralSubtree::DnsName(PERMITTED_SUBTREE.to_owned())],
404 excluded_subtrees: Vec::new(),
405 });
406 Ok(params)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 fn provider() -> rustls::crypto::CryptoProvider {
414 rustls::crypto::ring::default_provider()
415 }
416
417 #[test]
418 fn a_second_start_reuses_the_authority_and_the_leaf() {
419 let dir = tempdir();
420 let first = LocalFleet::bootstrap(
421 &dir.path().join("ca"),
422 Some(dir.path()),
423 &["agents.localhost".to_owned()],
424 )
425 .unwrap();
426 assert!(first.authority().was_generated());
427 let first_leaf = first
428 .resolver
429 .certificate_for("agents.localhost")
430 .unwrap()
431 .cert
432 .clone();
433
434 let second = LocalFleet::bootstrap(
435 &dir.path().join("ca"),
436 Some(dir.path()),
437 &["agents.localhost".to_owned()],
438 )
439 .unwrap();
440 assert!(!second.authority().was_generated());
441 assert_eq!(
442 first.authority().certificate_path(),
443 second.authority().certificate_path()
444 );
445 assert_eq!(
446 first_leaf,
447 second
448 .resolver
449 .certificate_for("agents.localhost")
450 .unwrap()
451 .cert
452 .clone()
453 );
454 }
455
456 #[test]
457 fn a_leaf_from_a_replaced_authority_is_signed_again() {
458 let dir = tempdir();
459 let first = LocalFleet::bootstrap(
460 &dir.path().join("ca"),
461 Some(dir.path()),
462 &["agents.localhost".to_owned()],
463 )
464 .unwrap();
465 let before = first
466 .resolver
467 .certificate_for("agents.localhost")
468 .unwrap()
469 .cert
470 .clone();
471
472 std::fs::remove_file(first.authority().certificate_path()).unwrap();
475 let second = LocalFleet::bootstrap(
476 &dir.path().join("ca"),
477 Some(dir.path()),
478 &["agents.localhost".to_owned()],
479 )
480 .unwrap();
481 assert!(second.authority().was_generated());
482 assert_ne!(
483 before,
484 second
485 .resolver
486 .certificate_for("agents.localhost")
487 .unwrap()
488 .cert
489 .clone()
490 );
491 }
492
493 #[test]
494 fn a_zone_that_is_not_local_is_refused() {
495 let dir = tempdir();
496 let err = LocalFleet::bootstrap(
497 &dir.path().join("ca"),
498 Some(dir.path()),
499 &["pds.did.bot".to_owned()],
500 )
501 .unwrap_err();
502 assert!(matches!(err, LocalError::Refused(_)), "{err:?}");
503 assert!(!dir.path().join("ca").join("ca.pem").exists());
505 }
506
507 #[test]
508 fn a_leaf_covers_its_zone_and_one_label_under_it() {
509 let dir = tempdir();
510 let authority = LocalAuthority::open(&dir.path().join("ca")).unwrap();
511 let (chain, key) = authority.issue("agents.localhost").unwrap();
512 let certified = certified_key_from_pem(&chain, &key, &provider()).unwrap();
513 assert_eq!(certified.cert.len(), 2);
516 let (_, leaf) = x509_parser::parse_x509_certificate(certified.cert[0].as_ref()).unwrap();
517 let names: Vec<String> = leaf
518 .subject_alternative_name()
519 .unwrap()
520 .unwrap()
521 .value
522 .general_names
523 .iter()
524 .map(|name| name.to_string())
525 .collect();
526 assert!(names.iter().any(|n| n.contains("agents.localhost")));
527 assert!(names.iter().any(|n| n.contains("*.agents.localhost")));
528 }
529
530 fn tempdir() -> TempDir {
533 let mut path = std::env::temp_dir();
534 path.push(format!(
535 "didbot-local-tls-{}-{:?}",
536 std::process::id(),
537 std::thread::current().id()
538 ));
539 let _ = std::fs::remove_dir_all(&path);
540 std::fs::create_dir_all(&path).unwrap();
541 TempDir(path)
542 }
543
544 struct TempDir(PathBuf);
545
546 impl TempDir {
547 fn path(&self) -> &Path {
548 &self.0
549 }
550 }
551
552 impl Drop for TempDir {
553 fn drop(&mut self) {
554 let _ = std::fs::remove_dir_all(&self.0);
555 }
556 }
557}