diff --git a/Cargo.lock b/Cargo.lock index 5ad94d59..adc1000f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,6 +1414,7 @@ dependencies = [ "toml", "uuid", "walkdir", + "x509-parser", ] [[package]] diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 08c1dfda..a45faebf 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -46,3 +46,4 @@ cli = ["dep:clap"] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tempfile = "3" rustls-pemfile = "2" +x509-parser = "0.16" diff --git a/crates/sandlock-core/src/transparent_proxy/ca.rs b/crates/sandlock-core/src/transparent_proxy/ca.rs index a11c07ad..888791cb 100644 --- a/crates/sandlock-core/src/transparent_proxy/ca.rs +++ b/crates/sandlock-core/src/transparent_proxy/ca.rs @@ -8,13 +8,14 @@ use rcgen::{CertificateParams, KeyPair}; /// Pre-generated dummy CA for HTTP-only mode, avoiding per-spawn keygen cost. fn dummy_ca() -> std::io::Result<(KeyPair, rcgen::Certificate)> { - use rcgen::{BasicConstraints, DnType, IsCa}; + use rcgen::{BasicConstraints, DnType, IsCa, KeyUsagePurpose}; let kp = KeyPair::generate().map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("keygen failed: {e}")) })?; let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; // A distinct subject DN is required: leaf certs minted under this CA must // have a subject that differs from their issuer, otherwise an empty-DN leaf // looks self-signed (subject == issuer) and clients reject it. diff --git a/crates/sandlock-core/src/transparent_proxy/tls.rs b/crates/sandlock-core/src/transparent_proxy/tls.rs index 280e3e77..6d1f3c5b 100644 --- a/crates/sandlock-core/src/transparent_proxy/tls.rs +++ b/crates/sandlock-core/src/transparent_proxy/tls.rs @@ -29,7 +29,11 @@ impl CertSigner { let ca_cert = ca_params.self_signed(&ca_key).map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA rebuild: {e}")) })?; - Ok(Self { ca_cert, ca_key, cache: Mutex::new(HashMap::new()) }) + Ok(Self { + ca_cert, + ca_key, + cache: Mutex::new(HashMap::new()), + }) } /// Mint (or cache-hit) a ServerConfig presenting a leaf cert for `sni`. @@ -37,21 +41,7 @@ impl CertSigner { if let Some(cfg) = self.cache.lock().unwrap().get(sni) { return Ok(Arc::clone(cfg)); } - let leaf_key = KeyPair::generate().map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("leaf keygen: {e}")) - })?; - // new(vec![sni]) sets subject_alt_names to a single DnsName(sni) entry. - let mut params = CertificateParams::new(vec![sni.to_string()]).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("leaf params: {e}")) - })?; - // Give the leaf a subject CN distinct from the CA's subject, so the leaf - // is not mistaken for self-signed (subject == issuer) by clients. - params.distinguished_name.push(DnType::CommonName, sni); - // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the - // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. - let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("leaf sign: {e}")) - })?; + let (leaf, leaf_key) = self.mint_leaf(sni)?; // Present only the leaf; the CA is the trust anchor in the client's store. let chain = vec![leaf.der().clone()]; @@ -63,26 +53,111 @@ impl CertSigner { let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut cfg = ServerConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")))? + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")) + })? .with_no_client_auth() .with_single_cert(chain, key_der) - .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")))?; + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")) + })?; cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; let cfg = Arc::new(cfg); - self.cache.lock().unwrap().insert(sni.to_string(), Arc::clone(&cfg)); + self.cache + .lock() + .unwrap() + .insert(sni.to_string(), Arc::clone(&cfg)); Ok(cfg) } + + fn mint_leaf(&self, sni: &str) -> std::io::Result<(Certificate, KeyPair)> { + let leaf_key = KeyPair::generate().map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf keygen: {e}")) + })?; + // new(vec![sni]) sets subject_alt_names to a single DnsName(sni) entry. + let mut params = CertificateParams::new(vec![sni.to_string()]).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("leaf params: {e}"), + ) + })?; + // Give the leaf a subject CN distinct from the CA's subject, so the leaf + // is not mistaken for self-signed (subject == issuer) by clients. + params.distinguished_name.push(DnType::CommonName, sni); + // RFC 5280 4.2.1.1: non-self-signed certificates identify their issuer's + // key. OpenSSL strict verification (Python 3.13+) requires this. + params.use_authority_key_identifier_extension = true; + // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the + // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. + let leaf = params + .signed_by(&leaf_key, &self.ca_cert, &self.ca_key) + .map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf sign: {e}")) + })?; + + Ok((leaf, leaf_key)) + } } #[cfg(test)] mod tests { use super::*; + use x509_parser::extensions::ParsedExtension; + use x509_parser::prelude::parse_x509_certificate; fn test_ca() -> (String, String) { - let m = crate::transparent_proxy::resolve_ca(None, None, true).unwrap().unwrap(); + let m = crate::transparent_proxy::resolve_ca(None, None, true) + .unwrap() + .unwrap(); (m.cert_pem, m.key_pem) } + #[test] + fn generated_ca_has_key_cert_sign() { + let (cert, _) = test_ca(); + let (_, pem) = x509_parser::pem::parse_x509_pem(cert.as_bytes()).expect("CA PEM"); + let (_, ca) = parse_x509_certificate(&pem.contents).expect("CA DER"); + let usage = ca + .key_usage() + .expect("valid CA key usage") + .expect("CA KeyUsage required"); + assert!( + usage.value.key_cert_sign(), + "CA KeyUsage must include keyCertSign" + ); + } + + #[test] + fn minted_leaf_aki_matches_generated_ca_ski() { + let (cert, key) = test_ca(); + let signer = CertSigner::new(&cert, &key).expect("signer"); + let (leaf, _) = signer.mint_leaf("localhost").expect("minted leaf"); + let (_, pem) = x509_parser::pem::parse_x509_pem(cert.as_bytes()).expect("CA PEM"); + let (_, ca) = parse_x509_certificate(&pem.contents).expect("CA DER"); + let (_, leaf) = parse_x509_certificate(leaf.der()).expect("leaf DER"); + let ski = ca + .extensions() + .iter() + .find_map(|ext| match ext.parsed_extension() { + ParsedExtension::SubjectKeyIdentifier(ski) => Some(ski.0), + _ => None, + }) + .expect("CA SubjectKeyIdentifier required"); + let aki = leaf + .extensions() + .iter() + .find_map(|ext| match ext.parsed_extension() { + ParsedExtension::AuthorityKeyIdentifier(aki) => Some(aki), + _ => None, + }) + .expect("leaf AuthorityKeyIdentifier required"); + let key_id = aki + .key_identifier + .as_ref() + .expect("leaf AKI keyIdentifier required"); + assert_eq!(key_id.0, ski, "leaf AKI must identify the original CA SKI"); + } + #[test] fn mints_distinct_configs_per_sni() { let (cert, key) = test_ca(); diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 753ca102..d21c1a63 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -73,6 +73,9 @@ mod test_protection; #[path = "integration/test_http_inject_ca.rs"] mod test_http_inject_ca; +#[path = "integration/test_http_strict_tls.rs"] +mod test_http_strict_tls; + #[path = "integration/test_restore.rs"] mod test_restore; diff --git a/crates/sandlock-core/tests/integration/test_http_strict_tls.rs b/crates/sandlock-core/tests/integration/test_http_strict_tls.rs new file mode 100644 index 00000000..186ceb86 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_http_strict_tls.rs @@ -0,0 +1,77 @@ +use sandlock_core::Sandbox; +use std::time::Duration; + +/// Strict verification must complete before the proxy denies the HTTP path. +/// localhost resolves without public DNS and passes the proxy's Host/IP check; +/// no upstream listener is needed because the ACL rejects before forwarding. +#[tokio::test] +async fn strict_x509_reaches_http_acl_denial() { + let dir = tempfile::tempdir().expect("temporary trust bundle directory"); + let bundle = dir.path().join("bundle.pem"); + std::fs::write(&bundle, b"").expect("create empty trust bundle"); + let path = std::env::var_os("PATH").expect("PATH to locate python3"); + let python_dir = std::env::split_paths(&path) + .find(|dir| dir.join("python3").is_file()) + .expect("python3 installed on PATH"); + + let mut sandbox = Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .fs_read(&python_dir) + .fs_read(dir.path()) + .clean_env(true) + .env_var("PATH", python_dir.to_str().expect("UTF-8 Python directory")) + .http_allow("GET localhost/allowed") + .http_inject_ca(&bundle) + .build() + .expect("strict TLS sandbox policy"); + + let script = r#" +import ssl +import sys +import urllib.error +import urllib.request + +context = ssl.create_default_context(cafile=sys.argv[1]) +context.verify_flags |= ssl.VERIFY_X509_STRICT +assert context.verify_mode == ssl.CERT_REQUIRED +assert context.check_hostname +opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=context), +) +try: + with opener.open('https://localhost/denied', timeout=10) as response: + raise AssertionError(f'expected ACL denial, got {response.status}') +except urllib.error.HTTPError as error: + with error: + assert error.code == 403, f'expected 403, got {error.code}' + body = error.read() + assert body == b'Blocked by sandlock HTTP ACL policy', repr(body) + print('strict X509 verified; HTTP 403: Blocked by sandlock HTTP ACL policy') +"#; + let result = tokio::time::timeout( + Duration::from_secs(30), + sandbox.run(&["python3", "-c", script, bundle.to_str().unwrap()]), + ) + .await + .expect("strict TLS sandbox must finish within 30 seconds") + .expect("run strict TLS sandbox"); + assert!( + result.success(), + "strict TLS client status={:?}\nstdout={}\nstderr={}", + result.code(), + String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()), + String::from_utf8_lossy(result.stderr.as_deref().unwrap_or_default()), + ); + assert_eq!( + std::fs::read(&bundle).unwrap(), + b"", + "host bundle unchanged" + ); +}