Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions rs/moq-native/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,17 @@ pub struct Client {
backoff: Backoff,
#[cfg(feature = "websocket")]
websocket: crate::websocket::Client,
tls: rustls::ClientConfig,
/// Prebuilt rustls config for the resolved verification policy, or `None` when
/// no CA roots resolved. Left unresolved so a per-connection `http://`
/// fingerprint bootstrap can still connect without roots; any other connection
/// then surfaces [`crate::tls::Error::NoRoots`] at connect time.
tls: Option<rustls::ClientConfig>,
/// The TLS config itself, kept to build a freshly pinned rustls config per
/// connection when bootstrapping a self-signed relay over `http://`. Only the
/// rustls-based quinn/noq backends rebuild from it; quiche carries its own
/// resolved policy.
#[cfg(any(feature = "quinn", feature = "noq"))]
tls_config: crate::tls::Client,
#[cfg(feature = "noq")]
noq: Option<crate::noq::NoqClient>,
#[cfg(feature = "quinn")]
Expand Down Expand Up @@ -153,7 +163,14 @@ impl Client {
panic!("no QUIC backend compiled; enable noq, quinn, or quiche feature");
});

let tls = config.tls.build()?;
// Resolve the verification policy once. Building the rustls config is
// deferred to `None` when no roots resolved, so construction doesn't fail
// before a per-connection `http://` bootstrap gets a chance to pin a cert.
let tls = config
.tls
.verification()?
.map(|v| config.tls.build_with(v))
.transpose()?;

