416 lines
16 KiB
Rust
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)
|
|
}
|