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}