diff --git a/Cargo.lock b/Cargo.lock index f52a3eeb..e38ccdd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1565,6 +1565,7 @@ dependencies = [ "bytes", "didbot-dns", "didbot-http", + "didbot-identity", "http", "http-body-util", "instant-acme", diff --git a/crates/didbot-tls/Cargo.toml b/crates/didbot-tls/Cargo.toml index 3b3303d4..c141bdc9 100644 --- a/crates/didbot-tls/Cargo.toml +++ b/crates/didbot-tls/Cargo.toml @@ -14,9 +14,15 @@ axum-server.workspace = true bytes.workspace = true didbot-dns.workspace = true didbot-http.workspace = true +# The one place a zone's certificate source is decided reads the same +# loopback-host rule the rest of the workspace mints and resolves by. +didbot-identity.workspace = true http.workspace = true http-body-util = "0.1" instant-acme.workspace = true +# Generating the local authority and signing its leaves. A well-supported +# certificate generator rather than anything hand-rolled here. +rcgen.workspace = true reqwest.workspace = true rustls.workspace = true serde.workspace = true @@ -30,7 +36,6 @@ x509-parser.workspace = true [dev-dependencies] didbot-http = { workspace = true, features = ["test-support"] } -rcgen.workspace = true # The in-process AWS stand-in, so a DNS-01 order can be driven against a real # `Route53Dns` over a hosted zone that refuses the challenge write. didbot-dns = { workspace = true, features = ["aws-fake"] } diff --git a/crates/didbot-tls/src/lib.rs b/crates/didbot-tls/src/lib.rs index c4324d59..264bcc92 100644 --- a/crates/didbot-tls/src/lib.rs +++ b/crates/didbot-tls/src/lib.rs @@ -19,6 +19,11 @@ //! be surfaced, as pure functions of a supplied clock. //! * [`storage`] -- the certificate, its key and the ACME account key on //! disk, `0600` inside `0700`. +//! * [`source`] -- which authority a zone's certificate comes from. One +//! table, so ACME and the local authority can never both apply to a zone. +//! * [`local`] -- the authority a developer's machine trusts, for a zone no +//! public authority can issue for. Read this module for what bounds a +//! certificate that sits in a trust store. //! * [`fleet`] -- one [`manager::CertificateManager`] per configured zone, //! and the SNI dispatch between their certificates. Read this module for //! why a deployment cannot have one certificate. @@ -47,9 +52,11 @@ pub mod directory; pub mod dns; pub mod fleet; pub mod http_client; +pub mod local; pub mod manager; pub mod renew; pub mod resolver; +pub mod source; pub mod storage; #[cfg(test)] diff --git a/crates/didbot-tls/src/local.rs b/crates/didbot-tls/src/local.rs new file mode 100644 index 00000000..4fc314ae --- /dev/null +++ b/crates/didbot-tls/src/local.rs @@ -0,0 +1,543 @@ +//! A certificate authority for a zone no public authority can issue for. +//! +//! A server always serves TLS, including on a developer's machine. A +//! `.localhost` zone has no public DNS, so ACME cannot complete a challenge +//! for it; this module is where its certificate comes from instead. +//! +//! The authority lives in a directory of its own, one per machine rather +//! than one per server: a developer trusts its certificate once, and every +//! local server on that machine then presents leaves it signed. It signs a +//! `` + `*.` leaf for each configured zone, cached in the data +//! directory when there is one and signed afresh at every start when there +//! is not. +//! +//! # What makes this safe to put in a trust store +//! +//! A developer installs this certificate into their own trust store, so it +//! can vouch for names on their machine the way a public authority vouches +//! for names on the internet. Two things bound what it can vouch for: +//! +//! * The certificate carries a name constraint permitting `localhost` and +//! everything under it, and nothing else. A verifier that honours name +//! constraints — rustls, and the browsers — refuses a leaf from this +//! authority for any other name, so a key that leaks cannot be used to +//! impersonate a real site. +//! * [`crate::source`] refuses this mode for any zone that is not under +//! `.localhost`, so the authority is never asked to issue outside that +//! constraint in the first place. +//! +//! The key still belongs to whoever can read the data directory, which is +//! why it is written `0600` and why the directory is `0700`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rcgen::{ + BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, + GeneralSubtree, IsCa, Issuer, KeyPair, KeyUsagePurpose, NameConstraints, +}; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::CertificateDer; +use time::{Duration, OffsetDateTime}; + +use crate::cert::{certified_key_from_pem, leaf_expiry, CertError}; +use crate::fleet::{FleetError, ZoneResolver}; +use crate::resolver::SwappableCert; +use crate::storage::{CaStore, CertMeta, CertStore, StorageError}; + +/// What [`CertMeta::directory_url`] records for a leaf this module signed, +/// where an ACME leaf records the directory that issued it. No ACME +/// directory URL can collide with it: they are absolute `https://` URLs. +pub const LOCAL_SOURCE: &str = "local"; + +/// The subject of the authority's own certificate, and what a developer sees +/// in their trust store's list. +pub const CA_COMMON_NAME: &str = "didbot local development authority"; + +/// The one name this authority may vouch for, plus everything under it. +/// Written into the certificate as an RFC 5280 name constraint, so a +/// verifier enforces it rather than taking this module's word for it. +const PERMITTED_SUBTREE: &str = "localhost"; + +/// How long the authority's own certificate is good for. +/// +/// Five years: long enough that trusting it is a one-time step rather than a +/// recurring chore, short enough that a key a developer forgot about stops +/// working eventually. A run past the expiry generates a new authority, +/// which has to be trusted again. +pub const CA_VALIDITY: Duration = Duration::days(5 * 365); + +/// How long a zone's leaf is good for. Under the 398 days browsers accept, +/// and short enough that a leaf outlives neither the machine it was minted +/// on nor anyone's memory of minting it. +pub const LEAF_VALIDITY: Duration = Duration::days(90); + +/// How close to a leaf's expiry a start signs a fresh one rather than +/// serving what is on disk. There is no renewal loop here: signing costs a +/// few milliseconds and needs nothing off this machine, so a start does it +/// whenever the answer is in doubt. +pub const LEAF_REISSUE_BEFORE: Duration = Duration::days(30); + +/// Backdating on both the authority and the leaves, so a machine whose clock +/// is a little behind the one that signed still accepts them. +const CLOCK_SKEW: Duration = Duration::hours(1); + +/// Why a local certificate could not be generated, loaded or served. +#[derive(Debug, thiserror::Error)] +pub enum LocalError { + /// The data directory could not be read or written. + #[error("data directory: {0}")] + Storage(#[from] StorageError), + /// Generating or signing a certificate failed. + #[error("generating a local certificate: {0}")] + Generate(#[from] rcgen::Error), + /// A certificate this module just signed or loaded could not be turned + /// into something `rustls` can serve. + #[error("the local certificate this run signed or loaded: {0}")] + Cert(#[from] CertError), + /// The zones could not be assembled into one resolver. + #[error("{0}")] + Fleet(#[from] FleetError), + /// A zone this mode was asked to serve is not one it may serve. Carries + /// [`crate::source::CertificateSource::refusal_for`]'s sentence. + #[error("{0}")] + Refused(String), +} + +/// This machine's own certificate authority, loaded from the data directory +/// or generated into it. +pub struct LocalAuthority { + store: CaStore, + issuer: Issuer<'static, KeyPair>, + ca_pem: String, + generated: bool, +} + +impl std::fmt::Debug for LocalAuthority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LocalAuthority") + .field("certificate", &self.certificate_path()) + .field("generated", &self.generated) + .finish_non_exhaustive() + } +} + +impl LocalAuthority { + /// The authority in `dir`, generated the first time and reused after. + /// + /// Reused means reused: a second start signs no new authority, so a + /// developer trusts one certificate once rather than every time they + /// restart a server. + pub fn open(dir: &Path) -> Result { + let store = CaStore::open(dir)?; + if let Some((ca_pem, key_pem)) = store.load()? { + match KeyPair::from_pem(&key_pem) { + Ok(key) => { + return Ok(Self { + issuer: Issuer::new(ca_params(OffsetDateTime::now_utc())?, key), + ca_pem, + store, + generated: false, + }) + } + Err(error) => { + // A key that does not parse is a key nothing can sign + // with, so the alternative to replacing it is refusing + // to start. Loud, because every leaf signed by the old + // authority stops verifying and the certificate in the + // developer's trust store is now the wrong one. + tracing::warn!( + %error, + path = %store.certificate_path().display(), + "the local authority's key does not parse; generating a new authority, \ + which has to be trusted again" + ); + } + } + } + + let key = KeyPair::generate()?; + let params = ca_params(OffsetDateTime::now_utc())?; + let ca_pem = params.self_signed(&key)?.pem(); + store.save(&ca_pem, &key.serialize_pem())?; + Ok(Self { + issuer: Issuer::new(params, key), + ca_pem, + store, + generated: true, + }) + } + + /// The authority certificate's path: the file to trust. + pub fn certificate_path(&self) -> PathBuf { + self.store.certificate_path() + } + + /// Whether this run generated the authority rather than finding it. + pub fn was_generated(&self) -> bool { + self.generated + } + + /// What to do with [`Self::certificate_path`], as lines to print. + /// + /// One instruction per tool that has its own trust store, because a + /// machine does not have one: the system store covers `curl` and + /// anything reading it, and the browsers and Node each keep their own. + pub fn trust_instructions(&self) -> Vec { + let path = self.certificate_path(); + let path = path.display(); + vec![ + format!("sudo cp {path} /usr/local/share/ca-certificates/didbot-local.crt && sudo update-ca-certificates"), + format!("certutil -d sql:$HOME/.pki/nssdb -A -t C,, -n didbot-local -i {path}"), + format!("export DIDBOT_EXTRA_CA_CERTS={path} NODE_EXTRA_CA_CERTS={path}"), + ] + } + + /// A `` + `*.` certificate chain and key for `zone`, both + /// PEM, signed now. + /// + /// The chain is the leaf followed by the authority, the order a public + /// authority hands its own back in, so a client that already trusts the + /// authority verifies from the leaf up without holding anything else. + pub fn issue(&self, zone: &str) -> Result<(String, String), LocalError> { + let mut params = CertificateParams::new(vec![zone.to_owned(), format!("*.{zone}")])?; + let now = OffsetDateTime::now_utc(); + params.not_before = now - CLOCK_SKEW; + params.not_after = now + LEAF_VALIDITY; + params.distinguished_name = DistinguishedName::new(); + params + .distinguished_name + .push(DnType::CommonName, zone.to_owned()); + params.use_authority_key_identifier_extension = true; + params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + + let key = KeyPair::generate()?; + let leaf = params.signed_by(&key, &self.issuer)?; + Ok(( + format!("{}{}", leaf.pem(), self.ca_pem), + key.serialize_pem(), + )) + } + + /// `zone`'s certificate: the one under `leaves` if this authority + /// signed it and it has life left, and a freshly signed one otherwise, + /// saved over it. + /// + /// `leaves` is a data directory, or `None` for a server that keeps + /// nothing — which then signs a leaf at every start. Signing costs + /// milliseconds and needs nothing off this machine, so a run with no + /// data directory loses nothing by it. + fn certificate_for( + &self, + zone: &str, + leaves: Option<&Path>, + provider: &rustls::crypto::CryptoProvider, + ) -> Result { + if let Some(certified) = leaves.and_then(|dir| self.stored_certificate(zone, dir, provider)) + { + tracing::debug!(zone, "reusing the local certificate on disk"); + return Ok(certified); + } + let (chain_pem, key_pem) = self.issue(zone)?; + // Loaded before it is saved, so a pair that does not load is never + // written where the next start reads it. + let certified = certified_key_from_pem(&chain_pem, &key_pem, provider)?; + if let Some(dir) = leaves { + CertStore::open_zone(dir, zone)?.save_certificate( + &chain_pem, + &key_pem, + &CertMeta { + directory_url: LOCAL_SOURCE.to_owned(), + issued_at: OffsetDateTime::now_utc(), + }, + )?; + } + tracing::info!(zone, "signed a local certificate for this zone and *.zone"); + Ok(certified) + } + + /// The stored certificate for `zone` if this run can serve it. + /// + /// `None` — sign a new one — covers every way it could be unusable, and + /// they are not distinguished because the answer to all of them is the + /// same: nothing saved, a set a crash left incomplete, a leaf from an + /// ACME directory or from an authority this one replaced, one that does + /// not load, and one close enough to expiry that this run would outlive + /// it. + fn stored_certificate( + &self, + zone: &str, + leaves: &Path, + provider: &rustls::crypto::CryptoProvider, + ) -> Option { + let store = CertStore::open_zone(leaves, zone).ok()?; + let (chain_pem, key_pem, meta) = store.certificate().ok()??; + if meta.directory_url != LOCAL_SOURCE { + return None; + } + let certified = certified_key_from_pem(&chain_pem, &key_pem, provider).ok()?; + // The authority at the end of the stored chain, against this run's: + // a leaf whose authority was replaced verifies against nothing a + // developer has trusted. + if certified.cert.last()? != &self.certificate_der().ok()? { + return None; + } + let expiry = leaf_expiry(&certified.cert).ok()?; + (expiry - OffsetDateTime::now_utc() > LEAF_REISSUE_BEFORE).then_some(certified) + } + + /// The authority's certificate as DER, for comparing a stored chain's + /// last entry against. + fn certificate_der(&self) -> Result, CertError> { + CertificateDer::pem_slice_iter(self.ca_pem.as_bytes()) + .next() + .unwrap_or(Err(rustls::pki_types::pem::Error::NoItemsFound)) + .map_err(CertError::Chain) + } +} + +/// Every zone served from [`LocalAuthority`], behind the one SNI-dispatching +/// resolver a multi-zone deployment uses for ACME certificates. +/// +/// No renewal loop: a certificate is signed at start whenever what is on +/// disk will not last, which is all a certificate nothing but this machine +/// trusts needs. +#[derive(Debug)] +pub struct LocalFleet { + authority: LocalAuthority, + resolver: Arc, +} + +impl LocalFleet { + /// Loads or signs a certificate for every zone in `zones`, refusing any + /// zone [`crate::source`] does not put under this authority. + /// + /// `ca_dir` holds the authority itself — one per machine — and `leaves` + /// is the data directory the signed certificates are cached in, or + /// `None` for a server that keeps nothing. + /// + /// The first entry in `zones` is the primary: the certificate a + /// `ClientHello` with no server name gets. + pub fn bootstrap( + ca_dir: &Path, + leaves: Option<&Path>, + zones: &[String], + ) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let provider = rustls::crypto::ring::default_provider(); + + for zone in zones { + if let Some(refusal) = crate::source::CertificateSource::Local.refusal_for(zone) { + return Err(LocalError::Refused(refusal)); + } + } + + let authority = LocalAuthority::open(ca_dir)?; + let mut certificates = Vec::with_capacity(zones.len()); + for zone in zones { + let certified = authority.certificate_for(zone, leaves, &provider)?; + certificates.push((zone.clone(), SwappableCert::new(certified))); + } + let resolver = ZoneResolver::new(certificates)?; + tracing::info!( + zones = ?resolver.zones(), + authority = %authority.certificate_path().display(), + "local tls certificates ready, one pair per zone" + ); + Ok(Self { + authority, + resolver, + }) + } + + /// The authority behind these certificates, for printing its path. + pub fn authority(&self) -> &LocalAuthority { + &self.authority + } + + /// The one `rustls::ServerConfig` this process serves with. + pub fn server_config(&self) -> Arc { + let mut config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(self.resolver.clone()); + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + Arc::new(config) + } +} + +/// The authority certificate's parameters, fixed so that a later start +/// rebuilds the same issuer from the saved key alone. +fn ca_params(now: OffsetDateTime) -> Result { + let mut params = CertificateParams::new(Vec::::new())?; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.not_before = now - CLOCK_SKEW; + params.not_after = now + CA_VALIDITY; + params.distinguished_name = DistinguishedName::new(); + params + .distinguished_name + .push(DnType::CommonName, CA_COMMON_NAME); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::CrlSign, + KeyUsagePurpose::DigitalSignature, + ]; + params.name_constraints = Some(NameConstraints { + permitted_subtrees: vec![GeneralSubtree::DnsName(PERMITTED_SUBTREE.to_owned())], + excluded_subtrees: Vec::new(), + }); + Ok(params) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn provider() -> rustls::crypto::CryptoProvider { + rustls::crypto::ring::default_provider() + } + + #[test] + fn a_second_start_reuses_the_authority_and_the_leaf() { + let dir = tempdir(); + let first = LocalFleet::bootstrap( + &dir.path().join("ca"), + Some(dir.path()), + &["agents.localhost".to_owned()], + ) + .unwrap(); + assert!(first.authority().was_generated()); + let first_leaf = first + .resolver + .certificate_for("agents.localhost") + .unwrap() + .cert + .clone(); + + let second = LocalFleet::bootstrap( + &dir.path().join("ca"), + Some(dir.path()), + &["agents.localhost".to_owned()], + ) + .unwrap(); + assert!(!second.authority().was_generated()); + assert_eq!( + first.authority().certificate_path(), + second.authority().certificate_path() + ); + assert_eq!( + first_leaf, + second + .resolver + .certificate_for("agents.localhost") + .unwrap() + .cert + .clone() + ); + } + + #[test] + fn a_leaf_from_a_replaced_authority_is_signed_again() { + let dir = tempdir(); + let first = LocalFleet::bootstrap( + &dir.path().join("ca"), + Some(dir.path()), + &["agents.localhost".to_owned()], + ) + .unwrap(); + let before = first + .resolver + .certificate_for("agents.localhost") + .unwrap() + .cert + .clone(); + + // What a developer who deleted half the data directory leaves + // behind: the zone's leaf, signed by an authority no longer there. + std::fs::remove_file(first.authority().certificate_path()).unwrap(); + let second = LocalFleet::bootstrap( + &dir.path().join("ca"), + Some(dir.path()), + &["agents.localhost".to_owned()], + ) + .unwrap(); + assert!(second.authority().was_generated()); + assert_ne!( + before, + second + .resolver + .certificate_for("agents.localhost") + .unwrap() + .cert + .clone() + ); + } + + #[test] + fn a_zone_that_is_not_local_is_refused() { + let dir = tempdir(); + let err = LocalFleet::bootstrap( + &dir.path().join("ca"), + Some(dir.path()), + &["pds.did.bot".to_owned()], + ) + .unwrap_err(); + assert!(matches!(err, LocalError::Refused(_)), "{err:?}"); + // Nothing was generated for a zone this mode refuses. + assert!(!dir.path().join("ca").join("ca.pem").exists()); + } + + #[test] + fn a_leaf_covers_its_zone_and_one_label_under_it() { + let dir = tempdir(); + let authority = LocalAuthority::open(&dir.path().join("ca")).unwrap(); + let (chain, key) = authority.issue("agents.localhost").unwrap(); + let certified = certified_key_from_pem(&chain, &key, &provider()).unwrap(); + // Leaf then authority: what a client verifying from the leaf up + // reads, and what tells a later start which authority signed this. + assert_eq!(certified.cert.len(), 2); + let (_, leaf) = x509_parser::parse_x509_certificate(certified.cert[0].as_ref()).unwrap(); + let names: Vec = leaf + .subject_alternative_name() + .unwrap() + .unwrap() + .value + .general_names + .iter() + .map(|name| name.to_string()) + .collect(); + assert!(names.iter().any(|n| n.contains("agents.localhost"))); + assert!(names.iter().any(|n| n.contains("*.agents.localhost"))); + } + + /// A directory that removes itself, so a test that signs certificates + /// leaves nothing behind. + fn tempdir() -> TempDir { + let mut path = std::env::temp_dir(); + path.push(format!( + "didbot-local-tls-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + TempDir(path) + } + + struct TempDir(PathBuf); + + impl TempDir { + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } +} diff --git a/crates/didbot-tls/src/source.rs b/crates/didbot-tls/src/source.rs new file mode 100644 index 00000000..01deeadb --- /dev/null +++ b/crates/didbot-tls/src/source.rs @@ -0,0 +1,87 @@ +//! Which authority a zone's certificate comes from. +//! +//! A server always serves TLS. Where the certificate comes from is decided +//! by the zone hostname alone, and decided here: a zone under `.localhost` +//! has no public DNS, so no ACME challenge can complete for it and it is +//! served from the local authority in [`crate::local`]; every other zone is +//! served from ACME. The two never overlap, because this is the only place +//! that chooses between them. + +use didbot_identity::did::is_loopback_host; + +/// Where a zone's certificate is issued. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CertificateSource { + /// A public authority, over ACME DNS-01. See [`crate::acme`]. + Acme, + /// This machine's own authority, kept in the data directory. See + /// [`crate::local`]. + Local, +} + +impl CertificateSource { + /// The source `zone` is served from. + pub fn for_zone(zone: &str) -> Self { + if is_loopback_host(zone) { + Self::Local + } else { + Self::Acme + } + } + + /// The spelling a caller passes on a command line or writes in a config + /// file. + pub fn as_str(self) -> &'static str { + match self { + Self::Acme => "acme", + Self::Local => "local", + } + } + + /// Why `zone` cannot be served from this source, or `None` if it can. + /// + /// One sentence per direction, both naming the source the zone does + /// take, because a refusal that does not say what to pass instead makes + /// the reader guess. + pub fn refusal_for(self, zone: &str) -> Option { + let allowed = Self::for_zone(zone); + if allowed == self { + return None; + } + Some(match self { + Self::Acme => format!( + "--tls acme needs a real zone; --zone {zone} is under .localhost, which no \ + DNS-01 challenge can complete against. Pass --tls local, which is what a \ + .localhost zone is served from." + ), + Self::Local => format!( + "--tls local serves a certificate only this machine trusts; --zone {zone} is a \ + real zone, so pass --tls acme and let a public authority issue for it." + ), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_zone_has_exactly_one_source() { + for zone in [ + "agents.localhost", + "localhost", + "opzone.localhost", + "127.0.0.1", + ] { + assert_eq!(CertificateSource::for_zone(zone), CertificateSource::Local); + assert!(CertificateSource::Local.refusal_for(zone).is_none()); + assert!(CertificateSource::Acme.refusal_for(zone).is_some()); + } + for zone in ["pds.did.bot", "agents.example.com", "localhost.example"] { + assert_eq!(CertificateSource::for_zone(zone), CertificateSource::Acme); + assert!(CertificateSource::Acme.refusal_for(zone).is_none()); + assert!(CertificateSource::Local.refusal_for(zone).is_some()); + } + } +} diff --git a/crates/didbot-tls/src/storage.rs b/crates/didbot-tls/src/storage.rs index 2b937c7d..ea3163b6 100644 --- a/crates/didbot-tls/src/storage.rs +++ b/crates/didbot-tls/src/storage.rs @@ -21,6 +21,8 @@ const FILE_MODE: u32 = 0o600; const DIR_MODE: u32 = 0o700; const ACCOUNT_FILE: &str = "acme-account.json"; +const CA_FILE: &str = "ca.pem"; +const CA_KEY_FILE: &str = "ca-key.pem"; const CERT_FILE: &str = "cert.pem"; const KEY_FILE: &str = "cert-key.pem"; const META_FILE: &str = "meta.json"; @@ -81,9 +83,12 @@ pub struct CertStore { /// from what the certificate actually says. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct CertMeta { - /// The ACME directory URL this certificate was issued against, mostly so - /// a staging certificate is never mistaken for a production one just - /// because it is sitting in the data directory. + /// Which authority issued this certificate: an ACME directory URL, or + /// [`crate::local::LOCAL_SOURCE`]. A certificate is only as good as the + /// authority behind it — a staging leaf, a production leaf and a leaf + /// from this machine's own authority are served by the same code and + /// trusted by different people — so the run that reads one back checks + /// this before serving it. pub directory_url: String, /// When this crate last wrote a certificate here. #[serde(with = "time::serde::rfc3339")] @@ -259,6 +264,71 @@ impl CertStore { } } +/// The local certificate authority's own certificate and key, in a +/// directory of their own. +/// +/// One directory per machine rather than per data directory: a developer +/// trusts this certificate once, and a second server started with a second +/// `--data` must present leaves the same trusted authority signed. +#[derive(Debug, Clone)] +pub struct CaStore { + dir: PathBuf, +} + +impl CaStore { + /// Opens `dir`, creating it `0700` if absent and tightening it if it + /// already existed. + pub fn open(dir: &Path) -> Result { + create_dir(dir)?; + Ok(Self { + dir: dir.to_owned(), + }) + } + + /// Where the certificate sits, whether or not it has been generated + /// yet. This is the file a developer trusts, so it has a path even + /// before it has contents. + pub fn certificate_path(&self) -> PathBuf { + self.dir.join(CA_FILE) + } + + /// The authority last saved here, as `(certificate, key)`, both PEM. + /// + /// A key with no certificate, or the reverse, is `None`: the pair is + /// useless by halves, and the caller's answer to both is the same. + pub fn load(&self) -> Result, StorageError> { + let cert_path = self.certificate_path(); + let key_path = self.dir.join(CA_KEY_FILE); + if !cert_path.is_file() || !key_path.is_file() { + return Ok(None); + } + Ok(Some(( + read_string_at(&cert_path)?, + read_string_at(&key_path)?, + ))) + } + + /// Persists the authority's certificate and key, both `0600`. + /// + /// The key is committed first and the certificate last, so a crash + /// between them leaves a key with no certificate — which [`Self::load`] + /// reads as nothing saved, and the next start replaces. + pub fn save(&self, cert_pem: &str, key_pem: &str) -> Result<(), StorageError> { + let key = Staged::new(&self.dir.join(CA_KEY_FILE), key_pem.as_bytes())?; + let cert = Staged::new(&self.certificate_path(), cert_pem.as_bytes())?; + key.commit()?; + cert.commit() + } +} + +/// Reads a file this crate wrote, naming it if it cannot be read. +fn read_string_at(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| StorageError::Io { + path: path.to_owned(), + source, + }) +} + /// Serialises `value` and writes it `0600` at `path`, replacing whatever was /// there in one step. fn write_json_at(path: &Path, value: &T) -> Result<(), StorageError> { diff --git a/crates/didbot-tls/tests/local_authority.rs b/crates/didbot-tls/tests/local_authority.rs new file mode 100644 index 00000000..3711d8a4 --- /dev/null +++ b/crates/didbot-tls/tests/local_authority.rs @@ -0,0 +1,142 @@ +//! The local authority through a real handshake: a client that trusts the +//! authority and nothing else, against the certificate a `.localhost` zone +//! actually serves. +//! +//! The unit tests in `src/local.rs` cover what is on disk. This covers what +//! a client sees, which is the part a developer's browser and every Rust +//! client in this workspace depend on: the leaf verifies for the zone and +//! for an account one label under it, and the authority's name constraint +//! refuses everything else. + +use std::sync::Arc; + +use didbot_tls::cert::certified_key_from_pem; +use didbot_tls::local::{LocalAuthority, LocalFleet}; +use rustls::pki_types::pem::PemObject; +use rustls::pki_types::{CertificateDer, ServerName}; +use rustls::{ClientConfig, RootCertStore, ServerConfig}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// A client that trusts `ca_pem` and no other authority. +fn client_trusting(ca_pem: &str) -> Arc { + let mut roots = RootCertStore::empty(); + for cert in CertificateDer::pem_slice_iter(ca_pem.as_bytes()) { + roots + .add(cert.expect("the authority's own certificate parses")) + .expect("and loads as a root"); + } + Arc::new( + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .expect("the ring provider supports the default versions") + .with_root_certificates(roots) + .with_no_client_auth(), + ) +} + +/// Serves `config` once on loopback and answers `pong`, returning whether +/// the client named `sni` completed its handshake. +async fn handshake(config: Arc, client: Arc, sni: &str) -> bool { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback binds"); + let addr = listener.local_addr().expect("the bound address reads back"); + let acceptor = tokio_rustls::TlsAcceptor::from(config); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("one connection arrives"); + if let Ok(mut tls) = acceptor.accept(stream).await { + let _ = tls.write_all(b"pong").await; + let _ = tls.shutdown().await; + } + }); + + let name = ServerName::try_from(sni.to_owned()).expect("a valid server name"); + let stream = TcpStream::connect(addr) + .await + .expect("the listener answers"); + let connected = tokio_rustls::TlsConnector::from(client) + .connect(name, stream) + .await; + let answered = match connected { + Ok(mut tls) => { + let mut body = Vec::new(); + tls.read_to_end(&mut body).await.is_ok() && body == b"pong" + } + Err(_) => false, + }; + server.await.expect("the server task finishes"); + answered +} + +/// A data directory that removes itself. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!("didbot-local-authority-{name}")); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("a scratch directory"); + Self(path) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn an_account_one_label_under_the_zone_verifies_against_the_authority() { + let scratch = Scratch::new("covers"); + let fleet = LocalFleet::bootstrap( + &scratch.0.join("ca"), + Some(&scratch.0), + &["agents.localhost".to_owned()], + ) + .expect("a .localhost zone is served from the local authority"); + let ca_pem = std::fs::read_to_string(fleet.authority().certificate_path()) + .expect("the authority's certificate was written"); + let client = client_trusting(&ca_pem); + + assert!( + handshake(fleet.server_config(), client.clone(), "agents.localhost").await, + "the zone itself" + ); + assert!( + handshake(fleet.server_config(), client, "kestrel.agents.localhost").await, + "an account one label under the zone" + ); +} + +#[tokio::test] +async fn the_authority_cannot_vouch_for_a_name_outside_localhost() { + let scratch = Scratch::new("constrained"); + let authority = LocalAuthority::open(&scratch.0.join("ca")).expect("an authority is generated"); + let ca_pem = std::fs::read_to_string(authority.certificate_path()) + .expect("the authority's certificate was written"); + + // Signed directly, bypassing the refusal in `LocalFleet::bootstrap`: + // the point is that a leaf for a real name does not verify even when + // this authority signs it, so a key that leaks cannot impersonate one. + let (chain, key) = authority + .issue("agents.example.com") + .expect("rcgen signs whatever it is asked to"); + let provider = rustls::crypto::ring::default_provider(); + let certified = certified_key_from_pem(&chain, &key, &provider).expect("the pair loads"); + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_cert_resolver(didbot_tls::resolver::SwappableCert::new(certified)); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + assert!( + !handshake( + Arc::new(config), + client_trusting(&ca_pem), + "agents.example.com" + ) + .await, + "the authority's name constraint refuses a leaf outside .localhost" + ); +}