mirror of
https://github.com/chatmail/core.git
synced 2026-05-03 13:26:28 +03:00
feat: custom TLS certificate verification
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -436,6 +436,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 {
|
||||
|
||||
@@ -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<impl SessionStream + 'a> {
|
||||
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<dyn SessionStream> = 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<impl SessionStream + 'a> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl CustomCertificateVerifier {
|
||||
pub(super) fn new(spki_hash: Option<String>) -> 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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
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:
|
||||
// <https://docs.rs/rustls/0.23.37/rustls/client/fn.verify_server_cert_signed_by_trust_anchor.html>
|
||||
// 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())
|
||||
}
|
||||
|
||||
|
||||
92
src/net/tls/spki.rs
Normal file
92
src/net/tls/spki.rs
Normal file
@@ -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 <https://www.rfc-editor.org/rfc/rfc7469>.
|
||||
/// You can calculate the same hash for any remote host with
|
||||
/// ```sh
|
||||
/// openssl s_client -connect "$HOST:993" -servername "$HOST" </dev/null 2>/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<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
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<Option<String>> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user