openlatch-client 0.3.3

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! TLS trust for outbound connections.
//!
//! The default is the operating system's own trust store, via reqwest 0.13's platform
//! verifier. That is the whole point: an enterprise that puts its interception CA in the
//! OS store through MDM has already done the work, and a client that ships its own bundled
//! roots would ignore it and fail with an error the operator cannot act on. "Two trust
//! stores in one product" is the dominant complaint class against tools in this space.
//!
//! `ca_bundle` merges a PEM file **on top of** the OS roots rather than replacing them, so
//! supplying one never narrows what already worked.

use crate::core::error::{OlError, ERR_PROXY_CONFIG_INVALID};

use super::config::EgressConfig;

/// Where the trust anchors for a connection came from. Mirrors the frozen `ca_source` enum.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaSource {
    /// The OS trust store alone.
    Native,
    /// The OS trust store plus a merged `ca_bundle`.
    Custom,
}

impl CaSource {
    /// The wire string for telemetry and diagnostics.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Native => "native",
            Self::Custom => "custom",
        }
    }
}

/// Which trust source `cfg` will produce.
pub fn ca_source(cfg: &EgressConfig) -> CaSource {
    if cfg.ca_bundle.is_some() {
        CaSource::Custom
    } else {
        CaSource::Native
    }
}

/// What a **successful** handshake says about TLS interception.
///
/// | Verified by | Verdict | Why |
/// | --- | --- | --- |
/// | a merged `ca_bundle` | `Some(true)` | The operator supplied a private CA and the chain needed it — that is interception |
/// | the OS trust store alone | `None` | Unknowable from here |
///
/// The `None` row is the load-bearing one. The obvious-looking test — read the
/// leaf's issuer and compare it against a bundled root set — classifies a genuine
/// connection and an intercepted one **identically**: a leaf is signed by an
/// intermediate, and an intermediate is never in any root set. An enterprise that
/// installed its interception CA in the OS store through MDM also produces a
/// handshake indistinguishable from a public one at this layer. So a native-store
/// success claims nothing rather than claiming `false`.
///
/// Only meaningful after a request succeeded: reqwest exposes no certificate at
/// all on a failed handshake, so there is no observation to make on that path.
pub fn interception_verdict(ca_source: CaSource) -> Option<bool> {
    match ca_source {
        CaSource::Custom => Some(true),
        CaSource::Native => None,
    }
}

/// Apply `cfg`'s trust settings to a builder.
///
/// The bundle was already read and shape-checked when the config resolved, so a failure
/// here means the file changed underneath us between startup and client construction —
/// still a config error, still `OL-1226`.
pub fn apply(
    builder: reqwest::ClientBuilder,
    cfg: &EgressConfig,
) -> Result<reqwest::ClientBuilder, OlError> {
    let Some(path) = cfg.ca_bundle.as_ref() else {
        return Ok(builder);
    };
    let certs = read_bundle(path)?;
    Ok(builder.tls_certs_merge(certs))
}

/// Blocking counterpart of [`apply`].
pub fn apply_blocking(
    builder: reqwest::blocking::ClientBuilder,
    cfg: &EgressConfig,
) -> Result<reqwest::blocking::ClientBuilder, OlError> {
    let Some(path) = cfg.ca_bundle.as_ref() else {
        return Ok(builder);
    };
    let certs = read_bundle(path)?;
    Ok(builder.tls_certs_merge(certs))
}

fn read_bundle(path: &std::path::Path) -> Result<Vec<reqwest::Certificate>, OlError> {
    let pem = std::fs::read(path).map_err(|e| {
        OlError::new(
            ERR_PROXY_CONFIG_INVALID,
            format!("[proxy] ca_bundle {} cannot be read: {e}", path.display()),
        )
        .with_suggestion("Point ca_bundle at a readable PEM file, or remove the key.")
    })?;

    let certs = reqwest::Certificate::from_pem_bundle(&pem).map_err(|e| {
        OlError::new(
            ERR_PROXY_CONFIG_INVALID,
            format!(
                "[proxy] ca_bundle {} is not a valid PEM bundle: {e}",
                path.display()
            ),
        )
        .with_suggestion(
            "The file must be PEM, not DER. Convert with: \
             openssl x509 -inform der -in cert.der -out cert.pem",
        )
    })?;

    // An empty-but-parsable bundle is the quiet failure: trust looks configured, nothing
    // was actually added, and the interception CA still is not trusted.
    if certs.is_empty() {
        return Err(OlError::new(
            ERR_PROXY_CONFIG_INVALID,
            format!(
                "[proxy] ca_bundle {} contains no certificates",
                path.display()
            ),
        )
        .with_suggestion("Check the file is the CA bundle you meant to point at."));
    }
    Ok(certs)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg_with_bundle(path: Option<&std::path::Path>) -> EgressConfig {
        let mut cfg = EgressConfig::direct();
        cfg.ca_bundle = path.map(|p| p.to_path_buf());
        cfg
    }

    #[test]
    fn no_bundle_is_native_trust() {
        assert_eq!(ca_source(&cfg_with_bundle(None)), CaSource::Native);
        assert_eq!(CaSource::Native.as_str(), "native");
    }

    #[test]
    fn interception_is_claimed_only_when_our_own_bundle_verified_it() {
        assert_eq!(interception_verdict(CaSource::Custom), Some(true));
        assert_eq!(
            interception_verdict(CaSource::Native),
            None,
            "a leaf issuer cannot tell an OS-installed private CA from a public one"
        );
    }

    #[test]
    fn a_bundle_makes_the_source_custom() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("ca.pem");
        std::fs::write(&path, b"placeholder").expect("write");
        assert_eq!(ca_source(&cfg_with_bundle(Some(&path))), CaSource::Custom);
        assert_eq!(CaSource::Custom.as_str(), "custom");
    }

    #[test]
    fn a_real_pem_merges() {
        let issued =
            rcgen::generate_simple_self_signed(vec!["ca.test".to_string()]).expect("generate cert");
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("ca.pem");
        std::fs::write(&path, issued.cert.pem()).expect("write");

        let cfg = cfg_with_bundle(Some(&path));
        let builder = apply(super::super::client_builder(), &cfg).expect("merge");
        builder.build().expect("client builds with a merged bundle");
    }

    #[test]
    fn a_garbage_bundle_is_rejected_not_ignored() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("bad.pem");
        std::fs::write(&path, b"-----BEGIN CERTIFICATE-----\nnot base64\n").expect("write");

        let cfg = cfg_with_bundle(Some(&path));
        let err = apply(super::super::client_builder(), &cfg).expect_err("must reject");
        assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
    }

    #[test]
    fn an_empty_bundle_is_rejected() {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("empty.pem");
        std::fs::write(&path, b"").expect("write");

        let cfg = cfg_with_bundle(Some(&path));
        let err = apply(super::super::client_builder(), &cfg).expect_err("must reject");
        assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
    }
}