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 for the Negotiate transport: two configurations, one trust source.
//!
//! reqwest builds its own rustls config from `tls_certs_merge`; this transport is not
//! reqwest, so it builds the equivalent here — and "equivalent" is the requirement. Both
//! read the **operating system's** trust store through the same platform verifier, and both
//! treat `ca_bundle` as an addition on top of it rather than a replacement. Two trust stores
//! in one product is the dominant complaint class against tools in this space, and two trust
//! stores in one *transport pair* would be worse: an enterprise CA that worked on the
//! reqwest path and failed on the Negotiate path is a bug nobody can diagnose.
//!
//! The two configurations differ in exactly one field, ALPN:
//!
//! | Hop | ALPN | Why |
//! | --- | ---- | --- |
//! | To the proxy (`https://` proxy) | **none** | An `h2` proxy hop would break the HTTP/1.1 CONNECT writer that runs on top of it. |
//! | To the target, inside the tunnel | `h2`, `http/1.1` (or `http/1.1` alone under `http1_only`) | Restored, so a destination that offers h2 gets it. |

use std::sync::Arc;

use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme};
use rustls_pki_types::{CertificateDer, ServerName, UnixTime};
use rustls_platform_verifier::BuilderVerifierExt;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio_rustls::TlsConnector;

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

use super::super::config::EgressConfig;

/// The two client configurations this transport hands to `tokio-rustls`.
#[derive(Clone)]
pub struct TlsSetup {
    /// No ALPN — the proxy hop.
    proxy: Arc<ClientConfig>,
    /// ALPN `h2`, `http/1.1` — the target hop.
    target: Arc<ClientConfig>,
    /// ALPN `http/1.1` only — the target hop under `[proxy] http1_only`.
    target_http1: Arc<ClientConfig>,
}

impl TlsSetup {
    /// Build both configurations from `cfg`'s trust settings.
    pub fn new(cfg: &EgressConfig) -> Result<Self, OlError> {
        // rustls needs a process-default CryptoProvider before a config can be built. The
        // egress factory installs `ring`; calling it here too makes the install a
        // precondition of *building a connector* rather than of running `main()`, which is
        // what keeps unit tests and library consumers off the panic path.
        super::super::init_crypto();

        let extra = match cfg.ca_bundle.as_ref() {
            Some(path) => read_roots(path)?,
            None => Vec::new(),
        };

        let base = || -> Result<ClientConfig, OlError> {
            if extra.is_empty() {
                return Ok(ClientConfig::builder()
                    .with_platform_verifier()
                    .map_err(|e| tls_config_error(&format!("platform verifier: {e}")))?
                    .with_no_client_auth());
            }
            // A `ca_bundle` is an *addition* to the OS roots, so both verifiers are built
            // and consulted in turn. The platform verifier is constructed directly rather
            // than extracted from a finished config -- rustls keeps that accessor private,
            // and the constructor is the supported way in.
            let builder = ClientConfig::builder();
            let provider = builder.crypto_provider().clone();
            let platform = rustls_platform_verifier::Verifier::new(provider.clone())
                .map_err(|e| tls_config_error(&format!("platform verifier: {e}")))?;
            let merged = MergedVerifier::new(Arc::new(platform), provider, extra.clone())?;
            Ok(builder
                .dangerous()
                .with_custom_certificate_verifier(Arc::new(merged))
                .with_no_client_auth())
        };

        let proxy = base()?;
        let mut target = base()?;
        target.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
        let mut target_http1 = base()?;
        target_http1.alpn_protocols = vec![b"http/1.1".to_vec()];

        Ok(Self {
            proxy: Arc::new(proxy),
            target: Arc::new(target),
            target_http1: Arc::new(target_http1),
        })
    }

    /// TLS to the proxy itself. ALPN excluded.
    pub async fn connect_proxy<S>(
        &self,
        host: &str,
        stream: S,
    ) -> std::io::Result<tokio_rustls::client::TlsStream<S>>
    where
        S: AsyncRead + AsyncWrite + Unpin,
    {
        TlsConnector::from(self.proxy.clone())
            .connect(server_name(host)?, stream)
            .await
    }

