Skip to main content

dynamic_config_server/
tls.rs

1//! TLS termination, and the client certificate that goes with it.
2//!
3//! This crate used to terminate no TLS at all, on the grounds that a second
4//! TLS stack doubles the CVE surface of a program whose job is holding other
5//! people's secrets and that every target deployment already has a
6//! terminator. The first half of that is still true and is why TLS is
7//! **opt-in twice** — a Cargo feature and a `[server.tls]` block — but the
8//! second half was never a rule about deployments, only about the ones that
9//! had been looked at. A server on a machine with no ingress, and a
10//! deployment that wants the config server's own socket to demand a client
11//! certificate, are both real, and neither is served by an answer that lives
12//! in somebody else's process.
13//!
14//! # What a client certificate is here
15//!
16//! **A second gate, not a second identity.** A caller that presents a
17//! certificate signed by the configured CA gets a TCP connection and nothing
18//! else: it is still nobody until it presents a bearer token, and the token
19//! is still what names it in the audit log and what its grants hang off.
20//!
21//! The two rejected alternatives are worth stating, because both are
22//! defensible and only one of these three can be in the code:
23//!
24//! - **A certificate *instead* of a token.** That makes the certificate a
25//!   way to bypass the token, which is the opposite of what a second factor
26//!   is for, and it moves authorisation onto a subject name — a string
27//!   issued by whoever holds the CA key, which is frequently not whoever
28//!   maintains this server's roster.
29//! - **A certificate that *names* a client** (subject → principal). One
30//!   identity, two spellings, and a second roster to keep in step with the
31//!   first. Worse, it makes the CA an authorisation authority: anybody who
32//!   can get a certificate with `CN=billing-pod` out of it reads `billing`,
33//!   and CAs are asked for certificates by processes that have never heard
34//!   of this server's grants.
35//!
36//! So the certificate says *this connection came from a machine the
37//! deployment provisioned*, and the token says *this caller may read
38//! `billing`*. Two independent facts, both required, neither able to stand
39//! in for the other. Nothing in the router changes because of TLS,
40//! and that is the property to preserve: a request that reached a handler
41//! is authorised exactly as it was before.
42//!
43//! # Posture
44//!
45//! Not invented here. The protocol versions are rustls's
46//! `with_safe_default_protocol_versions` and the cipher suites and key
47//! exchange groups are the `ring` provider's defaults, in the provider's own
48//! preference order. This module chooses no suite, disables no version and
49//! reorders nothing — the whole reason to use rustls is that these decisions
50//! are made by people who track them, and a hand-picked list here would be
51//! this crate's opinion frozen at the day it was written.
52//!
53//! The one thing it does set is ALPN: `http/1.1`, and only that, because
54//! axum is compiled here with `http1` alone. A client that negotiated `h2`
55//! against a server that cannot speak it is a connection that fails after
56//! the handshake instead of during it.
57//!
58//! # Revocation, and why there is none
59//!
60//! A certificate that chains to `client_ca` is good until it expires. This
61//! module configures no CRL, and `[server.tls] crl` is a startup refusal
62//! ([`Refusal::RevocationUnsupported`](crate::Refusal::RevocationUnsupported))
63//! rather than a key that is read — because a decorative revocation check is
64//! worse than an acknowledged absence, and this one would be decorative.
65//!
66//! rustls has the machinery, and it is about twenty lines:
67//! `ClientCertVerifierBuilder::with_crls`, a revocation-check depth and an
68//! unknown-status policy. What sank it is not the code but the freshness,
69//! and both halves were measured rather than assumed (the measurement is
70//! `tests/tls.rs::the_measurement_behind_refusing_revocation_still_holds`,
71//! which fails if either default moves):
72//!
73//! - **By default a stale CRL is used, silently.** rustls's
74//!   `ExpirationPolicy::Ignore` means a list whose `nextUpdate` passed in
75//!   2020 still verifies a handshake in 2026 with no error, no warning and
76//!   nothing in any log. So the naive build is a server that reports it
77//!   checks revocation and, from whenever the file stopped being refreshed,
78//!   does not. Worse, it *tests green*: the obvious test — revoke a
79//!   certificate, assert the handshake fails — passes against a six-year-old
80//!   list, because revocation itself keeps working. Only freshness rots, and
81//!   nothing observes it.
82//! - **The switch that fixes that breaks everything else.**
83//!   `enforce_revocation_expiration` refuses a stale list — and refuses every
84//!   *clean, unrevoked* client along with it, for as long as it is stale.
85//!   That makes the CA's publishing cadence a liveness dependency of every
86//!   service's configuration, in the one program a fleet cannot fetch
87//!   configuration without.
88//!
89//! The obvious escape is to re-read the file on the same watcher the
90//! sections use, and it does not work: a watcher fires on a **write**, and
91//! the failure to catch is the *absence* of one. No filesystem event says
92//! "this should have been rewritten an hour ago". Catching that needs a
93//! clock — a periodic wake-up — which is the polling loop this crate does
94//! not have and whose absence is a stated property of it. An HTTP
95//! distribution point is a fetch loop, and OCSP is a second protocol and a
96//! third dependency.
97//!
98//! What settles it is that the certificate is a **gate, not an identity**. A
99//! stolen certificate on its own buys a TCP connection and a 401; reading
100//! anything needs the bearer token. So a CRL here would revoke the credential
101//! that does not authorise, on a schedule this server cannot verify, while
102//! the credential that *does* authorise is a line in a file the operator
103//! already controls — deleted and restarted in seconds, with no CA, no
104//! cadence and no new way to fail. Issue short-lived certificates; revoke the
105//! token.
106
107use std::fmt;
108use std::fs::{File, Metadata};
109use std::io::Read;
110use std::path::{Path, PathBuf};
111use std::sync::Arc;
112
113use rustls::pki_types::pem::PemObject;
114use rustls::pki_types::{CertificateDer, PrivateKeyDer};
115use rustls::server::WebPkiClientVerifier;
116use rustls::RootCertStore;
117
118use crate::config::TlsConfig;
119
120/// A loaded TLS configuration: a certificate chain, a private key, and
121/// either a client-certificate verifier or the absence of one.
122///
123/// Built by [`Tls::load`] during [`Server::start`](crate::Server::start), so
124/// a key that cannot be read, a key with permissions that make it not a
125/// secret, and a certificate that does not match it are all startup
126/// refusals rather than the first connection's problem.
127pub struct Tls {
128    config: Arc<rustls::ServerConfig>,
129    mutual: bool,
130}
131
132impl Tls {
133    /// Reads the certificate, the key and — if one is configured — the
134    /// client CA, and builds the rustls configuration from them.
135    ///
136    /// # Errors
137    ///
138    /// A [`TlsError`] naming the file and the fix. None of them carries a
139    /// byte of the key: see [`TlsError`].
140    pub fn load(config: &TlsConfig) -> Result<Self, TlsError> {
141        let provider = Arc::new(rustls::crypto::ring::default_provider());
142        let chain = read_certificates(Path::new(&config.certificate), Role::Certificate)?;
143        let key = read_private_key(Path::new(&config.key))?;
144
145        let verifier = match &config.client_ca {
146            Some(authority) => {
147                let mut roots = RootCertStore::empty();
148
149                for certificate in read_certificates(Path::new(authority), Role::ClientCa)? {
150                    roots
151                        .add(certificate)
152                        .map_err(|source| TlsError::UnusableClientCa {
153                            path: PathBuf::from(authority),
154                            source,
155                        })?;
156                }
157
158                WebPkiClientVerifier::builder_with_provider(Arc::new(roots), Arc::clone(&provider))
159                    .build()
160                    .map_err(|_| TlsError::UnusableClientCa {
161                        path: PathBuf::from(authority),
162                        // `VerifierBuilderError` is "no anchors" or "a bad
163                        // CRL", and this crate configures no CRLs, so the
164                        // only reachable case is the first. Reported as our
165                        // own sentence rather than the builder's, so the
166                        // message names the key that fixes it.
167                        source: rustls::Error::General(
168                            "it contains no usable certificate authority".to_owned(),
169                        ),
170                    })?
171            }
172            // Not `allow_unauthenticated()`. A client certificate that the
173            // server would accept the *absence* of is decorative: it can be
174            // dropped by anything in the path and nothing notices. Either
175            // `client_ca` is configured and a certificate is required, or it
176            // is not configured and none is asked for.
177            None => WebPkiClientVerifier::no_client_auth(),
178        };
179
180        let mutual = config.client_ca.is_some();
181        let mut server = rustls::ServerConfig::builder_with_provider(provider)
182            .with_safe_default_protocol_versions()
183            .map_err(|_| TlsError::Provider)?
184            .with_client_cert_verifier(verifier)
185            .with_single_cert(chain, key)
186            .map_err(|_| TlsError::KeyDoesNotMatch {
187                certificate: PathBuf::from(&config.certificate),
188                key: PathBuf::from(&config.key),
189            })?;
190
191        server.alpn_protocols = vec![b"http/1.1".to_vec()];
192
193        Ok(Self {
194            config: Arc::new(server),
195            mutual,
196        })
197    }
198
199    /// The rustls configuration, for the acceptor.
200    #[must_use]
201    pub fn server_config(&self) -> Arc<rustls::ServerConfig> {
202        Arc::clone(&self.config)
203    }
204
205    /// Whether a client certificate is required.
206    ///
207    /// True exactly when `client_ca` was configured: there is no third state
208    /// in which a certificate is asked for and not required.
209    #[must_use]
210    pub fn is_mutual(&self) -> bool {
211        self.mutual
212    }
213}
214
215/// Hand-written, and the reason is the same one AGENTS.md records for
216/// tokens: this type holds a private key, and a derive prints every field.
217/// Two booleans is everything a debugger needs from it.
218impl fmt::Debug for Tls {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.debug_struct("Tls")
221            .field("mutual", &self.mutual)
222            .field("alpn", &"http/1.1")
223            .finish_non_exhaustive()
224    }
225}
226
227/// Which file is being read, for a message that says which one.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229enum Role {
230    Certificate,
231    ClientCa,
232}
233
234impl Role {
235    fn key(self) -> &'static str {
236        match self {
237            Self::Certificate => "certificate",
238            Self::ClientCa => "client_ca",
239        }
240    }
241}
242
243/// The permission bits a private key may not have: anything for group or
244/// other.
245///
246/// A key readable by every account on the host is not a key, in the same way
247/// a four-character token is not authentication — and this crate already
248/// refuses that. The fix is one command, and it is named in the message.
249#[cfg(unix)]
250const FORBIDDEN_BITS: u32 = 0o077;
251
252fn read_certificates(path: &Path, role: Role) -> Result<Vec<CertificateDer<'static>>, TlsError> {
253    let mut certificates = Vec::new();
254
255    for certificate in
256        CertificateDer::pem_file_iter(path).map_err(|source| unreadable(path, role, &source))?
257    {
258        certificates.push(certificate.map_err(|_| TlsError::UnusablePem {
259            path: path.to_owned(),
260            key: role.key(),
261        })?);
262    }
263
264    if certificates.is_empty() {
265        return Err(TlsError::NoCertificates {
266            path: path.to_owned(),
267            key: role.key(),
268        });
269    }
270
271    Ok(certificates)
272}
273
274/// Opens the key, refuses it if its permissions make it not a secret, and
275/// parses it.
276///
277/// The order is the point: the permission check runs against the metadata of
278/// the **open file**, so there is no window in which the file this checked
279/// and the file this read are two different files.
280fn read_private_key(path: &Path) -> Result<PrivateKeyDer<'static>, TlsError> {
281    let mut file = File::open(path).map_err(|source| TlsError::Unreadable {
282        path: path.to_owned(),
283        key: "key",
284        source,
285    })?;
286    let metadata = file.metadata().map_err(|source| TlsError::Unreadable {
287        path: path.to_owned(),
288        key: "key",
289        source,
290    })?;
291
292    refuse_permissive(path, &metadata)?;
293
294    let mut pem = Vec::new();
295
296    file.read_to_end(&mut pem)
297        .map_err(|source| TlsError::Unreadable {
298            path: path.to_owned(),
299            key: "key",
300            source,
301        })?;
302
303    // The parse error is dropped rather than reported. Every other error in
304    // this crate carries its source; this one must not, because the one
305    // thing a PEM parser has to hand is the bytes it could not parse, and
306    // those bytes are the private key.
307    let key = PrivateKeyDer::from_pem_slice(&pem).map_err(|_| TlsError::UnusableKey {
308        path: path.to_owned(),
309    });
310
311    drop(pem);
312
313    key
314}
315
316/// Refuses a private key that anyone but its owner can read.
317///
318/// The bytes never reach a diagnostic, so this is what is left: a key file
319/// with permissive bits has already been readable by every process on the
320/// host for as long as it has existed, and a server that starts anyway is a
321/// server that made that fine.
322#[cfg(unix)]
323fn refuse_permissive(path: &Path, metadata: &Metadata) -> Result<(), TlsError> {
324    use std::os::unix::fs::PermissionsExt as _;
325
326    let mode = metadata.permissions().mode() & 0o777;
327
328    if mode & FORBIDDEN_BITS == 0 {
329        return Ok(());
330    }
331
332    Err(TlsError::PermissiveKey {
333        path: path.to_owned(),
334        mode,
335    })
336}
337
338/// Windows has no mode to read, and its ACLs are not a bit pattern this
339/// crate can judge. Stated rather than silently skipped.
340#[cfg(not(unix))]
341fn refuse_permissive(_path: &Path, _metadata: &Metadata) -> Result<(), TlsError> {
342    Ok(())
343}
344
345fn unreadable(path: &Path, role: Role, source: &rustls::pki_types::pem::Error) -> TlsError {
346    // `pem::Error` is either an I/O error or a parse error. The first is
347    // worth reporting in full — "no such file", "permission denied" — and
348    // the second is not, for the same reason the key's is not: it is the
349    // only variant that has seen the file's contents.
350    match source {
351        rustls::pki_types::pem::Error::Io(error) => TlsError::Unreadable {
352            path: path.to_owned(),
353            key: role.key(),
354            source: std::io::Error::new(error.kind(), error.to_string()),
355        },
356        _ => TlsError::UnusablePem {
357            path: path.to_owned(),
358            key: role.key(),
359        },
360    }
361}
362
363/// Why TLS did not start.
364///
365/// **No variant carries key material, and two of them deliberately carry no
366/// source either**: a PEM parse error's one useful field is the input it
367/// choked on, and for `key` that input is the private key. A path, the
368/// configuration key that names it, and what to do about it is the whole
369/// budget.
370#[derive(Debug)]
371#[non_exhaustive]
372pub enum TlsError {
373    /// A configured file could not be opened or read.
374    Unreadable {
375        /// The file.
376        path: PathBuf,
377        /// The configuration key that named it.
378        key: &'static str,
379        /// The I/O error. Never a parse error — see the type's note.
380        source: std::io::Error,
381    },
382    /// A private key's permissions let somebody other than its owner read
383    /// it.
384    PermissiveKey {
385        /// The file.
386        path: PathBuf,
387        /// Its mode, masked to the permission bits.
388        mode: u32,
389    },
390    /// A file that should hold PEM does not.
391    UnusablePem {
392        /// The file.
393        path: PathBuf,
394        /// The configuration key that named it.
395        key: &'static str,
396    },
397    /// The key file holds no private key this build can use.
398    UnusableKey {
399        /// The file.
400        path: PathBuf,
401    },
402    /// A PEM file that should hold certificates holds none.
403    NoCertificates {
404        /// The file.
405        path: PathBuf,
406        /// The configuration key that named it.
407        key: &'static str,
408    },
409    /// `client_ca` holds nothing that can act as a trust anchor.
410    UnusableClientCa {
411        /// The file.
412        path: PathBuf,
413        /// What rustls made of it. A certificate is public, so this one may
414        /// carry its source.
415        source: rustls::Error,
416    },
417    /// The private key is not the certificate's key, or is of a type this
418    /// build cannot sign with.
419    KeyDoesNotMatch {
420        /// The certificate.
421        certificate: PathBuf,
422        /// The key.
423        key: PathBuf,
424    },
425    /// The cryptography provider would not accept the default protocol
426    /// versions, which is a build problem rather than a configuration one.
427    Provider,
428}
429
430impl fmt::Display for TlsError {
431    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432        match self {
433            Self::Unreadable { path, key, source } => write!(
434                f,
435                "`tls.{key}` names `{}`, which cannot be read: {source}",
436                path.display()
437            ),
438            Self::PermissiveKey { path, mode } => write!(
439                f,
440                "the private key `{}` is mode {mode:04o}, which lets an account other than its \
441                 owner read it; a key anybody on the host can read is not a key. `chmod 600` it \
442                 — or, on Kubernetes, mount the secret with `defaultMode: 0400`",
443                path.display()
444            ),
445            Self::UnusablePem { path, key } => write!(
446                f,
447                "`tls.{key}` names `{}`, which is not PEM this build can parse",
448                path.display()
449            ),
450            Self::UnusableKey { path } => write!(
451                f,
452                "`tls.key` names `{}`, which holds no PKCS#8, PKCS#1 or SEC1 private key. The \
453                 file's contents are deliberately not quoted here",
454                path.display()
455            ),
456            Self::NoCertificates { path, key } => write!(
457                f,
458                "`tls.{key}` names `{}`, which contains no certificate",
459                path.display()
460            ),
461            Self::UnusableClientCa { path, source } => write!(
462                f,
463                "`tls.client_ca` names `{}`, which cannot be a trust anchor: {source}",
464                path.display()
465            ),
466            Self::KeyDoesNotMatch { certificate, key } => write!(
467                f,
468                "the private key `{}` is not the key of the certificate `{}`, or is of a type \
469                 this build cannot sign with",
470                key.display(),
471                certificate.display()
472            ),
473            Self::Provider => f.write_str(
474                "the `ring` cryptography provider does not support rustls's default protocol \
475                 versions, which is a build problem rather than a configuration one",
476            ),
477        }
478    }
479}
480
481impl std::error::Error for TlsError {
482    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
483        match self {
484            Self::Unreadable { source, .. } => Some(source),
485            Self::UnusableClientCa { source, .. } => Some(source),
486            _ => None,
487        }
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    /// A key whose bytes are distinctive enough that finding them anywhere is
496    /// unambiguous. Not a real key — nothing here parses it.
497    const PLANTED: &str =
498        "-----BEGIN PRIVATE KEY-----\nPLANTED-KEY-MATERIAL\n-----END PRIVATE KEY-----\n";
499
500    fn written(name: &str, contents: &str, mode: u32) -> (tempfile::TempDir, PathBuf) {
501        let directory = tempfile::tempdir().expect("a temporary directory");
502        let path = directory.path().join(name);
503
504        std::fs::write(&path, contents).expect("writable");
505        chmod(&path, mode);
506
507        (directory, path)
508    }
509
510    #[cfg(unix)]
511    fn chmod(path: &Path, mode: u32) {
512        use std::os::unix::fs::PermissionsExt as _;
513
514        std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
515    }
516
517    #[cfg(not(unix))]
518    fn chmod(_path: &Path, _mode: u32) {}
519
520    /// The refusal this crate adds for the same reason it refuses a
521    /// four-character token, and with the same shape: it names the fix.
522    #[cfg(unix)]
523    #[test]
524    fn a_key_anybody_can_read_is_refused_and_the_message_names_the_fix() {
525        let (_directory, path) = written("key.pem", PLANTED, 0o644);
526        let error = read_private_key(&path).expect_err("mode 644 is not a secret");
527
528        assert!(
529            matches!(error, TlsError::PermissiveKey { mode: 0o644, .. }),
530            "{error:?}"
531        );
532        assert!(error.to_string().contains("chmod 600"), "{error}");
533    }
534
535    #[cfg(unix)]
536    #[test]
537    fn a_key_only_its_owner_can_read_passes_the_permission_check() {
538        // 0600 gets past the permission check and fails at the parse, which
539        // is the next check rather than this one.
540        let (_directory, path) = written("key.pem", PLANTED, 0o600);
541        let error = read_private_key(&path).expect_err("the planted key is not a key");
542
543        assert!(matches!(error, TlsError::UnusableKey { .. }), "{error:?}");
544    }
545
546    /// The rule this module exists to keep. Every error a key file can
547    /// produce, rendered both ways, and none of them holds the key.
548    #[test]
549    fn no_error_about_a_key_file_carries_the_key() {
550        let (_directory, path) = written("key.pem", PLANTED, 0o644);
551        let missing = read_private_key(Path::new("/nonexistent/key.pem")).unwrap_err();
552        let permissive = read_private_key(&path).unwrap_err();
553
554        chmod(&path, 0o600);
555
556        let unusable = read_private_key(&path).unwrap_err();
557
558        for error in [missing, permissive, unusable] {
559            let rendered = format!("{error} / {error:?}");
560
561            assert!(
562                !rendered.contains("PLANTED-KEY-MATERIAL"),
563                "a private key escaped through an error: {rendered}"
564            );
565        }
566    }
567
568    #[test]
569    fn a_certificate_file_that_is_not_pem_is_refused_without_being_quoted() {
570        let (_directory, path) = written("cert.pem", "not a certificate: hunter2\n", 0o644);
571        let error = read_certificates(&path, Role::Certificate).unwrap_err();
572        let rendered = format!("{error} / {error:?}");
573
574        assert!(rendered.contains("tls.certificate"), "{rendered}");
575        assert!(!rendered.contains("hunter2"), "{rendered}");
576    }
577}