Skip to content

Commit 280d93d

Browse files
committed
feat: allow TLS connections with invalid certificate if the key is unchanged
This change weakens TLS checks. Every time we make a successful TLS connection, we remember public key hash from the certificate in relation to the hostname. If later we connect to the same hostname and the public key does not change, we skip checking certificate chain. This way we will still connect successfully even if certificate expires or becomes invalid for another reason, but keeps the key. We always check that certificate corresponds to the hostname. We also do this for certificates starting with _ where we allow self-signed certificates, so self-signed certificates with mismatching domains are not allowed. Previously we did not check this for domains starting with _.
1 parent 942172a commit 280d93d

11 files changed

Lines changed: 337 additions & 31 deletions

File tree

src/context.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::key::self_fingerprint;
2525
use crate::log::warn;
2626
use crate::logged_debug_assert;
2727
use crate::message::{self, MessageState, MsgId};
28-
use crate::net::tls::TlsSessionStore;
28+
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
2929
use crate::peer_channels::Iroh;
3030
use crate::push::PushSubscriber;
3131
use crate::quota::QuotaInfo;
@@ -308,6 +308,13 @@ pub struct InnerContext {
308308
/// TLS session resumption cache.
309309
pub(crate) tls_session_store: TlsSessionStore,
310310

311+
/// Store for TLS SPKI hashes.
312+
///
313+
/// Used to remember public keys
314+
/// of TLS certificates to accept them
315+
/// even after they expire.
316+
pub(crate) spki_hash_store: SpkiHashStore,
317+
311318
/// Iroh for realtime peer channels.
312319
pub(crate) iroh: Arc<RwLock<Option<Iroh>>>,
313320

@@ -511,6 +518,7 @@ impl Context {
511518
push_subscriber,
512519
push_subscribed: AtomicBool::new(false),
513520
tls_session_store: TlsSessionStore::new(),
521+
spki_hash_store: SpkiHashStore::new(),
514522
iroh: Arc::new(RwLock::new(None)),
515523
self_fingerprint: OnceLock::new(),
516524
self_public_key: Mutex::new(None),

src/imap/client.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ impl Client {
220220
alpn(addr.port()),
221221
logging_stream,
222222
&context.tls_session_store,
223+
&context.spki_hash_store,
224+
&context.sql,
223225
)
224226
.await?;
225227
let buffered_stream = BufWriter::new(tls_stream);
@@ -282,6 +284,8 @@ impl Client {
282284
"",
283285
tcp_stream,
284286
&context.tls_session_store,
287+
&context.spki_hash_store,
288+
&context.sql,
285289
)
286290
.await
287291
.context("STARTTLS upgrade failed")?;
@@ -310,6 +314,8 @@ impl Client {
310314
alpn(port),
311315
proxy_stream,
312316
&context.tls_session_store,
317+
&context.spki_hash_store,
318+
&context.sql,
313319
)
314320
.await?;
315321
let buffered_stream = BufWriter::new(tls_stream);
@@ -373,6 +379,8 @@ impl Client {
373379
"",
374380
proxy_stream,
375381
&context.tls_session_store,
382+
&context.spki_hash_store,
383+
&context.sql,
376384
)
377385
.await
378386
.context("STARTTLS upgrade failed")?;

src/net.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tokio_io_timeout::TimeoutStream;
1212

1313
use crate::context::Context;
1414
use crate::net::session::SessionStream;
15-
use crate::net::tls::TlsSessionStore;
15+
use crate::net::tls::{SpkiHashStore, TlsSessionStore};
1616
use crate::sql::Sql;
1717
use crate::tools::time;
1818

@@ -130,6 +130,8 @@ pub(crate) async fn connect_tls_inner(
130130
strict_tls: bool,
131131
alpn: &str,
132132
tls_session_store: &TlsSessionStore,
133+
spki_hash_store: &SpkiHashStore,
134+
sql: &Sql,
133135
) -> Result<impl SessionStream + 'static> {
134136
let use_sni = true;
135137
let tcp_stream = connect_tcp_inner(addr).await?;
@@ -141,6 +143,8 @@ pub(crate) async fn connect_tls_inner(
141143
alpn,
142144
tcp_stream,
143145
tls_session_store,
146+
spki_hash_store,
147+
sql,
144148
)
145149
.await?;
146150
Ok(tls_stream)

src/net/http.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ where
8787
"",
8888
proxy_stream,
8989
&context.tls_session_store,
90+
&context.spki_hash_store,
91+
&context.sql,
9092
)
9193
.await?;
9294
Box::new(tls_stream)
@@ -99,6 +101,8 @@ where
99101
"",
100102
tcp_stream,
101103
&context.tls_session_store,
104+
&context.spki_hash_store,
105+
&context.sql,
102106
)
103107
.await?;
104108
Box::new(tls_stream)

