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}