bindcar/tls.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! TLS transport for the HTTP API.
5//!
6//! bindcar's REST API carries a privileged bearer credential on every request —
7//! a Kubernetes ServiceAccount token under TokenReview auth, or the shared
8//! `BIND_API_TOKEN`. Authentication proves *who* the caller is; it does nothing
9//! for confidentiality. Without TLS that credential crosses the pod network in
10//! the clear, readable by anything able to observe the link.
11//!
12//! This module resolves the TLS configuration and builds the [`rustls`] server
13//! config. TLS is **opt-in**: with no certificate configured bindcar serves
14//! plaintext exactly as before, so existing deployments are unaffected.
15//!
16//! # Configuration
17//!
18//! | CLI flag | Environment variable | Meaning |
19//! |---|---|---|
20//! | `--tls-cert` | `BIND_TLS_CERT` | PEM certificate chain, leaf first |
21//! | `--tls-key` | `BIND_TLS_KEY` | PEM private key (PKCS#8, PKCS#1 or SEC1) |
22//! | `--tls-client-ca` | `BIND_TLS_CLIENT_CA` | PEM CA bundle; presence turns on mTLS |
23//!
24//! # Fail-closed rules
25//!
26//! Misconfiguration is a startup error, never a silent downgrade:
27//!
28//! - Exactly one of cert/key set → [`TlsError::IncompleteKeyPair`]. Falling back
29//! to plaintext here would serve the API in the clear on a deployment whose
30//! operator believes TLS is on.
31//! - A client CA with no key pair → [`TlsError::ClientCaWithoutTls`], rather than
32//! accepting the flag and ignoring it.
33//! - Unreadable or unparseable PEM → an error before the listener binds.
34
35#[cfg(feature = "tls")]
36use std::sync::{Arc, RwLock};
37
38#[cfg(feature = "tls")]
39use tracing::{debug, info, warn};
40
41#[cfg(feature = "tls")]
42use rustls::server::WebPkiClientVerifier;
43#[cfg(feature = "tls")]
44use rustls::{RootCertStore, ServerConfig};
45#[cfg(feature = "tls")]
46use rustls_pki_types::pem::PemObject;
47#[cfg(feature = "tls")]
48use rustls_pki_types::{CertificateDer, PrivateKeyDer};
49
50/// ALPN protocols advertised by the TLS listener, preferred order first.
51#[cfg(feature = "tls")]
52///
53/// `h2` before `http/1.1` matches the HTTP versions the hyper auto-builder
54/// serves, so a client negotiating either lands on a protocol bindcar speaks.
55const ALPN_PROTOCOLS: [&[u8]; 2] = [b"h2", b"http/1.1"];
56
57/// Install the process-wide rustls crypto provider (aws-lc-rs).
58///
59/// ADR-0002: bindcar's own TLS listener and kube's client stack may enable
60/// different rustls provider features; with more than one provider feature
61/// enabled, rustls has no compile-time default and the first
62/// `ServerConfig::builder()` panics with "no process-level CryptoProvider".
63/// Installing explicitly makes the provider deterministic: aws-lc-rs, which
64/// carries the `X25519MLKEM768` hybrid post-quantum key exchange.
65///
66/// Idempotent: an already-installed provider is left in place, so this is
67/// safe to call from `main()`, from [`build_server_config`] and from tests
68/// in any order.
69#[cfg(any(feature = "tls", feature = "k8s-token-review"))]
70pub fn ensure_crypto_provider() {
71 // The Err carries the provider that was already installed; that is the
72 // desired end state, not a failure.
73 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
74}
75
76/// Errors raised while resolving or loading the TLS transport configuration.
77///
78/// Every variant is fatal at startup — bindcar refuses to bind rather than
79/// downgrade to plaintext.
80#[derive(Debug, thiserror::Error)]
81pub enum TlsError {
82 /// Exactly one of the certificate/key pair was supplied.
83 #[error(
84 "TLS is half-configured: {supplied} was set but {missing} was not; \
85 set both to enable TLS, or neither to serve plaintext"
86 )]
87 IncompleteKeyPair {
88 /// The option that was supplied.
89 supplied: &'static str,
90 /// The option that was missing.
91 missing: &'static str,
92 },
93
94 /// A client CA was supplied without a server certificate and key.
95 #[error(
96 "a TLS client CA was configured but no server certificate/key was; \
97 mutual TLS requires a TLS listener"
98 )]
99 ClientCaWithoutTls,
100
101 /// The certificate file could not be read or contained no certificates.
102 #[error("failed to read TLS certificate from {path}: {reason}")]
103 CertRead {
104 /// The configured certificate path.
105 path: String,
106 /// The underlying error, rendered.
107 reason: String,
108 },
109
110 /// The private key file could not be read.
111 #[error("failed to read TLS private key from {path}: {reason}")]
112 KeyRead {
113 /// The configured key path.
114 path: String,
115 /// The underlying error, rendered.
116 reason: String,
117 },
118
119 /// The private key did not match the certificate, or was otherwise rejected.
120 #[error("TLS private key from {path} was rejected: {message}")]
121 KeyParse {
122 /// The configured key path.
123 path: String,
124 /// Why rustls rejected the key.
125 message: String,
126 },
127
128 /// The client CA bundle could not be read.
129 #[error("failed to read TLS client CA bundle from {path}: {reason}")]
130 ClientCaRead {
131 /// The configured client CA path.
132 path: String,
133 /// The underlying error, rendered.
134 reason: String,
135 },
136
137 /// The client CA bundle parsed but produced no usable trust anchors.
138 #[error("TLS client CA bundle from {path} yielded no usable certificates: {message}")]
139 ClientCaInvalid {
140 /// The configured client CA path.
141 path: String,
142 /// Why the bundle was rejected.
143 message: String,
144 },
145}
146
147/// A resolved, syntactically valid TLS configuration.
148///
149/// Holding one of these means the operator asked for TLS and the combination of
150/// options is coherent. It does **not** mean the files exist or parse — that is
151/// [`build_server_config`]'s job.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct TlsSettings {
154 /// Path to the PEM certificate chain, leaf first.
155 pub cert_path: String,
156 /// Path to the PEM private key.
157 pub key_path: String,
158 /// Path to a PEM CA bundle used to verify client certificates, when mTLS is on.
159 pub client_ca_path: Option<String>,
160}
161
162impl TlsSettings {
163 /// Whether this configuration demands a client certificate (mutual TLS).
164 #[must_use]
165 pub fn requires_client_auth(&self) -> bool {
166 self.client_ca_path.is_some()
167 }
168}
169
170/// Normalize an option, treating an empty string as absent.
171///
172/// An unset environment variable that expands to `""` in a shell wrapper or a
173/// Kubernetes manifest must not half-configure the listener.
174fn non_empty(value: Option<String>) -> Option<String> {
175 value.filter(|v| !v.trim().is_empty())
176}
177
178/// Resolve TLS settings from the certificate, key and client-CA options.
179///
180/// # Arguments
181/// * `cert_path` - PEM certificate chain path, if configured
182/// * `key_path` - PEM private key path, if configured
183/// * `client_ca_path` - PEM client CA bundle path, if configured
184///
185/// # Returns
186/// `Ok(None)` when no TLS material is configured — bindcar serves plaintext.
187/// `Ok(Some(settings))` when the combination is coherent.
188///
189/// # Errors
190/// Returns [`TlsError::IncompleteKeyPair`] when exactly one of the certificate
191/// and key is set, and [`TlsError::ClientCaWithoutTls`] when a client CA is set
192/// without a server key pair. Both fail closed rather than serving plaintext.
193pub fn resolve_tls_settings(
194 cert_path: Option<String>,
195 key_path: Option<String>,
196 client_ca_path: Option<String>,
197) -> Result<Option<TlsSettings>, TlsError> {
198 let cert_path = non_empty(cert_path);
199 let key_path = non_empty(key_path);
200 let client_ca_path = non_empty(client_ca_path);
201
202 let (cert_path, key_path) = match (cert_path, key_path) {
203 (Some(cert), Some(key)) => (cert, key),
204 (Some(_), None) => {
205 return Err(TlsError::IncompleteKeyPair {
206 supplied: "--tls-cert / BIND_TLS_CERT",
207 missing: "--tls-key / BIND_TLS_KEY",
208 })
209 }
210 (None, Some(_)) => {
211 return Err(TlsError::IncompleteKeyPair {
212 supplied: "--tls-key / BIND_TLS_KEY",
213 missing: "--tls-cert / BIND_TLS_CERT",
214 })
215 }
216 (None, None) if client_ca_path.is_some() => return Err(TlsError::ClientCaWithoutTls),
217 (None, None) => return Ok(None),
218 };
219
220 Ok(Some(TlsSettings {
221 cert_path,
222 key_path,
223 client_ca_path,
224 }))
225}
226
227/// The URL scheme bindcar serves under, for log lines and the advertised docs URL.
228///
229/// # Arguments
230/// * `settings` - the resolved TLS settings, or `None` for plaintext
231#[must_use]
232pub fn scheme_for(settings: Option<&TlsSettings>) -> &'static str {
233 if settings.is_some() {
234 return "https";
235 }
236 "http"
237}
238
239#[cfg(feature = "tls")]
240/// Load the client CA bundle and build a verifier demanding a valid client certificate.
241///
242/// # Errors
243/// Returns [`TlsError::ClientCaRead`] if the bundle cannot be read and
244/// [`TlsError::ClientCaInvalid`] if it yields no usable trust anchors.
245pub(crate) fn build_client_verifier(
246 path: &str,
247) -> Result<Arc<dyn rustls::server::danger::ClientCertVerifier>, TlsError> {
248 let mut roots = RootCertStore::empty();
249
250 for cert in CertificateDer::pem_file_iter(path).map_err(|e| TlsError::ClientCaRead {
251 path: path.to_string(),
252 reason: e.to_string(),
253 })? {
254 let cert = cert.map_err(|e| TlsError::ClientCaRead {
255 path: path.to_string(),
256 reason: e.to_string(),
257 })?;
258 roots.add(cert).map_err(|e| TlsError::ClientCaInvalid {
259 path: path.to_string(),
260 message: e.to_string(),
261 })?;
262 }
263
264 if roots.is_empty() {
265 return Err(TlsError::ClientCaInvalid {
266 path: path.to_string(),
267 message: "bundle contained no certificates".to_string(),
268 });
269 }
270
271 WebPkiClientVerifier::builder(Arc::new(roots))
272 .build()
273 .map_err(|e| TlsError::ClientCaInvalid {
274 path: path.to_string(),
275 message: e.to_string(),
276 })
277}
278
279#[cfg(feature = "tls")]
280/// Build the [`rustls::ServerConfig`] for the API listener.
281///
282/// Loads the certificate chain and private key from disk, and — when a client
283/// CA is configured — a verifier that makes a valid client certificate
284/// mandatory.
285///
286/// # Arguments
287/// * `settings` - resolved TLS settings from [`resolve_tls_settings`]
288///
289/// # Errors
290/// Returns [`TlsError::CertRead`], [`TlsError::KeyRead`], [`TlsError::KeyParse`],
291/// [`TlsError::ClientCaRead`] or [`TlsError::ClientCaInvalid`] depending on which
292/// piece of material failed to load. All are fatal at startup.
293pub fn build_server_config(settings: &TlsSettings) -> Result<ServerConfig, TlsError> {
294 // Deterministic provider before the builder consults the process default
295 // (ADR-0002); idempotent when main() already installed it.
296 ensure_crypto_provider();
297
298 let certs = CertificateDer::pem_file_iter(&settings.cert_path)
299 .map_err(|e| TlsError::CertRead {
300 path: settings.cert_path.clone(),
301 reason: e.to_string(),
302 })?
303 .collect::<Result<Vec<_>, _>>()
304 .map_err(|e| TlsError::CertRead {
305 path: settings.cert_path.clone(),
306 reason: e.to_string(),
307 })?;
308
309 let key = PrivateKeyDer::from_pem_file(&settings.key_path).map_err(|e| TlsError::KeyRead {
310 path: settings.key_path.clone(),
311 reason: e.to_string(),
312 })?;
313
314 let builder = ServerConfig::builder();
315
316 let mut config = match &settings.client_ca_path {
317 Some(ca_path) => builder
318 .with_client_cert_verifier(build_client_verifier(ca_path)?)
319 .with_single_cert(certs, key),
320 None => builder.with_no_client_auth().with_single_cert(certs, key),
321 }
322 .map_err(|e| TlsError::KeyParse {
323 path: settings.key_path.clone(),
324 message: e.to_string(),
325 })?;
326
327 config.alpn_protocols = ALPN_PROTOCOLS.iter().map(|p| p.to_vec()).collect();
328
329 Ok(config)
330}
331
332// ---------------------------------------------------------------------------
333// Hot-reload (roadmap 06)
334// ---------------------------------------------------------------------------
335
336/// How often the certificate files are re-examined, in seconds.
337///
338/// Three file reads and three hashes a minute is negligible next to the cost of
339/// a rotation that silently never takes effect.
340pub const DEFAULT_RELOAD_INTERVAL_SECS: u64 = 60;
341
342#[cfg(feature = "tls")]
343/// Fingerprint the TLS material currently on disk.
344///
345/// Kubernetes `Secret` and projected volumes do not rewrite files in place —
346/// they swap a `..data` symlink — so mtime and inode are both unreliable across
347/// a renewal. Hashing the bytes is what actually detects the change.
348///
349/// The client CA is included so mTLS trust is part of the reloadable surface,
350/// not just the server certificate.
351///
352/// # Arguments
353/// * `settings` - the resolved TLS settings naming the files to hash
354///
355/// # Returns
356/// A SHA-256 digest over the certificate, key and (when configured) client CA.
357///
358/// # Errors
359/// Returns [`TlsError::CertRead`], [`TlsError::KeyRead`] or
360/// [`TlsError::ClientCaRead`] if a file cannot be read. Callers polling for
361/// changes should treat an error as "no change" and retry: a file is routinely
362/// unreadable for a moment during a renewal.
363pub fn fingerprint(settings: &TlsSettings) -> Result<[u8; 32], TlsError> {
364 use sha2::{Digest, Sha256};
365
366 let mut hasher = Sha256::new();
367
368 let cert = std::fs::read(&settings.cert_path).map_err(|e| TlsError::CertRead {
369 path: settings.cert_path.clone(),
370 reason: e.to_string(),
371 })?;
372 hasher.update(&cert);
373
374 let key = std::fs::read(&settings.key_path).map_err(|e| TlsError::KeyRead {
375 path: settings.key_path.clone(),
376 reason: e.to_string(),
377 })?;
378 hasher.update(&key);
379
380 if let Some(ca_path) = &settings.client_ca_path {
381 let ca = std::fs::read(ca_path).map_err(|e| TlsError::ClientCaRead {
382 path: ca_path.clone(),
383 reason: e.to_string(),
384 })?;
385 hasher.update(&ca);
386 }
387
388 Ok(hasher.finalize().into())
389}
390
391#[cfg(feature = "tls")]
392/// Holds the live [`rustls::ServerConfig`] and swaps it when the files change.
393///
394/// The accept loop reads [`TlsReloader::current`] once per connection, so a swap
395/// affects new connections only — established ones finish under the certificate
396/// they started with, which is what makes rotation non-disruptive.
397///
398/// # Failure behaviour
399///
400/// A reload that cannot be built is a **non-event**: the error is logged and the
401/// existing configuration keeps serving. This is deliberate and differs from
402/// startup, where bad TLS material is fatal. At startup, continuing would mean
403/// silently serving plaintext; here there is already a working configuration in
404/// memory, so retaining it is strictly safer than either failing or downgrading.
405///
406/// It also makes non-atomic renewals safe. A poll can easily observe a new
407/// certificate alongside a not-yet-replaced key; that pair does not build, the
408/// swap is skipped, and the next poll picks up the completed write.
409#[derive(Debug)]
410pub struct TlsReloader {
411 settings: TlsSettings,
412 config: RwLock<Arc<ServerConfig>>,
413 last_fingerprint: RwLock<[u8; 32]>,
414}
415
416#[cfg(feature = "tls")]
417impl TlsReloader {
418 /// Build the initial configuration and capture its fingerprint.
419 ///
420 /// # Arguments
421 /// * `settings` - resolved TLS settings
422 ///
423 /// # Errors
424 /// Propagates any [`TlsError`] from [`build_server_config`]. Unlike a later
425 /// reload, a failure here **is** fatal: there is no known-good config to
426 /// fall back to.
427 pub fn new(settings: TlsSettings) -> Result<Self, TlsError> {
428 let config = Arc::new(build_server_config(&settings)?);
429 // A fingerprint failure at startup cannot happen in practice — the files
430 // were just read successfully — but treat it as "unknown" rather than
431 // failing the server, so the first poll simply reloads.
432 let fp = fingerprint(&settings).unwrap_or([0u8; 32]);
433
434 Ok(Self {
435 settings,
436 config: RwLock::new(config),
437 last_fingerprint: RwLock::new(fp),
438 })
439 }
440
441 /// The configuration new connections should use.
442 ///
443 /// Cheap: clones an `Arc` while holding the read lock only for that clone,
444 /// never across an `await`.
445 #[must_use]
446 pub fn current(&self) -> Arc<ServerConfig> {
447 self.config
448 .read()
449 .unwrap_or_else(|poisoned| poisoned.into_inner())
450 .clone()
451 }
452
453 /// Whether a reload interval enables reloading at all.
454 ///
455 /// Zero restores the startup-only behaviour of earlier releases.
456 #[must_use]
457 pub fn reloading_enabled(interval_secs: u64) -> bool {
458 interval_secs > 0
459 }
460
461 /// Re-read the files and swap the live config if they changed and still build.
462 ///
463 /// # Returns
464 /// `true` only when a new configuration was actually installed. An unchanged
465 /// fingerprint, an unreadable file and a material that fails to build all
466 /// return `false` and leave the current configuration serving.
467 pub fn reload_if_changed(&self) -> bool {
468 let current_fp = match fingerprint(&self.settings) {
469 Ok(fp) => fp,
470 Err(e) => {
471 // Routine during a renewal; the next poll retries.
472 debug!("TLS material not readable while polling for changes: {}", e);
473 return false;
474 }
475 };
476
477 {
478 let last = self
479 .last_fingerprint
480 .read()
481 .unwrap_or_else(|p| p.into_inner());
482 if *last == current_fp {
483 return false;
484 }
485 }
486
487 let rebuilt = match build_server_config(&self.settings) {
488 Ok(config) => config,
489 Err(e) => {
490 warn!(
491 "TLS material changed but the new configuration is not usable, \
492continuing with the previous certificate: {}",
493 e
494 );
495 // Deliberately do NOT record the fingerprint: the write may still
496 // be in progress, and the next poll must try again.
497 return false;
498 }
499 };
500
501 *self.config.write().unwrap_or_else(|p| p.into_inner()) = Arc::new(rebuilt);
502 *self
503 .last_fingerprint
504 .write()
505 .unwrap_or_else(|p| p.into_inner()) = current_fp;
506
507 info!("reloaded TLS certificate material; new connections will use it");
508 true
509 }
510}