From f442eeb3b5ae54d5776fe2b697ecc1d941993e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Per=C5=BCy=C5=82o?= Date: Tue, 30 Jun 2026 15:47:28 +0200 Subject: [PATCH] feat(moq-native): add platform TLS verifier selection --- Cargo.lock | 4 + kt/moq/build.gradle.kts | 3 +- .../androidMain/kotlin/dev/moq/PlatformTLS.kt | 17 ++ rs/moq-ffi/Cargo.toml | 4 + rs/moq-ffi/build.sh | 2 +- rs/moq-ffi/src/android.rs | 16 ++ rs/moq-ffi/src/lib.rs | 2 + rs/moq-ffi/src/session.rs | 37 ++- rs/moq-native/Cargo.toml | 4 + rs/moq-native/src/quiche.rs | 11 +- rs/moq-native/src/tls.rs | 213 ++++++++++++++---- 11 files changed, 270 insertions(+), 43 deletions(-) create mode 100644 kt/moq/src/androidMain/kotlin/dev/moq/PlatformTLS.kt create mode 100644 rs/moq-ffi/src/android.rs diff --git a/Cargo.lock b/Cargo.lock index 1cc011d13..c29bebac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4086,11 +4086,13 @@ version = "0.2.25" dependencies = [ "bytes", "hang", + "jni 0.22.4", "moq-audio", "moq-mux", "moq-native", "moq-net", "pollster", + "rustls-platform-verifier 0.7.0", "serde_json", "thiserror 2.0.18", "tokio", @@ -4230,6 +4232,7 @@ dependencies = [ "reqwest 0.12.28", "rustls", "rustls-native-certs", + "rustls-platform-verifier 0.7.0", "rustls-webpki", "serde", "serde_with", @@ -4252,6 +4255,7 @@ dependencies = [ "web-transport-quiche", "web-transport-quinn", "web-transport-trait", + "webpki-roots", "x509-parser", ] diff --git a/kt/moq/build.gradle.kts b/kt/moq/build.gradle.kts index 8c531de89..78beaed96 100644 --- a/kt/moq/build.gradle.kts +++ b/kt/moq/build.gradle.kts @@ -98,6 +98,7 @@ kotlin { dependsOn(jvmAndAndroidMain) dependencies { implementation("net.java.dev.jna:jna:5.18.1@aar") + implementation("rustls:rustls-platform-verifier:0.1.1") } } val androidUnitTest by getting { @@ -132,7 +133,7 @@ if (androidEnabled) { mavenPublishing { publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) - signAllPublications() + signAllPublications() coordinates("dev.moq", "moq", version.toString()) pom { diff --git a/kt/moq/src/androidMain/kotlin/dev/moq/PlatformTLS.kt b/kt/moq/src/androidMain/kotlin/dev/moq/PlatformTLS.kt new file mode 100644 index 000000000..0f9d617d1 --- /dev/null +++ b/kt/moq/src/androidMain/kotlin/dev/moq/PlatformTLS.kt @@ -0,0 +1,17 @@ +package dev.moq + +import android.content.Context + +object PlatformTLS { + init { + System.loadLibrary("moq_ffi") + } + + @JvmStatic + fun initialize(context: Context) { + initializeNative(context.applicationContext) + } + + @JvmStatic + private external fun initializeNative(context: Context) +} diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index efa8e62f8..dfd06e388 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -36,6 +36,10 @@ tracing = "0.1" uniffi = { version = "0.31", features = ["cli"] } url = "2" +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.22" +rustls-platform-verifier = "0.7" + [build-dependencies] uniffi = { version = "0.31", features = ["build"] } diff --git a/rs/moq-ffi/build.sh b/rs/moq-ffi/build.sh index 171f204d4..02863674b 100755 --- a/rs/moq-ffi/build.sh +++ b/rs/moq-ffi/build.sh @@ -118,7 +118,7 @@ build_target() { if is_android "$target"; then # Android targets use cargo-ndk cargo ndk --target "$target" --platform 24 -- \ - build --release --package moq-ffi --manifest-path "$WORKSPACE_DIR/Cargo.toml" + build --release --package moq-ffi --features android-logcat --manifest-path "$WORKSPACE_DIR/Cargo.toml" else # Set up cross-compilation for Linux ARM64 if [[ "$target" == "aarch64-unknown-linux-gnu" ]]; then diff --git a/rs/moq-ffi/src/android.rs b/rs/moq-ffi/src/android.rs new file mode 100644 index 000000000..e5f5b48db --- /dev/null +++ b/rs/moq-ffi/src/android.rs @@ -0,0 +1,16 @@ +use jni::EnvUnowned; +use jni::errors::ThrowRuntimeExAndDefault; +use jni::objects::{JClass, JObject}; + +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_moq_PlatformTLS_initializeNative<'local>( + mut env: EnvUnowned<'local>, + _class: JClass<'local>, + context: JObject<'local>, +) { + env.with_env(|env| -> jni::errors::Result<()> { + rustls_platform_verifier::android::init_with_env(env, context)?; + Ok(()) + }) + .resolve::(); +} diff --git a/rs/moq-ffi/src/lib.rs b/rs/moq-ffi/src/lib.rs index 8a29c2193..038fef99e 100644 --- a/rs/moq-ffi/src/lib.rs +++ b/rs/moq-ffi/src/lib.rs @@ -3,6 +3,8 @@ //! Provides a Kotlin/Swift-compatible API for real-time pub/sub over QUIC. //! Uses async UniFFI objects instead of callbacks for a native async experience. +#[cfg(target_os = "android")] +mod android; pub mod audio; pub mod consumer; pub mod error; diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index b6ebf8605..38dd50859 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -39,6 +39,30 @@ fn map_connect_error(err: moq_native::Error) -> MoqError { } } +/// TLS server certificate verifier exposed through the FFI bindings. +#[derive(uniffi::Enum)] +pub enum MoqTlsVerifier { + /// Use the recommended verifier for the current platform. + Default, + /// Use the platform verifier. On Android, call PlatformTLS.initialize first. + Platform, + /// Use bundled Mozilla roots from webpki-roots. + WebPki, + /// Load roots with rustls-native-certs. + NativeRoots, +} + +impl From for moq_native::tls::ClientTlsVerifier { + fn from(verifier: MoqTlsVerifier) -> Self { + match verifier { + MoqTlsVerifier::Default => Self::Default, + MoqTlsVerifier::Platform => Self::Platform, + MoqTlsVerifier::WebPki => Self::WebPki, + MoqTlsVerifier::NativeRoots => Self::NativeRoots, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -83,10 +107,21 @@ impl MoqClient { } } + /// Select the TLS server certificate verifier. + /// + /// Default uses the recommended verifier for the platform. Android defaults + /// to WebPKI/Mozilla roots; platform verifier mode requires Android + /// PlatformTLS initialization before connecting. + pub fn set_tls_verifier(&self, verifier: MoqTlsVerifier) { + if let Some(mut state) = self.task.lock() { + state.config.tls.verifier = verifier.into(); + } + } + /// Trust these PEM root certificate file(s) instead of the system roots. /// /// Pass the paths to PEM-encoded CA certificates. An empty list restores the - /// default behavior of using the platform's native root store. + /// default behavior of using the configured default verifier roots. pub fn set_tls_roots(&self, paths: Vec) { if let Some(mut state) = self.task.lock() { state.config.tls.root = paths.into_iter().map(Into::into).collect(); diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 1983f9a7c..bf11a4e33 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -69,8 +69,12 @@ web-transport-proto = { workspace = true, optional = true } web-transport-quiche = { workspace = true, optional = true } web-transport-quinn = { workspace = true, optional = true } web-transport-trait = { workspace = true } +webpki-roots = "1" x509-parser = "0.18" +[target.'cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))'.dependencies] +rustls-platform-verifier = "0.7" + [target.'cfg(target_os = "android")'.dependencies] tracing-android = { version = "0.2", optional = true } diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index d51791189..d8f9cb22c 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -178,8 +178,15 @@ impl QuicheClient { Verification::Fingerprints(hashes) => { builder = builder.with_server_certificate_hashes(hashes); } - Verification::Roots(roots) => { - builder = builder.with_root_certificates(roots); + Verification::Roots { certs, .. } => { + if !certs.is_empty() { + builder = builder.with_root_certificates(certs); + } + } + Verification::Platform => { + return Err(Error::Tls(crate::tls::Error::PlatformVerifierUnsupported( + "quiche backend", + ))); } } diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 2ae4ee3d3..5489ffad2 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -1,6 +1,8 @@ use crate::crypto; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +#[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))] +use rustls_platform_verifier::BuilderVerifierExt; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{fs, io}; @@ -57,6 +59,12 @@ pub enum Error { #[error("failed to add root certificate")] AddRoot(#[source] rustls::Error), + #[error("TLS platform verifier cannot be combined with custom TLS roots")] + PlatformVerifierWithCustomRoots, + + #[error("TLS platform verifier is not supported by {0}")] + PlatformVerifierUnsupported(&'static str), + #[error("failed to configure client certificate")] ClientAuth(#[source] rustls::Error), @@ -106,6 +114,37 @@ pub(crate) fn read_certs(path: &Path) -> Result>> { // ── Client ────────────────────────────────────────────────────────── +/// Source used for default TLS server-certificate verification. +#[derive(Clone, Copy, Default, Debug, clap::ValueEnum, serde::Serialize, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum ClientTlsVerifier { + /// Use the recommended verifier for the current platform. + /// + /// Android uses bundled Mozilla/WebPKI roots, Apple platforms use the + /// platform verifier, and other platforms use rustls-native-certs. + #[default] + Default, + + /// Use the platform verifier. + /// + /// On Android this requires rustls-platform-verifier Android initialization + /// before connecting. + Platform, + + /// Use bundled Mozilla roots from webpki-roots. + WebPki, + + /// Load roots with rustls-native-certs. + NativeRoots, +} + +impl ClientTlsVerifier { + fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + /// TLS configuration for the client. #[serde_with::serde_as] #[derive(Clone, Default, Debug, clap::Args, serde::Serialize, serde::Deserialize)] @@ -127,12 +166,13 @@ pub struct Client { #[serde_as(as = "serde_with::OneOrMany<_>")] pub root: Vec, - /// Also trust the platform's native root certificates. + /// Also trust the configured system/default root source. /// /// Defaults to enabled only when no `--client-tls-root` is given. Set it - /// explicitly to trust the system roots alongside any custom roots, or set it - /// to false to trust only the custom roots. Trusting neither (no custom root - /// and system roots disabled) is rejected, since verification could never pass. + /// explicitly to trust the selected verifier's roots alongside any custom + /// roots, or set it to false to trust only the custom roots. Trusting neither + /// (no custom root and system roots disabled) is rejected, since verification + /// could never pass. #[serde(skip_serializing_if = "Option::is_none")] #[arg( id = "client-tls-system-roots", @@ -145,6 +185,21 @@ pub struct Client { )] pub system_roots: Option, + /// TLS verifier to use when system/default roots are enabled. + /// + /// The default is platform-specific: Android uses bundled Mozilla/WebPKI + /// roots, Apple platforms use the platform verifier, and other platforms use + /// rustls-native-certs. + #[serde(default, skip_serializing_if = "ClientTlsVerifier::is_default")] + #[arg( + id = "client-tls-verifier", + long = "client-tls-verifier", + env = "MOQ_CLIENT_TLS_VERIFIER", + value_enum, + default_value = "default" + )] + pub verifier: ClientTlsVerifier, + /// Pin the peer to a certificate with one of these SHA-256 fingerprints, encoded as hex. /// /// This is the native equivalent of the browser's WebTransport `serverCertificateHashes`, @@ -251,9 +306,15 @@ pub(crate) enum Verification { /// this is mutually exclusive with any roots. Fingerprints(Vec<[u8; 32]>), - /// Standard verification against these roots (system and/or custom, already - /// resolved). The two sets are additive. - Roots(Vec>), + /// Standard verification against these roots. On Android, `webpki_roots` + /// represents Mozilla roots from the `webpki-roots` crate. + Roots { + certs: Vec>, + webpki_roots: bool, + }, + + /// Standard verification delegated to the platform verifier. + Platform, } impl Client { @@ -329,12 +390,33 @@ impl Client { let system_roots = self.effective_system_roots().unwrap_or(root.is_empty()); let mut roots = Vec::new(); + let mut webpki_roots = false; + if system_roots { - let native = rustls_native_certs::load_native_certs(); - for err in native.errors { - tracing::warn!(%err, "failed to load root cert"); + match self.effective_verifier() { + ClientTlsVerifier::Default => unreachable!("effective verifier resolves default"), + ClientTlsVerifier::Platform => { + if !root.is_empty() { + return Err(Error::PlatformVerifierWithCustomRoots); + } + if !Self::platform_verifier_supported() { + return Err(Error::PlatformVerifierUnsupported("this target")); + } + tracing::debug!("using platform TLS certificate verifier for system roots"); + return Ok(Verification::Platform); + } + ClientTlsVerifier::WebPki => { + tracing::debug!("using WebPKI Mozilla TLS roots for system roots"); + webpki_roots = true; + } + ClientTlsVerifier::NativeRoots => { + let native = rustls_native_certs::load_native_certs(); + for err in native.errors { + tracing::warn!(%err, "failed to load root cert"); + } + roots.extend(native.certs); + } } - roots.extend(native.certs); } for root in &root { let certs = read_certs(root)?; @@ -346,11 +428,39 @@ impl Client { // WebPKI needs at least one trusted root to ever succeed, so fail fast // instead of producing confusing handshake errors later. - if roots.is_empty() { + let has_roots = !roots.is_empty() || webpki_roots; + if !has_roots { return Err(Error::NoRoots); } - Ok(Verification::Roots(roots)) + Ok(Verification::Roots { + certs: roots, + webpki_roots, + }) + } + + fn effective_verifier(&self) -> ClientTlsVerifier { + match self.verifier { + ClientTlsVerifier::Default => { + #[cfg(target_os = "android")] + { + ClientTlsVerifier::WebPki + } + #[cfg(any(target_os = "ios", target_os = "macos"))] + { + ClientTlsVerifier::Platform + } + #[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))] + { + ClientTlsVerifier::NativeRoots + } + } + verifier => verifier, + } + } + + fn platform_verifier_supported() -> bool { + cfg!(any(target_os = "android", target_os = "ios", target_os = "macos")) } /// Whether an insecure `http://` certificate-fingerprint bootstrap may be @@ -384,34 +494,23 @@ impl Client { let provider = crypto::provider(); let verification = self.verification()?; - let mut roots = rustls::RootCertStore::empty(); - if let Verification::Roots(certs) = &verification { - for cert in certs { - roots.add(cert.clone()).map_err(Error::AddRoot)?; - } - } - // Allow TLS 1.2 in addition to 1.3 for WebSocket compatibility. // QUIC always negotiates TLS 1.3 regardless of this setting. let builder = rustls::ClientConfig::builder_with_provider(provider.clone()) - .with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])? - .with_root_certificates(roots); + .with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])?; - let mut tls = match (&self.cert, &self.key) { - (Some(cert_path), Some(key_path)) => { - let cert_pem = fs::read(cert_path).map_err(Error::ReadFile)?; - let chain: Vec> = CertificateDer::pem_slice_iter(&cert_pem) - .collect::>() - .map_err(Error::Read)?; - if chain.is_empty() { - return Err(Error::Empty); - } - let key_pem = fs::read(key_path).map_err(Error::ReadFile)?; - let key = PrivateKeyDer::from_pem_slice(&key_pem).map_err(Error::Key)?; - builder.with_client_auth_cert(chain, key).map_err(Error::ClientAuth)? - } - (None, None) => builder.with_no_client_auth(), - _ => return Err(Error::IncompleteClientAuth), + #[cfg(any(target_os = "android", target_os = "ios", target_os = "macos"))] + let mut tls = if matches!(verification, Verification::Platform) { + self.build_client_config(builder.with_platform_verifier()?)? + } else { + let roots = Self::root_store(&verification)?; + self.build_client_config(builder.with_root_certificates(roots))? + }; + + #[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))] + let mut tls = { + let roots = Self::root_store(&verification)?; + self.build_client_config(builder.with_root_certificates(roots))? }; match verification { @@ -428,11 +527,49 @@ impl Client { tls.dangerous().set_certificate_verifier(Arc::new(verifier)); } // Roots are already in the store above; use the default WebPKI verifier. - Verification::Roots(_) => {} + Verification::Roots { .. } => {} + // Platform verifier was installed by the builder above. + Verification::Platform => {} } Ok(tls) } + + fn root_store(verification: &Verification) -> Result { + let mut roots = rustls::RootCertStore::empty(); + if let Verification::Roots { certs, .. } = verification { + if let Verification::Roots { webpki_roots: true, .. } = verification { + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + } + + for cert in certs { + roots.add(cert.clone()).map_err(Error::AddRoot)?; + } + } + Ok(roots) + } + + fn build_client_config( + &self, + builder: rustls::ConfigBuilder, + ) -> Result { + Ok(match (&self.cert, &self.key) { + (Some(cert_path), Some(key_path)) => { + let cert_pem = fs::read(cert_path).map_err(Error::ReadFile)?; + let chain: Vec> = CertificateDer::pem_slice_iter(&cert_pem) + .collect::>() + .map_err(Error::Read)?; + if chain.is_empty() { + return Err(Error::Empty); + } + let key_pem = fs::read(key_path).map_err(Error::ReadFile)?; + let key = PrivateKeyDer::from_pem_slice(&key_pem).map_err(Error::Key)?; + builder.with_client_auth_cert(chain, key).map_err(Error::ClientAuth)? + } + (None, None) => builder.with_no_client_auth(), + _ => return Err(Error::IncompleteClientAuth), + }) + } } // ── Server ──────────────────────────────────────────────────────────