Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/sandlock-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion crates/sandlock-core/src/transparent_proxy/ca.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 95 additions & 20 deletions crates/sandlock-core/src/transparent_proxy/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,29 +29,19 @@ 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`.
pub(crate) fn server_config_for(&self, sni: &str) -> std::io::Result<Arc<ServerConfig>> {
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()];
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions crates/sandlock-core/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
77 changes: 77 additions & 0 deletions crates/sandlock-core/tests/integration/test_http_strict_tls.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
Loading