#[cfg(feature = "noq")]
#[allow(unreachable_patterns)]
Expand Down Expand Up @@ -183,6 +200,8 @@ impl Client {
#[cfg(feature = "websocket")]
websocket: config.websocket,
tls,
#[cfg(any(feature = "quinn", feature = "noq"))]
tls_config: config.tls,
#[cfg(feature = "noq")]
noq,
#[cfg(feature = "quinn")]
Expand Down Expand Up @@ -302,9 +321,12 @@ impl Client {

#[cfg(feature = "noq")]
if let Some(noq) = self.noq.as_ref() {
let tls = self.tls.clone();
let quic_url = url.clone();
let quic_handle = async { noq.connect(&tls, quic_url).await.map_err(Error::from) };
let quic_handle = async {
noq.connect(self.tls.as_ref(), &self.tls_config, quic_url)
.await
.map_err(Error::from)
};

#[cfg(feature = "websocket")]
{
Expand All @@ -320,9 +342,13 @@ impl Client {

#[cfg(feature = "quinn")]
if let Some(quinn) = self.quinn.as_ref() {
let tls = self.tls.clone();
let quic_url = url.clone();
let quic_handle = async { quinn.connect(&tls, quic_url).await.map_err(Error::from) };
let quic_handle = async {
quinn
.connect(self.tls.as_ref(), &self.tls_config, quic_url)
.await
.map_err(Error::from)
};

#[cfg(feature = "websocket")]
{
Expand Down Expand Up @@ -356,7 +382,7 @@ impl Client {
#[cfg(feature = "websocket")]
{
let alpns = self.versions.alpns();
let session = crate::websocket::connect(&self.websocket, &self.tls, url, &alpns).await?;
let session = crate::websocket::connect(&self.websocket, self.tls.as_ref(), url, &alpns).await?;
return Ok(self.moq.connect(session).await?);
}

Expand All @@ -374,7 +400,7 @@ impl Client {
let ws_config = self.websocket.clone();
let ws_tls = self.tls.clone();
let websocket = async move {
crate::websocket::race_handle(&ws_config, &ws_tls, url, &alpns)
crate::websocket::race_handle(&ws_config, ws_tls.as_ref(), url, &alpns)
.await
.map(|res| res.map_err(Error::from))
};
Expand Down Expand Up @@ -525,6 +551,22 @@ mod tests {
assert_eq!(config.tls.disable_verify, None);
}

#[cfg(feature = "quinn")]
#[tokio::test]
async fn init_defers_no_roots() {
// Regression: a roots-less config (system roots off, no custom root, no
// fingerprint) used to fail construction with NoRoots before any URL scheme
// was known. It now builds, so a per-connection http:// fingerprint
// bootstrap can still connect; other schemes surface NoRoots at connect time.
let mut tls = crate::tls::Client::default();
tls.system_roots = Some(false);
let config = ClientConfig {
tls,
..Default::default()
};
assert!(config.init().is_ok());
}

#[test]
fn test_toml_fingerprint_survives_update_from() {
let toml = r#"
Expand Down
5 changes: 5 additions & 0 deletions rs/moq-native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
/// Default maximum number of concurrent QUIC streams (bidi and uni) per connection.
pub(crate) const DEFAULT_MAX_STREAMS: u64 = 1024;

/// Overall timeout for the insecure `http://` certificate-fingerprint fetch, so a
/// stalled `/certificate.sha256` response can't block connect indefinitely.
#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))]
pub(crate) const FINGERPRINT_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);

pub mod bind;
mod client;
mod connect;
Expand Down
90 changes: 62 additions & 28 deletions rs/moq-native/src/noq.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::client::ClientConfig;
use crate::server::{ServerConfig, ServerId};
use crate::tls::{FingerprintVerifier, ServeCerts};
use crate::tls::ServeCerts;
use std::sync::{Arc, RwLock};
use std::time::Duration;
use std::{net, time};
Expand Down Expand Up @@ -50,6 +50,10 @@ pub enum Error {
#[error("invalid fingerprint")]
InvalidFingerprint(#[from] hex::FromHexError),

/// The fetched `http://` certificate fingerprint didn't decode to a 32-byte SHA-256 digest.
#[error("certificate fingerprint must be 32 bytes (SHA-256), got {0}")]
FingerprintLength(usize),
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[error("url scheme must be 'https', 'moqt', or 'moql'")]
InvalidScheme,

Expand Down Expand Up @@ -156,9 +160,20 @@ impl NoqClient {
})
}

pub async fn connect(&self, tls: &rustls::ClientConfig, url: Url) -> Result<web_transport_noq::Session> {
/// Connect to `url`, resolving TLS now that the scheme is known.
///
/// `base` is the prebuilt config for the resolved verification policy, or
/// `None` when no roots resolved. An eligible `http://` URL fetches
/// `/certificate.sha256`, pins it (rebuilding from `tls_config`, no roots
/// needed), and upgrades to `https://`; any other scheme reuses `base` and
/// surfaces [`crate::tls::Error::NoRoots`] when it is `None`.
pub async fn connect(
&self,
base: Option<&rustls::ClientConfig>,
tls_config: &crate::tls::Client,
url: Url,
) -> Result<web_transport_noq::Session> {
let mut url = url;
let mut config = tls.clone();

let host = url.host().ok_or(Error::InvalidDnsName)?.to_string();
let port = url.port().unwrap_or(443);
Expand All @@ -174,36 +189,28 @@ impl NoqClient {
.map_err(Error::DnsLookup)?;
let ip = crate::util::pick_addr(addrs, local).ok_or(Error::NoDnsEntries)?;

if url.scheme() == "http" {
// Insecure per-connection bootstrap: only honored when no stronger
// verification is configured, so an attacker controlling the plaintext
// fetch can't weaken an explicit pin or re-enable disabled verification.
if self.http_bootstrap {
// Perform a HTTP request to fetch the certificate fingerprint.
let mut fingerprint = url.clone();
fingerprint.set_path("/certificate.sha256");
fingerprint.set_query(None);
fingerprint.set_fragment(None);

tracing::warn!(url = %fingerprint, "performing insecure HTTP request for certificate");

let resp = reqwest::get(fingerprint.as_str())
.await
.map_err(Error::FetchFingerprint)?
.error_for_status()
.map_err(Error::FingerprintStatus)?;

let fingerprint = resp.text().await.map_err(Error::ReadFingerprint)?;
let fingerprint = hex::decode(fingerprint.trim())?;

let verifier = FingerprintVerifier::new(config.crypto_provider().clone(), vec![fingerprint]);
config.dangerous().set_certificate_verifier(Arc::new(verifier));
} else {
// Resolve the per-connection rustls config now that the scheme is known.
// An `http://` bootstrap pins a freshly fetched fingerprint (no roots
// needed); anything else reuses the resolved policy, surfacing the
// deferred `NoRoots` when none was configured.
let mut config = if url.scheme() == "http" && self.http_bootstrap {
// Insecure per-connection bootstrap: an `http://` URL is a deliberate
// opt-in to replace CA validation with a freshly fetched pin for this
// connection. It's refused for an explicit `--client-tls-fingerprint`
// or disabled verification, so an attacker controlling the plaintext
// fetch can't weaken a stronger choice the user already made.
let pin = fetch_fingerprint(&url).await?;
tls_config.build_with(crate::tls::Verification::Fingerprints(vec![pin]))?
} else {
if url.scheme() == "http" {
tracing::warn!(
"ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification"
);
}
base.cloned().ok_or(crate::tls::Error::NoRoots)?
};

if url.scheme() == "http" {
url.set_scheme("https").expect("failed to set scheme");
}

Expand Down Expand Up @@ -259,6 +266,33 @@ impl NoqClient {
}
}

/// Fetch a relay's certificate SHA-256 over an insecure `http://` request and
/// return it as a pin. Mirrors the browser's self-signed bootstrap: GET
/// `/certificate.sha256` and pin the returned hash.
async fn fetch_fingerprint(url: &Url) -> Result<[u8; 32]> {
let mut fp = url.clone();
fp.set_path("/certificate.sha256");
fp.set_query(None);
fp.set_fragment(None);

tracing::warn!(url = %fp, "performing insecure HTTP request for certificate");

// Bound the fetch so a stalled response can't block connect indefinitely.
let resp = reqwest::Client::builder()
.timeout(crate::FINGERPRINT_FETCH_TIMEOUT)
.build()
.map_err(Error::FetchFingerprint)?
.get(fp.as_str())
.send()
.await
.map_err(Error::FetchFingerprint)?
.error_for_status()
.map_err(Error::FingerprintStatus)?;
let text = resp.text().await.map_err(Error::ReadFingerprint)?;
let bytes = hex::decode(text.trim())?;
bytes.try_into().map_err(|v: Vec<u8>| Error::FingerprintLength(v.len()))
}

impl Error {
pub(crate) fn connect_error(&self) -> Option<crate::ConnectError> {
match self {
Expand Down
16 changes: 12 additions & 4 deletions rs/moq-native/src/quiche.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ type Result<T> = std::result::Result<T, Error>;
pub(crate) struct QuicheClient {
pub bind: net::SocketAddr,
/// Resolved server-verification policy, shared with the other backends.
pub verification: crate::tls::Verification,
/// `None` when no CA roots resolved: deferred so an `http://` bootstrap can
/// still pin a fetched fingerprint, while other schemes surface `NoRoots`.
pub verification: Option<crate::tls::Verification>,
/// Whether an `http://` URL may bootstrap a pin (see [crate::tls::Client::allows_http_bootstrap]).
pub http_bootstrap: bool,
pub max_streams: u64,
Expand Down Expand Up @@ -145,10 +147,10 @@ impl QuicheClient {
tracing::warn!(
"ignoring insecure http:// fingerprint bootstrap; using the configured TLS verification"
);
(https, self.verification.clone())
(https, self.verification.clone().ok_or(crate::tls::Error::NoRoots)?)
}
} else {
(url, self.verification.clone())
(url, self.verification.clone().ok_or(crate::tls::Error::NoRoots)?)
};

let alpns: Vec<Vec<u8>> = match url.scheme() {
Expand Down Expand Up @@ -238,7 +240,13 @@ async fn fetch_fingerprint(url: &Url) -> Result<[u8; 32]> {

tracing::warn!(url = %fp, "performing insecure HTTP request for certificate fingerprint");

let resp = reqwest::get(fp.as_str())
// Bound the fetch so a stalled response can't block connect indefinitely.
let resp = reqwest::Client::builder()
.timeout(crate::FINGERPRINT_FETCH_TIMEOUT)
.build()
.map_err(Error::FetchFingerprint)?
.get(fp.as_str())
.send()
.await
.map_err(Error::FetchFingerprint)?
.error_for_status()
Expand Down
Loading
Loading