    /// TLS to the destination, inside whatever the hop turned out to be.
    pub async fn connect_target<S>(
        &self,
        host: &str,
        stream: S,
        http1_only: bool,
    ) -> std::io::Result<tokio_rustls::client::TlsStream<S>>
    where
        S: AsyncRead + AsyncWrite + Unpin,
    {
        let config = if http1_only {
            self.target_http1.clone()
        } else {
            self.target.clone()
        };
        TlsConnector::from(config)
            .connect(server_name(host)?, stream)
            .await
    }
}

fn server_name(host: &str) -> std::io::Result<ServerName<'static>> {
    ServerName::try_from(host.to_string()).map_err(|e| {
        std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("\"{host}\" is not a valid TLS server name: {e}"),
        )
    })
}

fn tls_config_error(detail: &str) -> OlError {
    OlError::new(
        ERR_PROXY_CONFIG_INVALID,
        format!("could not build the Negotiate transport's TLS configuration: {detail}"),
    )
    .with_suggestion("Check [proxy] ca_bundle points at a readable PEM bundle.")
}

/// Parse a PEM bundle into trust anchors.
fn read_roots(path: &std::path::Path) -> Result<Vec<CertificateDer<'static>>, 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 mut reader = std::io::BufReader::new(std::io::Cursor::new(pem));
    let certs: Vec<_> = rustls_pemfile_certs(&mut reader);
    if certs.is_empty() {
        // An empty-but-parsable bundle is the quiet failure: trust looks configured and
        // nothing was added, so the interception CA is still untrusted.
        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)
}

/// Minimal PEM certificate reader.
///
/// Hand-rolled rather than pulling `rustls-pemfile` in: this crate already ships a base64
/// decoder for the SPNEGO tokens, the PEM grammar for a certificate block is four lines, and
/// a new direct dependency in a CISO-audited binary needs a better reason than saving them.
fn rustls_pemfile_certs<R: std::io::BufRead>(reader: &mut R) -> Vec<CertificateDer<'static>> {
    const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
    const END: &str = "-----END CERTIFICATE-----";

    let mut out = Vec::new();
    let mut body = String::new();
    let mut inside = false;
    let mut line = String::new();
    while reader.read_line(&mut line).unwrap_or(0) > 0 {
        let trimmed = line.trim();
        if trimmed == BEGIN {
            inside = true;
            body.clear();
        } else if trimmed == END {
            if inside {
                if let Some(der) = super::b64_decode(&body) {
                    out.push(CertificateDer::from(der));
                }
            }
            inside = false;
        } else if inside {
            body.push_str(trimmed);
        }
        line.clear();
    }
    out
}

/// The OS trust store, plus the operator's extra roots.
///
/// Merged rather than replaced. The platform verifier answers first, so everything that
/// worked before a `ca_bundle` was configured keeps working; the extra roots are consulted
/// only for what it rejects. Supplying a bundle can therefore never *narrow* trust, which is
/// the failure mode an operator cannot diagnose ("it worked until I added our CA").
#[derive(Debug)]
struct MergedVerifier {
    platform: Arc<dyn ServerCertVerifier>,
    extra: Arc<dyn ServerCertVerifier>,
}

impl MergedVerifier {
    fn new(
        platform: Arc<dyn ServerCertVerifier>,
        provider: Arc<rustls::crypto::CryptoProvider>,
        extra: Vec<CertificateDer<'static>>,
    ) -> Result<Self, OlError> {
        let mut roots = rustls::RootCertStore::empty();
        for cert in extra {
            roots.add(cert).map_err(|e| {
                tls_config_error(&format!("a ca_bundle certificate is not usable: {e}"))
            })?;
        }
        let extra =
            rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider)
                .build()
                .map_err(|e| tls_config_error(&format!("ca_bundle roots: {e}")))?;

        Ok(Self { platform, extra })
    }
}

