Skip to main content

krabka_security/
jwks_trust.rs

1//! Build a rustls `ClientConfig` whose trust roots are exclusively the
2//! certificates in a user-supplied PEM bundle.
3//!
4//! This backs the broker's outbound HTTPS to the JWKS endpoint when the
5//! operator configures a private `IdP` CA. It follows the Strimzi-shaped
6//! tlsTrustedCertificates "replace" semantic: when a caller uses this config,
7//! rustls does not consult webpki-roots.
8
9use std::{
10    io,
11    path::{Path, PathBuf},
12    sync::Arc,
13};
14
15use rustls::pki_types::{CertificateDer, pem::PemObject};
16use thiserror::Error;
17
18#[derive(Debug, Error)]
19pub enum JwksTrustError {
20    #[error("read {0}: {1}")]
21    Io(PathBuf, io::Error),
22    #[error("no certificates in {0}")]
23    Empty(PathBuf),
24    #[error("rustls add cert: {0}")]
25    Rustls(#[from] rustls::Error),
26}
27
28/// Read a PEM bundle of one or more CA certificates and produce a
29/// `rustls::ClientConfig` that trusts exactly those certificates.
30///
31/// The returned config has no client auth. The broker does not present a client
32/// cert when it fetches the JWKS endpoint.
33// JWKS-endpoint trust-anchor setup. skip_all keeps the loaded certs out of
34// span fields (the PEM path is not secret but adds no useful cardinality here).
35// `err` surfaces IO / parse / rustls failures (Debug).
36#[tracing::instrument(level = "info", skip_all, err)]
37/// # Errors
38/// Returns an error when credentials or key material are invalid, cryptographic verification fails, or the TLS, SASL, or Kerberos exchange is rejected.
39pub fn build_client_config_from_pem(
40    path: &Path,
41) -> Result<Arc<rustls::ClientConfig>, JwksTrustError> {
42    let certs: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(path)
43        .map_err(|e| JwksTrustError::Io(path.into(), io::Error::other(e.to_string())))?
44        .collect::<Result<Vec<_>, _>>()
45        .map_err(|e| JwksTrustError::Io(path.into(), io::Error::other(e.to_string())))?;
46    if certs.is_empty() {
47        return Err(JwksTrustError::Empty(path.into()));
48    }
49    let mut roots = rustls::RootCertStore::empty();
50    for cert in certs {
51        roots.add(cert)?;
52    }
53    Ok(Arc::new(
54        rustls::ClientConfig::builder()
55            .with_root_certificates(roots)
56            .with_no_client_auth(),
57    ))
58}
59
60#[cfg(test)]
61mod tests {
62    use std::{fs::File, io::Write};
63
64    use rustls::crypto::ring::default_provider;
65
66    use super::*;
67
68    fn install_provider() {
69        // rustls requires an explicit CryptoProvider when no default feature
70        // is compiled in. Mirrors the pattern in tls.rs.
71        let _ = default_provider().install_default();
72    }
73
74    fn dev_cert_pem() -> &'static str {
75        include_str!("../tests/fixtures/dev_cert.pem")
76    }
77
78    #[test]
79    fn build_client_config_from_pem_loads_single_cert() {
80        install_provider();
81        let dir = tempfile::tempdir().unwrap();
82        let path = dir.path().join("ca.pem");
83        File::create(&path)
84            .unwrap()
85            .write_all(dev_cert_pem().as_bytes())
86            .unwrap();
87        let cfg = build_client_config_from_pem(&path).expect("single cert PEM should load");
88        // Smoke-check it's actually a ClientConfig (compile-level type
89        // check is the main assertion; runtime use is exercised by HTTPS
90        // integration tests).
91        let _: Arc<rustls::ClientConfig> = cfg;
92    }
93
94    #[test]
95    fn build_client_config_from_pem_loads_concatenated_chain() {
96        install_provider();
97        let dir = tempfile::tempdir().unwrap();
98        let path = dir.path().join("ca-chain.pem");
99        let mut f = File::create(&path).unwrap();
100        f.write_all(dev_cert_pem().as_bytes()).unwrap();
101        f.write_all(b"\n").unwrap();
102        f.write_all(dev_cert_pem().as_bytes()).unwrap();
103        let cfg = build_client_config_from_pem(&path);
104        assert2::assert!(cfg.is_ok());
105    }
106
107    #[test]
108    fn build_client_config_from_pem_rejects_missing_file() {
109        install_provider();
110        let err = build_client_config_from_pem(Path::new("/nonexistent/path.pem")).unwrap_err();
111        assert2::assert!(matches!(err, JwksTrustError::Io(_, _)));
112    }
113
114    #[test]
115    fn build_client_config_from_pem_rejects_empty_pem() {
116        install_provider();
117        let dir = tempfile::tempdir().unwrap();
118        let path = dir.path().join("empty.pem");
119        File::create(&path)
120            .unwrap()
121            .write_all(b"# no certs in this file\n")
122            .unwrap();
123        let err = build_client_config_from_pem(&path).unwrap_err();
124        // Either Empty (parser returned zero certs) or Io (parser
125        // errored on the "comment-only" content). Both are acceptable
126        // rejection shapes — both reach the right outcome (no
127        // ClientConfig produced).
128        assert2::assert!(matches!(
129            err,
130            JwksTrustError::Empty(_) | JwksTrustError::Io(_, _)
131        ));
132    }
133
134    #[test]
135    fn build_client_config_from_pem_rejects_non_pem_garbage() {
136        install_provider();
137        let dir = tempfile::tempdir().unwrap();
138        let path = dir.path().join("garbage.bin");
139        File::create(&path).unwrap().write_all(&[0u8; 64]).unwrap();
140        let err = build_client_config_from_pem(&path);
141        assert2::assert!(err.is_err());
142    }
143}