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}