use std::time::Duration;
use der::{Decode, Encode};
use x509_cert::Certificate;
use super::ocsp::{extract_aia_urls, AiaAccessMethod};
use crate::error::LtvError;
use crate::net::AttestedHttpClient;
use crate::trust::TrustStore;
const MAX_CHAIN_DEPTH: usize = 10;
const MAX_CERT_BODY_SIZE: usize = 1024 * 1024;
#[derive(Debug, Clone)]
pub struct ChainBuilder {
http_client: AttestedHttpClient,
timeout: Duration,
max_body_size: usize,
}
impl ChainBuilder {
pub fn new() -> Result<Self, LtvError> {
Ok(Self {
http_client: crate::net::hardened_http_client()?,
timeout: Duration::from_secs(30),
max_body_size: MAX_CERT_BODY_SIZE,
})
}
pub fn http_client(mut self, client: AttestedHttpClient) -> Self {
self.http_client = client;
self
}
#[cfg(not(feature = "fips"))]
pub fn unverified_http_client(mut self, client: reqwest::Client) -> Result<Self, LtvError> {
self.http_client = crate::net::unverified_http_client(client)?;
Ok(self)
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn max_body_size(mut self, max: usize) -> Self {
self.max_body_size = max;
self
}
pub async fn build_chain(
&self,
leaf: &Certificate,
trust_store: &TrustStore,
) -> Result<Vec<Vec<u8>>, LtvError> {
let mut chain: Vec<Vec<u8>> = Vec::new();
let mut current = leaf.clone();
for depth in 0..MAX_CHAIN_DEPTH {
let current_der = current
.to_der()
.map_err(|e| LtvError::Chain(format!("failed to encode certificate: {e}")))?;
chain.push(current_der.clone());
if trust_store.find_issuer(¤t).is_some() {
log::debug!("Chain complete at depth {depth}: found trust anchor");
return Ok(chain);
}
if is_self_signed(¤t) {
log::debug!("Chain complete at depth {depth}: self-signed certificate");
return Ok(chain);
}
let ca_issuer_urls = extract_aia_urls(¤t, AiaAccessMethod::CaIssuers);
if ca_issuer_urls.is_empty() {
log::debug!("Chain building stopped at depth {depth}: no AIA caIssuers URLs");
return Ok(chain);
}
let mut found_issuer = false;
for url in &ca_issuer_urls {
match self.fetch_certificate(url).await {
Ok(issuer_cert) => {
current = issuer_cert;
found_issuer = true;
break;
}
Err(e) => {
log::warn!("Failed to fetch CA cert from {url}: {e}");
}
}
}
if !found_issuer {
log::debug!(
"Chain building stopped at depth {depth}: could not fetch issuer from any AIA URL"
);
return Ok(chain);
}
}
log::warn!("Chain building reached maximum depth ({MAX_CHAIN_DEPTH})");
Ok(chain)
}
pub fn build_chain_from_certs(
leaf: &Certificate,
available_certs: &[Certificate],
trust_store: &TrustStore,
) -> Vec<Vec<u8>> {
let mut chain: Vec<Vec<u8>> = Vec::new();
let mut current = leaf.clone();
for _ in 0..MAX_CHAIN_DEPTH {
let Ok(current_der) = current.to_der() else {
break;
};
chain.push(current_der);
if trust_store.find_issuer(¤t).is_some() {
break;
}
if is_self_signed(¤t) {
break;
}
let issuer_name = ¤t.tbs_certificate.issuer;
let found = available_certs
.iter()
.find(|c| &c.tbs_certificate.subject == issuer_name);
match found {
Some(issuer) => {
current = issuer.clone();
}
None => break,
}
}
chain
}
async fn fetch_certificate(&self, url: &str) -> Result<Certificate, LtvError> {
log::debug!("Fetching CA certificate from {url}");
match tokio::time::timeout(self.timeout, crate::net::validate_fetch_url(url)).await {
Ok(result) => result.map_err(|e| LtvError::Chain(format!("AIA caIssuers {e}")))?,
Err(_) => {
return Err(LtvError::Chain(format!(
"AIA caIssuers URL validation timed out after {:?}",
self.timeout
)))
}
}
let response = self
.http_client
.client()
.get(url)
.timeout(self.timeout)
.send()
.await
.map_err(|e| LtvError::Chain(format!("failed to fetch cert from {url}: {e}")))?;
if !response.status().is_success() {
return Err(LtvError::Chain(format!(
"cert fetch from {url} returned HTTP {}",
response.status()
)));
}
if let Some(len) = response.content_length() {
if len > self.max_body_size as u64 {
return Err(LtvError::Chain(format!(
"cert from {url} exceeds max body size ({len} > {})",
self.max_body_size
)));
}
}
let mut cert_bytes: Vec<u8> = Vec::new();
let mut response = response;
loop {
let chunk = response
.chunk()
.await
.map_err(|e| LtvError::Chain(format!("failed to read cert response: {e}")))?;
let Some(chunk) = chunk else { break };
if cert_bytes.len() + chunk.len() > self.max_body_size {
return Err(LtvError::Chain(format!(
"cert from {url} exceeds max body size (> {})",
self.max_body_size
)));
}
cert_bytes.extend_from_slice(&chunk);
}
if let Ok(cert) = Certificate::from_der(&cert_bytes) {
return Ok(cert);
}
if let Ok((_label, der)) = pem_rfc7468::decode_vec(&cert_bytes) {
if let Ok(cert) = Certificate::from_der(&der) {
return Ok(cert);
}
}
Err(LtvError::Chain(format!(
"could not parse certificate from {url} (tried DER and PEM)"
)))
}
}
fn is_self_signed(cert: &Certificate) -> bool {
cert.tbs_certificate.subject == cert.tbs_certificate.issuer
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chain_builder_default() {
let builder = ChainBuilder::new().unwrap();
assert_eq!(builder.timeout, Duration::from_secs(30));
assert_eq!(builder.max_body_size, MAX_CERT_BODY_SIZE);
}
#[tokio::test]
async fn test_fetch_certificate_rejects_private_ip_aia_url() {
let builder = ChainBuilder::new().unwrap();
for url in [
"http://127.0.0.1/ca.crt",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/ca.crt",
"http://[::1]/ca.crt",
] {
let err = builder
.fetch_certificate(url)
.await
.expect_err("private/loopback AIA URL must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("non-public") || msg.contains("SSRF"),
"expected SSRF rejection for {url}, got: {msg}"
);
}
}
#[tokio::test]
async fn test_fetch_certificate_rejects_non_http_scheme() {
let builder = ChainBuilder::new().unwrap();
let err = builder
.fetch_certificate("file:///etc/passwd")
.await
.expect_err("non-http AIA URL must be rejected");
assert!(
format!("{err}").contains("scheme not allowed"),
"expected scheme rejection, got: {err}"
);
}
#[test]
fn test_is_self_signed() {
let cert_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ca_cert.pem"
));
let (_label, der) = pem_rfc7468::decode_vec(cert_pem.as_bytes()).unwrap();
let cert = Certificate::from_der(&der).unwrap();
assert!(is_self_signed(&cert), "root CA should be self-signed");
}
#[test]
fn test_is_not_self_signed() {
let cert_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/signer_cert.pem"
));
let (_label, der) = pem_rfc7468::decode_vec(cert_pem.as_bytes()).unwrap();
let cert = Certificate::from_der(&der).unwrap();
assert!(
!is_self_signed(&cert),
"signer cert should not be self-signed"
);
}
#[test]
fn test_build_chain_from_certs() {
let ca_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/ca_cert.pem"
));
let signer_pem = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/signer_cert.pem"
));
let (_label, ca_der) = pem_rfc7468::decode_vec(ca_pem.as_bytes()).unwrap();
let (_label, signer_der) = pem_rfc7468::decode_vec(signer_pem.as_bytes()).unwrap();
let signer_cert = Certificate::from_der(&signer_der).unwrap();
let mut trust_store = TrustStore::new();
trust_store.add_der_certificate(&ca_der).unwrap();
let chain_pem_path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/chain.pem");
let chain_pem = std::fs::read_to_string(chain_pem_path).unwrap_or_default();
let mut available_certs = vec![signer_cert.clone()];
let pem_data = chain_pem.as_bytes();
if let Ok((_label, der)) = pem_rfc7468::decode_vec(pem_data) {
if let Ok(cert) = Certificate::from_der(&der) {
available_certs.push(cert);
}
}
let chain =
ChainBuilder::build_chain_from_certs(&signer_cert, &available_certs, &trust_store);
assert!(!chain.is_empty(), "chain should not be empty");
assert_eq!(chain[0], signer_der, "first cert should be the signer");
}
}