soth-mitm 0.2.2

Rust intercepting proxy crate with deterministic handler/event contracts for SOTH.
Documentation
    #[test]
    fn cert_store_force_rotate_changes_ca_and_resets_cache() {
        let store =
            MitmCertificateStore::new(CertificateAuthorityConfig::default()).expect("cert store");
        let before_ca = store.ca_certificate_pem().expect("ca cert");
        let _ = store
            .server_config_for_host("api.example.com")
            .expect("first leaf");
        store.force_rotate().expect("force rotate");
        let after_ca = store.ca_certificate_pem().expect("ca cert");
        assert_ne!(before_ca, after_ca);

        let post_rotate = store
            .server_config_for_host("api.example.com")
            .expect("leaf after rotate");
        assert_eq!(post_rotate.cache_status, LeafCacheStatus::Miss);
        assert_eq!(store.metrics_snapshot().ca_rotations, 1);
    }

    #[test]
    fn cert_store_auto_rotates_after_configured_interval() {
        let config = CertificateAuthorityConfig {
            ca_rotate_after_seconds: Some(1),
            ..CertificateAuthorityConfig::default()
        };
        let store = MitmCertificateStore::new(config).expect("cert store");
        let before_ca = store.ca_certificate_pem().expect("ca cert");

        let _ = store
            .server_config_for_host("first.example.com")
            .expect("first leaf");
        std::thread::sleep(Duration::from_millis(1_200));
        let _ = store
            .server_config_for_host("second.example.com")
            .expect("second leaf");
        let after_ca = store.ca_certificate_pem().expect("ca cert");

        assert_ne!(before_ca, after_ca);
        assert_eq!(store.metrics_snapshot().ca_rotations, 1);
    }

    #[test]
    fn cert_store_leaf_san_and_cn_cover_domain_wildcard_and_ip() {
        let store =
            MitmCertificateStore::new(CertificateAuthorityConfig::default()).expect("cert store");

        let domain = store
            .server_config_for_host("api.example.com")
            .expect("domain leaf");
        assert_leaf_dns_name(&domain.leaf_cert_der, "api.example.com");
        assert_leaf_common_name(&domain.leaf_cert_der, "api.example.com");

        let wildcard = store
            .server_config_for_host("*.example.com")
            .expect("wildcard leaf");
        assert_leaf_dns_name(&wildcard.leaf_cert_der, "*.example.com");
        assert_leaf_common_name(&wildcard.leaf_cert_der, "*.example.com");

        let ip = store.server_config_for_host("127.0.0.1").expect("ip leaf");
        assert_leaf_ip(&ip.leaf_cert_der, [127, 0, 0, 1]);
        assert_leaf_common_name(&ip.leaf_cert_der, "127.0.0.1");
    }

    #[test]
    fn cert_store_loads_existing_ca_from_disk() {
        let temp_dir = unique_temp_dir("soth-mitm-ca-load");
        fs::create_dir_all(&temp_dir).expect("create temp dir");
        let ca_cert_path = temp_dir.join("ca-cert.pem");
        let ca_key_path = temp_dir.join("ca-key.pem");

        let config = CertificateAuthorityConfig {
            ca_cert_pem_path: Some(path_to_string(&ca_cert_path)),
            ca_key_pem_path: Some(path_to_string(&ca_key_path)),
            ..CertificateAuthorityConfig::default()
        };

        let store_first = MitmCertificateStore::new(config.clone()).expect("first store");
        let first_ca = store_first.ca_certificate_pem().expect("first ca");
        drop(store_first);

        let store_second = MitmCertificateStore::new(config).expect("second store");
        let second_ca = store_second.ca_certificate_pem().expect("second ca");
        assert_eq!(first_ca, second_ca);

        fs::remove_dir_all(&temp_dir).expect("cleanup temp dir");
    }

    #[test]
    fn cert_store_uses_persisted_ca_subject_when_config_subject_changes() {
        let temp_dir = unique_temp_dir("soth-mitm-ca-subject-drift");
        fs::create_dir_all(&temp_dir).expect("create temp dir");
        let ca_cert_path = temp_dir.join("ca-cert.pem");
        let ca_key_path = temp_dir.join("ca-key.pem");

        let initial_common_name = "initial.soth-mitm-ca";
        let initial_config = CertificateAuthorityConfig {
            ca_cert_pem_path: Some(path_to_string(&ca_cert_path)),
            ca_key_pem_path: Some(path_to_string(&ca_key_path)),
            ca_common_name: initial_common_name.to_string(),
            ca_organization: "initial.soth-mitm-org".to_string(),
            ..CertificateAuthorityConfig::default()
        };
        let first_store = MitmCertificateStore::new(initial_config).expect("first store");
        let first_ca = first_store.ca_certificate_pem().expect("first ca");
        drop(first_store);

        let drifted_config = CertificateAuthorityConfig {
            ca_cert_pem_path: Some(path_to_string(&ca_cert_path)),
            ca_key_pem_path: Some(path_to_string(&ca_key_path)),
            ca_common_name: "drifted.soth-mitm-ca".to_string(),
            ca_organization: "drifted.soth-mitm-org".to_string(),
            ..CertificateAuthorityConfig::default()
        };
        let second_store = MitmCertificateStore::new(drifted_config).expect("second store");
        let second_ca = second_store.ca_certificate_pem().expect("second ca");
        assert_eq!(first_ca, second_ca);

        let leaf = second_store
            .server_config_for_host("api.example.com")
            .expect("leaf");
        assert_leaf_issuer_common_name(&leaf.leaf_cert_der, initial_common_name);

        fs::remove_dir_all(&temp_dir).expect("cleanup temp dir");
    }

    #[test]
    fn cert_store_rejects_partial_ca_path_configuration() {
        let config = CertificateAuthorityConfig {
            ca_cert_pem_path: Some("/tmp/soth-mitm-only-cert.pem".to_string()),
            ca_key_pem_path: None,
            ..CertificateAuthorityConfig::default()
        };
        let error = match MitmCertificateStore::new(config) {
            Ok(_) => panic!("partial CA path configuration unexpectedly succeeded"),
            Err(error) => error,
        };
        assert!(
            error
                .to_string()
                .contains("must either both be set or both be unset"),
            "{error}"
        );
    }

    fn assert_leaf_common_name(
        cert_der: &rustls::pki_types::CertificateDer<'static>,
        expected: &str,
    ) {
        let (_, cert) = parse_x509_certificate(cert_der.as_ref()).expect("parse x509");
        let subject = cert.subject();
        let cn = subject
            .iter_common_name()
            .next()
            .expect("commonName")
            .as_str()
            .expect("commonName as utf8");
        assert_eq!(cn, expected);
    }

    fn assert_leaf_issuer_common_name(
        cert_der: &rustls::pki_types::CertificateDer<'static>,
        expected: &str,
    ) {
        let (_, cert) = parse_x509_certificate(cert_der.as_ref()).expect("parse x509");
        let issuer = cert.issuer();
        let cn = issuer
            .iter_common_name()
            .next()
            .expect("issuer commonName")
            .as_str()
            .expect("issuer commonName as utf8");
        assert_eq!(cn, expected);
    }

    fn assert_leaf_dns_name(cert_der: &rustls::pki_types::CertificateDer<'static>, expected: &str) {
        let (_, cert) = parse_x509_certificate(cert_der.as_ref()).expect("parse x509");
        let san = cert
            .subject_alternative_name()
            .expect("san extension parse")
            .expect("san extension present");
        let found = san
            .value
            .general_names
            .iter()
            .any(|name| matches!(name, GeneralName::DNSName(value) if *value == expected));
        assert!(found, "expected SAN DNSName {expected}");
    }

    fn assert_leaf_ip(cert_der: &rustls::pki_types::CertificateDer<'static>, expected: [u8; 4]) {
        let (_, cert) = parse_x509_certificate(cert_der.as_ref()).expect("parse x509");
        let san = cert
            .subject_alternative_name()
            .expect("san extension parse")
            .expect("san extension present");
        let found = san
            .value
            .general_names
            .iter()
            .any(|name| matches!(name, GeneralName::IPAddress(value) if *value == expected));
        assert!(found, "expected SAN IPAddress {expected:?}");
    }

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock");
        std::env::temp_dir().join(format!(
            "{prefix}-{}-{}",
            std::process::id(),
            now.as_nanos()
        ))
    }

    fn path_to_string(path: &Path) -> String {
        path.to_string_lossy().to_string()
    }