Skip to main content

postrust_proxy/tls/
validate.rs

1//! Checking a certificate before it is trusted with traffic.
2//!
3//! For certificates that arrive from outside — a tenant uploading its own —
4//! rather than ones we obtained ourselves. Everything here is a check that, if
5//! skipped, produces a listener that accepts the certificate happily and then
6//! fails every handshake at runtime, when the person who could fix it is no
7//! longer looking.
8
9use chrono::{DateTime, Utc};
10
11use crate::error::{ProxyError, ProxyResult};
12
13/// What a certificate turned out to be.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct CertificateFacts {
16    /// When the leaf expires.
17    pub expires_at: DateTime<Utc>,
18    /// Every name the leaf covers: its subject alternative names, plus the
19    /// common name when there are no SANs at all.
20    pub names: Vec<String>,
21}
22
23/// Check a PEM certificate chain and key, and report what they cover.
24///
25/// Four things, each of which has to hold before this certificate can serve a
26/// domain:
27///
28/// 1. The chain and the key parse.
29/// 2. **The key belongs to the certificate.** Delegated to rustls, which
30///    compares the public keys — this is the check whose absence produces a
31///    listener that fails every handshake with no clue why.
32/// 3. The leaf has not expired.
33/// 4. The leaf covers `domain`, wildcards included.
34pub fn validate_for_domain(
35    domain: &str,
36    cert_pem: &[u8],
37    key_pem: &[u8],
38) -> ProxyResult<CertificateFacts> {
39    // (1) and (2). `build_server_config` parses both and hands them to rustls,
40    // which refuses a key that does not match the leaf.
41    crate::tls::server::build_server_config(cert_pem, key_pem)?;
42
43    let facts = facts(cert_pem)?;
44
45    // (3)
46    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    // (4)
55    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
65/// Read the expiry and the covered names out of a PEM chain.
66///
67/// The leaf is the first certificate: RFC 8446 section 4.4.2 requires the
68/// sender's own certificate first, and every tool that writes a chain follows
69/// it.
70pub 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    // A certificate with no SAN at all: fall back to the common name. Modern
96    // certificates always carry a SAN and browsers ignore CN entirely, but a
97    // self-signed one made by hand may not, and refusing it for that alone
98    // would be unhelpful.
99    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
111/// The expiry of a chain, or `None` if it cannot be read.
112///
113/// For a certificate we obtained ourselves and are storing regardless. Callers
114/// must not store the `None` as-is: the renewal scan selects on
115/// `expires_at IS NOT NULL`, so a null expiry means the certificate is never
116/// renewed at all, not merely renewed on a worse schedule. `AcmeIssuer::issue`
117/// substitutes a deliberately short assumed lifetime.
118pub fn expiry_of(cert_pem: &[u8]) -> Option<DateTime<Utc>> {
119    facts(cert_pem).ok().map(|f| f.expires_at)
120}
121
122/// Whether a certificate name covers a host.
123///
124/// Wildcards match exactly one label, per RFC 9110 section 4.3.4 and RFC 6125
125/// section 6.4.3 — `*.example.com` covers `api.example.com` but not
126/// `example.com` and not `a.b.example.com`. Getting this wrong in the generous
127/// direction means accepting a certificate for a domain it does not actually
128/// authorise.
129fn 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    // Only a leading `*.` is a wildcard. `foo*.example.com` is not one.
141    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    // Exactly one label, and it must be a label rather than nothing.
151    !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        // Not the bare domain: RFC 6125 section 6.4.3.
177        assert!(!covers("*.example.com", "example.com"));
178        // Not two labels.
179        assert!(!covers("*.example.com", "a.b.example.com"));
180        // Not an empty label.
181        assert!(!covers("*.example.com", ".example.com"));
182    }
183
184    #[test]
185    fn a_wildcard_does_not_escape_its_suffix() {
186        // The generous mistakes: a suffix match rather than a label match would
187        // accept all of these.
188        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        // `foo*.example.com` is a literal name, not a pattern.
196        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        // No SAN in the fixture, so the common name is the fallback.
214        assert_eq!(facts.names, vec!["expiry.test".to_string()]);
215    }
216
217    // Fixtures, generated once with openssl so these need no CA and no clock.
218    // See testdata/README.md for the commands.
219    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        // The fixture covers example.test and www.example.test.
239        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        // The check that matters most: without it the listener accepts this and
246        // then fails every TLS handshake, with nothing in the logs pointing
247        // here.
248        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        // And through the whole check, which must not panic either.
290        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}