Skip to main content

didbot_tls/
local.rs

1//! A certificate authority for a zone no public authority can issue for.
2//!
3//! A server always serves TLS, including on a developer's machine. A
4//! `.localhost` zone has no public DNS, so ACME cannot complete a challenge
5//! for it; this module is where its certificate comes from instead.
6//!
7//! The authority lives in a directory of its own, one per machine rather
8//! than one per server: a developer trusts its certificate once, and every
9//! local server on that machine then presents leaves it signed. It signs a
10//! `<zone>` + `*.<zone>` leaf for each configured zone, cached in the data
11//! directory when there is one and signed afresh at every start when there
12//! is not.
13//!
14//! # What makes this safe to put in a trust store
15//!
16//! A developer installs this certificate into their own trust store, so it
17//! can vouch for names on their machine the way a public authority vouches
18//! for names on the internet. Two things bound what it can vouch for:
19//!
20//! * The certificate carries a name constraint permitting `localhost` and
21//!   everything under it, and nothing else. A verifier that honours name
22//!   constraints — rustls, and the browsers — refuses a leaf from this
23//!   authority for any other name, so a key that leaks cannot be used to
24//!   impersonate a real site.
25//! * [`crate::source`] refuses this mode for any zone that is not under
26//!   `.localhost`, so the authority is never asked to issue outside that
27//!   constraint in the first place.
28//!
29//! The key still belongs to whoever can read the data directory, which is
30//! why it is written `0600` and why the directory is `0700`.
31
32use 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
48/// What [`CertMeta::directory_url`] records for a leaf this module signed,
49/// where an ACME leaf records the directory that issued it. No ACME
50/// directory URL can collide with it: they are absolute `https://` URLs.
51pub const LOCAL_SOURCE: &str = "local";
52
53/// The subject of the authority's own certificate, and what a developer sees
54/// in their trust store's list.
55pub const CA_COMMON_NAME: &str = "didbot local development authority";
56
57/// The one name this authority may vouch for, plus everything under it.
58/// Written into the certificate as an RFC 5280 name constraint, so a
59/// verifier enforces it rather than taking this module's word for it.
60const PERMITTED_SUBTREE: &str = "localhost";
61
62/// How long the authority's own certificate is good for.
63///
64/// Five years: long enough that trusting it is a one-time step rather than a
65/// recurring chore, short enough that a key a developer forgot about stops
66/// working eventually. A run past the expiry generates a new authority,
67/// which has to be trusted again.
68pub const CA_VALIDITY: Duration = Duration::days(5 * 365);
69
70/// How long a zone's leaf is good for. Under the 398 days browsers accept,
71/// and short enough that a leaf outlives neither the machine it was minted
72/// on nor anyone's memory of minting it.
73pub const LEAF_VALIDITY: Duration = Duration::days(90);
74
75/// How close to a leaf's expiry a start signs a fresh one rather than
76/// serving what is on disk. There is no renewal loop here: signing costs a
77/// few milliseconds and needs nothing off this machine, so a start does it
78/// whenever the answer is in doubt.
79pub const LEAF_REISSUE_BEFORE: Duration = Duration::days(30);
80
81/// Backdating on both the authority and the leaves, so a machine whose clock
82/// is a little behind the one that signed still accepts them.
83const CLOCK_SKEW: Duration = Duration::hours(1);
84
85/// Why a local certificate could not be generated, loaded or served.
86#[derive(Debug, thiserror::Error)]
87pub enum LocalError {
88    /// The data directory could not be read or written.
89    #[error("data directory: {0}")]
90    Storage(#[from] StorageError),
91    /// Generating or signing a certificate failed.
92    #[error("generating a local certificate: {0}")]
93    Generate(#[from] rcgen::Error),
94    /// A certificate this module just signed or loaded could not be turned
95    /// into something `rustls` can serve.
96    #[error("the local certificate this run signed or loaded: {0}")]
97    Cert(#[from] CertError),
98    /// The zones could not be assembled into one resolver.
99    #[error("{0}")]
100    Fleet(#[from] FleetError),
101    /// A zone this mode was asked to serve is not one it may serve. Carries
102    /// [`crate::source::CertificateSource::refusal_for`]'s sentence.
103    #[error("{0}")]
104    Refused(String),
105}
106
107/// This machine's own certificate authority, loaded from the data directory
108/// or generated into it.
109pub 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    /// The authority in `dir`, generated the first time and reused after.
127    ///
128    /// Reused means reused: a second start signs no new authority, so a
129    /// developer trusts one certificate once rather than every time they
130    /// restart a server.
131    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                    // A key that does not parse is a key nothing can sign
145                    // with, so the alternative to replacing it is refusing
146                    // to start. Loud, because every leaf signed by the old
147                    // authority stops verifying and the certificate in the
148                    // developer's trust store is now the wrong one.
149                    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    /// The authority certificate's path: the file to trust.
172    pub fn certificate_path(&self) -> PathBuf {
173        self.store.certificate_path()
174    }
175
176    /// Whether this run generated the authority rather than finding it.
177    pub fn was_generated(&self) -> bool {
178        self.generated
179    }
180
181    /// What to do with [`Self::certificate_path`], as lines to print.
182    ///
183    /// One instruction per tool that has its own trust store, because a
184    /// machine does not have one: the system store covers `curl` and
185    /// anything reading it, and the browsers and Node each keep their own.
186    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    /// A `<zone>` + `*.<zone>` certificate chain and key for `zone`, both
197    /// PEM, signed now.
198    ///
199    /// The chain is the leaf followed by the authority, the order a public
200    /// authority hands its own back in, so a client that already trusts the
201    /// authority verifies from the leaf up without holding anything else.
202    ///
203    /// A wildcard matches exactly one label, which is why a zone needs its
204    /// own certificate rather than one at the root; see [`crate::fleet`].
205    pub fn issue(&self, zone: &str) -> Result<(String, String), LocalError> {
206        self.issue_names(&[zone.to_owned(), format!("*.{zone}")])
207    }
208
209    /// A certificate for exactly `names`, for a caller that wants something
210    /// other than a zone and its wildcard.
211    ///
212    /// A wildcard needs a name to sit under: `*.localhost` is not a DNS
213    /// identifier a verifier will accept, so a server answering as
214    /// `localhost` itself asks for that one name here.
215    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    /// `zone`'s certificate: the one under `leaves` if this authority
241    /// signed it and it has life left, and a freshly signed one otherwise,
242    /// saved over it.
243    ///
244    /// `leaves` is a data directory, or `None` for a server that keeps
245    /// nothing — which then signs a leaf at every start. Signing costs
246    /// milliseconds and needs nothing off this machine, so a run with no
247    /// data directory loses nothing by it.
248    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        // Loaded before it is saved, so a pair that does not load is never
261        // written where the next start reads it.
262        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    /// The stored certificate for `zone` if this run can serve it.
278    ///
279    /// `None` — sign a new one — covers every way it could be unusable, and
280    /// they are not distinguished because the answer to all of them is the
281    /// same: nothing saved, a set a crash left incomplete, a leaf from an
282    /// ACME directory or from an authority this one replaced, one that does
283    /// not load, and one close enough to expiry that this run would outlive
284    /// it.
285    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        // The authority at the end of the stored chain, against this run's:
298        // a leaf whose authority was replaced verifies against nothing a
299        // developer has trusted.
300        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    /// The authority's certificate as DER, for comparing a stored chain's
308    /// last entry against.
309    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/// Every zone served from [`LocalAuthority`], behind the one SNI-dispatching
318/// resolver a multi-zone deployment uses for ACME certificates.
319///
320/// No renewal loop: a certificate is signed at start whenever what is on
321/// disk will not last, which is all a certificate nothing but this machine
322/// trusts needs.
323#[derive(Debug)]
324pub struct LocalFleet {
325    authority: LocalAuthority,
326    resolver: Arc<ZoneResolver>,
327}
328
329impl LocalFleet {
330    /// Loads or signs a certificate for every zone in `zones`, refusing any
331    /// zone [`crate::source`] does not put under this authority.
332    ///
333    /// `ca_dir` holds the authority itself — one per machine — and `leaves`
334    /// is the data directory the signed certificates are cached in, or
335    /// `None` for a server that keeps nothing.
336    ///
337    /// The first entry in `zones` is the primary: the certificate a
338    /// `ClientHello` with no server name gets.
339    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    /// The authority behind these certificates, for printing its path.
372    pub fn authority(&self) -> &LocalAuthority {
373        &self.authority
374    }
375
376    /// The one `rustls::ServerConfig` this process serves with.
377    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
386/// The authority certificate's parameters, fixed so that a later start
387/// rebuilds the same issuer from the saved key alone.
388fn 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        // What a developer who deleted half the data directory leaves
473        // behind: the zone's leaf, signed by an authority no longer there.
474        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        // Nothing was generated for a zone this mode refuses.
504        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        // Leaf then authority: what a client verifying from the leaf up
514        // reads, and what tells a later start which authority signed this.
515        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    /// A directory that removes itself, so a test that signs certificates
531    /// leaves nothing behind.
532    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}