Skip to main content

hashiverse_lib/tools/
cert_validation.rs

1//! # Offline X.509 chain validation for `AnnounceV2` proofs
2//!
3//! [`is_cert_valid`] decides whether a leaf+intermediate chain (presented inline by
4//! an announcing peer) is a real, current, public-CA-issued certificate for the
5//! announced IP address. The whole point is to gate Kademlia admission *without
6//! pinging the announcer*: a peer that doesn't control its announced IP can't
7//! complete ACME's HTTP-01 / TLS-ALPN-01 challenge, so it can't put a chain in its
8//! announce that satisfies all three checks here.
9//!
10//! Checks (all offline, no network):
11//!
12//! 1. **Path validation** against the bundled Mozilla NSS root store from
13//!    `webpki-roots`. Enforces server-auth EKU and the cert's own validity window
14//!    against the supplied `now`.
15//! 2. **SAN match** against the announced IP — per [project invariant], hashiverse
16//!    servers identify by raw IP, so we expect an IP SAN (not DNS).
17//!
18//! Out of scope: OCSP/CRL revocation (would need network), liveness of the listener
19//! (the existing prune-on-RPC-failure path catches that).
20//!
21//! The function is wrapped by `HttpsTransportOwnershipProof::prove` in
22//! `hashiverse-server-lib`; tests live alongside in this module.
23
24use crate::tools::time::TimeMillis;
25use rustls_pki_types::{CertificateDer, ServerName, UnixTime};
26use std::net::IpAddr;
27use std::str::FromStr;
28use std::time::Duration;
29use webpki::EndEntityCert;
30
31/// Offline-validate a TLS chain against the bundled public-CA roots and the
32/// announced IP address. Returns `true` only if every check passes.
33pub fn is_cert_valid(chain_der: &[Vec<u8>], announced_address: &str, now: TimeMillis) -> bool {
34    let Some((leaf_der_vec, intermediate_der_vecs)) = chain_der.split_first() else {
35        return false;
36    };
37
38    let leaf_der: CertificateDer<'_> = CertificateDer::from(leaf_der_vec.as_slice());
39    let leaf_cert: EndEntityCert<'_> = match EndEntityCert::try_from(&leaf_der) {
40        Ok(c) => c,
41        Err(_) => return false,
42    };
43
44    let intermediate_ders: Vec<CertificateDer<'_>> = intermediate_der_vecs.iter().map(|d| CertificateDer::from(d.as_slice())).collect();
45
46    let now_unix: UnixTime = match time_millis_to_unix_time(now) {
47        Some(t) => t,
48        None => return false,
49    };
50
51    let trust_anchors: &[rustls_pki_types::TrustAnchor<'_>] = webpki_roots::TLS_SERVER_ROOTS;
52
53    if leaf_cert
54        .verify_for_usage(
55            webpki::ALL_VERIFICATION_ALGS,
56            trust_anchors,
57            &intermediate_ders,
58            now_unix,
59            webpki::KeyUsage::server_auth(),
60            None,
61            None,
62        )
63        .is_err()
64    {
65        return false;
66    }
67
68    let Some(ip_str) = strip_port_from_address(announced_address) else {
69        return false;
70    };
71
72    let server_name: ServerName<'_> = match build_ip_server_name(&ip_str) {
73        Some(name) => name,
74        None => return false,
75    };
76
77    leaf_cert.verify_is_valid_for_subject_name(&server_name).is_ok()
78}
79
80fn time_millis_to_unix_time(time_millis: TimeMillis) -> Option<UnixTime> {
81    let millis: i64 = time_millis.0;
82    if millis < 0 {
83        return None;
84    }
85    Some(UnixTime::since_unix_epoch(Duration::from_millis(millis as u64)))
86}
87
88/// Split a `<host>:<port>` or `[<ipv6>]:<port>` address into just the host part. Bare hosts
89/// (no port) are returned unchanged. Returns `None` for empty input, unbalanced IPv6
90/// brackets, junk after the bracketed host, or a non-numeric port — so a caller can't
91/// quietly normalise a malformed announce into a plausible-looking host string.
92fn strip_port_from_address(announced_address: &str) -> Option<String> {
93    let trimmed: &str = announced_address.trim();
94    if trimmed.is_empty() {
95        return None;
96    }
97
98    if let Some(rest_after_open_bracket) = trimmed.strip_prefix('[') {
99        let close_bracket_pos: usize = rest_after_open_bracket.find(']')?;
100        let ipv6_body: &str = &rest_after_open_bracket[..close_bracket_pos];
101        let suffix: &str = &rest_after_open_bracket[close_bracket_pos + 1..];
102        // After the closing `]` the only legal suffixes are nothing (`[::1]`) or `:<digits>+`
103        // (`[::1]:443`). Anything else (`[::1]garbage`, `[::1]:notaport`, `[::1]:`) is
104        // malformed and must be rejected — otherwise the bracket body alone would be returned
105        // as if it were a clean host.
106        if !suffix.is_empty() {
107            let port_str: &str = suffix.strip_prefix(':')?;
108            if port_str.is_empty() || !port_str.bytes().all(|b| b.is_ascii_digit()) {
109                return None;
110            }
111        }
112        return Some(ipv6_body.to_string());
113    }
114
115    match trimmed.rsplit_once(':') {
116        Some((host_part, port_part)) if !host_part.contains(':') => {
117            // Plain `host:port` form. The port must be present and all digits; `1.2.3.4:`
118            // and `1.2.3.4:notaport` are malformed.
119            if port_part.is_empty() || !port_part.bytes().all(|b| b.is_ascii_digit()) {
120                return None;
121            }
122            Some(host_part.to_string())
123        }
124        // Either no colon at all (bare IPv4 host) or multiple colons with no port suffix
125        // (bare IPv6 host). Either way, hand the trimmed input back; the downstream
126        // `IpAddr::from_str` in `build_ip_server_name` is the final validator.
127        _ => Some(trimmed.to_string()),
128    }
129}
130
131fn build_ip_server_name(host_str: &str) -> Option<ServerName<'static>> {
132    let ip_addr: IpAddr = IpAddr::from_str(host_str).ok()?;
133    let ip_addr_typed: rustls_pki_types::IpAddr = match ip_addr {
134        IpAddr::V4(v4) => rustls_pki_types::IpAddr::V4(rustls_pki_types::Ipv4Addr::from(v4.octets())),
135        IpAddr::V6(v6) => rustls_pki_types::IpAddr::V6(rustls_pki_types::Ipv6Addr::from(v6.segments())),
136    };
137    Some(ServerName::IpAddress(ip_addr_typed))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn empty_chain_is_invalid() {
146        let now: TimeMillis = TimeMillis(1_700_000_000_000);
147        assert!(!is_cert_valid(&[], "127.0.0.1:8080", now));
148    }
149
150    #[test]
151    fn garbage_der_in_leaf_is_invalid() {
152        let chain_der: Vec<Vec<u8>> = vec![vec![0xff, 0xfe, 0xfd, 0xfc]];
153        let now: TimeMillis = TimeMillis(1_700_000_000_000);
154        assert!(!is_cert_valid(&chain_der, "127.0.0.1:8080", now));
155    }
156
157    #[test]
158    fn empty_address_is_invalid() {
159        let chain_der: Vec<Vec<u8>> = vec![vec![0xff]];
160        let now: TimeMillis = TimeMillis(1_700_000_000_000);
161        assert!(!is_cert_valid(&chain_der, "", now));
162    }
163
164    #[test]
165    fn negative_time_is_invalid() {
166        let chain_der: Vec<Vec<u8>> = vec![vec![0xff]];
167        let now: TimeMillis = TimeMillis(-1);
168        assert!(!is_cert_valid(&chain_der, "127.0.0.1:8080", now));
169    }
170
171    #[test]
172    fn strip_port_ipv4_with_port() {
173        assert_eq!(strip_port_from_address("1.2.3.4:8080"), Some("1.2.3.4".to_string()));
174    }
175
176    #[test]
177    fn strip_port_ipv4_without_port() {
178        assert_eq!(strip_port_from_address("1.2.3.4"), Some("1.2.3.4".to_string()));
179    }
180
181    #[test]
182    fn strip_port_ipv6_bracketed_with_port() {
183        assert_eq!(strip_port_from_address("[::1]:8080"), Some("::1".to_string()));
184    }
185
186    #[test]
187    fn strip_port_ipv6_bracketed_without_port() {
188        assert_eq!(strip_port_from_address("[2001:db8::1]"), Some("2001:db8::1".to_string()));
189    }
190
191    #[test]
192    fn strip_port_bare_ipv6_no_port() {
193        assert_eq!(strip_port_from_address("2001:db8::1"), Some("2001:db8::1".to_string()));
194    }
195
196    #[test]
197    fn strip_port_empty() {
198        assert_eq!(strip_port_from_address(""), None);
199    }
200
201    #[test]
202    fn strip_port_whitespace_trimmed() {
203        assert_eq!(strip_port_from_address("  1.2.3.4:8080  "), Some("1.2.3.4".to_string()));
204    }
205
206    #[test]
207    fn strip_port_rejects_non_numeric_port_on_ipv4() {
208        assert_eq!(strip_port_from_address("1.2.3.4:notaport"), None);
209    }
210
211    #[test]
212    fn strip_port_rejects_empty_port_on_ipv4() {
213        assert_eq!(strip_port_from_address("1.2.3.4:"), None);
214    }
215
216    #[test]
217    fn strip_port_rejects_bracketed_with_junk_suffix() {
218        assert_eq!(strip_port_from_address("[::1]garbage"), None);
219    }
220
221    #[test]
222    fn strip_port_rejects_bracketed_with_non_numeric_port() {
223        assert_eq!(strip_port_from_address("[::1]:notaport"), None);
224    }
225
226    #[test]
227    fn strip_port_rejects_bracketed_with_empty_port() {
228        assert_eq!(strip_port_from_address("[::1]:"), None);
229    }
230
231    #[test]
232    fn strip_port_rejects_unbalanced_bracket() {
233        assert_eq!(strip_port_from_address("[::1"), None);
234    }
235
236    #[test]
237    fn build_server_name_for_ipv4() {
238        let server_name: Option<ServerName<'static>> = build_ip_server_name("1.2.3.4");
239        assert!(server_name.is_some());
240        assert!(matches!(server_name.unwrap(), ServerName::IpAddress(_)));
241    }
242
243    #[test]
244    fn build_server_name_for_ipv6() {
245        let server_name: Option<ServerName<'static>> = build_ip_server_name("::1");
246        assert!(server_name.is_some());
247        assert!(matches!(server_name.unwrap(), ServerName::IpAddress(_)));
248    }
249
250    #[test]
251    fn build_server_name_rejects_dns() {
252        let server_name: Option<ServerName<'static>> = build_ip_server_name("example.com");
253        assert!(server_name.is_none());
254    }
255
256    /// Per project preference ([[feedback_fuzzer_choice]]) bolero is the fuzzer of choice.
257    /// `is_cert_valid` is exposed to bytes that arrive over the wire from untrusted peers,
258    /// so the invariant under test is "never panics, always returns a bool". In `cargo
259    /// nextest run` this exercises a small deterministic sample (bolero's default property-
260    /// test mode); under `cargo bolero test` it runs as a coverage-guided fuzzer.
261    #[test]
262    fn fuzz_is_cert_valid_never_panics() {
263        bolero::check!()
264            .with_type::<(Vec<Vec<u8>>, String, i64)>()
265            .for_each(|(chain_der, announced_address, now_millis)| {
266                let _ = is_cert_valid(chain_der, announced_address, TimeMillis(*now_millis));
267            });
268    }
269
270    #[test]
271    fn fuzz_strip_port_from_address_never_panics() {
272        bolero::check!()
273            .with_type::<String>()
274            .for_each(|input| {
275                let _ = strip_port_from_address(input);
276            });
277    }
278}