use super::{Error, pool::Origin};
use bytes::BytesMut;
use nago_rustls::{
TlsSession,
rustls::{ClientConfig, ClientConnection, RootCertStore},
rustls_pki_types::ServerName,
};
use nagoya::{net::TcpStream, reactor::Handle};
use ps_rustcrypto::{
fallback,
policy::{self, ct::CtVerifier},
};
use std::{
collections::HashMap,
sync::{Arc, Mutex, OnceLock},
};
const READ_CHUNK: usize = 16 * 1024;
pub(super) fn default_config() -> Result<Arc<ClientConfig>, Error> {
static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
match CONFIG.get_or_init(build_config) {
Ok(config) => Ok(config.clone()),
Err(error) => Err(Error::Tls(error.clone())),
}
}
pub(super) fn rsa_fallback_config() -> Result<Arc<ClientConfig>, Error> {
static CONFIG: OnceLock<Result<Arc<ClientConfig>, String>> = OnceLock::new();
match CONFIG.get_or_init(build_rsa_fallback_config) {
Ok(config) => Ok(config.clone()),
Err(error) => Err(Error::Tls(error.clone())),
}
}
fn native_roots() -> Result<RootCertStore, String> {
let native = rustls_native_certs::load_native_certs();
let mut roots = RootCertStore::empty();
let (accepted, rejected) = roots.add_parsable_certificates(native.certs);
if accepted == 0 {
return Err(format!(
"no usable native TLS trust anchors: {} loading errors, {rejected} rejected certificates",
native.errors.len(),
));
}
if !native.errors.is_empty() || rejected != 0 {
eprintln!(
"TLS trust store: {} loading errors, {rejected} rejected certificates",
native.errors.len(),
);
}
Ok(roots)
}
fn public_roots(native: &RootCertStore) -> RootCertStore {
let mut public = RootCertStore::empty();
public.roots = native
.roots
.iter()
.filter(|root| {
webpki_roots::TLS_SERVER_ROOTS.iter().any(|built_in| {
root.subject.as_ref() == built_in.subject.as_ref()
&& root.subject_public_key_info.as_ref()
== built_in.subject_public_key_info.as_ref()
})
})
.cloned()
.collect();
public
}
fn build_rsa_fallback_config() -> Result<Arc<ClientConfig>, String> {
let roots = native_roots()?;
let public = public_roots(&roots);
let verifier = fallback::rsa_fallback_ct_verifier(roots, public)
.map_err(|error| format!("TLS trust store: {error}"))?;
let provider = Arc::new(ps_rustcrypto::provider_with_rsa_pss());
let mut config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|error| error.to_string())?
.dangerous()
.with_custom_certificate_verifier(verifier)
.with_no_client_auth();
config.alpn_protocols = alpn();
Ok(Arc::new(config))
}
pub(super) fn alpn() -> Vec<Vec<u8>> {
vec![b"h2".to_vec(), b"http/1.1".to_vec()]
}
fn build_config() -> Result<Arc<ClientConfig>, String> {
let roots = native_roots()?;
let public = public_roots(&roots);
let provider = Arc::new(ps_rustcrypto::provider());
let mut config = ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.map_err(|error| error.to_string())?
.dangerous()
.with_custom_certificate_verifier(
CtVerifier::ecc_only(roots, public)
.map_err(|error| format!("TLS trust store: {error}"))?,
)
.with_no_client_auth();
config.alpn_protocols = alpn();
Ok(Arc::new(config))
}
static HOSTS: Mutex<Option<HashMap<String, HostStats>>> = Mutex::new(None);
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct HostStats {
pub connections: u32,
pub refusals: u32,
pub fallbacks: u32,
pub last_rsa_fallback: Option<bool>,
pub last_alpn: Option<&'static str>,
}
fn update(host: &str, change: impl FnOnce(&mut HostStats)) -> HostStats {
let Ok(mut map) = HOSTS.lock() else {
return HostStats::default();
};
let stats = map
.get_or_insert_with(HashMap::new)
.entry(host.to_ascii_lowercase())
.or_default();
change(stats);
*stats
}
pub(super) fn record_alpn(host: &str, alpn: &'static str) {
update(host, |stats| stats.last_alpn = Some(alpn));
}
fn record(host: &str, used_fallback: bool) {
update(host, |stats| {
stats.last_rsa_fallback = Some(used_fallback);
stats.fallbacks += u32::from(used_fallback);
});
}
pub fn connection_stats() -> Vec<(String, HostStats)> {
let mut all: Vec<_> = HOSTS
.lock()
.ok()
.and_then(|map| {
map.as_ref()
.map(|map| map.iter().map(|(h, s)| (h.clone(), *s)).collect())
})
.unwrap_or_default();
all.sort_by(|a, b| a.0.cmp(&b.0));
all
}
pub fn rsa_fallback_state(host: &str) -> Option<bool> {
HOSTS
.lock()
.ok()?
.as_ref()?
.get(&host.to_ascii_lowercase())
.and_then(|stats| stats.last_rsa_fallback)
}
pub(super) async fn open(
origin: &Origin,
handle: &Handle,
config: Arc<ClientConfig>,
rsa_fallback: Option<Arc<ClientConfig>>,
) -> Result<Transport, Error> {
if !origin.tls {
return Ok(Transport::Plain(connect(origin, handle).await?));
}
let error = match handshake(origin, handle, config).await? {
Ok(tls) => {
record(&origin.host, false);
return Ok(Transport::Tls(tls));
}
Err(error) => error,
};
let Some(rsa_fallback) = rsa_fallback.filter(|_| {
error
.rustls
.as_ref()
.is_some_and(fallback::wants_rsa_fallback)
}) else {
return Err(Error::Tls(error.message));
};
let host = &origin.host;
let reason = if error.rustls.as_ref().is_some_and(policy::is_rsa_refusal) {
"refused the RSA certificate chain, alert 43 unsupported_certificate sent"
} else if matches!(
error.rustls,
Some(nago_rustls::rustls::Error::AlertReceived(_))
) {
"server rejected the ECC-only hello with alert 40 handshake_failure"
} else {
"server answered the ECC-only hello with a non-handshake record"
};
update(host, |stats| stats.refusals += 1);
info(format_args!("TLS {host}: ECC-only {reason}"));
info(format_args!(
"TLS {host}: RSA fallback, new connection allowing RSA-PSS CertificateVerify and RSA certificate signatures"
));
match handshake(origin, handle, rsa_fallback).await? {
Ok(tls) => {
record(host, true);
Ok(Transport::Tls(tls))
}
Err(error) => {
info(format_args!(
"TLS {host}: RSA fallback failed: {}",
error.message
));
Err(Error::Tls(format!("RSA fallback: {}", error.message)))
}
}
}
fn info(message: std::fmt::Arguments<'_>) {
#[cfg(feature = "tracing")]
tracing::info!("{message}");
#[cfg(not(feature = "tracing"))]
eprintln!("info: {message}");
}
struct Failed {
message: String,
rustls: Option<nago_rustls::rustls::Error>,
}
async fn connect(origin: &Origin, handle: &Handle) -> Result<TcpStream, Error> {
let host = origin.host.clone();
let port = origin.port;
let addresses = nagoya::spawn(async move {
nagoya::net::resolve(&host, port).map_err(|error| Error::Dns(error.to_string()))
})
.await
.ok_or_else(|| Error::Dns("resolver task was cancelled".to_owned()))??;
let stats = update(&origin.host, |stats| stats.connections += 1);
info(format_args!(
"net {}: new connection #{} (tls {})",
origin.host, stats.connections, origin.tls
));
nagoya::net::connect_any(&addresses, handle)
.await
.map_err(|error| Error::Io(error.to_string()))
}
async fn handshake(
origin: &Origin,
handle: &Handle,
config: Arc<ClientConfig>,
) -> Result<Result<Box<TlsSession<TcpStream>>, Failed>, Error> {
let stream = connect(origin, handle).await?;
let name = ServerName::try_from(origin.host.to_string())
.map_err(|_| Error::InvalidRequest("invalid TLS server name"))?;
let session =
ClientConnection::new(config, name).map_err(|error| Error::Tls(error.to_string()))?;
let mut tls = TlsSession::client(stream, session);
if let Err(errno) = tls.handshake().await {
return Ok(Err(Failed {
message: handshake_failure(tls.tls_error(), errno),
rustls: tls.tls_error().cloned(),
}));
}
if tls
.alpn_protocol()
.is_some_and(|protocol| protocol != b"http/1.1" && protocol != b"h2")
{
return Err(Error::Tls(
"peer negotiated an unsupported ALPN protocol".to_owned(),
));
}
Ok(Ok(Box::new(tls)))
}
fn handshake_failure(
error: Option<&nago_rustls::rustls::Error>,
errno: impl std::fmt::Display,
) -> String {
match error {
Some(error) if policy::is_rsa_refusal(error) => {
"site requires RSA, blocked by policy (alert 43 unsupported_certificate)".to_owned()
}
Some(error) if policy::is_key_type_refusal(error) => {
"site certificate key is not ECDSA or Ed25519, blocked by policy (alert 43 unsupported_certificate)".to_owned()
}
Some(error) if policy::ct::is_not_logged(error) => "certificate not publicly logged".to_owned(),
Some(error) => format!("handshake: {error}"),
None => format!("handshake: {errno}"),
}
}
pub(super) enum Transport {
Plain(TcpStream),
Tls(Box<TlsSession<TcpStream>>),
}
impl Transport {
pub async fn read_into(&mut self, buffer: &mut BytesMut) -> Result<usize, Error> {
match self {
Self::Plain(stream) => {
buffer.reserve(READ_CHUNK);
stream
.read_buf(buffer)
.await
.map_err(|error| Error::Io(error.to_string()))
}
Self::Tls(stream) => {
let start = buffer.len();
buffer.resize(start + READ_CHUNK, 0);
match stream.read(&mut buffer[start..]).await {
Ok(read) => {
buffer.truncate(start + read);
Ok(read)
}
Err(error) => {
buffer.truncate(start);
Err(Error::Io(error.to_string()))
}
}
}
}
}
pub async fn write_all(&mut self, bytes: &[u8]) -> Result<(), Error> {
match self {
Self::Plain(stream) => stream
.write_all(bytes)
.await
.map_err(|error| Error::Write(error.to_string())),
Self::Tls(stream) => stream
.write_all(bytes)
.await
.map_err(|error| Error::Write(error.to_string())),
}
}
}