Skip to main content

bitwarden_access_connector/integrations/active_directory/
tls.rs

1use std::{path::Path, sync::Arc};
2
3use bitwarden_pam::RotationFailureCode;
4use rustls::{
5    ClientConfig, RootCertStore,
6    pki_types::{CertificateDer, pem::PemObject as _},
7};
8use rustls_platform_verifier::Verifier;
9
10use super::classify::{Phase, target_unreachable};
11use crate::{
12    error::{Retry, SafeDetail},
13    integrations::IntegrationError,
14};
15
16/// Verifies the DC's certificate with the platform verifier, the same one the connector's HTTPS
17/// uses, plus the PEM file's anchors if given, never the file alone. Failing to load the file is
18/// fatal rather than a quiet fallback to platform trust, and the error carries a fixed kind, never
19/// the path.
20pub(super) fn tls_config(
21    ca_certificate: Option<&str>,
22    phase: Phase,
23) -> Result<Arc<ClientConfig>, IntegrationError> {
24    let provider = Arc::new(rustls::crypto::ring::default_provider());
25    let verifier = match ca_certificate {
26        Some(path) => Verifier::new_with_extra_roots(read_anchors(path, phase)?, provider.clone()),
27        None => Verifier::new(provider.clone()),
28    }
29    .map_err(|_| target_unreachable(Retry::Permanent, phase.settled(), "TlsVerifierUnavailable"))?;
30    let config = ClientConfig::builder_with_provider(provider)
31        .with_safe_default_protocol_versions()
32        .map_err(|_| anchor_failure(phase, "TlsProviderUnavailable"))?
33        // rustls gates any non-default verifier behind `dangerous()`. This one is the platform
34        // verifier, so the OS (or webpki over system roots on Linux) still checks chain and host.
35        .dangerous()
36        .with_custom_certificate_verifier(Arc::new(verifier))
37        .with_no_client_auth();
38    Ok(Arc::new(config))
39}
40
41fn read_anchors(
42    path: &str,
43    phase: Phase,
44) -> Result<Vec<CertificateDer<'static>>, IntegrationError> {
45    let anchors: Vec<CertificateDer<'static>> = CertificateDer::pem_file_iter(Path::new(path))
46        .map_err(|_| anchor_failure(phase, "CaCertificateUnreadable"))?
47        .collect::<Result<Vec<_>, _>>()
48        .map_err(|_| anchor_failure(phase, "CaCertificateInvalid"))?;
49    if anchors.is_empty() {
50        return Err(anchor_failure(phase, "CaCertificateEmpty"));
51    }
52
53    // Reject an unusable anchor here, as `CaCertificateInvalid`, not as a generic verifier error.
54    let mut scratch = RootCertStore::empty();
55    for anchor in &anchors {
56        scratch
57            .add(anchor.clone())
58            .map_err(|_| anchor_failure(phase, "CaCertificateInvalid"))?;
59    }
60    Ok(anchors)
61}
62
63fn anchor_failure(phase: Phase, kind: &'static str) -> IntegrationError {
64    IntegrationError::new(
65        Retry::Permanent,
66        phase.settled(),
67        RotationFailureCode::CredentialsUnresolved,
68        SafeDetail::from_kind(kind),
69    )
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::integrations::TargetEffect;
76
77    /// A self-signed certificate used only as a parseable trust anchor.
78    const TEST_CA_PEM: &str = "\
79-----BEGIN CERTIFICATE-----\n\
80MIIDJTCCAg2gAwIBAgIUP9bWk+gm4n6VqzhqhQz9YDiwR/UwDQYJKoZIhvcNAQEL\n\
81BQAwIjEgMB4GA1UEAwwXcm90YXRpb24tZGFlbW9uLXRlc3QtY2EwHhcNMjYwODMx\n\
82MjIzODU5WhcNMzYwODI4MjIzODU5WjAiMSAwHgYDVQQDDBdyb3RhdGlvbi1kYWVt\n\
83b24tdGVzdC1jYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCVyUmM\n\
8486vormZ7E/vOxdZMvJOYVjp17OrAI1tPWq0PywOizZ1gJORWXjArJFT0PoG2OqmA\n\
85hafaOoZTeZH0J4DwEjgGVn/rGVIa8f5a8qjfnJdDn23fnqa2Wd+9Tf1/LDeCUZil\n\
867jZ4NqtddZe+GOkkWJXB+55u7JhxPyX/AdfVK9idmGANXGuCOUZhTLSnz+p5SrLP\n\
87WWDgQeAHTvFlWcpu8eff5Agj5cdhqyJiBcpaLzga0xiUifpdG4yEU/7H/909XKOQ\n\
88/O0wiPwnObHwOVFALDppeHo9FtGmvK0OQArxgo40gaeWEvctURyXc6Lqu5u8eZXV\n\
89MX5nIS+hPAYLZ50CAwEAAaNTMFEwHQYDVR0OBBYEFE7MLaoHaLKLcBeZsdAfGTk0\n\
900ToLMB8GA1UdIwQYMBaAFE7MLaoHaLKLcBeZsdAfGTk00ToLMA8GA1UdEwEB/wQF\n\
91MAMBAf8wDQYJKoZIhvcNAQELBQADggEBAJyvTvcTCL6s3MPlhYHrcCAfc6/PcVro\n\
92NvLM9fOUvmlqdlyXyvwZ/FOwGogLUSVVR+68wLQ4NFac4nhyeaApiFsKoHP+bA6X\n\
93bsgmmsTYuH0fw43szFTlI+Lw2a8Ai9Nx8JTeyILL9FzCV6ranWW7YcQBEPTvpfB7\n\
94vBWeCGTA+sHqgBbltU/5jsWYslt9YhOpxkoJHHxyt2JA8XYCpdSkfgFOsjReofHn\n\
95SavmVaBabR7Vq3QVTfvp+/WO41z7hGKsGAZ2dVldkHeOjgu4oktTOeblDbnZogZ2\n\
96D2kYx4ka5O/dEuqcrMf/qd6GW5H2PJcrdc4PtRJeIBDR0TgweD2xjJ0=\n\
97-----END CERTIFICATE-----\n";
98
99    fn write_temp(contents: &str) -> tempfile::NamedTempFile {
100        use std::io::Write as _;
101        let mut f = tempfile::NamedTempFile::new().expect("temp file");
102        f.write_all(contents.as_bytes()).expect("write pem");
103        f.flush().expect("flush");
104        f
105    }
106
107    #[test]
108    fn valid_ca_certificate_builds_a_client_config() {
109        let f = write_temp(TEST_CA_PEM);
110        let path = f.path().to_str().expect("utf8 path");
111        assert!(tls_config(Some(path), Phase::BeforeWrite).is_ok());
112    }
113
114    #[test]
115    fn no_ca_certificate_builds_a_client_config() {
116        assert!(tls_config(None, Phase::BeforeWrite).is_ok());
117    }
118
119    #[test]
120    fn missing_ca_certificate_file_is_fatal() {
121        let err = tls_config(
122            Some("/nonexistent/path/to/ca-bundle-2f8a1c.pem"),
123            Phase::BeforeWrite,
124        )
125        .expect_err("a missing PEM must not fall back to platform roots");
126        assert_eq!(err.retry, Retry::Permanent);
127        assert_eq!(err.code, RotationFailureCode::CredentialsUnresolved);
128        assert_eq!(err.detail.as_str(), "error kind: CaCertificateUnreadable");
129    }
130
131    #[test]
132    fn malformed_ca_certificate_is_fatal() {
133        let truncated = "-----BEGIN CERTIFICATE-----\nMIIDJTCCAg2gAwIBAgIUP9bWk\n";
134        let f = write_temp(truncated);
135        let path = f.path().to_str().expect("utf8 path");
136        let err = tls_config(Some(path), Phase::BeforeWrite)
137            .expect_err("a malformed PEM must not fall back to platform roots");
138        assert_eq!(err.retry, Retry::Permanent);
139        assert_eq!(err.detail.as_str(), "error kind: CaCertificateInvalid");
140    }
141
142    #[test]
143    fn ca_certificate_without_a_certificate_is_fatal() {
144        let f = write_temp("# no PEM sections here\n");
145        let path = f.path().to_str().expect("utf8 path");
146        let err = tls_config(Some(path), Phase::BeforeWrite)
147            .expect_err("a PEM with no certificate must not fall back to platform roots");
148        assert_eq!(err.retry, Retry::Permanent);
149        assert_eq!(err.detail.as_str(), "error kind: CaCertificateEmpty");
150    }
151
152    #[test]
153    fn ca_certificate_path_never_reaches_the_error_detail() {
154        let path = "/etc/secrets/ad-ca-9f3b2e.pem";
155        let err = tls_config(Some(path), Phase::BeforeWrite).expect_err("missing file");
156        assert!(
157            !err.to_string().contains("9f3b2e"),
158            "the configured path must not appear in the error: {err}"
159        );
160    }
161
162    #[test]
163    fn ca_certificate_failure_after_rotation_reports_applied() {
164        let err =
165            tls_config(Some("/nonexistent/ca.pem"), Phase::AfterWrite).expect_err("missing file");
166        assert_eq!(err.effect, TargetEffect::Applied);
167    }
168}