1use std::path::Path;
11use std::sync::Arc;
12
13use rustls::RootCertStore;
14use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
15
16#[derive(Debug, thiserror::Error)]
18pub enum CaLoadError {
19 #[error("read CA file {path}: {source}")]
21 Read {
22 path: String,
24 #[source]
26 source: rustls::pki_types::pem::Error,
27 },
28 #[error("parse CA certificate in {path}: {source}")]
30 Parse {
31 path: String,
33 #[source]
35 source: rustls::pki_types::pem::Error,
36 },
37 #[error("add CA certificate from {path} to root store: {source}")]
40 Reject {
41 path: String,
43 #[source]
45 source: rustls::Error,
46 },
47 #[error("CA file {path} contained no certificates")]
50 Empty {
51 path: String,
53 },
54}
55
56pub 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
93pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub enum IdentityPart {
136 AuthorityCertificate,
138 ClientCertificate,
140 ClientKey,
142 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#[derive(Debug, thiserror::Error)]
167#[error("the {part} for the {subject} at {path} did not load")]
168pub struct ClientIdentityError {
169 subject: String,
171 part: IdentityPart,
173 path: String,
175 #[source]
177 source: Box<ClientIdentityCause>,
178}
179
180impl ClientIdentityError {
181 #[must_use]
183 pub const fn part(&self) -> IdentityPart {
184 self.part
185 }
186
187 #[must_use]
189 pub const fn cause(&self) -> &ClientIdentityCause {
190 &self.source
191 }
192}
193
194#[derive(Debug, thiserror::Error)]
196pub enum ClientIdentityCause {
197 #[error(transparent)]
199 Authority(#[from] CaLoadError),
200 #[error(transparent)]
202 Read(#[from] std::io::Error),
203 #[error("{0}")]
205 Parse(String),
206 #[error(transparent)]
208 Build(#[from] rustls::Error),
209}
210
211pub 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 #[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}