use std::sync::Arc;
use rustls::{ClientConfig, RootCertStore};
use serde::Serialize;
use tokio::sync::OnceCell;
use tungstenite::Error as TungsteniteError;
use crate::DeepgramError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TlsTrust {
Webpki,
WebpkiAndNative,
WebpkiNativeUnavailable,
Custom,
}
#[cfg_attr(not(feature = "connect-diagnostics"), allow(dead_code))]
pub(crate) const fn default_trust() -> TlsTrust {
if cfg!(feature = "rustls-tls-native-roots") {
TlsTrust::WebpkiAndNative
} else {
TlsTrust::Webpki
}
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedTls {
pub(crate) config: Arc<ClientConfig>,
pub(crate) trust: TlsTrust,
}
impl ResolvedTls {
pub(crate) fn connector(&self) -> tokio_tungstenite::Connector {
tokio_tungstenite::Connector::Rustls(self.config.clone())
}
}
#[derive(Debug, Clone)]
pub(crate) enum ConnectTls {
Plain,
Tls(ResolvedTls),
}
impl ConnectTls {
pub(crate) fn connector(&self) -> tokio_tungstenite::Connector {
match self {
ConnectTls::Plain => tokio_tungstenite::Connector::Plain,
ConnectTls::Tls(tls) => tls.connector(),
}
}
#[cfg_attr(not(feature = "connect-diagnostics"), allow(dead_code))]
pub(crate) fn trust(&self) -> Option<TlsTrust> {
match self {
ConnectTls::Plain => None,
ConnectTls::Tls(tls) => Some(tls.trust),
}
}
pub(crate) fn connect_error(&self, err: TungsteniteError, host: &str) -> DeepgramError {
match self {
ConnectTls::Plain => DeepgramError::from(err),
ConnectTls::Tls(tls) => connect_error(err, host, tls.trust),
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct TlsSettings {
custom: Option<Arc<ClientConfig>>,
default: Arc<OnceCell<ResolvedTls>>,
}
impl TlsSettings {
pub(crate) fn new() -> Self {
TlsSettings {
custom: None,
default: Arc::new(OnceCell::new()),
}
}
pub(crate) fn custom(config: Arc<ClientConfig>) -> Self {
TlsSettings {
custom: Some(config),
default: Arc::new(OnceCell::new()),
}
}
#[cfg_attr(not(feature = "connect-diagnostics"), allow(dead_code))]
pub(crate) fn trust(&self) -> TlsTrust {
if self.custom.is_some() {
TlsTrust::Custom
} else if let Some(default) = self.default.get() {
default.trust
} else {
default_trust()
}
}
pub(crate) async fn resolve(&self) -> ResolvedTls {
if let Some(config) = &self.custom {
return ResolvedTls {
config: config.clone(),
trust: TlsTrust::Custom,
};
}
self.default.get_or_init(build_default).await.clone()
}
pub(crate) async fn resolve_for(&self, url: &url::Url) -> ConnectTls {
if url.scheme() == "wss" {
ConnectTls::Tls(self.resolve().await)
} else {
ConnectTls::Plain
}
}
}
async fn build_default() -> ResolvedTls {
let (roots, trust) = default_root_store().await;
ResolvedTls {
config: Arc::new(
ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth(),
),
trust,
}
}
pub(crate) async fn default_root_store() -> (RootCertStore, TlsTrust) {
#[cfg(feature = "rustls-tls-native-roots")]
let (mut store, trust) = match tokio::task::spawn_blocking(native_root_store).await {
Ok(store) if !store.is_empty() => (store, TlsTrust::WebpkiAndNative),
Ok(store) => (store, TlsTrust::WebpkiNativeUnavailable),
Err(join_error) => {
tracing::warn!(
"loading native root certificates panicked; continuing with the bundled \
webpki roots only: {join_error}"
);
(RootCertStore::empty(), TlsTrust::WebpkiNativeUnavailable)
}
};
#[cfg(not(feature = "rustls-tls-native-roots"))]
let (mut store, trust) = (RootCertStore::empty(), TlsTrust::Webpki);
store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
(store, trust)
}
#[cfg(feature = "rustls-tls-native-roots")]
fn native_root_store() -> RootCertStore {
let mut store = RootCertStore::empty();
let rustls_native_certs::CertificateResult { certs, errors, .. } =
rustls_native_certs::load_native_certs();
if !errors.is_empty() {
tracing::warn!("errors while loading native root certificates: {errors:?}");
}
let total = certs.len();
let (added, ignored) = store.add_parsable_certificates(certs);
if added == 0 {
tracing::warn!(
"no native root certificates were found; continuing with the bundled webpki \
roots only"
);
} else {
tracing::debug!("added {added}/{total} native root certificates ({ignored} ignored)");
}
store
}
pub(crate) fn connect_error(err: TungsteniteError, host: &str, trust: TlsTrust) -> DeepgramError {
if is_unknown_issuer(&err) {
DeepgramError::UntrustedTlsCertificate {
host: host.to_owned(),
trust,
source: Box::new(err),
}
} else {
DeepgramError::from(err)
}
}
fn is_unknown_issuer(err: &TungsteniteError) -> bool {
let TungsteniteError::Io(io) = err else {
return false;
};
matches!(
io.get_ref().and_then(|e| e.downcast_ref::<rustls::Error>()),
Some(rustls::Error::InvalidCertificate(
rustls::CertificateError::UnknownIssuer
))
)
}
pub(crate) fn untrusted_hint(trust: &TlsTrust) -> &'static str {
match trust {
TlsTrust::Webpki => {
"By default the SDK trusts only the bundled public (webpki) roots. If this \
connection goes through a TLS-inspecting proxy or to a server with a private \
CA, enable the `rustls-tls-native-roots` cargo feature to also trust the OS \
certificate store, or pass your own rustls config to `Deepgram::tls_config`."
}
TlsTrust::WebpkiAndNative => {
"The bundled public roots and the OS certificate store were both checked and \
neither contains this certificate's issuer. Install the issuing CA in the OS \
store, or point `SSL_CERT_FILE` at a PEM bundle that contains it together \
with the public roots you rely on (the variable replaces the OS store for \
these WebSockets and, on Linux, for the REST client too), or pass your own \
rustls config to `Deepgram::tls_config`."
}
TlsTrust::WebpkiNativeUnavailable => {
"The `rustls-tls-native-roots` feature is enabled, but no native root \
certificates could be loaded, so only the bundled public (webpki) roots were \
checked and they do not contain this certificate's issuer. The load errors \
were logged at `tracing` WARN level; the usual causes are an `SSL_CERT_FILE` / \
`SSL_CERT_DIR` override that is missing, unreadable, or not PEM, or a system \
with no OS certificate store. Point `SSL_CERT_FILE` at a valid PEM bundle \
containing the issuing CA together with the public roots you rely on, or \
pass your own rustls config to `Deepgram::tls_config`."
}
TlsTrust::Custom => {
"The rustls config passed to `Deepgram::tls_config` does not trust this \
certificate's issuer."
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn default_store_always_contains_the_public_roots() {
let (store, trust) = default_root_store().await;
assert!(store.len() >= webpki_roots::TLS_SERVER_ROOTS.len());
let native_added = store.len() - webpki_roots::TLS_SERVER_ROOTS.len();
let expected = match (cfg!(feature = "rustls-tls-native-roots"), native_added) {
(false, _) => TlsTrust::Webpki,
(true, 0) => TlsTrust::WebpkiNativeUnavailable,
(true, _) => TlsTrust::WebpkiAndNative,
};
assert_eq!(trust, expected);
}
#[tokio::test]
async fn default_config_matches_tokio_tungstenite_defaults() {
let config = build_default().await.config;
assert!(!config.client_auth_cert_resolver.has_certs());
let roots = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
assert_eq!(
config.crypto_provider().cipher_suites,
ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth()
.crypto_provider()
.cipher_suites,
);
}
#[tokio::test]
async fn default_config_is_built_once_per_client_and_shared_by_clones() {
let settings = TlsSettings::new();
let clone = settings.clone();
assert_eq!(settings.trust(), default_trust());
let a = settings.resolve().await;
let b = clone.resolve().await;
assert!(Arc::ptr_eq(&a.config, &b.config));
assert_eq!(a.trust, b.trust);
assert_ne!(a.trust, TlsTrust::Custom);
assert_eq!(settings.trust(), a.trust);
assert_eq!(clone.trust(), a.trust);
}
#[tokio::test]
async fn custom_config_is_used_verbatim() {
let custom = Arc::new(
ClientConfig::builder()
.with_root_certificates(RootCertStore::empty())
.with_no_client_auth(),
);
let settings = TlsSettings::custom(custom.clone());
let resolved = settings.resolve().await;
assert!(Arc::ptr_eq(&resolved.config, &custom));
assert_eq!(resolved.trust, TlsTrust::Custom);
assert_eq!(settings.trust(), TlsTrust::Custom);
}
#[test]
fn unknown_issuer_is_classified_and_hinted() {
let rustls_err = rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer);
let io = std::io::Error::new(std::io::ErrorKind::InvalidData, rustls_err);
let err = connect_error(TungsteniteError::Io(io), "proxy.example", TlsTrust::Webpki);
match &err {
DeepgramError::UntrustedTlsCertificate { host, trust, .. } => {
assert_eq!(host, "proxy.example");
assert_eq!(*trust, TlsTrust::Webpki);
}
other => panic!("expected UntrustedTlsCertificate, got {other:?}"),
}
let message = err.to_string();
assert!(message.contains("proxy.example"), "{message}");
assert!(message.contains("UnknownIssuer"), "{message}");
assert!(message.contains("rustls-tls-native-roots"), "{message}");
assert!(message.contains("Deepgram::tls_config"), "{message}");
}
#[test]
fn other_errors_stay_ws_errors() {
let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
let err = connect_error(TungsteniteError::Io(io), "h", TlsTrust::Webpki);
assert!(matches!(err, DeepgramError::WsError(_)), "{err:?}");
let not_for_name =
rustls::Error::InvalidCertificate(rustls::CertificateError::NotValidForName);
let io = std::io::Error::new(std::io::ErrorKind::InvalidData, not_for_name);
let err = connect_error(TungsteniteError::Io(io), "h", TlsTrust::Webpki);
assert!(matches!(err, DeepgramError::WsError(_)), "{err:?}");
}
#[test]
fn hint_follows_trust_in_effect() {
assert!(untrusted_hint(&TlsTrust::Webpki).contains("rustls-tls-native-roots"));
assert!(untrusted_hint(&TlsTrust::WebpkiAndNative).contains("SSL_CERT_FILE"));
assert!(untrusted_hint(&TlsTrust::Custom).contains("tls_config"));
}
#[test]
fn ssl_cert_file_hints_ask_for_a_full_bundle() {
for trust in [TlsTrust::WebpkiAndNative, TlsTrust::WebpkiNativeUnavailable] {
let hint = untrusted_hint(&trust);
assert!(hint.contains("SSL_CERT_FILE"), "{hint}");
assert!(hint.contains("together with the public roots"), "{hint}");
assert!(!hint.contains("`SSL_CERT_FILE` at it"), "{hint}");
}
assert!(untrusted_hint(&TlsTrust::WebpkiAndNative).contains("replaces the OS store"),);
}
#[tokio::test]
async fn plaintext_ws_resolves_nothing() {
let settings = TlsSettings::new();
let ws = url::Url::parse("ws://localhost:8080/v1/listen").unwrap();
let plain = settings.resolve_for(&ws).await;
assert!(matches!(plain, ConnectTls::Plain), "{plain:?}");
assert_eq!(plain.trust(), None);
assert!(matches!(
plain.connector(),
tokio_tungstenite::Connector::Plain
));
assert!(
settings.default.get().is_none(),
"default config was built for ws://"
);
let wss = url::Url::parse("wss://localhost:8080/v1/listen").unwrap();
let secure = settings.resolve_for(&wss).await;
let ConnectTls::Tls(resolved) = &secure else {
panic!("expected resolved TLS for wss://, got {secure:?}");
};
assert_eq!(secure.trust(), Some(resolved.trust));
assert!(settings.default.get().is_some());
let rustls_err = rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer);
let io = std::io::Error::new(std::io::ErrorKind::InvalidData, rustls_err);
let err = plain.connect_error(TungsteniteError::Io(io), "localhost");
assert!(matches!(err, DeepgramError::WsError(_)), "{err:?}");
}
#[test]
fn failed_native_load_gets_its_own_hint() {
let loaded = untrusted_hint(&TlsTrust::WebpkiAndNative);
let unavailable = untrusted_hint(&TlsTrust::WebpkiNativeUnavailable);
assert_ne!(loaded, unavailable);
assert!(loaded.contains("were both checked"), "{loaded}");
assert!(
!loaded.contains("no native root certificates could be loaded"),
"{loaded}"
);
assert!(
unavailable.contains("no native root certificates could be loaded"),
"{unavailable}"
);
assert!(!unavailable.contains("were both checked"), "{unavailable}");
assert!(unavailable.contains("SSL_CERT_FILE"), "{unavailable}");
assert!(
unavailable.contains("Deepgram::tls_config"),
"{unavailable}"
);
let rustls_err = rustls::Error::InvalidCertificate(rustls::CertificateError::UnknownIssuer);
let io = std::io::Error::new(std::io::ErrorKind::InvalidData, rustls_err);
let err = connect_error(
TungsteniteError::Io(io),
"proxy.example",
TlsTrust::WebpkiNativeUnavailable,
);
assert!(
matches!(
err,
DeepgramError::UntrustedTlsCertificate {
trust: TlsTrust::WebpkiNativeUnavailable,
..
}
),
"{err:?}"
);
assert!(err.to_string().ends_with(unavailable), "{err}");
}
#[test]
fn trust_serializes_snake_case() {
assert_eq!(
serde_json::to_string(&TlsTrust::WebpkiAndNative).unwrap(),
"\"webpki_and_native\""
);
assert_eq!(
serde_json::to_string(&TlsTrust::WebpkiNativeUnavailable).unwrap(),
"\"webpki_native_unavailable\""
);
}
}