src/net/proxy.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,8 @@ impl ProxyConfig {
436436
"",
437437
tcp_stream,
438438
&context.tls_session_store,
439+
&context.spki_hash_store,
440+
&context.sql,
439441
)
440442
.await?;
441443
let auth = if let Some((username, password)) = &https_config.user_password {

src/net/tls.rs

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@ use std::sync::Arc;
66
use anyhow::Result;
77

88
use crate::net::session::SessionStream;
9+
use crate::sql::Sql;
10+
use crate::tools::time;
911

1012
use tokio_rustls::rustls;
1113
use tokio_rustls::rustls::client::ClientSessionStore;
14+
use tokio_rustls::rustls::server::ParsedCertificate;
1215

1316
mod danger;
14-
use danger::NoCertificateVerification;
17+
use danger::CustomCertificateVerifier;
1518

19+
mod spki;
20+
pub use spki::SpkiHashStore;
21+
22+
#[expect(clippy::too_many_arguments)]
1623
pub async fn wrap_tls<'a>(
1724
strict_tls: bool,
1825
hostname: &str,
@@ -21,10 +28,21 @@ pub async fn wrap_tls<'a>(
2128
alpn: &str,
2229
stream: impl SessionStream + 'static,
2330
tls_session_store: &TlsSessionStore,
31+
spki_hash_store: &SpkiHashStore,
32+
sql: &Sql,
2433
) -> Result<impl SessionStream + 'a> {
2534
if strict_tls {
26-
let tls_stream =
27-
wrap_rustls(hostname, port, use_sni, alpn, stream, tls_session_store).await?;
35+
let tls_stream = wrap_rustls(
36+
hostname,
37+
port,
38+
use_sni,
39+
alpn,
40+
stream,
41+
tls_session_store,
42+
spki_hash_store,
43+
sql,
44+
)
45+
.await?;
2846
let boxed_stream: Box<dyn SessionStream> = Box::new(tls_stream);
2947
Ok(boxed_stream)
3048
} else {
@@ -94,16 +112,19 @@ impl TlsSessionStore {
94112
}
95113
}
96114

115+
#[expect(clippy::too_many_arguments)]
97116
pub async fn wrap_rustls<'a>(
98117
hostname: &str,
99118
port: u16,
100119
use_sni: bool,
101120
alpn: &str,
102121
stream: impl SessionStream + 'a,
103122
tls_session_store: &TlsSessionStore,
123+
spki_hash_store: &SpkiHashStore,
124+
sql: &Sql,
104125
) -> Result<impl SessionStream + 'a> {
105-
let mut root_cert_store = rustls::RootCertStore::empty();
106-
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
126+
let root_cert_store =
127+
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
107128

108129
let mut config = rustls::ClientConfig::builder()
109130
.with_root_certificates(root_cert_store)
@@ -127,20 +148,28 @@ pub async fn wrap_rustls<'a>(
127148
config.resumption = resumption;
128149
config.enable_sni = use_sni;
129150

130-
// Do not verify certificates for hostnames starting with `_`.
131-
// They are used for servers with self-signed certificates, e.g. for local testing.
132-
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
133-
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
134-
// explicitly state that domains should start with a letter, digit or hyphen:
135-
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
136-
if hostname.starts_with("_") {
137-
config
138-
.dangerous()
139-
.set_certificate_verifier(Arc::new(NoCertificateVerification::new()));
140-
}
151+
config
152+
.dangerous()
153+
.set_certificate_verifier(Arc::new(CustomCertificateVerifier::new(
154+
spki_hash_store.get_spki_hash(hostname, sql).await?,
155+
)));
141156

142157
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
143158
let name = tokio_rustls::rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
144159
let tls_stream = tls.connect(name, stream).await?;
160+
161+
// Successfully connected.
162+
// Remember SPKI hash to accept it later if certificate expires.
163+
let (_io, client_connection) = tls_stream.get_ref();
164+
if let Some(end_entity) = client_connection
165+
.peer_certificates()
166+
.and_then(|certs| certs.first())
167+
{
168+
let now = time();
169+
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
170+
let spki = parsed_certificate.subject_public_key_info();
171+
spki_hash_store.save_spki(hostname, &spki, sql, now).await?;
172+
}
173+
145174
Ok(tls_stream)
146175
}

