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 substrate `ateapi` client, 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 rustls::RootCertStore;
11use rustls::pki_types::{CertificateDer, pem::PemObject};
12
13/// A CA bundle failed to load into a [`RootCertStore`].
14#[derive(Debug, thiserror::Error)]
15pub enum CaLoadError {
16    /// The file couldn't be opened or read.
17    #[error("read CA file {path}: {source}")]
18    Read {
19        /// The path that failed to read.
20        path: String,
21        /// The underlying I/O/PEM-decode error.
22        #[source]
23        source: rustls::pki_types::pem::Error,
24    },
25    /// A certificate in the file was structurally invalid.
26    #[error("parse CA certificate in {path}: {source}")]
27    Parse {
28        /// The file the invalid certificate came from.
29        path: String,
30        /// The underlying PEM-decode error.
31        #[source]
32        source: rustls::pki_types::pem::Error,
33    },
34    /// A certificate was well-formed but rustls rejected it (e.g. an
35    /// unsupported signature algorithm).
36    #[error("add CA certificate from {path} to root store: {source}")]
37    Reject {
38        /// The file the rejected certificate came from.
39        path: String,
40        /// The underlying rustls error.
41        #[source]
42        source: rustls::Error,
43    },
44    /// The file parsed but named zero certificates — an explicitly
45    /// configured CA that's empty or malformed in a way that doesn't error.
46    #[error("CA file {path} contained no certificates")]
47    Empty {
48        /// The empty file's path.
49        path: String,
50    },
51}
52
53/// Load every certificate in the PEM file `path` into `roots`.
54///
55/// Fail-closed: refusing to silently produce a verifier that trusts nothing
56/// (which would reject every real connection with no signal *why*) is the
57/// caller's job — this function only reports the file-level problem
58/// (unreadable, unparseable, empty) so the caller can refuse to start rather
59/// than guess.
60///
61/// # Errors
62///
63/// Returns [`CaLoadError`] if the file can't be read, contains an
64/// unparseable or rustls-rejected certificate, or names no certificates at
65/// all.
66pub fn load_ca_into(roots: &mut RootCertStore, path: &str) -> Result<(), CaLoadError> {
67    let mut added = 0usize;
68    for cert in CertificateDer::pem_file_iter(path).map_err(|source| CaLoadError::Read {
69        path: path.to_owned(),
70        source,
71    })? {
72        let cert = cert.map_err(|source| CaLoadError::Parse {
73            path: path.to_owned(),
74            source,
75        })?;
76        roots.add(cert).map_err(|source| CaLoadError::Reject {
77            path: path.to_owned(),
78            source,
79        })?;
80        added += 1;
81    }
82    if added == 0 {
83        return Err(CaLoadError::Empty {
84            path: path.to_owned(),
85        });
86    }
87    Ok(())
88}
89
90/// Load every certificate in the in-memory PEM bundle `pem` into `roots`.
91///
92/// Same fail-closed contract and error shape as [`load_ca_into`], for a CA
93/// bundle already held in memory (e.g. read from a Kubernetes Secret) rather
94/// than a file on disk. `label` identifies the source in error messages
95/// (there's no path to report).
96///
97/// # Errors
98///
99/// Returns [`CaLoadError`] if `pem` contains an unparseable or
100/// rustls-rejected certificate, or names no certificates at all.
101pub fn load_ca_pem_into(
102    roots: &mut RootCertStore,
103    label: &str,
104    pem: &[u8],
105) -> Result<(), CaLoadError> {
106    let mut added = 0usize;
107    for cert in CertificateDer::pem_slice_iter(pem) {
108        let cert = cert.map_err(|source| CaLoadError::Parse {
109            path: label.to_owned(),
110            source,
111        })?;
112        roots.add(cert).map_err(|source| CaLoadError::Reject {
113            path: label.to_owned(),
114            source,
115        })?;
116        added += 1;
117    }
118    if added == 0 {
119        return Err(CaLoadError::Empty {
120            path: label.to_owned(),
121        });
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
129    use super::*;
130
131    #[test]
132    fn missing_file_is_a_read_error() {
133        let mut roots = RootCertStore::empty();
134        let err = load_ca_into(&mut roots, "/nonexistent/ca.pem").unwrap_err();
135        assert!(matches!(err, CaLoadError::Read { .. }), "got {err:?}");
136        assert!(err.to_string().contains("ca.pem"), "got: {err}");
137    }
138
139    #[test]
140    fn empty_file_is_an_empty_error() {
141        let dir = std::env::temp_dir();
142        let path = dir.join(format!("polyc-crypto-tls-test-{}.pem", std::process::id()));
143        std::fs::write(&path, b"").unwrap();
144        let mut roots = RootCertStore::empty();
145        let err = load_ca_into(&mut roots, path.to_str().unwrap()).unwrap_err();
146        std::fs::remove_file(&path).ok();
147        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
148    }
149
150    #[test]
151    fn empty_pem_bytes_is_an_empty_error() {
152        let mut roots = RootCertStore::empty();
153        let err = load_ca_pem_into(&mut roots, "test CA", b"").unwrap_err();
154        assert!(matches!(err, CaLoadError::Empty { .. }), "got {err:?}");
155        assert!(err.to_string().contains("test CA"), "got: {err}");
156    }
157}