Skip to main content

polyc_crypto/
tls.rs

1//! Shared CA-bundle PEM loading for TLS trust stores.
2//!
3//! Every trust-store consumer (the #1167
4//! control-plane<->harness mTLS pair) needs the same fail-closed
5//! "read this PEM file, add every certificate in it to a root store, error
6//! loudly if the file is unreadable or carries none" shape. One shared
7//! loader means that failure-loudness behavior can't drift between call
8//! sites.
9
10use std::path::Path;
11use std::sync::Arc;
12
13use rustls::RootCertStore;
14use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
15
16/// A CA bundle failed to load into a [`RootCertStore`].
17#[derive(Debug, thiserror::Error)]
18pub enum CaLoadError {
19    /// The file couldn't be opened or read.
20    #[error("read CA file {path}: {source}")]
21    Read {
22        /// The path that failed to read.
23        path: String,
24        /// The underlying I/O/PEM-decode error.
25        #[source]
26        source: rustls::pki_types::pem::Error,
27    },
28    /// A certificate in the file was structurally invalid.
29    #[error("parse CA certificate in {path}: {source}")]
30    Parse {
31        /// The file the invalid certificate came from.
32        path: String,
33        /// The underlying PEM-decode error.
34        #[source]
35        source: rustls::pki_types::pem::Error,
36    },
37    /// A certificate was well-formed but rustls rejected it (e.g. an
38    /// unsupported signature algorithm).
39    #[error("add CA certificate from {path} to root store: {source}")]
40    Reject {
41        /// The file the rejected certificate came from.
42        path: String,
43        /// The underlying rustls error.
44        #[source]
45        source: rustls::Error,
46    },
47    /// The file parsed but named zero certificates — an explicitly
48    /// configured CA that's empty or malformed in a way that doesn't error.
49    #[error("CA file {path} contained no certificates")]
50    Empty {
51        /// The empty file's path.
52        path: String,
53    },
54}
55
56/// Load every certificate in the PEM file `path` into `roots`.
57///
58/// Fail-closed: refusing to silently produce a verifier that trusts nothing
59/// (which would reject every real connection with no signal *why*) is the
60/// caller's job — this function only reports the file-level problem
61/// (unreadable, unparseable, empty) so the caller can refuse to start rather
62/// than guess.
63///
64/// # Errors
65///
66/// Returns [`CaLoadError`] if the file can't be read, contains an
67/// unparseable or rustls-rejected certificate, or names no certificates at
68/// all.
69pub fn load_ca_into(roots: &mut RootCertStore, path: &str) -> Result<(), CaLoadError> {
70    let mut added = 0usize;
71    for cert in CertificateDer::pem_file_iter(path).map_err(|source| CaLoadError::Read {
72        path: path.to_owned(),
73        source,
74    })? {
75        let cert = cert.map_err(|source| CaLoadError::Parse {
76            path: path.to_owned(),
77            source,
78        })?;
79        roots.add(cert).map_err(|source| CaLoadError::Reject {
80            path: path.to_owned(),
81            source,
82        })?;
83        added += 1;
84    }
85    if added == 0 {
86        return Err(CaLoadError::Empty {
87            path: path.to_owned(),
88        });
89    }
90    Ok(())
91}
92
93/// Load every certificate in the in-memory PEM bundle `pem` into `roots`.
94///
95/// Same fail-closed contract and error shape as [`load_ca_into`], for a CA
96/// bundle already held in memory (e.g. read from a Kubernetes Secret) rather
97/// than a file on disk. `label` identifies the source in error messages
98/// (there's no path to report).
99///
100/// # Errors
101///
102/// Returns [`CaLoadError`] if `pem` contains an unparseable or
103/// rustls-rejected certificate, or names no certificates at all.
104pub fn load_ca_pem_into(
105    roots: &mut RootCertStore,
106    label: &str,
107    pem: &[u8],
108) -> Result<(), CaLoadError> {
109    let mut added = 0usize;
110    for cert in CertificateDer::pem_slice_iter(pem) {
111        let cert = cert.map_err(|source| CaLoadError::Parse {
112            path: label.to_owned(),
113            source,
114        })?;
115        roots.add(cert).map_err(|source| CaLoadError::Reject {
116            path: label.to_owned(),
117            source,
118        })?;
119        added += 1;
120    }
121    if added == 0 {
122        return Err(CaLoadError::Empty {
123            path: label.to_owned(),
124        });
125    }
126    Ok(())
127}
128
129/// Which piece of a mutual-TLS client identity one load failure names.
130///
131/// An enum rather than a string, so the set a reader is promised and the set
132/// [`mutual_tls_client_config`] emits cannot drift apart: adding a piece here
133/// is a compile error at every match over it.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum IdentityPart {
136    /// The authority bundle the listener's own leaf is verified against.
137    AuthorityCertificate,
138    /// The caller's own certificate chain.
139    ClientCertificate,
140    /// The private key that chain is presented with.
141    ClientKey,
142    /// The assembled pair, which rustls refused as a unit rather than naming
143    /// either half.
144    CertificateAndKey,
145}
146
147impl std::fmt::Display for IdentityPart {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.write_str(match self {
150            Self::AuthorityCertificate => "authority certificate",
151            Self::ClientCertificate => "client certificate",
152            Self::ClientKey => "client key",
153            Self::CertificateAndKey => "certificate and key",
154        })
155    }
156}
157
158/// A mutual-TLS client identity failed to load.
159///
160/// `subject` names whose identity it is, in the caller's own words, so one
161/// shared loader can serve several callers without any of them reporting a
162/// failure as someone else's, and `part` names which piece of that identity
163/// went wrong. The cause is boxed to keep the error small: `rustls::Error`
164/// alone is wide enough that carrying [`ClientIdentityCause`] inline trips
165/// `clippy::result_large_err` on every `Result` in this module.
166#[derive(Debug, thiserror::Error)]
167#[error("the {part} for the {subject} at {path} did not load")]
168pub struct ClientIdentityError {
169    /// Whose identity was being loaded.
170    subject: String,
171    /// Which piece of that identity went wrong.
172    part: IdentityPart,
173    /// The path the piece was read from.
174    path: String,
175    /// What actually went wrong.
176    #[source]
177    source: Box<ClientIdentityCause>,
178}
179
180impl ClientIdentityError {
181    /// Returns which piece of the identity failed.
182    #[must_use]
183    pub const fn part(&self) -> IdentityPart {
184        self.part
185    }
186
187    /// Returns what went wrong with that piece.
188    #[must_use]
189    pub const fn cause(&self) -> &ClientIdentityCause {
190        &self.source
191    }
192}
193
194/// What went wrong with one piece of a mutual-TLS client identity.
195#[derive(Debug, thiserror::Error)]
196pub enum ClientIdentityCause {
197    /// The authority bundle did not yield a usable trust store.
198    #[error(transparent)]
199    Authority(#[from] CaLoadError),
200    /// The file could not be opened or read.
201    #[error(transparent)]
202    Read(#[from] std::io::Error),
203    /// The PEM contents did not decode.
204    #[error("{0}")]
205    Parse(String),
206    /// rustls refused the assembled certificate and key.
207    #[error(transparent)]
208    Build(#[from] rustls::Error),
209}
210
211/// Builds the mutual-TLS client configuration `subject` presents to a listener
212/// that admits callers by client certificate.
213///
214/// One loader, because a second hand-rolled copy is how two callers of the same
215/// authenticated listener come to disagree about what they present: `ca` is the
216/// authority the server's leaf is verified against, and `cert`/`key` are the
217/// caller's own leaf, whose digest is the workload identity the listener admits
218/// it under. Hostname verification stays the ordinary one — the server's leaf
219/// carries the name it is dialed by.
220///
221/// Fail-closed throughout: an unreadable, unparseable, or empty piece is an
222/// error rather than a configuration that trusts nothing and refuses every real
223/// connection with no signal why.
224///
225/// # Errors
226///
227/// Returns [`ClientIdentityError`] when the authority bundle does not load,
228/// when either the certificate chain or the private key cannot be read or
229/// parsed, or when rustls refuses the assembled pair.
230pub fn mutual_tls_client_config(
231    subject: &str,
232    ca: &Path,
233    cert: &Path,
234    key: &Path,
235) -> Result<Arc<rustls::ClientConfig>, ClientIdentityError> {
236    let fail = |part: IdentityPart, path: &Path, cause: ClientIdentityCause| ClientIdentityError {
237        subject: subject.to_owned(),
238        part,
239        path: path.display().to_string(),
240        source: Box::new(cause),
241    };
242    let read = |path: &Path, part: IdentityPart| -> Result<Vec<u8>, ClientIdentityError> {
243        std::fs::read(path).map_err(|err| fail(part, path, err.into()))
244    };
245
246    let ca_pem = read(ca, IdentityPart::AuthorityCertificate)?;
247    let mut roots = RootCertStore::empty();
248    load_ca_pem_into(&mut roots, subject, &ca_pem)
249        .map_err(|err| fail(IdentityPart::AuthorityCertificate, ca, err.into()))?;
250
251    let cert_pem = read(cert, IdentityPart::ClientCertificate)?;
252    let chain: Vec<CertificateDer<'static>> = CertificateDer::pem_slice_iter(&cert_pem)
253        .collect::<Result<_, _>>()
254        .map_err(|err| {
255            fail(
256                IdentityPart::ClientCertificate,
257                cert,
258                ClientIdentityCause::Parse(err.to_string()),
259            )
260        })?;
261
262    let key_pem = read(key, IdentityPart::ClientKey)?;
263    let private = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|err| {
264        fail(
265            IdentityPart::ClientKey,
266            key,
267            ClientIdentityCause::Parse(err.to_string()),
268        )
269    })?;
270
271    rustls::ClientConfig::builder()
272        .with_root_certificates(roots)
273        .with_client_auth_cert(chain, private)
274        .map(Arc::new)
275        .map_err(|err| fail(IdentityPart::CertificateAndKey, cert, err.into()))
276}
277
278#[cfg(test)]
279mod tests {
280    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
281    use super::*;
282
283    #[test]
284    fn missing_file_is_a_read_error() {
285        let mut roots = RootCertStore::empty();
286        let err = load_ca_into(&mut roots, "/nonexistent/ca.pem").unwrap_err();
287        assert!(matches!(err, CaLoadError::Read { .. }), "got {err:?}");
288        assert!(err.to_string().contains("ca.pem"), "got: {err}");
289    }
290
291    #[test]
292    fn empty_file_is_an_empty_error() {
293        let dir = std::env::temp_dir();
294        let path = dir.join(format!("polyc-crypto-tls-test-{}.pem", std::process::id()));
295        std::fs::write(&path, b"").unwrap();
296        let mut roots = RootCertStore::empty();
297        let err = load_ca_into(&mut roots, path.to_str().unwrap()).unwrap_err();
298        std::fs::remove_file(&path).ok();
299        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
300    }
301
302    #[test]
303    fn empty_pem_bytes_is_an_empty_error() {
304        let mut roots = RootCertStore::empty();
305        let err = load_ca_pem_into(&mut roots, "test CA", b"").unwrap_err();
306        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
307        assert!(err.to_string().contains("test CA"), "got: {err}");
308    }
309
310    #[test]
311    fn a_missing_client_identity_names_its_subject_and_the_piece_that_is_gone() {
312        let missing = Path::new("/nonexistent/polychrome/ca.crt");
313        let err = mutual_tls_client_config("State client", missing, missing, missing).unwrap_err();
314        assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
315        assert!(
316            matches!(err.cause(), ClientIdentityCause::Read(_)),
317            "got {:?}",
318            err.cause()
319        );
320        let rendered = err.to_string();
321        assert_eq!(
322            rendered,
323            "the authority certificate for the State client at /nonexistent/polychrome/ca.crt \
324             did not load"
325        );
326    }
327
328    /// Every piece reads as a sentence beside its subject.
329    ///
330    /// The subject is a caller's own noun phrase and the piece is this
331    /// module's, so the two meet in one line a person reads. The earlier
332    /// wording put them adjacent and produced "the State client client
333    /// certificate"; this pins that they no longer collide, for every piece
334    /// rather than the one an easy-to-reach test happens to hit.
335    #[test]
336    fn every_identity_piece_reads_beside_its_subject() {
337        for (part, expected) in [
338            (
339                IdentityPart::AuthorityCertificate,
340                "the authority certificate for the State client at /x did not load",
341            ),
342            (
343                IdentityPart::ClientCertificate,
344                "the client certificate for the State client at /x did not load",
345            ),
346            (
347                IdentityPart::ClientKey,
348                "the client key for the State client at /x did not load",
349            ),
350            (
351                IdentityPart::CertificateAndKey,
352                "the certificate and key for the State client at /x did not load",
353            ),
354        ] {
355            let err = ClientIdentityError {
356                subject: "State client".to_owned(),
357                part,
358                path: "/x".to_owned(),
359                source: Box::new(ClientIdentityCause::Parse("unused".to_owned())),
360            };
361            assert_eq!(err.to_string(), expected);
362        }
363    }
364
365    #[test]
366    fn an_empty_authority_bundle_is_refused_rather_than_trusting_nothing() {
367        let dir = std::env::temp_dir();
368        let ca = dir.join(format!("polyc-crypto-mtls-{}.pem", std::process::id()));
369        std::fs::write(&ca, b"").unwrap();
370        let err = mutual_tls_client_config("State client", &ca, &ca, &ca).unwrap_err();
371        std::fs::remove_file(&ca).ok();
372        assert_eq!(err.part(), IdentityPart::AuthorityCertificate);
373        assert!(
374            matches!(
375                err.cause(),
376                ClientIdentityCause::Authority(CaLoadError::Empty { .. })
377            ),
378            "got {:?}",
379            err.cause()
380        );
381    }
382}