v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,418 @@
|
||||
/// 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>> {
|
||||
safe_name(cert_file)?;
|
||||
let certificate = read_pem(&directory.join(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> {
|
||||
safe_name(cert_file)?;
|
||||
let certificate = read_pem(&directory.join(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> {
|
||||
safe_name(cert)?;
|
||||
safe_name(key)?;
|
||||
parse_pair(
|
||||
&read_pem(&directory.join(cert), false)?,
|
||||
&read_pem(&directory.join(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());
|
||||
let mut checker = ResolvesServerCertUsingSni::new();
|
||||
checker
|
||||
.add(&validation_name, key.clone())
|
||||
.with_context(|| format!("certificate does not cover {host}"))?;
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user