postrust_proxy/tls/
validate.rs1use chrono::{DateTime, Utc};
10
11use crate::error::{ProxyError, ProxyResult};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CertificateFacts {
16 pub expires_at: DateTime<Utc>,
18 pub names: Vec<String>,
21}
22
23pub fn validate_for_domain(
35 domain: &str,
36 cert_pem: &[u8],
37 key_pem: &[u8],
38) -> ProxyResult<CertificateFacts> {
39 crate::tls::server::build_server_config(cert_pem, key_pem)?;
42
43 let facts = facts(cert_pem)?;
44
45 let now = Utc::now();
47 if facts.expires_at <= now {
48 return Err(ProxyError::Validation(format!(
49 "the certificate expired at {} and cannot be used",
50 facts.expires_at
51 )));
52 }
53
54 if !facts.names.iter().any(|name| covers(name, domain)) {
56 return Err(ProxyError::Validation(format!(
57 "the certificate does not cover {domain}; it covers {}",
58 facts.names.join(", ")
59 )));
60 }
61
62 Ok(facts)
63}
64
65pub fn facts(cert_pem: &[u8]) -> ProxyResult<CertificateFacts> {
71 let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem)
72 .map_err(|e| ProxyError::Validation(format!("could not parse the certificate: {e}")))?;
73 let (_, cert) = x509_parser::parse_x509_certificate(&pem.contents)
74 .map_err(|e| ProxyError::Validation(format!("could not parse the certificate: {e}")))?;
75
76 let expires_at = DateTime::from_timestamp(cert.validity().not_after.timestamp(), 0)
77 .ok_or_else(|| ProxyError::Validation("the certificate's expiry is not a date".into()))?;
78
79 let mut names: Vec<String> = cert
80 .subject_alternative_name()
81 .ok()
82 .flatten()
83 .map(|san| {
84 san.value
85 .general_names
86 .iter()
87 .filter_map(|name| match name {
88 x509_parser::extensions::GeneralName::DNSName(dns) => Some(dns.to_string()),
89 _ => None,
90 })
91 .collect()
92 })
93 .unwrap_or_default();
94
95 if names.is_empty() {
100 names.extend(
101 cert.subject()
102 .iter_common_name()
103 .filter_map(|cn| cn.as_str().ok())
104 .map(str::to_owned),
105 );
106 }
107
108 Ok(CertificateFacts { expires_at, names })
109}
110
111pub fn expiry_of(cert_pem: &[u8]) -> Option<DateTime<Utc>> {
119 facts(cert_pem).ok().map(|f| f.expires_at)
120}
121
122fn covers(cert_name: &str, host: &str) -> bool {
130 let cert_name = cert_name.trim().trim_end_matches('.').to_ascii_lowercase();
131 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
132
133 if cert_name.is_empty() || host.is_empty() {
134 return false;
135 }
136 if cert_name == host {
137 return true;
138 }
139
140 let Some(suffix) = cert_name.strip_prefix("*.") else {
142 return false;
143 };
144 if suffix.is_empty() {
145 return false;
146 }
147 let Some(label) = host.strip_suffix(&format!(".{suffix}")) else {
148 return false;
149 };
150 !label.is_empty() && !label.contains('.')
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn an_exact_name_covers_its_host() {
160 assert!(covers("example.com", "example.com"));
161 assert!(covers("api.example.com", "api.example.com"));
162 }
163
164 #[test]
165 fn matching_is_case_insensitive_and_ignores_a_trailing_dot() {
166 assert!(covers("EXAMPLE.com", "example.COM"));
167 assert!(covers("example.com.", "example.com"));
168 assert!(covers("example.com", "example.com."));
169 }
170
171 #[test]
172 fn a_wildcard_covers_exactly_one_label() {
173 assert!(covers("*.example.com", "api.example.com"));
174 assert!(covers("*.example.com", "www.example.com"));
175
176 assert!(!covers("*.example.com", "example.com"));
178 assert!(!covers("*.example.com", "a.b.example.com"));
180 assert!(!covers("*.example.com", ".example.com"));
182 }
183
184 #[test]
185 fn a_wildcard_does_not_escape_its_suffix() {
186 assert!(!covers("*.example.com", "api.example.com.evil.test"));
189 assert!(!covers("*.example.com", "notexample.com"));
190 assert!(!covers("*.example.com", "api.evil-example.com"));
191 }
192
193 #[test]
194 fn a_partial_wildcard_is_not_a_wildcard() {
195 assert!(!covers("foo*.example.com", "foobar.example.com"));
197 assert!(!covers("*", "example.com"));
198 assert!(!covers("*.", "example.com"));
199 }
200
201 #[test]
202 fn empty_names_never_match() {
203 assert!(!covers("", "example.com"));
204 assert!(!covers("example.com", ""));
205 assert!(!covers("", ""));
206 }
207
208 #[test]
209 fn a_real_certificate_yields_its_expiry() {
210 let pem = include_str!("testdata/expiry.pem");
211 let facts = facts(pem.as_bytes()).expect("the fixture should parse");
212 assert!(facts.expires_at > DateTime::from_timestamp(0, 0).unwrap());
213 assert_eq!(facts.names, vec!["expiry.test".to_string()]);
215 }
216
217 const VALID: &[u8] = include_bytes!("testdata/valid.pem");
220 const VALID_KEY: &[u8] = include_bytes!("testdata/valid.key.pem");
221 const OTHER: &[u8] = include_bytes!("testdata/other.pem");
222 const OTHER_KEY: &[u8] = include_bytes!("testdata/other.key.pem");
223 const EXPIRED: &[u8] = include_bytes!("testdata/expired.pem");
224 const EXPIRED_KEY: &[u8] = include_bytes!("testdata/expired.key.pem");
225 const WILDCARD: &[u8] = include_bytes!("testdata/wildcard.pem");
226 const WILDCARD_KEY: &[u8] = include_bytes!("testdata/wildcard.key.pem");
227
228 #[test]
229 fn a_matching_certificate_and_key_for_the_domain_is_accepted() {
230 let facts = validate_for_domain("example.test", VALID, VALID_KEY)
231 .expect("a valid pair for the domain should be accepted");
232 assert!(facts.names.contains(&"example.test".to_string()));
233 assert!(facts.expires_at > Utc::now());
234 }
235
236 #[test]
237 fn a_san_other_than_the_first_still_counts() {
238 validate_for_domain("www.example.test", VALID, VALID_KEY)
240 .expect("any SAN on the certificate should satisfy the check");
241 }
242
243 #[test]
244 fn a_key_from_a_different_certificate_is_refused() {
245 validate_for_domain("example.test", VALID, OTHER_KEY)
249 .expect_err("a key that does not match the chain must be refused");
250 }
251
252 #[test]
253 fn a_certificate_for_another_domain_is_refused() {
254 let error = validate_for_domain("example.test", OTHER, OTHER_KEY)
255 .expect_err("a certificate for another domain must be refused");
256 assert!(
257 error.to_string().contains("does not cover example.test"),
258 "the rejection should name the domain: {error}"
259 );
260 }
261
262 #[test]
263 fn an_expired_certificate_is_refused() {
264 let error = validate_for_domain("example.test", EXPIRED, EXPIRED_KEY)
265 .expect_err("an expired certificate must be refused");
266 assert!(
267 error.to_string().contains("expired"),
268 "the rejection should say it expired: {error}"
269 );
270 }
271
272 #[test]
273 fn a_wildcard_certificate_covers_a_subdomain_but_not_the_apex() {
274 validate_for_domain("api.example.test", WILDCARD, WILDCARD_KEY)
275 .expect("*.example.test should cover api.example.test");
276
277 let error = validate_for_domain("example.test", WILDCARD, WILDCARD_KEY)
278 .expect_err("*.example.test must not cover the apex");
279 assert!(error.to_string().contains("does not cover"), "{error}");
280 }
281
282 #[test]
283 fn rubbish_is_refused_rather_than_panicking() {
284 assert!(facts(b"").is_err());
285 assert!(facts(b"not a certificate").is_err());
286 assert!(facts(b"-----BEGIN CERTIFICATE-----\nzz\n-----END CERTIFICATE-----").is_err());
287 assert!(expiry_of(b"").is_none());
288
289 assert!(validate_for_domain("example.test", b"", b"").is_err());
291 assert!(validate_for_domain("example.test", VALID, b"not a key").is_err());
292 }
293}