src/net/tls/danger.rs

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,93 @@
1-
//! Dangerous TLS implementation of accepting invalid certificates for Rustls.
1+
//! Custom TLS verification.
2+
//!
3+
//! We want to accept expired certificates.
24
5+
use rustls::RootCertStore;
6+
use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name};
37
use rustls::pki_types::{CertificateDer, ServerName, UnixTime};
8+
use rustls::server::ParsedCertificate;
49
use tokio_rustls::rustls;
510

11+
use crate::net::tls::spki::spki_hash;
12+
613
#[derive(Debug)]
7-
pub(super) struct NoCertificateVerification();
14+
pub(super) struct CustomCertificateVerifier {
15+
/// Root certificates.
16+
root_cert_store: RootCertStore,
17+
18+
/// Expected SPKI hash as a base64 of SHA-256.
19+
spki_hash: Option<String>,
20+
}
821

9-
impl NoCertificateVerification {
10-
pub(super) fn new() -> Self {
11-
Self()
22+
impl CustomCertificateVerifier {
23+
pub(super) fn new(spki_hash: Option<String>) -> Self {
24+
let root_cert_store =
25+
RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
26+
Self {
27+
root_cert_store,
28+
spki_hash,
29+
}
1230
}
1331
}
1432

15-
impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification {
33+
impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier {
1634
fn verify_server_cert(
1735
&self,
18-
_end_entity: &CertificateDer<'_>,
19-
_intermediates: &[CertificateDer<'_>],
20-
_server_name: &ServerName<'_>,
36+
end_entity: &CertificateDer<'_>,
37+
intermediates: &[CertificateDer<'_>],
38+
server_name: &ServerName<'_>,
39+
// OCSP is a certificate revocation mechanism that is intentionally ignored.
40+
// It is practically not used and is essentially deprecated
41+
// in favor of Certificate Revocation Lists.
42+
// Let's Encrypt has disabled OCSP responders in 2025:
43+
// <https://letsencrypt.org/2025/08/06/ocsp-service-has-reached-end-of-life>.
44+
// Theoretically checking of stapled OCSP responses could be implemented,
45+
// but it is not interesting to implement it because it is not used
46+
// by the servers: <https://github.com/rustls/webpki/issues/217>.
2147
_ocsp_response: &[u8],
22-
_now: UnixTime,
48+
now: UnixTime,
2349
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
50+
let parsed_certificate = ParsedCertificate::try_from(end_entity)?;
51+
52+
let spki = parsed_certificate.subject_public_key_info();
53+
54+
let provider = rustls::crypto::ring::default_provider();
55+
56+
if let ServerName::DnsName(dns_name) = server_name
57+
&& dns_name.as_ref().starts_with("_")
58+
{
59+
// Do not verify certificates for hostnames starting with `_`.
60+
// They are used for servers with self-signed certificates, e.g. for local testing.
61+
// Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates.
62+
// It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements
63+
// explicitly state that domains should start with a letter, digit or hyphen:
64+
// https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md
65+
} else if let Some(hash) = &self.spki_hash
66+
&& spki_hash(&spki) == *hash
67+
{
68+
// Last time we successfully connected to this hostname with TLS checks,
69+
// SPKI had this hash.
70+
// It does not matter if certificate has now expired.
71+
} else {
72+
// verify_server_cert_signed_by_trust_anchor does no revocation checking:
73+
// <https://docs.rs/rustls/0.23.37/rustls/client/fn.verify_server_cert_signed_by_trust_anchor.html>
74+
// We don't do it either.
75+
verify_server_cert_signed_by_trust_anchor(
76+
&parsed_certificate,
77+
&self.root_cert_store,
78+
intermediates,
79+
now,
80+
provider.signature_verification_algorithms.all,
81+
)?;
82+
}
83+
84+
// Verify server name unconditionally.
85+
//
86+
// We do this even for self-signed certificates when hostname starts with `_`
87+
// so we don't try to connect to captive portals
88+
// and fail on MITM certificates if they are generated once
89+
// and reused for all hostnames.
90+
verify_server_name(&parsed_certificate, server_name)?;
2491
Ok(rustls::client::danger::ServerCertVerified::assertion())
2592
}
2693

0 commit comments

Comments
 (0)