impl ServerCertVerifier for MergedVerifier {
    fn verify_server_cert(
        &self,
        end_entity: &CertificateDer<'_>,
        intermediates: &[CertificateDer<'_>],
        server_name: &ServerName<'_>,
        ocsp_response: &[u8],
        now: UnixTime,
    ) -> Result<ServerCertVerified, rustls::Error> {
        match self.platform.verify_server_cert(
            end_entity,
            intermediates,
            server_name,
            ocsp_response,
            now,
        ) {
            Ok(verified) => Ok(verified),
            // The OS store said no. That is the common case for a corporate interception CA
            // that was handed to us as a file instead of installed — so the extra roots get
            // their turn before this becomes a failure.
            Err(os_error) => self
                .extra
                .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
                // The OS store's error is the one reported: it is the trust source the
                // operator is most likely to have meant, and its message names the actual
                // chain problem.
                .map_err(|_| os_error),
        }
    }

    fn verify_tls12_signature(
        &self,
        message: &[u8],
        cert: &CertificateDer<'_>,
        dss: &DigitallySignedStruct,
    ) -> Result<HandshakeSignatureValid, rustls::Error> {
        // Signature verification is a property of the crypto provider, not of which root
        // store accepted the chain, so there is nothing to merge here.
        self.platform.verify_tls12_signature(message, cert, dss)
    }

    fn verify_tls13_signature(
        &self,
        message: &[u8],
        cert: &CertificateDer<'_>,
        dss: &DigitallySignedStruct,
    ) -> Result<HandshakeSignatureValid, rustls::Error> {
        self.platform.verify_tls13_signature(message, cert, dss)
    }

    fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
        self.platform.supported_verify_schemes()
    }
}

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

    #[test]
    fn a_configuration_without_a_bundle_builds() {
        let setup = TlsSetup::new(&EgressConfig::direct()).expect("build");
        assert!(
            setup.proxy.alpn_protocols.is_empty(),
            "the proxy hop must advertise no ALPN"
        );
        assert_eq!(
            setup.target.alpn_protocols,
            vec![b"h2".to_vec(), b"http/1.1".to_vec()],
            "ALPN must be restored on the target hop"
        );
        assert_eq!(
            setup.target_http1.alpn_protocols,
            vec![b"http/1.1".to_vec()]
        );
    }

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

        let mut cfg = EgressConfig::direct();
        cfg.ca_bundle = Some(path);
        TlsSetup::new(&cfg).expect("a bundle on top of the OS roots must build");
    }

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

        let mut cfg = EgressConfig::direct();
        cfg.ca_bundle = Some(path);
        let err = match TlsSetup::new(&cfg) {
            Err(e) => e,
            Ok(_) => panic!("an empty bundle must be rejected"),
        };
        assert_eq!(err.code, ERR_PROXY_CONFIG_INVALID);
    }

    #[test]
    fn the_pem_reader_finds_every_certificate_in_a_bundle() {
        let one = rcgen::generate_simple_self_signed(vec!["a.test".into()]).expect("cert");
        let two = rcgen::generate_simple_self_signed(vec!["b.test".into()]).expect("cert");
        let bundle = format!("{}\n{}", one.cert.pem(), two.cert.pem());
        let mut reader = std::io::BufReader::new(std::io::Cursor::new(bundle.into_bytes()));
        let certs = rustls_pemfile_certs(&mut reader);
        assert_eq!(certs.len(), 2);
        assert_eq!(certs[0].as_ref(), one.cert.der().as_ref());
        assert_eq!(certs[1].as_ref(), two.cert.der().as_ref());
    }

    #[test]
    fn the_pem_reader_ignores_anything_outside_a_certificate_block() {
        let issued = rcgen::generate_simple_self_signed(vec!["a.test".into()]).expect("cert");
        let bundle = format!(
            "# a comment\nsubject=CN=a.test\n{}\ntrailing junk\n",
            issued.cert.pem()
        );
        let mut reader = std::io::BufReader::new(std::io::Cursor::new(bundle.into_bytes()));
        assert_eq!(rustls_pemfile_certs(&mut reader).len(), 1);
    }
}