use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa,
Issuer, KeyPair, KeyUsagePurpose,
};
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
use rustls::server::{ClientHello, ResolvesServerCert};
use rustls::sign::CertifiedKey;
use rustls::ServerConfig;
use time::{Duration, OffsetDateTime};
use crate::error::{OlError, ERR_MODEL_RELAY_IO};
pub const CA_VALIDITY_DAYS: i64 = 365;
pub const LEAF_VALIDITY_DAYS: i64 = 90;
pub const CA_WARN_DAYS: i64 = 30;
pub const CA_RELEASE_DAYS: i64 = 7;
const LEAF_REMINT_DAYS: i64 = 30;
const BACKDATE: Duration = Duration::hours(1);
const CA_COMMON_NAME: &str = "OpenLatch Model Relay CA";
pub fn ca_dir(openlatch_dir: &Path) -> PathBuf {
openlatch_dir.join("model-relay").join("ca")
}
pub fn ca_key_path(dir: &Path) -> PathBuf {
dir.join("ca.key")
}
pub fn ca_pem_path(dir: &Path) -> PathBuf {
dir.join("ca.pem")
}
fn sibling_dir(dir: &Path, name: &str) -> PathBuf {
dir.with_file_name(name)
}
fn io_err(path: &Path, e: std::io::Error) -> OlError {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay CA {}: {e}", path.display()),
)
}
fn rcgen_err(path: &Path, e: rcgen::Error) -> OlError {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay CA {}: {e}", path.display()),
)
}
fn loadable(dir: &Path) -> bool {
ca_key_path(dir).exists() && ca_pem_path(dir).exists() && LocalCa::load(dir).is_ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaInfo {
pub sha256_hex: String,
pub not_after: OffsetDateTime,
}
pub struct LocalCa {
issuer: Issuer<'static, KeyPair>,
sha256_hex: String,
not_after: OffsetDateTime,
dir: PathBuf,
}
impl LocalCa {
pub fn load_or_generate(dir: &Path) -> Result<LocalCa, OlError> {
let next_dir = sibling_dir(dir, "ca.next");
if !loadable(dir) && loadable(&next_dir) {
let prev_dir = sibling_dir(dir, "ca.prev");
if dir.exists() && !prev_dir.exists() {
std::fs::rename(dir, &prev_dir).map_err(|e| io_err(dir, e))?;
}
std::fs::rename(&next_dir, dir).map_err(|e| io_err(&next_dir, e))?;
}
if ca_key_path(dir).exists() && ca_pem_path(dir).exists() {
match Self::load(dir) {
Ok(ca) => return Ok(ca),
Err(e) => {
tracing::warn!(
error = %e.message,
"model relay CA unparseable; regenerating"
);
}
}
}
Self::generate_into(dir)
}
pub fn generate_into(dir: &Path) -> Result<LocalCa, OlError> {
let now = OffsetDateTime::now_utc();
Self::build_and_persist(dir, now - BACKDATE, now + Duration::days(CA_VALIDITY_DAYS))
}
#[cfg(test)]
fn generate_with_validity(
dir: &Path,
not_before: OffsetDateTime,
not_after: OffsetDateTime,
) -> Result<LocalCa, OlError> {
Self::build_and_persist(dir, not_before, not_after)
}
fn build_and_persist(
dir: &Path,
not_before: OffsetDateTime,
not_after: OffsetDateTime,
) -> Result<LocalCa, OlError> {
let mut params =
CertificateParams::new(Vec::<String>::new()).map_err(|e| rcgen_err(dir, e))?;
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, CA_COMMON_NAME);
name.push(DnType::OrganizationName, "OpenLatch");
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
params.not_before = not_before;
params.not_after = not_after;
let key = KeyPair::generate().map_err(|e| rcgen_err(dir, e))?;
let cert = params.self_signed(&key).map_err(|e| rcgen_err(dir, e))?;
crate::fs_secure::create_dir_owner_only(dir).map_err(|e| io_err(dir, e))?;
crate::fs_secure::write_owner_only(&ca_key_path(dir), &key.serialize_pem())
.map_err(|e| io_err(&ca_key_path(dir), e))?;
crate::fs_secure::write_readable(&ca_pem_path(dir), &cert.pem())
.map_err(|e| io_err(&ca_pem_path(dir), e))?;
Self::load(dir)
}
fn load(dir: &Path) -> Result<LocalCa, OlError> {
let key_path = ca_key_path(dir);
let pem_path = ca_pem_path(dir);
let key_pem = std::fs::read_to_string(&key_path).map_err(|e| io_err(&key_path, e))?;
let pem = std::fs::read_to_string(&pem_path).map_err(|e| io_err(&pem_path, e))?;
let key = KeyPair::from_pem(&key_pem).map_err(|e| rcgen_err(&key_path, e))?;
let issuer = Issuer::from_ca_cert_pem(&pem, key).map_err(|e| rcgen_err(&pem_path, e))?;
let cert_der = CertificateDer::from_pem_slice(pem.as_bytes()).map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay CA {}: {e}", pem_path.display()),
)
})?;
let (sha256_hex, not_after) = cert_facts(&cert_der)?;
Ok(LocalCa {
issuer,
sha256_hex,
not_after,
dir: dir.to_path_buf(),
})
}
pub fn sha256_hex(&self) -> String {
self.sha256_hex.clone()
}
pub fn not_after(&self) -> OffsetDateTime {
self.not_after
}
pub fn pem_path(&self) -> PathBuf {
ca_pem_path(&self.dir)
}
fn mint(&self, host: &str) -> Result<Leaf, OlError> {
let mut params =
CertificateParams::new(vec![host.to_string()]).map_err(|e| rcgen_err(&self.dir, e))?;
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, host);
params.distinguished_name = name;
params.is_ca = IsCa::NoCa;
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.use_authority_key_identifier_extension = true;
let now = OffsetDateTime::now_utc();
params.not_before = now - BACKDATE;
let wanted = now + Duration::days(LEAF_VALIDITY_DAYS);
params.not_after = if wanted < self.not_after {
wanted
} else {
self.not_after
};
let key = KeyPair::generate().map_err(|e| rcgen_err(&self.dir, e))?;
let not_after = params.not_after;
let cert = params
.signed_by(&key, &self.issuer)
.map_err(|e| rcgen_err(&self.dir, e))?;
Ok(Leaf {
cert,
key,
not_after,
})
}
}
fn cert_facts(der: &[u8]) -> Result<(String, OffsetDateTime), OlError> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(der);
let sha256_hex = hex::encode(hasher.finalize());
let (_, x509) = x509_parser::parse_x509_certificate(der).map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay CA certificate could not be parsed: {e}"),
)
})?;
let not_after = x509.validity().not_after.to_datetime();
Ok((sha256_hex, not_after))
}
pub fn inspect(dir: &Path) -> Option<CaInfo> {
let pem = std::fs::read_to_string(ca_pem_path(dir)).ok()?;
let der = CertificateDer::from_pem_slice(pem.as_bytes()).ok()?;
let (sha256_hex, not_after) = cert_facts(&der).ok()?;
Some(CaInfo {
sha256_hex,
not_after,
})
}
pub fn remove(dir: &Path) -> Result<bool, OlError> {
match std::fs::remove_dir_all(dir) {
Ok(()) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(io_err(dir, e)),
}
}
struct Leaf {
cert: rcgen::Certificate,
key: KeyPair,
not_after: OffsetDateTime,
}
#[derive(Debug)]
struct LeafEntry {
key: Arc<CertifiedKey>,
not_after: OffsetDateTime,
}
#[derive(Debug)]
struct OlAuthority {
leaves: Arc<RwLock<BTreeMap<String, LeafEntry>>>,
pin: Option<String>,
}
impl ResolvesServerCert for OlAuthority {
fn resolve(&self, hello: ClientHello<'_>) -> Option<Arc<CertifiedKey>> {
let want = match (hello.server_name(), &self.pin) {
(Some(sni), Some(pin)) if !sni.eq_ignore_ascii_case(pin) => return None,
(Some(sni), _) => sni.to_ascii_lowercase(),
(None, Some(pin)) => pin.clone(),
(None, None) => return None,
};
let leaves = self.leaves.read().unwrap_or_else(|e| e.into_inner());
leaves.get(&want).map(|l| l.key.clone())
}
}
pub struct Interceptor {
ca: LocalCa,
leaves: Arc<RwLock<BTreeMap<String, LeafEntry>>>,
pinned: RwLock<BTreeMap<String, Arc<ServerConfig>>>,
any_host: Arc<ServerConfig>,
}
impl Interceptor {
pub fn new(
dir: &Path,
hosts: impl IntoIterator<Item = impl Into<String>>,
) -> Result<Interceptor, OlError> {
let ca = LocalCa::load_or_generate(dir)?;
let leaves: Arc<RwLock<BTreeMap<String, LeafEntry>>> =
Arc::new(RwLock::new(BTreeMap::new()));
let any_host = server_config_with(&leaves, None)?;
let interceptor = Interceptor {
ca,
leaves,
pinned: RwLock::new(BTreeMap::new()),
any_host,
};
interceptor.set_hosts(hosts)?;
Ok(interceptor)
}
pub fn set_hosts(
&self,
hosts: impl IntoIterator<Item = impl Into<String>>,
) -> Result<(), OlError> {
let want: BTreeSet<String> = hosts
.into_iter()
.map(|h| h.into().trim().to_ascii_lowercase())
.filter(|h| !h.is_empty())
.collect();
let mut minted: BTreeMap<String, LeafEntry> = BTreeMap::new();
{
let existing = self.leaves.read().unwrap_or_else(|e| e.into_inner());
let now = OffsetDateTime::now_utc();
for h in &want {
if let Some(entry) = existing.get(h) {
if entry.not_after - now > Duration::days(LEAF_REMINT_DAYS) {
minted.insert(
h.clone(),
LeafEntry {
key: entry.key.clone(),
not_after: entry.not_after,
},
);
}
}
}
}
let to_mint: Vec<&String> = want.iter().filter(|h| !minted.contains_key(*h)).collect();
for h in to_mint {
let leaf = self.ca.mint(h)?;
let signing_key = rustls::crypto::ring::sign::any_ecdsa_type(&PrivateKeyDer::Pkcs8(
leaf.key.serialize_der().into(),
))
.map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay leaf key for {h}: {e}"),
)
})?;
let certified = CertifiedKey::new(vec![leaf.cert.der().clone()], signing_key);
minted.insert(
h.clone(),
LeafEntry {
key: Arc::new(certified),
not_after: leaf.not_after,
},
);
}
let mut pinned_new: BTreeMap<String, Arc<ServerConfig>> = BTreeMap::new();
{
let existing_pinned = self.pinned.read().unwrap_or_else(|e| e.into_inner());
for h in &want {
let cfg = match existing_pinned.get(h) {
Some(cfg) => cfg.clone(),
None => server_config_with(&self.leaves, Some(h))?,
};
pinned_new.insert(h.clone(), cfg);
}
}
*self.leaves.write().unwrap_or_else(|e| e.into_inner()) = minted;
*self.pinned.write().unwrap_or_else(|e| e.into_inner()) = pinned_new;
Ok(())
}
pub fn intercepts(&self, host: &str) -> bool {
let host = host.to_ascii_lowercase();
self.leaves
.read()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&host)
}
pub fn server_config(&self) -> Arc<ServerConfig> {
self.any_host.clone()
}
pub(crate) fn pinned_config(&self, host: &str) -> Option<Arc<ServerConfig>> {
let host = host.to_ascii_lowercase();
self.pinned
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&host)
.cloned()
}
pub fn mint_leaf_pem(&self, host: &str) -> Result<String, OlError> {
let key = host.to_ascii_lowercase();
if !self
.leaves
.read()
.unwrap_or_else(|e| e.into_inner())
.contains_key(&key)
{
return Err(OlError::new(
ERR_MODEL_RELAY_IO,
format!("not a declared intercept host: {host}"),
));
}
Ok(self.ca.mint(&key)?.cert.pem())
}
pub fn ca(&self) -> &LocalCa {
&self.ca
}
}
fn server_config_with(
leaves: &Arc<RwLock<BTreeMap<String, LeafEntry>>>,
pin: Option<&str>,
) -> Result<Arc<ServerConfig>, OlError> {
let mut config =
ServerConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
.with_safe_default_protocol_versions()
.map_err(|e| {
OlError::new(
ERR_MODEL_RELAY_IO,
format!("model relay TLS server config: {e}"),
)
})?
.with_no_client_auth()
.with_cert_resolver(Arc::new(OlAuthority {
leaves: leaves.clone(),
pin: pin.map(str::to_string),
}));
config.alpn_protocols = vec![b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
#[cfg(test)]
mod tests {
use super::*;
use x509_parser::extensions::GeneralName;
#[test]
fn the_ca_is_generated_once_and_reused() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let first = LocalCa::load_or_generate(&dir).expect("first generate");
let second = LocalCa::load_or_generate(&dir).expect("second load");
assert_eq!(first.sha256_hex(), second.sha256_hex());
let days = (second.not_after() - OffsetDateTime::now_utc()).whole_days();
assert!(
(CA_VALIDITY_DAYS - 1..=CA_VALIDITY_DAYS).contains(&days),
"expected not_after within a day of {CA_VALIDITY_DAYS}, got {days}"
);
std::fs::remove_file(ca_key_path(&dir)).expect("remove key");
let third = LocalCa::load_or_generate(&dir).expect("regenerate after missing key");
assert_ne!(second.sha256_hex(), third.sha256_hex());
std::fs::write(ca_pem_path(&dir), b"not a certificate").expect("corrupt pem");
let fourth = LocalCa::load_or_generate(&dir).expect("regenerate after garbage pem");
assert_ne!(third.sha256_hex(), fourth.sha256_hex());
}
#[test]
fn an_expired_ca_is_loaded_not_regenerated() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let now = OffsetDateTime::now_utc();
let expired = LocalCa::generate_with_validity(
&dir,
now - Duration::days(400),
now - Duration::days(1),
)
.expect("generate an already-expired CA");
let loaded = LocalCa::load_or_generate(&dir).expect("load, never regenerate an expired CA");
assert_eq!(expired.sha256_hex(), loaded.sha256_hex());
assert!(loaded.not_after() < now);
}
#[test]
#[cfg(unix)]
fn the_ca_key_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
LocalCa::load_or_generate(&dir).expect("generate");
let key_mode = std::fs::metadata(ca_key_path(&dir))
.expect("key metadata")
.permissions()
.mode();
assert_eq!(key_mode & 0o777, 0o600);
let pem_mode = std::fs::metadata(ca_pem_path(&dir))
.expect("pem metadata")
.permissions()
.mode();
assert_eq!(pem_mode & 0o777, 0o644);
let dir_mode = std::fs::metadata(&dir)
.expect("dir metadata")
.permissions()
.mode();
assert_eq!(dir_mode & 0o777, 0o700);
}
#[test]
#[cfg(not(unix))]
fn the_ca_key_is_owner_only() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
LocalCa::load_or_generate(&dir).expect("generate");
assert!(ca_key_path(&dir).exists());
assert!(ca_pem_path(&dir).exists());
}
#[test]
fn leaves_use_their_own_keys() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let ca = LocalCa::load_or_generate(&dir).expect("generate");
let leaf1 = ca.mint("a.test").expect("mint 1");
let leaf2 = ca.mint("a.test").expect("mint 2");
let spki = |der: &[u8]| -> Vec<u8> {
x509_parser::parse_x509_certificate(der)
.expect("parse leaf")
.1
.tbs_certificate
.subject_pki
.subject_public_key
.data
.to_vec()
};
let ca_pem = std::fs::read_to_string(ca.pem_path()).expect("read ca.pem");
let ca_der = CertificateDer::from_pem_slice(ca_pem.as_bytes()).expect("parse ca pem");
let spki_ca = spki(&ca_der);
let spki1 = spki(leaf1.cert.der());
let spki2 = spki(leaf2.cert.der());
assert_ne!(
spki1, spki2,
"two mints for one host must use distinct keys"
);
assert_ne!(spki1, spki_ca);
assert_ne!(spki2, spki_ca);
}
#[test]
fn a_leaf_names_its_host_and_chains_to_the_ca() {
use rustls::client::danger::ServerCertVerifier;
use rustls::client::WebPkiServerVerifier;
use rustls::pki_types::{ServerName, UnixTime};
use rustls::RootCertStore;
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let ca = LocalCa::load_or_generate(&dir).expect("generate");
let ca_pem = std::fs::read_to_string(ca.pem_path()).expect("read ca.pem");
let ca_der = CertificateDer::from_pem_slice(ca_pem.as_bytes()).expect("parse ca pem");
let mut roots = RootCertStore::empty();
roots.add(ca_der).expect("trust the CA");
let verifier = WebPkiServerVerifier::builder_with_provider(
Arc::new(roots),
Arc::new(rustls::crypto::ring::default_provider()),
)
.build()
.expect("build verifier");
let leaf = ca.mint("a.test").expect("mint a.test");
let leaf_der = leaf.cert.der().clone();
let ok_name = ServerName::try_from("a.test").expect("server name");
verifier
.verify_server_cert(&leaf_der, &[], &ok_name, &[], UnixTime::now())
.expect("a.test's leaf must verify for a.test");
let bad_name = ServerName::try_from("b.test").expect("server name");
verifier
.verify_server_cert(&leaf_der, &[], &bad_name, &[], UnixTime::now())
.expect_err("a.test's leaf must not verify for b.test");
let ip_leaf = ca.mint("127.0.0.1").expect("mint IP leaf");
let ip_der = ip_leaf.cert.der().clone();
let ip_name = ServerName::try_from("127.0.0.1").expect("IP server name");
verifier
.verify_server_cert(&ip_der, &[], &ip_name, &[], UnixTime::now())
.expect("an IP leaf must verify for its own IP SAN");
}
#[test]
fn a_leaf_never_outlives_its_ca() {
let tmp = tempfile::tempdir().expect("tempdir");
let short_dir = tmp.path().join("short");
let now = OffsetDateTime::now_utc();
let short_ca =
LocalCa::generate_with_validity(&short_dir, now - BACKDATE, now + Duration::days(10))
.expect("generate a short-lived CA");
let short_leaf = short_ca.mint("a.test").expect("mint under a short CA");
assert!(short_leaf.not_after <= short_ca.not_after());
let long_dir = tmp.path().join("long");
let long_ca = LocalCa::load_or_generate(&long_dir).expect("generate a full-validity CA");
let long_leaf = long_ca
.mint("a.test")
.expect("mint under a full-validity CA");
let days = (long_leaf.not_after - OffsetDateTime::now_utc()).whole_days();
assert!(
(LEAF_VALIDITY_DAYS - 1..=LEAF_VALIDITY_DAYS).contains(&days),
"expected leaf not_after within a day of {LEAF_VALIDITY_DAYS}, got {days}"
);
}
#[test]
fn inspect_never_generates() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
assert!(inspect(&dir).is_none());
assert!(!dir.exists(), "inspect must never create the directory");
let ca = LocalCa::load_or_generate(&dir).expect("generate");
let info = inspect(&dir).expect("inspect after generate");
assert_eq!(info.sha256_hex, ca.sha256_hex());
assert_eq!(info.not_after, ca.not_after());
}
#[test]
fn remove_deletes_the_directory_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
LocalCa::load_or_generate(&dir).expect("generate");
assert!(remove(&dir).expect("first remove"));
assert!(!dir.exists());
assert!(!remove(&dir).expect("second remove is not an error"));
}
#[test]
fn generate_into_replaces_the_ca() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let first = LocalCa::load_or_generate(&dir).expect("first generate");
let second = LocalCa::generate_into(&dir).expect("replace");
assert_ne!(first.sha256_hex(), second.sha256_hex());
let third = LocalCa::load_or_generate(&dir).expect("load the replacement");
assert_eq!(second.sha256_hex(), third.sha256_hex());
}
#[test]
fn an_interrupted_promotion_recovers_from_ca_next() {
{
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let next_dir = dir.with_file_name("ca.next");
let next_ca = LocalCa::generate_into(&next_dir).expect("generate ca.next");
assert!(!dir.exists());
let loaded = LocalCa::load_or_generate(&dir).expect("promote and load");
assert_eq!(loaded.sha256_hex(), next_ca.sha256_hex());
assert!(
!next_dir.exists(),
"ca.next must be consumed by the promotion"
);
}
{
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let _old = LocalCa::load_or_generate(&dir).expect("generate the old CA");
std::fs::write(ca_pem_path(&dir), b"garbage").expect("corrupt the old pem");
let next_dir = dir.with_file_name("ca.next");
let next_ca = LocalCa::generate_into(&next_dir).expect("generate ca.next");
let prev_dir = dir.with_file_name("ca.prev");
assert!(!prev_dir.exists());
let loaded = LocalCa::load_or_generate(&dir).expect("promote over a corrupt ca/");
assert_eq!(loaded.sha256_hex(), next_ca.sha256_hex());
assert!(!next_dir.exists());
assert!(
prev_dir.exists(),
"the old ca/ must be preserved at ca.prev/"
);
}
{
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let next_dir = dir.with_file_name("ca.next");
std::fs::create_dir_all(&next_dir).expect("create ca.next");
std::fs::write(next_dir.join("ca.key"), b"garbage-key").expect("write garbage key");
std::fs::write(next_dir.join("ca.pem"), b"garbage-pem").expect("write garbage pem");
let _generated = LocalCa::load_or_generate(&dir).expect("fresh generation");
assert!(dir.exists());
assert_eq!(
std::fs::read(next_dir.join("ca.key")).expect("ca.next/ca.key"),
b"garbage-key"
);
}
}
#[test]
fn set_hosts_mints_new_hosts_and_drops_removed_ones() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let ic = Interceptor::new(&dir, ["a.test"]).expect("new");
ic.set_hosts(["b.test"]).expect("set_hosts");
assert!(ic.intercepts("b.test"));
assert!(ic.intercepts("B.TEST"));
assert!(!ic.intercepts("a.test"));
assert!(ic.pinned_config("a.test").is_none());
let key_before = ic
.leaves
.read()
.expect("read leaves")
.get("b.test")
.expect("b.test leaf")
.key
.clone();
let config_before = ic.pinned_config("b.test").expect("b.test config");
ic.set_hosts(["b.test"]).expect("set_hosts again");
let key_after = ic
.leaves
.read()
.expect("read leaves")
.get("b.test")
.expect("b.test leaf")
.key
.clone();
assert!(
Arc::ptr_eq(&key_before, &key_after),
"an unexpired leaf must be reused, not re-minted"
);
let config_after = ic.pinned_config("b.test").expect("b.test config");
assert!(
Arc::ptr_eq(&config_before, &config_after),
"the pinned config must be reused when its leaf is reused"
);
}
#[test]
fn mint_leaf_pem_refuses_an_undeclared_host() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let ic = Interceptor::new(&dir, ["a.test"]).expect("new");
let pem = ic.mint_leaf_pem("a.test").expect("declared host mints");
let der = CertificateDer::from_pem_slice(pem.as_bytes()).expect("parse minted pem");
let (_, x509) = x509_parser::parse_x509_certificate(&der).expect("parse x509");
assert!(!x509.is_ca(), "a leaf must not itself be a CA");
let san = x509
.subject_alternative_name()
.expect("SAN parses")
.expect("leaf carries a SAN");
assert!(san
.value
.general_names
.iter()
.any(|n| matches!(n, GeneralName::DNSName(h) if *h == "a.test")));
assert!(format!("{}", x509.issuer()).contains(CA_COMMON_NAME));
let err = ic
.mint_leaf_pem("z.test")
.expect_err("an undeclared host must be refused");
assert_eq!(err.code, ERR_MODEL_RELAY_IO);
}
#[tokio::test]
async fn the_pinned_resolver_refuses_a_foreign_sni() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("ca");
let ic = Interceptor::new(&dir, ["a.test", "c.test"]).expect("new");
let ca_pem = std::fs::read_to_string(ic.ca().pem_path()).expect("read ca.pem");
let ca_der = CertificateDer::from_pem_slice(ca_pem.as_bytes()).expect("parse ca pem");
let config = ic.pinned_config("a.test").expect("a.test is declared");
async fn one_handshake(
config: Arc<ServerConfig>,
ca_der: &CertificateDer<'static>,
sni: &str,
) -> (std::io::Result<()>, std::io::Result<()>) {
let (client_io, server_io) = tokio::io::duplex(8192);
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let mut roots = rustls::RootCertStore::empty();
roots.add(ca_der.clone()).expect("trust the CA");
let client_config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(client_config));
let server_name =
rustls::pki_types::ServerName::try_from(sni.to_string()).expect("server name");
let (server_res, client_res) = tokio::time::timeout(
std::time::Duration::from_secs(5),
futures_util::future::join(
acceptor.accept(server_io),
connector.connect(server_name, client_io),
),
)
.await
.expect("handshake did not hang");
(server_res.map(|_| ()), client_res.map(|_| ()))
}
let (server_ok, client_ok) = one_handshake(config.clone(), &ca_der, "a.test").await;
server_ok.expect("server accepts a matching SNI");
client_ok.expect("client completes against a matching SNI");
let (server_err, client_err) = one_handshake(config, &ca_der, "c.test").await;
let server_err = server_err.expect_err("server must refuse a foreign SNI");
assert!(
server_err
.to_string()
.contains("no server certificate chain resolved"),
"unexpected server error: {server_err}"
);
let client_err = client_err.expect_err("client must see the refusal");
let inner = client_err
.get_ref()
.and_then(|e| e.downcast_ref::<rustls::Error>());
assert_eq!(
inner,
Some(&rustls::Error::AlertReceived(
rustls::AlertDescription::AccessDenied
)),
"unexpected client error: {client_err}"
);
}
}