Skip to main content

max / makenotwork

Verify TLS chains against both the web PKI and the OS trust store The TLS check handshook against the bundled webpki roots only, so the OS trust store was never measured. The two answer different questions: the bundled roots give the verdict any browser would reach, while the platform store also reflects enterprise roots, admin-installed CAs and OS-level distrust decisions. They can legitimately disagree, and that divergence was invisible. Probe both concurrently and report each result independently. Neither verdict is derived from the other, so a chain trusted by one and rejected by the other now surfaces instead of being hidden. When both fail, both reasons are reported rather than only the first. Each probe opens its own connection, since a handshake consumes the stream and the verifier is fixed when the config is built. Certificate details prefer the web PKI chain for stable reporting but fall back to the platform chain, so a certificate trusted only by a private root still has its expiry tracked instead of being dropped entirely. `valid` keeps its established meaning of web-PKI trusted and unexpired so the health contract is unchanged; the new fields are additive. The crypto provider is read from the process default with a direct fallback, so the probes no longer depend on install ordering in main. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 17:44 UTC
Commit: 68f44d7a6b51e64dce032e09aa84feb0f2616877
Parent: c836558
5 files changed, +205 insertions, -30 deletions
@@ -1724,6 +1724,7 @@ dependencies = [
1724 1724 "reqwest",
1725 1725 "rmcp",
1726 1726 "rustls-pki-types",
1727 + "rustls-platform-verifier",
1727 1728 "schemars",
1728 1729 "serde",
1729 1730 "serde_json",
@@ -70,6 +70,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
70 70
71 71 # Graceful shutdown
72 72 tokio-util = { version = "0.7", features = ["rt"] }
73 + rustls-platform-verifier = "0.7.0"
73 74
74 75 [dev-dependencies]
75 76 tower = { version = "0.5", features = ["util"] }
@@ -11,38 +11,79 @@ use tracing::instrument;
11 11 use crate::config::TlsConfig;
12 12 use crate::types::TlsStatus;
13 13
14 - /// Connect to host:port, complete TLS handshake, and extract leaf cert fields.
15 - #[instrument(skip_all)]
16 - pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
17 - let checked_at = chrono::Utc::now().to_rfc3339();
18 - let addr = format!("{}:{}", config.host, config.port);
14 + /// Trust stores we probe against. Both are checked on every run: they answer
15 + /// different questions and can legitimately disagree, so reporting only one
16 + /// would hide a real failure mode.
17 + ///
18 + /// - [`TrustStore::Webpki`] is the bundled Mozilla root set. It answers "does
19 + /// this chain validate against the public web PKI?", independent of whatever
20 + /// the monitoring host happens to trust.
21 + /// - [`TrustStore::Platform`] is the OS trust store. It answers "does this chain
22 + /// validate for a client on this machine?", which additionally reflects
23 + /// enterprise roots, admin-installed CAs, and OS-level distrust decisions.
24 + ///
25 + /// A chain trusted by one and rejected by the other is exactly the kind of drift
26 + /// worth alerting on, so neither result is derived from the other.
27 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 + enum TrustStore {
29 + Webpki,
30 + Platform,
31 + }
32 +
33 + /// The crypto provider backing both probes. `main` installs aws-lc-rs as the
34 + /// process default; fall back to constructing it directly so the checks still
35 + /// work if that install is ever removed or reordered.
36 + fn crypto_provider() -> Arc<rustls::crypto::CryptoProvider> {
37 + rustls::crypto::CryptoProvider::get_default()
38 + .cloned()
39 + .unwrap_or_else(|| Arc::new(rustls::crypto::aws_lc_rs::default_provider()))
40 + }
41 +
42 + /// Build a client config pinned to one trust store.
43 + fn client_config(store: TrustStore) -> Result<rustls::ClientConfig, String> {
44 + match store {
45 + TrustStore::Webpki => {
46 + let mut root_store = rustls::RootCertStore::empty();
47 + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
48 + Ok(rustls::ClientConfig::builder()
49 + .with_root_certificates(root_store)
50 + .with_no_client_auth())
51 + }
52 + TrustStore::Platform => {
53 + let verifier = rustls_platform_verifier::Verifier::new(crypto_provider())
54 + .map_err(|e| format!("platform verifier unavailable: {e}"))?;
55 + Ok(rustls::ClientConfig::builder()
56 + .dangerous()
57 + .with_custom_certificate_verifier(Arc::new(verifier))
58 + .with_no_client_auth())
59 + }
60 + }
61 + }
62 +
63 + /// Connect and handshake against one trust store, returning the peer chain.
64 + ///
65 + /// Each probe opens its own connection: a rustls handshake consumes the stream,
66 + /// and the verifier is fixed when the config is built, so the two stores cannot
67 + /// share one.
68 + async fn probe(
69 + addr: &str,
70 + server_name: rustls_pki_types::ServerName<'static>,
71 + store: TrustStore,
72 + ) -> Result<Vec<rustls_pki_types::CertificateDer<'static>>, String> {
73 + let tls_config = client_config(store)?;
19 74
20 - // TCP connect with timeout
21 75 let tcp = match tokio::time::timeout(
22 76 std::time::Duration::from_secs(10),
23 - TcpStream::connect(&addr),
77 + TcpStream::connect(addr),
24 78 )
25 79 .await
26 80 {
27 81 Ok(Ok(stream)) => stream,
28 - Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TCP connect failed: {e}")),
29 - Err(_) => return tls_error(target_name, config, &checked_at, "TCP connect timed out"),
82 + Ok(Err(e)) => return Err(format!("TCP connect failed: {e}")),
83 + Err(_) => return Err("TCP connect timed out".to_string()),
30 84 };
31 85
32 - // Build rustls config with webpki trust store
33 - let mut root_store = rustls::RootCertStore::empty();
34 - root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
35 - let tls_config = rustls::ClientConfig::builder()
36 - .with_root_certificates(root_store)
37 - .with_no_client_auth();
38 -
39 86 let connector = TlsConnector::from(Arc::new(tls_config));
40 - let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
41 - Ok(name) => name,
42 - Err(e) => return tls_error(target_name, config, &checked_at, &format!("invalid server name: {e}")),
43 - };
44 -
45 - // TLS handshake with timeout
46 87 let tls_stream = match tokio::time::timeout(
47 88 std::time::Duration::from_secs(10),
48 89 connector.connect(server_name, tcp),
@@ -50,19 +91,69 @@ pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
50 91 .await
51 92 {
52 93 Ok(Ok(stream)) => stream,
53 - Ok(Err(e)) => return tls_error(target_name, config, &checked_at, &format!("TLS handshake failed: {e}")),
54 - Err(_) => return tls_error(target_name, config, &checked_at, "TLS handshake timed out"),
94 + Ok(Err(e)) => return Err(format!("TLS handshake failed: {e}")),
95 + Err(_) => return Err("TLS handshake timed out".to_string()),
55 96 };
56 97
57 - // Extract peer certificates
58 98 let (_io, client_conn) = tls_stream.into_inner();
59 - let certs = match client_conn.peer_certificates() {
60 - Some(certs) if !certs.is_empty() => certs,
61 - _ => return tls_error(target_name, config, &checked_at, "no peer certificates"),
99 + match client_conn.peer_certificates() {
100 + Some(certs) if !certs.is_empty() => {
101 + Ok(certs.iter().map(|c| c.clone().into_owned()).collect())
102 + }
103 + _ => Err("no peer certificates".to_string()),
104 + }
105 + }
106 +
107 + /// Connect to host:port and inspect the leaf cert, verifying the chain against
108 + /// both the bundled web-PKI roots and the OS trust store. See [`TrustStore`].
109 + #[instrument(skip_all)]
110 + pub async fn check_tls(target_name: &str, config: &TlsConfig) -> TlsStatus {
111 + let checked_at = chrono::Utc::now().to_rfc3339();
112 + let addr = format!("{}:{}", config.host, config.port);
113 +
114 + let server_name = match rustls_pki_types::ServerName::try_from(config.host.clone()) {
115 + Ok(name) => name,
116 + Err(e) => return tls_error(target_name, config, &checked_at, &format!("invalid server name: {e}")),
62 117 };
63 118
64 - // Parse the leaf (first) certificate
65 - parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref())
119 + let (webpki_result, platform_result) = tokio::join!(
120 + probe(&addr, server_name.clone(), TrustStore::Webpki),
121 + probe(&addr, server_name, TrustStore::Platform),
122 + );
123 +
124 + // Cert details come from whichever probe completed a handshake. Prefer the
125 + // web-PKI chain so the reported fields stay stable across hosts, but fall
126 + // back to the platform chain — a cert trusted only by a private root still
127 + // has an expiry worth tracking.
128 + let certs = webpki_result
129 + .as_ref()
130 + .ok()
131 + .or(platform_result.as_ref().ok());
132 +
133 + let Some(certs) = certs else {
134 + // Both failed. Surface both reasons rather than just the first.
135 + let webpki_err = webpki_result.as_ref().err().map(String::as_str).unwrap_or("unknown");
136 + let platform_err = platform_result.as_ref().err().map(String::as_str).unwrap_or("unknown");
137 + let mut status = tls_error(
138 + target_name,
139 + config,
140 + &checked_at,
141 + &format!("web PKI: {webpki_err}; platform: {platform_err}"),
142 + );
143 + status.webpki_error = webpki_result.err();
144 + status.platform_error = platform_result.err();
145 + return status;
146 + };
147 +
148 + let mut status = parse_leaf_cert(target_name, config, &checked_at, certs[0].as_ref());
149 + status.webpki_trusted = webpki_result.is_ok();
150 + status.platform_trusted = platform_result.is_ok();
151 + status.webpki_error = webpki_result.err();
152 + status.platform_error = platform_result.err();
153 + // `valid` keeps its established meaning for the health contract: trusted by
154 + // the public web PKI and not expired.
155 + status.valid = status.valid && status.webpki_trusted;
156 + status
66 157 }
67 158
68 159 /// Parse DER-encoded leaf cert bytes into a TlsStatus.
@@ -110,6 +201,13 @@ pub fn parse_leaf_cert(
110 201 issuer,
111 202 checked_at: checked_at.to_string(),
112 203 error: None,
204 + // Trust results are filled in by `check_tls`, which owns both probes.
205 + // Callers parsing a bare cert (tests, cached DER) get the conservative
206 + // "not established" default rather than a fabricated pass.
207 + webpki_trusted: false,
208 + platform_trusted: false,
209 + webpki_error: None,
210 + platform_error: None,
113 211 }
114 212 }
115 213
@@ -126,6 +224,10 @@ fn tls_error(target_name: &str, config: &TlsConfig, checked_at: &str, error: &st
126 224 issuer: String::new(),
127 225 checked_at: checked_at.to_string(),
128 226 error: Some(error.to_string()),
227 + webpki_trusted: false,
228 + platform_trusted: false,
229 + webpki_error: None,
230 + platform_error: None,
129 231 }
130 232 }
131 233
@@ -133,6 +235,41 @@ fn tls_error(target_name: &str, config: &TlsConfig, checked_at: &str, error: &st
133 235 mod tests {
134 236 use super::*;
135 237
238 + /// Live-network check that both trust stores are actually exercised and
239 + /// report independently. Ignored by default so the suite stays offline; run
240 + /// with `cargo test -- --ignored dual_trust` when touching the probe logic.
241 + #[tokio::test]
242 + #[ignore = "requires network access"]
243 + async fn dual_trust_probes_agree_on_public_and_reject_bad_certs() {
244 + let cfg = |host: &str| TlsConfig {
245 + host: host.to_string(),
246 + port: 443,
247 + warn_days: 30,
248 + };
249 +
250 + // A normal public cert: trusted by the bundled web PKI and by the OS.
251 + let good = check_tls("good", &cfg("makenot.work")).await;
252 + assert!(good.webpki_trusted, "web PKI should trust makenot.work: {:?}", good.webpki_error);
253 + assert!(good.platform_trusted, "OS store should trust makenot.work: {:?}", good.platform_error);
254 + assert!(good.valid);
255 + assert!(good.days_remaining > 0);
256 +
257 + // An expired cert must be rejected by both, and each must say why
258 + // rather than inheriting the other's verdict.
259 + let expired = check_tls("expired", &cfg("expired.badssl.com")).await;
260 + assert!(!expired.webpki_trusted);
261 + assert!(!expired.platform_trusted);
262 + assert!(expired.webpki_error.is_some());
263 + assert!(expired.platform_error.is_some());
264 + assert!(!expired.valid);
265 +
266 + // A self-signed cert chains to no public or OS root.
267 + let self_signed = check_tls("self-signed", &cfg("self-signed.badssl.com")).await;
268 + assert!(!self_signed.webpki_trusted);
269 + assert!(!self_signed.platform_trusted);
270 + assert!(!self_signed.valid);
271 + }
272 +
136 273 fn test_config() -> TlsConfig {
137 274 TlsConfig {
138 275 host: "example.com".to_string(),
@@ -300,6 +300,22 @@ pub struct TlsStatus {
300 300 pub checked_at: String,
301 301 /// Human-readable error if the TLS handshake or validation failed.
302 302 pub error: Option<String>,
303 + /// Whether the chain validates against the bundled web-PKI root set. This is
304 + /// the trust decision a browser anywhere would make, independent of what the
305 + /// monitoring host trusts.
306 + #[serde(default)]
307 + pub webpki_trusted: bool,
308 + /// Whether the chain validates against this machine's OS trust store, which
309 + /// also reflects enterprise roots and OS-level distrust decisions. Divergence
310 + /// from `webpki_trusted` is meaningful and deliberately reported separately.
311 + #[serde(default)]
312 + pub platform_trusted: bool,
313 + /// Why the web-PKI probe failed, if it did.
314 + #[serde(default, skip_serializing_if = "Option::is_none")]
315 + pub webpki_error: Option<String>,
316 + /// Why the platform-trust-store probe failed, if it did.
317 + #[serde(default, skip_serializing_if = "Option::is_none")]
318 + pub platform_error: Option<String>,
303 319 }
304 320
305 321 #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -886,6 +886,10 @@ async fn migration_v3_creates_tls_checks_table() {
886 886 issuer: "CN=Let's Encrypt".to_string(),
887 887 checked_at: "2026-03-11T00:00:00Z".to_string(),
888 888 error: None,
889 + webpki_trusted: true,
890 + platform_trusted: true,
891 + webpki_error: None,
892 + platform_error: None,
889 893 };
890 894 let id = db::insert_tls_check(&pool, &status).await.unwrap();
891 895 assert!(id > 0);
@@ -907,6 +911,10 @@ async fn tls_check_insert_and_query() {
907 911 issuer: "CN=Let's Encrypt".to_string(),
908 912 checked_at: "2026-03-11T00:00:00Z".to_string(),
909 913 error: None,
914 + webpki_trusted: true,
915 + platform_trusted: true,
916 + webpki_error: None,
917 + platform_error: None,
910 918 };
911 919 db::insert_tls_check(&pool, &status).await.unwrap();
912 920
@@ -936,6 +944,10 @@ async fn tls_check_error_stored() {
936 944 issuer: String::new(),
937 945 checked_at: "2026-03-11T00:00:00Z".to_string(),
938 946 error: Some("connection refused".to_string()),
947 + webpki_trusted: false,
948 + platform_trusted: false,
949 + webpki_error: None,
950 + platform_error: None,
939 951 };
940 952 db::insert_tls_check(&pool, &status).await.unwrap();
941 953
@@ -976,6 +988,10 @@ async fn prune_removes_old_tls_checks() {
976 988 issuer: String::new(),
977 989 checked_at: chrono::Utc::now().to_rfc3339(),
978 990 error: None,
991 + webpki_trusted: true,
992 + platform_trusted: true,
993 + webpki_error: None,
994 + platform_error: None,
979 995 };
980 996 db::insert_tls_check(&pool, &status).await.unwrap();
981 997
@@ -1018,6 +1034,10 @@ host = "makenot.work"
1018 1034 issuer: "CN=Let's Encrypt".to_string(),
1019 1035 checked_at: "2026-03-11T00:00:00Z".to_string(),
1020 1036 error: None,
1037 + webpki_trusted: true,
1038 + platform_trusted: true,
1039 + webpki_error: None,
1040 + platform_error: None,
1021 1041 };
1022 1042 db::insert_tls_check(&pool, &status).await.unwrap();
1023 1043