Files
RustProxy/src/tls/server_config.rs
T
2026-10-20 22:09:50 +02:00

416 lines
16 KiB
Rust

/// Extract the DNS SubjectAltName set from the leaf certificate. This parser is
/// deliberately narrow: it walks only the DER structures required to locate the
/// SAN extension and ignores non-DNS GeneralName variants. Certificate validity
/// and key matching are still delegated to rustls.
fn certificate_dns_names(certificate: &[u8]) -> Result<Vec<String>> {
let mut reader = certificate;
let certs = rustls_pemfile::certs(&mut reader).collect::<io::Result<Vec<_>>>()?;
let leaf = certs.first().context("certificate chain is empty")?;
let der = leaf.as_ref();
let mut root_offset = 0;
let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?;
ensure!(
tag == 0x30 && root_offset == der.len(),
"invalid X.509 certificate DER"
);
let mut certificate_offset = 0;
let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?;
ensure!(tag == 0x30, "invalid X.509 TBSCertificate");
let mut tbs_offset = 0;
if tbs.first() == Some(&0xa0) {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
// serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo
for _ in 0..6 {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
let mut names = Vec::new();
while tbs_offset < tbs.len() {
let (tag, value) = der_tlv(tbs, &mut tbs_offset)?;
if tag != 0xa3 {
continue;
}
let mut wrapper_offset = 0;
let (tag, extensions) = der_tlv(value, &mut wrapper_offset)?;
ensure!(tag == 0x30, "invalid X.509 extensions");
let mut extensions_offset = 0;
while extensions_offset < extensions.len() {
let (tag, extension) = der_tlv(extensions, &mut extensions_offset)?;
ensure!(tag == 0x30, "invalid X.509 extension");
let mut extension_offset = 0;
let (oid_tag, oid) = der_tlv(extension, &mut extension_offset)?;
ensure!(oid_tag == 0x06, "invalid X.509 extension OID");
if extension_offset < extension.len() && extension[extension_offset] == 0x01 {
let _ = der_tlv(extension, &mut extension_offset)?;
}
let (value_tag, encoded_value) = der_tlv(extension, &mut extension_offset)?;
ensure!(value_tag == 0x04, "invalid X.509 extension value");
if oid != [0x55, 0x1d, 0x11] {
continue;
}
let mut san_offset = 0;
let (san_tag, general_names) = der_tlv(encoded_value, &mut san_offset)?;
ensure!(san_tag == 0x30, "invalid SubjectAltName extension");
let mut names_offset = 0;
while names_offset < general_names.len() {
let (name_tag, raw) = der_tlv(general_names, &mut names_offset)?;
if name_tag != 0x82 {
continue;
}
let Ok(name) = std::str::from_utf8(raw) else {
continue;
};
if let Ok(name) = normalize_host_pattern(name) {
names.push(name);
}
}
}
break;
}
names.sort();
names.dedup();
Ok(names)
}
#[derive(Debug, Clone, Serialize)]
pub struct CertificateMetadata {
pub covered_hosts: Vec<String>,
pub not_before: Option<String>,
pub not_after: Option<String>,
pub not_before_unix: Option<i64>,
pub not_after_unix: Option<i64>,
pub days_remaining: Option<i64>,
pub issuer: Option<String>,
pub expiry_status: &'static str,
}
fn der_text(tag: u8, raw: &[u8]) -> Option<String> {
let value = match tag {
0x0c | 0x13 | 0x14 | 0x16 => std::str::from_utf8(raw).ok()?.to_owned(),
0x1e if raw.len() % 2 == 0 => {
let units: Vec<u16> = raw
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&units).ok()?
}
_ => return None,
};
let value: String = value
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.take(256)
.collect();
(!value.trim().is_empty()).then(|| value.trim().to_owned())
}
fn certificate_issuer_name(encoded: &[u8]) -> Option<String> {
let mut offset = 0;
let mut parts = Vec::new();
while offset < encoded.len() && parts.len() < 16 {
let (set_tag, set_value) = der_tlv(encoded, &mut offset).ok()?;
if set_tag != 0x31 {
continue;
}
let mut set_offset = 0;
while set_offset < set_value.len() && parts.len() < 16 {
let (sequence_tag, sequence) = der_tlv(set_value, &mut set_offset).ok()?;
if sequence_tag != 0x30 {
continue;
}
let mut sequence_offset = 0;
let (oid_tag, oid) = der_tlv(sequence, &mut sequence_offset).ok()?;
if oid_tag != 0x06 || sequence_offset >= sequence.len() {
continue;
}
let (value_tag, value) = der_tlv(sequence, &mut sequence_offset).ok()?;
let Some(value) = der_text(value_tag, value) else {
continue;
};
let label = if oid == [0x55, 0x04, 0x03] {
"CN"
} else if oid == [0x55, 0x04, 0x06] {
"C"
} else if oid == [0x55, 0x04, 0x07] {
"L"
} else if oid == [0x55, 0x04, 0x08] {
"ST"
} else if oid == [0x55, 0x04, 0x0a] {
"O"
} else if oid == [0x55, 0x04, 0x0b] {
"OU"
} else {
continue;
};
parts.push(format!("{label}={value}"));
}
}
(!parts.is_empty()).then(|| parts.join(", "))
}
fn x509_days_in_month(year: i32, month: u32) -> Option<u32> {
let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
Some(match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return None,
})
}
fn x509_days_from_civil(year: i32, month: u32, day: u32) -> i64 {
let year = year - if month <= 2 { 1 } else { 0 };
let era = (if year >= 0 { year } else { year - 399 }) / 400;
let yoe = year - era * 400;
let month = i32::try_from(month).unwrap_or(0);
let day = i32::try_from(day).unwrap_or(0);
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
i64::from(era * 146_097 + doe - 719_468)
}
fn x509_decimal(raw: &[u8]) -> Option<u32> {
let text = std::str::from_utf8(raw).ok()?;
text.parse().ok()
}
fn x509_time(tag: u8, raw: &[u8]) -> Option<(String, i64)> {
let year_digits = match tag {
0x17 => 2,
0x18 => 4,
_ => return None,
};
let base_digits = year_digits + 10;
if raw.len() < base_digits + 1 || !raw[..base_digits].iter().all(|byte| byte.is_ascii_digit()) {
return None;
}
let mut year = i32::try_from(x509_decimal(&raw[..year_digits])?).ok()?;
if year_digits == 2 {
year += if year >= 50 { 1900 } else { 2000 };
}
let mut cursor = year_digits;
let month = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let day = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let hour = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let minute = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
let second = x509_decimal(&raw[cursor..cursor + 2])?;
cursor += 2;
if day == 0 || day > x509_days_in_month(year, month)? || hour > 23 || minute > 59 || second > 60
{
return None;
}
while cursor < raw.len() && matches!(raw[cursor], b'.' | b',') {
cursor += 1;
while cursor < raw.len() && raw[cursor].is_ascii_digit() {
cursor += 1;
}
}
let (offset_seconds, suffix) = match raw.get(cursor).copied()? {
b'Z' if cursor + 1 == raw.len() => (0i64, "Z".to_owned()),
sign @ (b'+' | b'-') if cursor + 5 == raw.len() => {
let zone_hour = x509_decimal(&raw[cursor + 1..cursor + 3])?;
let zone_minute = x509_decimal(&raw[cursor + 3..cursor + 5])?;
if zone_hour > 23 || zone_minute > 59 {
return None;
}
let seconds =
i64::from(zone_hour * 3600 + zone_minute * 60) * if sign == b'+' { 1 } else { -1 };
(
seconds,
format!(
"{}{:02}:{:02}",
if sign == b'+' { '+' } else { '-' },
zone_hour,
zone_minute
),
)
}
_ => return None,
};
let unix = x509_days_from_civil(year, month, day)
.checked_mul(86_400)?
.checked_add(i64::from(hour * 3600 + minute * 60 + second.min(59)))?
.checked_sub(offset_seconds)?;
let display =
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}{suffix}");
Some((display, unix))
}
fn certificate_public_metadata(certificate: &[u8]) -> Result<CertificateMetadata> {
let covered_hosts = certificate_dns_names(certificate)?;
let mut reader = certificate;
let certs = rustls_pemfile::certs(&mut reader).collect::<io::Result<Vec<_>>>()?;
let leaf = certs.first().context("certificate chain is empty")?;
let der = leaf.as_ref();
let mut root_offset = 0;
let (tag, certificate_sequence) = der_tlv(der, &mut root_offset)?;
ensure!(
tag == 0x30 && root_offset == der.len(),
"invalid X.509 certificate DER"
);
let mut certificate_offset = 0;
let (tag, tbs) = der_tlv(certificate_sequence, &mut certificate_offset)?;
ensure!(tag == 0x30, "invalid X.509 TBSCertificate");
let mut tbs_offset = 0;
if tbs.first() == Some(&0xa0) {
let _ = der_tlv(tbs, &mut tbs_offset)?;
}
let _serial = der_tlv(tbs, &mut tbs_offset)?;
let _signature = der_tlv(tbs, &mut tbs_offset)?;
let (issuer_tag, issuer) = der_tlv(tbs, &mut tbs_offset)?;
ensure!(issuer_tag == 0x30, "invalid X.509 issuer");
let (validity_tag, validity) = der_tlv(tbs, &mut tbs_offset)?;
ensure!(validity_tag == 0x30, "invalid X.509 validity");
let mut validity_offset = 0;
let (not_before_tag, not_before_raw) = der_tlv(validity, &mut validity_offset)?;
let (not_after_tag, not_after_raw) = der_tlv(validity, &mut validity_offset)?;
let not_before = x509_time(not_before_tag, not_before_raw);
let not_after = x509_time(not_after_tag, not_after_raw);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|value| i64::try_from(value.as_secs()).unwrap_or(i64::MAX))
.unwrap_or(0);
let days_remaining = not_after.as_ref().map(|(_, unix)| {
let delta = unix.saturating_sub(now);
if delta >= 0 {
delta.saturating_add(86_399) / 86_400
} else {
-(delta.saturating_abs().saturating_add(86_399) / 86_400)
}
});
let expiry_status = match not_after.as_ref() {
Some((_, unix)) if *unix < now => "expired",
Some(_) if days_remaining.is_some_and(|days| days <= 30) => "warning",
Some(_) => "ok",
None => "unknown",
};
Ok(CertificateMetadata {
covered_hosts,
not_before: not_before.as_ref().map(|(value, _)| value.clone()),
not_after: not_after.as_ref().map(|(value, _)| value.clone()),
not_before_unix: not_before.map(|(_, value)| value),
not_after_unix: not_after.map(|(_, value)| value),
days_remaining,
issuer: certificate_issuer_name(issuer),
expiry_status,
})
}
/// Read DNS SubjectAltName coverage directly from an existing certificate file.
/// Used by the authenticated certificate inventory to enrich older/manual entries
/// that predate persisted `covered_hosts` metadata. Private key material is not read.
pub fn certificate_covered_hosts(directory: &Path, cert_file: &str) -> Result<Vec<String>> {
let certificate = read_configured_pem(directory, cert_file, false)?;
certificate_dns_names(&certificate)
}
/// Read non-secret metadata directly from the configured public leaf certificate.
/// Private keys are never opened by this path.
pub fn certificate_metadata(directory: &Path, cert_file: &str) -> Result<CertificateMetadata> {
let certificate = read_configured_pem(directory, cert_file, false)?;
certificate_public_metadata(&certificate)
}
fn parse_pair(certificate: &[u8], key: &[u8]) -> Result<CertifiedKey> {
let mut certificate_reader = certificate;
let certs = rustls_pemfile::certs(&mut certificate_reader).collect::<io::Result<Vec<_>>>()?;
ensure!(
!certs.is_empty() && certs.len() <= 16,
"certificate chain must contain 1..16 PEM certificates"
);
let mut key_reader = key;
let key = rustls_pemfile::private_key(&mut key_reader)?
.context("no supported PEM private key found")?;
let certified = CertifiedKey::from_der(certs, key, &rustls::crypto::ring::default_provider())?;
certified
.keys_match()
.context("certificate and private key do not match")?;
Ok(certified)
}
fn load_pair(directory: &Path, cert: &str, key: &str) -> Result<CertifiedKey> {
certificate_files_are_external(cert, key)?;
parse_pair(
&read_configured_pem(directory, cert, false)?,
&read_configured_pem(directory, key, true)?,
)
}
/// Runs on a bounded control-plane blocking worker, never from a handshake.
pub fn compile(settings: &Settings, directory: Option<&Path>) -> Result<Option<Arc<ServerConfig>>> {
settings.policy.validate()?;
let Some(directory) = directory else {
ensure!(
settings.default_certificate.is_none() && settings.certificates.is_empty(),
"certificate configuration requires a certificate directory"
);
return Ok(None);
};
let mut names = HashMap::<String, Arc<CertifiedKey>>::new();
let mut ids = HashSet::new();
let mut hosts = HashSet::new();
let mut default = None;
for cert in &settings.certificates {
safe_name(&cert.id)?;
ensure!(ids.insert(cert.id.clone()), "duplicate certificate id");
let key = load_pair(directory, &cert.cert_file, &cert.key_file)?;
for host in &cert.hosts {
let host = normalize_host_pattern(host)?;
ensure!(hosts.insert(host.clone()), "duplicate TLS hostname binding");
// Validate certificate/name compatibility with a concrete SNI name.
// rustls' stock resolver does not model routing wildcard precedence,
// so the immutable RustProxy resolver below owns exact/wildcard lookup.
let validation_name = host
.strip_prefix("*.")
.map(|base| format!("rustproxy-wildcard-check.{base}"))
.unwrap_or_else(|| host.clone());
if !settings.forced_mismatches.contains(&(cert.id.clone(), host.clone())) {
let mut checker = ResolvesServerCertUsingSni::new();
checker
.add(&validation_name, key.clone())
.with_context(|| format!("certificate does not cover {host}; explicit Vhost consent is required to override"))?;
} else {
tracing::warn!(certificate = %cert.id, hostname = %host,
"Explicitly serving a TLS certificate without hostname verification; clients may reject it");
}
names.insert(host, Arc::new(key.clone()));
}
if settings.default_certificate.as_deref() == Some(cert.id.as_str()) {
default = Some(Arc::new(key));
}
}
if settings.default_certificate.is_some() {
ensure!(
default.is_some(),
"default_certificate references an unknown id"
);
}
let fallback = match default {
Some(key) => key,
None => Arc::new(load_pair(directory, "default.pem", "default.key")?),
};
Ok(Some(server_config(
Arc::new(Resolver { names, fallback }),
&settings.policy,
true,
)?))
}
/// Reuse the already validated immutable certificate resolver while narrowing
/// protocols/ciphers for one frontend. No certificate I/O occurs here.
pub fn variant(
base: &Arc<ServerConfig>,
policy: &ProtocolPolicy,
http2: bool,
) -> Result<Arc<ServerConfig>> {
server_config(base.cert_resolver.clone(), policy, http2)
}