diff --git a/src/context.rs b/src/context.rs index adcb3b2a7..798c79432 100644 --- a/src/context.rs +++ b/src/context.rs @@ -25,7 +25,7 @@ use crate::key::self_fingerprint; use crate::log::warn; use crate::logged_debug_assert; use crate::message::{self, MessageState, MsgId}; -use crate::net::tls::TlsSessionStore; +use crate::net::tls::{SpkiHashStore, TlsSessionStore}; use crate::peer_channels::Iroh; use crate::push::PushSubscriber; use crate::quota::QuotaInfo; @@ -308,6 +308,13 @@ pub struct InnerContext { /// TLS session resumption cache. pub(crate) tls_session_store: TlsSessionStore, + /// Store for TLS SPKI hashes. + /// + /// Used to remember public keys + /// of TLS certificates to accept them + /// even after they expire. + pub(crate) spki_hash_store: SpkiHashStore, + /// Iroh for realtime peer channels. pub(crate) iroh: Arc>>, @@ -511,6 +518,7 @@ impl Context { push_subscriber, push_subscribed: AtomicBool::new(false), tls_session_store: TlsSessionStore::new(), + spki_hash_store: SpkiHashStore::new(), iroh: Arc::new(RwLock::new(None)), self_fingerprint: OnceLock::new(), self_public_key: Mutex::new(None), diff --git a/src/imap/client.rs b/src/imap/client.rs index 335557fce..adf6e8db1 100644 --- a/src/imap/client.rs +++ b/src/imap/client.rs @@ -220,6 +220,8 @@ impl Client { alpn(addr.port()), logging_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; let buffered_stream = BufWriter::new(tls_stream); @@ -282,6 +284,8 @@ impl Client { "", tcp_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await .context("STARTTLS upgrade failed")?; @@ -310,6 +314,8 @@ impl Client { alpn(port), proxy_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; let buffered_stream = BufWriter::new(tls_stream); @@ -373,6 +379,8 @@ impl Client { "", proxy_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await .context("STARTTLS upgrade failed")?; diff --git a/src/net.rs b/src/net.rs index 3ff181efd..c46f26017 100644 --- a/src/net.rs +++ b/src/net.rs @@ -12,7 +12,7 @@ use tokio_io_timeout::TimeoutStream; use crate::context::Context; use crate::net::session::SessionStream; -use crate::net::tls::TlsSessionStore; +use crate::net::tls::{SpkiHashStore, TlsSessionStore}; use crate::sql::Sql; use crate::tools::time; @@ -130,6 +130,8 @@ pub(crate) async fn connect_tls_inner( strict_tls: bool, alpn: &str, tls_session_store: &TlsSessionStore, + spki_hash_store: &SpkiHashStore, + sql: &Sql, ) -> Result { let use_sni = true; let tcp_stream = connect_tcp_inner(addr).await?; @@ -141,6 +143,8 @@ pub(crate) async fn connect_tls_inner( alpn, tcp_stream, tls_session_store, + spki_hash_store, + sql, ) .await?; Ok(tls_stream) diff --git a/src/net/http.rs b/src/net/http.rs index 2ab1a297c..062245f39 100644 --- a/src/net/http.rs +++ b/src/net/http.rs @@ -87,6 +87,8 @@ where "", proxy_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; Box::new(tls_stream) @@ -99,6 +101,8 @@ where "", tcp_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; Box::new(tls_stream) diff --git a/src/net/proxy.rs b/src/net/proxy.rs index 4a7f03fa9..9202f93e7 100644 --- a/src/net/proxy.rs +++ b/src/net/proxy.rs @@ -439,6 +439,8 @@ impl ProxyConfig { "", tcp_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; let auth = if let Some((username, password)) = &https_config.user_password { diff --git a/src/net/tls.rs b/src/net/tls.rs index 0013f23be..972da0ac1 100644 --- a/src/net/tls.rs +++ b/src/net/tls.rs @@ -6,13 +6,19 @@ use std::sync::Arc; use anyhow::Result; use crate::net::session::SessionStream; +use crate::sql::Sql; use tokio_rustls::rustls; use tokio_rustls::rustls::client::ClientSessionStore; +use tokio_rustls::rustls::server::ParsedCertificate; mod danger; -use danger::NoCertificateVerification; +use danger::CustomCertificateVerifier; +mod spki; +pub use spki::SpkiHashStore; + +#[expect(clippy::too_many_arguments)] pub async fn wrap_tls<'a>( strict_tls: bool, hostname: &str, @@ -21,10 +27,21 @@ pub async fn wrap_tls<'a>( alpn: &str, stream: impl SessionStream + 'static, tls_session_store: &TlsSessionStore, + spki_hash_store: &SpkiHashStore, + sql: &Sql, ) -> Result { if strict_tls { - let tls_stream = - wrap_rustls(hostname, port, use_sni, alpn, stream, tls_session_store).await?; + let tls_stream = wrap_rustls( + hostname, + port, + use_sni, + alpn, + stream, + tls_session_store, + spki_hash_store, + sql, + ) + .await?; let boxed_stream: Box = Box::new(tls_stream); Ok(boxed_stream) } else { @@ -94,6 +111,7 @@ impl TlsSessionStore { } } +#[expect(clippy::too_many_arguments)] pub async fn wrap_rustls<'a>( hostname: &str, port: u16, @@ -101,6 +119,8 @@ pub async fn wrap_rustls<'a>( alpn: &str, stream: impl SessionStream + 'a, tls_session_store: &TlsSessionStore, + spki_hash_store: &SpkiHashStore, + sql: &Sql, ) -> Result { let mut root_cert_store = rustls::RootCertStore::empty(); root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); @@ -127,20 +147,27 @@ pub async fn wrap_rustls<'a>( config.resumption = resumption; config.enable_sni = use_sni; - // Do not verify certificates for hostnames starting with `_`. - // They are used for servers with self-signed certificates, e.g. for local testing. - // Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates. - // It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements - // explicitly state that domains should start with a letter, digit or hyphen: - // https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md - if hostname.starts_with("_") { - config - .dangerous() - .set_certificate_verifier(Arc::new(NoCertificateVerification::new())); - } + config + .dangerous() + .set_certificate_verifier(Arc::new(CustomCertificateVerifier::new( + spki_hash_store.get_spki_hash(hostname, sql).await?, + ))); let tls = tokio_rustls::TlsConnector::from(Arc::new(config)); let name = tokio_rustls::rustls::pki_types::ServerName::try_from(hostname)?.to_owned(); let tls_stream = tls.connect(name, stream).await?; + + // Successfully connected. + // Remember SPKI hash to accept it later if certificate expires. + let (_io, client_connection) = tls_stream.get_ref(); + if let Some(end_entity) = client_connection + .peer_certificates() + .and_then(|certs| certs.first()) + { + let parsed_certificate = ParsedCertificate::try_from(end_entity)?; + let spki = parsed_certificate.subject_public_key_info(); + spki_hash_store.save_spki(hostname, &spki, sql).await?; + } + Ok(tls_stream) } diff --git a/src/net/tls/danger.rs b/src/net/tls/danger.rs index bd0267ddc..1b106454e 100644 --- a/src/net/tls/danger.rs +++ b/src/net/tls/danger.rs @@ -1,26 +1,85 @@ -//! Dangerous TLS implementation of accepting invalid certificates for Rustls. +//! Custom TLS verification. +//! +//! We want to accept expired certificates. +use rustls::RootCertStore; +use rustls::client::{verify_server_cert_signed_by_trust_anchor, verify_server_name}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::server::ParsedCertificate; use tokio_rustls::rustls; -#[derive(Debug)] -pub(super) struct NoCertificateVerification(); +use crate::net::tls::spki::spki_hash; -impl NoCertificateVerification { - pub(super) fn new() -> Self { - Self() +#[derive(Debug)] +pub(super) struct CustomCertificateVerifier { + /// Root certificates. + root_cert_store: RootCertStore, + + /// Expected SPKI hash as a base64 of SHA-256. + spki_hash: Option, +} + +impl CustomCertificateVerifier { + pub(super) fn new(spki_hash: Option) -> Self { + let mut root_cert_store = rustls::RootCertStore::empty(); + root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Self { + root_cert_store, + spki_hash, + } } } -impl rustls::client::danger::ServerCertVerifier for NoCertificateVerification { +impl rustls::client::danger::ServerCertVerifier for CustomCertificateVerifier { fn verify_server_cert( &self, - _end_entity: &CertificateDer<'_>, - _intermediates: &[CertificateDer<'_>], - _server_name: &ServerName<'_>, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, _ocsp_response: &[u8], - _now: UnixTime, + now: UnixTime, ) -> Result { + let parsed_certificate = ParsedCertificate::try_from(end_entity)?; + + let spki = parsed_certificate.subject_public_key_info(); + + let provider = rustls::crypto::ring::default_provider(); + + if let ServerName::DnsName(dns_name) = server_name + && dns_name.as_ref().starts_with("_") + { + // Do not verify certificates for hostnames starting with `_`. + // They are used for servers with self-signed certificates, e.g. for local testing. + // Hostnames starting with `_` can have only self-signed TLS certificates or wildcard certificates. + // It is not possible to get valid non-wildcard TLS certificates because CA/Browser Forum requirements + // explicitly state that domains should start with a letter, digit or hyphen: + // https://github.com/cabforum/servercert/blob/24f38fd4765e019db8bb1a8c56bf63c7115ce0b0/docs/BR.md + } else if let Some(hash) = &self.spki_hash + && spki_hash(&spki) == *hash + { + // Last time we successfully connected to this hostname with TLS checks, + // SPKI had this hash. + // It does not matter if certificate has now expired. + } else { + // verify_server_cert_signed_by_trust_anchor does no revocation checking: + // + // We don't do it either. + verify_server_cert_signed_by_trust_anchor( + &parsed_certificate, + &self.root_cert_store, + intermediates, + now, + provider.signature_verification_algorithms.all, + )?; + } + + // Verify server name unconditionally. + // + // We do this even for self-signed certificates when hostname starts with `_` + // so we don't try to connect to captive portals + // and fail on MITM certificates if they are generated once + // and reused for all hostnames. + verify_server_name(&parsed_certificate, server_name)?; Ok(rustls::client::danger::ServerCertVerified::assertion()) } diff --git a/src/net/tls/spki.rs b/src/net/tls/spki.rs new file mode 100644 index 000000000..140a24580 --- /dev/null +++ b/src/net/tls/spki.rs @@ -0,0 +1,92 @@ +//! SPKI hash storage. +//! +//! We store hashes of Subject Public Key Info from TLS certificates +//! after successful connection to allow connecting when +//! server certificate expires as long as the key is not changed. + +use std::collections::BTreeMap; + +use anyhow::Result; +use base64::Engine as _; +use parking_lot::RwLock; +use sha2::{Digest, Sha256}; +use tokio_rustls::rustls::pki_types::SubjectPublicKeyInfoDer; + +use crate::sql::Sql; + +/// Calculates Subject Public Key Info SHA-256 hash and returns it as base64. +/// +/// This is the same format as used in . +/// You can calculate the same hash for any remote host with +/// ```sh +/// openssl s_client -connect "$HOST:993" -servername "$HOST" /dev/null | +/// openssl x509 -pubkey -noout | +/// openssl pkey -pubin -outform der | +/// openssl dgst -sha256 -binary | +/// openssl enc -base64 +/// ``` +pub fn spki_hash(spki: &SubjectPublicKeyInfoDer) -> String { + let spki_hash = Sha256::digest(spki); + base64::engine::general_purpose::STANDARD.encode(spki_hash) +} + +/// Write-through cache for SPKI hashes. +#[derive(Debug)] +pub struct SpkiHashStore { + /// Map from hostnames to base64 of SHA-256 hashes. + pub hash_store: RwLock>, +} + +impl SpkiHashStore { + pub fn new() -> Self { + Self { + hash_store: RwLock::new(BTreeMap::new()), + } + } + + /// Returns base64 of SPKI hash if we have previously successfully connected to given hostname. + pub async fn get_spki_hash(&self, hostname: &str, sql: &Sql) -> Result> { + if let Some(hash) = self.hash_store.read().get(hostname).cloned() { + return Ok(Some(hash)); + } + + match sql + .query_row_optional( + "SELECT spki_hash FROM tls_spki WHERE host=?", + (hostname,), + |row| { + let spki_hash: String = row.get(0)?; + Ok(spki_hash) + }, + ) + .await? + { + Some(hash) => { + self.hash_store + .write() + .insert(hostname.to_string(), hash.clone()); + Ok(Some(hash)) + } + None => Ok(None), + } + } + + /// Saves SPKI hash after successful connection. + pub async fn save_spki( + &self, + hostname: &str, + spki: &SubjectPublicKeyInfoDer<'_>, + sql: &Sql, + ) -> Result<()> { + let hash = spki_hash(spki); + self.hash_store + .write() + .insert(hostname.to_string(), hash.clone()); + sql.execute( + "INSERT OR REPLACE INTO tls_spki (host, spki_hash) VALUES (?, ?)", + (hostname, hash), + ) + .await?; + Ok(()) + } +} diff --git a/src/smtp/connect.rs b/src/smtp/connect.rs index e535b77c9..28215a4ca 100644 --- a/src/smtp/connect.rs +++ b/src/smtp/connect.rs @@ -11,11 +11,12 @@ use crate::log::warn; use crate::net::dns::{lookup_host_with_cache, update_connect_timestamp}; use crate::net::proxy::ProxyConfig; use crate::net::session::SessionBufStream; -use crate::net::tls::{TlsSessionStore, wrap_tls}; +use crate::net::tls::{SpkiHashStore, TlsSessionStore, wrap_tls}; use crate::net::{ connect_tcp_inner, connect_tls_inner, run_connection_attempts, update_connection_history, }; use crate::oauth2::get_oauth2_access_token; +use crate::sql::Sql; use crate::tools::time; use crate::transport::ConnectionCandidate; use crate::transport::ConnectionSecurity; @@ -111,10 +112,26 @@ async fn connection_attempt( ); let res = match security { ConnectionSecurity::Tls => { - connect_secure(resolved_addr, host, strict_tls, &context.tls_session_store).await + connect_secure( + resolved_addr, + host, + strict_tls, + &context.tls_session_store, + &context.spki_hash_store, + &context.sql, + ) + .await } ConnectionSecurity::Starttls => { - connect_starttls(resolved_addr, host, strict_tls, &context.tls_session_store).await + connect_starttls( + resolved_addr, + host, + strict_tls, + &context.tls_session_store, + &context.spki_hash_store, + &context.sql, + ) + .await } ConnectionSecurity::Plain => connect_insecure(resolved_addr).await, }; @@ -240,6 +257,8 @@ async fn connect_secure_proxy( alpn(port), proxy_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await?; let mut buffered_stream = BufStream::new(tls_stream); @@ -273,6 +292,8 @@ async fn connect_starttls_proxy( "", tcp_stream, &context.tls_session_store, + &context.spki_hash_store, + &context.sql, ) .await .context("STARTTLS upgrade failed")?; @@ -299,6 +320,8 @@ async fn connect_secure( hostname: &str, strict_tls: bool, tls_session_store: &TlsSessionStore, + spki_hash_store: &SpkiHashStore, + sql: &Sql, ) -> Result> { let tls_stream = connect_tls_inner( addr, @@ -306,6 +329,8 @@ async fn connect_secure( strict_tls, alpn(addr.port()), tls_session_store, + spki_hash_store, + sql, ) .await?; let mut buffered_stream = BufStream::new(tls_stream); @@ -319,6 +344,8 @@ async fn connect_starttls( host: &str, strict_tls: bool, tls_session_store: &TlsSessionStore, + spki_hash_store: &SpkiHashStore, + sql: &Sql, ) -> Result> { let use_sni = false; let tcp_stream = connect_tcp_inner(addr).await?; @@ -336,6 +363,8 @@ async fn connect_starttls( "", tcp_stream, tls_session_store, + spki_hash_store, + sql, ) .await .context("STARTTLS upgrade failed")?; diff --git a/src/sql/migrations.rs b/src/sql/migrations.rs index d5fd95e73..7b9faa235 100644 --- a/src/sql/migrations.rs +++ b/src/sql/migrations.rs @@ -2316,6 +2316,18 @@ ALTER TABLE contacts ADD COLUMN name_normalized TEXT; .await?; } + inc_and_check(&mut migration_version, 150)?; + if dbversion < migration_version { + sql.execute_migration( + "CREATE TABLE tls_spki ( + host TEXT NOT NULL UNIQUE, + spki_hash TEXT NOT NULL -- base64 of SPKI SHA-256 hash + ) STRICT", + migration_version, + ) + .await?; + } + let new_version = sql .get_raw_config_int(VERSION_CFG) .await?