Skip to main content

soth_mitm/
ca.rs

1use rcgen::{
2    BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, KeyUsagePurpose,
3};
4
5use crate::ca_trust;
6use crate::{CaError, MitmError};
7
8/// A certificate authority used for TLS interception.
9///
10/// Obtain one via [`generate_ca`], [`load_ca`], or [`load_ca_from_files`].
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CertificateAuthority {
13    pub cert_pem: Vec<u8>,
14    pub(crate) key_pem: Vec<u8>,
15    pub fingerprint: String,
16}
17
18impl CertificateAuthority {
19    /// Returns the CA private key in PEM format.
20    ///
21    /// This is sensitive material — avoid logging or persisting without
22    /// appropriate access controls.
23    pub fn key_pem(&self) -> &[u8] {
24        &self.key_pem
25    }
26}
27
28/// Generates a new self-signed CA keypair for TLS interception.
29pub fn generate_ca() -> Result<CertificateAuthority, CaError> {
30    let key = KeyPair::generate().map_err(|error| CaError::InvalidMaterial(error.to_string()))?;
31    let mut params = CertificateParams::default();
32    params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
33    params.key_usages = vec![
34        KeyUsagePurpose::DigitalSignature,
35        KeyUsagePurpose::KeyCertSign,
36        KeyUsagePurpose::CrlSign,
37    ];
38
39    let mut dn = DistinguishedName::new();
40    dn.push(DnType::CommonName, "soth-mitm Local CA");
41    dn.push(DnType::OrganizationName, "soth-mitm");
42    params.distinguished_name = dn;
43
44    let cert = params
45        .self_signed(&key)
46        .map_err(|error| CaError::InvalidMaterial(error.to_string()))?;
47
48    let cert_pem = cert.pem().into_bytes();
49    let key_pem = key.serialize_pem().into_bytes();
50
51    Ok(CertificateAuthority {
52        fingerprint: fingerprint_from_pem(&cert_pem),
53        cert_pem,
54        key_pem,
55    })
56}
57
58/// Loads a CA from in-memory PEM-encoded certificate and key bytes.
59pub fn load_ca(cert: &[u8], key: &[u8]) -> Result<CertificateAuthority, CaError> {
60    if cert.is_empty() {
61        return Err(CaError::InvalidMaterial(
62            "certificate PEM must not be empty".to_string(),
63        ));
64    }
65    if key.is_empty() {
66        return Err(CaError::InvalidMaterial(
67            "private key PEM must not be empty".to_string(),
68        ));
69    }
70
71    Ok(CertificateAuthority {
72        cert_pem: cert.to_vec(),
73        key_pem: key.to_vec(),
74        fingerprint: fingerprint_from_pem(cert),
75    })
76}
77
78/// Loads a CA from PEM files on disk.
79pub fn load_ca_from_files(
80    cert_path: impl AsRef<std::path::Path>,
81    key_path: impl AsRef<std::path::Path>,
82) -> Result<CertificateAuthority, CaError> {
83    let cert = std::fs::read(cert_path.as_ref()).map_err(|error| {
84        if error.kind() == std::io::ErrorKind::PermissionDenied {
85            return CaError::PermissionDenied {
86                operation: "read_ca_cert".to_string(),
87                detail: error.to_string(),
88            };
89        }
90        CaError::Io(error)
91    })?;
92
93    let key = std::fs::read(key_path.as_ref()).map_err(|error| {
94        if error.kind() == std::io::ErrorKind::PermissionDenied {
95            return CaError::PermissionDenied {
96                operation: "read_ca_key".to_string(),
97                detail: error.to_string(),
98            };
99        }
100        CaError::Io(error)
101    })?;
102
103    load_ca(&cert, &key)
104}
105
106/// Installs the CA into the system trust store (platform-specific).
107pub fn install_ca_system_trust(_ca: &CertificateAuthority) -> Result<(), CaError> {
108    ca_trust::install(_ca)
109}
110
111/// Removes the soth-mitm CA from the system trust store.
112pub fn uninstall_ca_system_trust() -> Result<(), CaError> {
113    ca_trust::uninstall()
114}
115
116/// Checks whether a CA with the given fingerprint is installed in the system trust store.
117pub fn is_ca_trusted(_fingerprint: &str) -> Result<bool, CaError> {
118    ca_trust::is_trusted(_fingerprint)
119}
120
121#[allow(dead_code)]
122pub(crate) fn map_ca_error_to_mitm_error(error: CaError) -> MitmError {
123    match error {
124        CaError::PermissionDenied { operation, detail } => {
125            MitmError::CaLoadFailed(format!("permission denied for {operation}: {detail}"))
126        }
127        CaError::OperationFailed(detail) => MitmError::CaOperationFailed(detail),
128        CaError::InvalidMaterial(detail) => MitmError::CaLoadFailed(detail),
129        CaError::UnsupportedOperation(detail) => MitmError::CaOperationFailed(detail),
130        CaError::Io(error) => MitmError::CaLoadFailed(error.to_string()),
131    }
132}
133
134#[allow(dead_code)]
135pub(crate) fn load_ca_for_startup(
136    cert: &[u8],
137    key: &[u8],
138) -> Result<CertificateAuthority, MitmError> {
139    load_ca(cert, key).map_err(map_ca_error_to_mitm_error)
140}
141
142fn fingerprint_from_pem(cert: &[u8]) -> String {
143    let mut rendered = String::with_capacity(2 * cert.len().min(16) + 4);
144    for byte in cert.iter().take(16) {
145        rendered.push(hex_digit(byte >> 4));
146        rendered.push(hex_digit(byte & 0x0f));
147    }
148    rendered.push(':');
149    rendered.push_str(&cert.len().to_string());
150    rendered
151}
152
153fn hex_digit(value: u8) -> char {
154    match value {
155        0..=9 => (b'0' + value) as char,
156        10..=15 => (b'a' + (value - 10)) as char,
157        _ => '0',
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::{generate_ca, load_ca, load_ca_for_startup, map_ca_error_to_mitm_error};
164    use crate::{CaError, MitmError};
165
166    #[test]
167    fn ca_generate_load_api_contract() {
168        let generated = generate_ca().expect("generate ca");
169        let loaded = load_ca(&generated.cert_pem, &generated.key_pem).expect("load generated ca");
170        assert!(!loaded.fingerprint.is_empty());
171        assert_eq!(loaded.cert_pem, generated.cert_pem);
172        assert_eq!(loaded.key_pem, generated.key_pem);
173    }
174
175    #[test]
176    fn ca_permission_denied_error_mapping() {
177        let mapped = map_ca_error_to_mitm_error(CaError::PermissionDenied {
178            operation: "read_ca_cert".to_string(),
179            detail: "os error 13".to_string(),
180        });
181        match mapped {
182            MitmError::CaLoadFailed(detail) => {
183                assert!(detail.contains("permission denied"));
184                assert!(detail.contains("read_ca_cert"));
185            }
186            other => panic!("unexpected mapped error: {other}"),
187        }
188    }
189
190    #[test]
191    fn startup_fails_with_ca_load_failed_when_ca_invalid() {
192        let error = load_ca_for_startup(b"", b"key").expect_err("invalid ca should fail");
193        match error {
194            MitmError::CaLoadFailed(detail) => {
195                assert!(detail.contains("certificate PEM must not be empty"));
196            }
197            other => panic!("unexpected startup error: {other}"),
198        }
199    }
200}