Skip to main content

dynamic_config_store_core/
tls.rs

1//! One TLS vocabulary for the seven store crates.
2//!
3//! Every store here already had a door to TLS, and every one of them opened
4//! onto a different type: `ConnectOptions` for etcd, a `ureq::Agent` for
5//! Vault, Consul and Firestore, an `SdkConfig` for S3, nothing at all for
6//! Redis. That is the right door for options nobody anticipated — it still
7//! is, and none of it was removed — but it has two costs the repository
8//! owner asked to fix. A deployment behind a private CA could not reach
9//! four of the seven at all, and *nothing* here could ever cross into the
10//! Python wheels, because there is no Python spelling for a `tonic` TLS
11//! configuration or a `ureq` agent.
12//!
13//! So this module holds data and nothing else: paths and PEM bytes, no
14//! client type anywhere in a signature. A caller says what it has, and each
15//! store translates that into whatever its own client understands.
16//!
17//! ```
18//! # use dynamic_config_store_core::tls::TlsConfig;
19//! let tls = TlsConfig::new()
20//!     .with_ca_certificate_file("/etc/ssl/private-ca.pem")
21//!     .with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key");
22//! ```
23//!
24//! # Not every store can express all of it
25//!
26//! The clients differ, and where one cannot express a setting **the store
27//! refuses the whole configuration and says which setting and why**. A
28//! silently ignored `ca_certificate` is a program that believes it is
29//! pinned to a private CA and is not, which is worse than a program that
30//! will not start.
31//!
32//! | Store | CA from a file | CA from bytes | Client certificate |
33//! |---|---|---|---|
34//! | etcd, Consul, Vault, Firestore, Redis, git | yes | yes | yes |
35//! | NATS | yes | **no** — its client takes paths | file paths only |
36//! | S3 | yes | yes | **no** — the SDK's TLS context has no client-certificate slot |
37//!
38//! git is in the table but not in this crate's dependents: `dynamic-config-git`
39//! re-exports [`TlsConfig`] and speaks the same vocabulary over `https://`
40//! only, because an `ssh://` remote's trust is `known_hosts` and its identity
41//! is a key rather than a certificate.
42//!
43//! Each store's own documentation repeats its row, because that is where
44//! somebody reads it.
45//!
46//! # There is no `skip_verification`
47//!
48//! Deliberately, and the reasoning is worth stating rather than leaving as
49//! an absence.
50//!
51//! **It could not be uniform.** `tonic` offers no such switch, and neither
52//! does the AWS SDK's TLS context; `async-nats` reaches it only through a
53//! hand-built `rustls::ClientConfig`. A knob in this type that four of
54//! seven stores had to refuse would be a vocabulary word that mostly means
55//! "error", which is the opposite of what one vocabulary is for.
56//!
57//! **It answers nothing [`with_ca_certificate_file`](TlsConfig::with_ca_certificate_file)
58//! does not.** The two situations people reach for it in — a development
59//! server with a self-signed certificate, an enterprise private CA — are
60//! both a matter of trusting one more certificate, which is one line here
61//! and keeps the server authenticated. Turning verification off does not
62//! make TLS weaker in the way a checklist means; it makes it *absent*,
63//! and leaves a connection that any party on the path can read and rewrite.
64//!
65//! **The escape hatch is still there for the case nobody anticipated.**
66//! `with_agent`, `with_options`, `from_client` and `with_config` all
67//! survive, and every client underneath has its own dangerous switch under
68//! its own frightening name. A caller who genuinely needs it names that
69//! API, in their own code, where a reviewer sees it — rather than reaching
70//! for a short word on a type whose other options are safe.
71
72use std::path::{Path, PathBuf};
73
74use dynamic_config::Error;
75
76/// A PEM document: a file to read at connect time, or bytes already in hand.
77///
78/// Both spellings exist because both deployments do. A file is what a
79/// Kubernetes secret mount or a `/etc/ssl` layout produces; bytes are what a
80/// program that already fetched its material from a secrets manager has,
81/// and writing those to a temporary file so a client could read them back
82/// would put a private key on a disk that never asked for one.
83///
84/// Never `Debug`-derived: a [`Pem::Bytes`] may be a private key.
85#[derive(Clone, PartialEq, Eq)]
86pub enum Pem {
87    /// A path, read when the store builds its client — not when this is
88    /// constructed, so a missing file is an error from the store that names
89    /// it rather than a panic from a builder.
90    File(PathBuf),
91    /// PEM bytes.
92    Bytes(Vec<u8>),
93}
94
95impl Pem {
96    /// The path, for the one client in this family that takes paths rather
97    /// than bytes.
98    ///
99    /// `None` for [`Pem::Bytes`], which is how [`Nats`] knows it has been
100    /// handed something it cannot pass on.
101    ///
102    /// [`Nats`]: https://docs.rs/dynamic-config-nats
103    #[must_use]
104    pub fn path(&self) -> Option<&Path> {
105        match self {
106            Self::File(path) => Some(path),
107            Self::Bytes(_) => None,
108        }
109    }
110
111    /// The PEM bytes, reading the file if that is what this is.
112    ///
113    /// `what` names the material — `"the CA certificate"` — and `described`
114    /// is the store's own `describe()`, so a failure says which source and
115    /// which of its three files.
116    ///
117    /// # Errors
118    ///
119    /// If the file cannot be read. The message carries the path and the
120    /// operating system's reason and **never the contents**: this is the
121    /// call that reads private keys.
122    pub fn read(&self, described: &str, what: &str) -> Result<Vec<u8>, Error> {
123        match self {
124            Self::Bytes(bytes) => Ok(bytes.clone()),
125            Self::File(path) => std::fs::read(path).map_err(|error| {
126                Error::remote(format!(
127                    "{described}: reading {what} from {}: {error}",
128                    path.display()
129                ))
130            }),
131        }
132    }
133}
134
135// Hand-written, never derived. `Pem::Bytes` is the shape a private key
136// arrives in, and a derive would print it — into a `dbg!`, into a
137// `tracing::debug!(?tls)`, into whatever caught the panic. Shape only: what
138// a debugger needs to tell "the file is wrong" from "the bytes are empty",
139// and nothing a key could hide in.
140impl std::fmt::Debug for Pem {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            Self::File(path) => write!(f, "file {}", path.display()),
144            Self::Bytes(_) => f.write_str("<pem bytes>"),
145        }
146    }
147}
148
149/// A client certificate's PEM bytes and its private key's, in that order.
150///
151/// Named only so the pair reads as one thing at a call site — the two halves
152/// travel together everywhere, because presenting either alone is not mTLS.
153pub type CertificateAndKey = (Vec<u8>, Vec<u8>);
154
155/// A client certificate and the private key that goes with it.
156///
157/// The pair is one thing because presenting either half alone is not mTLS,
158/// it is a misconfiguration — so there is no way to set one and forget the
159/// other.
160#[derive(Clone, PartialEq, Eq)]
161pub struct ClientCertificate {
162    certificate: Pem,
163    key: Pem,
164}
165
166impl ClientCertificate {
167    /// The certificate, or the chain ending in it.
168    #[must_use]
169    pub fn certificate(&self) -> &Pem {
170        &self.certificate
171    }
172
173    /// The private key.
174    #[must_use]
175    pub fn key(&self) -> &Pem {
176        &self.key
177    }
178}
179
180// The private key half is never rendered, whatever it holds: a path is a
181// diagnostic and its bytes are the sharpest secret in this module.
182impl std::fmt::Debug for ClientCertificate {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("ClientCertificate")
185            .field("certificate", &self.certificate)
186            .field(
187                "key",
188                &match &self.key {
189                    // A path names which key, which is the question a
190                    // debugger is actually asking, and is not itself secret.
191                    Pem::File(path) => format!("file {}", path.display()),
192                    Pem::Bytes(_) => "<redacted>".to_owned(),
193                },
194            )
195            .finish()
196    }
197}
198
199/// What a store needs to speak TLS to somewhere this machine does not
200/// already trust.
201///
202/// Data only — no client type appears anywhere in it, which is what makes
203/// it the same three settings in all seven crates and what makes it
204/// expressible from a language that has never heard of `tonic`.
205///
206/// Empty by default, and an empty one is not "no TLS": it is the platform's
207/// own trust store, which is what a public certificate authority needs and
208/// what every store already did.
209///
210/// See the [module documentation](self) for what each store can express and
211/// for why there is no way to turn verification off.
212#[derive(Clone, Default, PartialEq, Eq)]
213pub struct TlsConfig {
214    ca: Option<Pem>,
215    client: Option<ClientCertificate>,
216}
217
218impl TlsConfig {
219    /// An empty configuration: the platform's trust store, no client
220    /// certificate.
221    #[must_use]
222    pub fn new() -> Self {
223        Self::default()
224    }
225
226    /// Trust the certificate authority in this PEM file.
227    ///
228    /// The file may hold several certificates; all of them are trusted,
229    /// which is what a private CA with an intermediate needs.
230    ///
231    /// The file is read when the store builds its client, not here — so a
232    /// rotated CA is picked up by rebuilding the source, and a missing file
233    /// is an error naming it rather than a panic in a builder chain.
234    #[must_use]
235    pub fn with_ca_certificate_file(mut self, path: impl Into<PathBuf>) -> Self {
236        self.ca = Some(Pem::File(path.into()));
237        self
238    }
239
240    /// Trust the certificate authority in these PEM bytes.
241    ///
242    /// For a program that already has the material — from a secrets
243    /// manager, from its own configuration — and should not have to put it
244    /// on a disk for a client to read back.
245    #[must_use]
246    pub fn with_ca_certificate_pem(mut self, pem: impl Into<Vec<u8>>) -> Self {
247        self.ca = Some(Pem::Bytes(pem.into()));
248        self
249    }
250
251    /// Present this client certificate and private key (mTLS).
252    ///
253    /// Both are PEM files. `certificate` may be a chain; the leaf comes
254    /// first, as every TLS stack here expects.
255    #[must_use]
256    pub fn with_client_certificate_files(
257        mut self,
258        certificate: impl Into<PathBuf>,
259        key: impl Into<PathBuf>,
260    ) -> Self {
261        self.client = Some(ClientCertificate {
262            certificate: Pem::File(certificate.into()),
263            key: Pem::File(key.into()),
264        });
265        self
266    }
267
268    /// Present this client certificate and private key (mTLS), from bytes.
269    ///
270    /// The private key is the sharpest secret this crate handles. It is
271    /// never rendered by [`Debug`](std::fmt::Debug), never quoted into an
272    /// error, and never written anywhere: it goes from here into the
273    /// client's own key type and stops.
274    #[must_use]
275    pub fn with_client_certificate_pem(
276        mut self,
277        certificate: impl Into<Vec<u8>>,
278        key: impl Into<Vec<u8>>,
279    ) -> Self {
280        self.client = Some(ClientCertificate {
281            certificate: Pem::Bytes(certificate.into()),
282            key: Pem::Bytes(key.into()),
283        });
284        self
285    }
286
287    /// Whether this asks for nothing at all.
288    ///
289    /// A store uses it to tell "the caller wants the platform defaults"
290    /// from "the caller wants something", which is the difference between
291    /// leaving its client alone and building one.
292    #[must_use]
293    pub fn is_empty(&self) -> bool {
294        self.ca.is_none() && self.client.is_none()
295    }
296
297    /// The certificate authority to trust, if one was named.
298    #[must_use]
299    pub fn ca_certificate(&self) -> Option<&Pem> {
300        self.ca.as_ref()
301    }
302
303    /// The client certificate to present, if one was named.
304    #[must_use]
305    pub fn client_certificate(&self) -> Option<&ClientCertificate> {
306        self.client.as_ref()
307    }
308
309    /// The CA certificate's PEM bytes, reading the file if that is what it
310    /// is.
311    ///
312    /// # Errors
313    ///
314    /// If the file cannot be read; the message names the path.
315    pub fn ca_certificate_pem(&self, described: &str) -> Result<Option<Vec<u8>>, Error> {
316        self.ca
317            .as_ref()
318            .map(|pem| pem.read(described, "the CA certificate"))
319            .transpose()
320    }
321
322    /// The client certificate and key as PEM bytes, reading the files if
323    /// that is what they are.
324    ///
325    /// # Errors
326    ///
327    /// If either file cannot be read; the message names the path and never
328    /// the contents.
329    pub fn client_certificate_pem(
330        &self,
331        described: &str,
332    ) -> Result<Option<CertificateAndKey>, Error> {
333        let Some(client) = &self.client else {
334            return Ok(None);
335        };
336
337        let certificate = client
338            .certificate
339            .read(described, "the client certificate")?;
340        let key = client.key.read(described, "the client private key")?;
341
342        Ok(Some((certificate, key)))
343    }
344}
345
346// Hand-written for the reason every store's `Debug` is: this type's fields
347// include a private key.
348impl std::fmt::Debug for TlsConfig {
349    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350        f.debug_struct("TlsConfig")
351            .field("ca_certificate", &self.ca)
352            .field("client_certificate", &self.client)
353            .finish()
354    }
355}
356
357/// The refusal a store returns for part of a [`TlsConfig`] its client
358/// cannot express.
359///
360/// One wording for all seven, because the important half is the same
361/// everywhere: what was asked for, that it was *not* applied, and what to
362/// use instead. A store that quietly dropped the setting would leave a
363/// program believing it had pinned a private CA when it had not.
364///
365/// `described` is the store's `describe()`, `setting` names the call —
366/// `"a CA certificate from PEM bytes"` — and `instead` is the way out.
367#[must_use]
368pub fn unsupported(described: &str, setting: &str, instead: &str) -> Error {
369    Error::remote(format!(
370        "{described}: {setting} cannot be expressed here, and is refused \
371         rather than ignored; {instead}"
372    ))
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    /// The key material every test in this module plants. If this string
380    /// ever appears in a rendering, something printed a private key.
381    const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
382
383    fn planted_key() -> String {
384        format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n")
385    }
386
387    #[test]
388    fn a_planted_private_key_never_reaches_debug() {
389        let tls = TlsConfig::new()
390            .with_ca_certificate_pem("-----BEGIN CERTIFICATE-----\nca\n-----END CERTIFICATE-----")
391            .with_client_certificate_pem("cert", planted_key());
392
393        let rendered = format!("{tls:?}");
394
395        assert!(
396            !rendered.contains(PLANTED),
397            "the private key reached `Debug`: {rendered}"
398        );
399        assert!(
400            rendered.contains("<redacted>"),
401            "the key should be visibly withheld rather than absent: {rendered}"
402        );
403    }
404
405    #[test]
406    fn debug_prints_shape_and_never_material() {
407        let files = TlsConfig::new()
408            .with_ca_certificate_file("/etc/ssl/private-ca.pem")
409            .with_client_certificate_files("/etc/ssl/app.crt", "/etc/ssl/app.key");
410
411        let rendered = format!("{files:?}");
412
413        // A path is a diagnostic, not a secret, and it is the question a
414        // debugger is actually asking.
415        assert!(rendered.contains("/etc/ssl/private-ca.pem"), "{rendered}");
416        assert!(rendered.contains("/etc/ssl/app.key"), "{rendered}");
417
418        let bytes = TlsConfig::new().with_ca_certificate_pem("cert-material-here");
419        let rendered = format!("{bytes:?}");
420
421        assert!(rendered.contains("<pem bytes>"), "{rendered}");
422        assert!(
423            !rendered.contains("cert-material-here"),
424            "even a certificate's bytes are noise in a log: {rendered}"
425        );
426    }
427
428    #[test]
429    fn a_planted_private_key_never_reaches_a_read_error() {
430        // A directory, so the read fails with the key still in hand: the
431        // error must name the path and nothing else.
432        let directory = std::env::temp_dir();
433        let tls = TlsConfig::new().with_client_certificate_files(&directory, &directory);
434
435        let error = tls
436            .client_certificate_pem("vault https://vault.internal path myapp/db")
437            .expect_err("a directory is not a PEM file");
438
439        let rendered = error.to_string();
440
441        assert!(!rendered.contains(PLANTED), "{rendered}");
442        assert!(rendered.contains("the client certificate"), "{rendered}");
443        assert!(
444            rendered.contains("vault https://vault.internal"),
445            "{rendered}"
446        );
447    }
448
449    #[test]
450    fn a_read_error_names_the_material_that_failed() {
451        let tls = TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem");
452
453        let error = tls
454            .ca_certificate_pem("consul http://consul:8500 key myapp/db.json")
455            .expect_err("the file is not there");
456
457        assert!(error.to_string().contains("the CA certificate"), "{error}");
458        assert!(
459            error.to_string().contains("/nonexistent/private-ca.pem"),
460            "{error}"
461        );
462    }
463
464    /// A store URL may embed `user:password@host`, and a `describe()` that
465    /// carries one must reach this module already redacted — this pins that
466    /// nothing here *un*-redacts it by, say, quoting a raw address instead.
467    #[test]
468    fn a_refusal_carries_the_description_it_was_given_and_adds_nothing() {
469        let error = unsupported(
470            "nats nats://***@nats.internal:4222 key db.json",
471            "a CA certificate from PEM bytes",
472            "name a file with `with_ca_certificate_file`",
473        );
474
475        let rendered = error.to_string();
476
477        assert!(
478            rendered.contains("nats://***@nats.internal:4222"),
479            "{rendered}"
480        );
481        assert!(!rendered.contains("hunter2"), "{rendered}");
482        assert!(
483            rendered.contains("refused rather than ignored"),
484            "{rendered}"
485        );
486    }
487
488    #[test]
489    fn bytes_and_a_file_resolve_to_the_same_material() {
490        let directory = std::env::temp_dir().join("dynamic-config-store-core-tls-test");
491        std::fs::create_dir_all(&directory).unwrap();
492        let path = directory.join("ca.pem");
493        std::fs::write(&path, b"-----BEGIN CERTIFICATE-----\nca\n").unwrap();
494
495        let from_file = TlsConfig::new().with_ca_certificate_file(&path);
496        let from_bytes =
497            TlsConfig::new().with_ca_certificate_pem(&b"-----BEGIN CERTIFICATE-----\nca\n"[..]);
498
499        assert_eq!(
500            from_file.ca_certificate_pem("store").unwrap(),
501            from_bytes.ca_certificate_pem("store").unwrap()
502        );
503
504        let _ = std::fs::remove_file(&path);
505    }
506
507    #[test]
508    fn an_empty_configuration_is_the_platform_trust_store() {
509        assert!(TlsConfig::new().is_empty());
510        assert!(!TlsConfig::new().with_ca_certificate_pem("x").is_empty());
511        assert!(!TlsConfig::new()
512            .with_client_certificate_pem("c", "k")
513            .is_empty());
514    }
515
516    #[test]
517    fn a_path_is_offered_only_by_the_file_spelling() {
518        let files = TlsConfig::new().with_ca_certificate_file("/etc/ssl/ca.pem");
519        assert_eq!(
520            files.ca_certificate().and_then(Pem::path),
521            Some(Path::new("/etc/ssl/ca.pem"))
522        );
523
524        let bytes = TlsConfig::new().with_ca_certificate_pem("x");
525        assert_eq!(bytes.ca_certificate().and_then(Pem::path), None);
526    }
527}