mirror of
https://github.com/aphrody-labs/mullvadvpn-app.git
synced 2026-10-13 22:10:39 +00:00
Add mullvad-tls-client and use it for Mullvad infra targets
Four crates each built their own rustls configuration for the same endpoints, and each carried its own copy of the pinned root certificate, two of them reaching outside their package directory to include it. The parameters that make a connection to Mullvad infrastructure safe were therefore stated in several places and could drift apart, which is how the Android release tool ended up fetching metadata unpinned for as long as it did. Move the certificate and the configuration into a crate that owns them, and have call sites name a destination instead of assembling security parameters. SNI is now disabled for the version metadata fetchers as well. Both hosts they contact serve a usable certificate without it.
This commit is contained in:
23 files changed
+281
-171
No files matched your search
+1
-1
@@ -13,6 +13,6 @@
|
||||
# that breaks reproducible builds.
|
||||
# `-text` disables EOL conversion so every checkout embeds
|
||||
# identical bytes regardless of platform or core.autocrlf.
|
||||
mullvad-api/le_root_cert.pem -text
|
||||
mullvad-tls-client/le_root_cert.pem -text
|
||||
mullvad-update/trusted-metadata-signing-pubkeys -text
|
||||
*.manifest -text
|
||||
Generated
+11
-3
@@ -3341,10 +3341,10 @@ dependencies = [
|
||||
"mullvad-api-constants",
|
||||
"mullvad-encrypted-dns-proxy",
|
||||
"mullvad-fs",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-types",
|
||||
"mullvad-update",
|
||||
"mullvad-version",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shadowsocks",
|
||||
@@ -3714,10 +3714,9 @@ dependencies = [
|
||||
"clap",
|
||||
"mullvad-api",
|
||||
"mullvad-api-constants",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-version",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -3745,6 +3744,14 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mullvad-tls-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mullvad-types"
|
||||
version = "0.0.0"
|
||||
@@ -3779,6 +3786,7 @@ dependencies = [
|
||||
"log",
|
||||
"mockito",
|
||||
"mullvad-api-constants",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-version",
|
||||
"proptest",
|
||||
"rand 0.9.4",
|
||||
|
||||
@@ -25,6 +25,7 @@ members = [
|
||||
"mullvad-relay-selector",
|
||||
"mullvad-release-android",
|
||||
"mullvad-setup",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-types",
|
||||
"mullvad-types/intersection-derive",
|
||||
"mullvad-update",
|
||||
|
||||
+1
-1
@@ -139,7 +139,7 @@ problem report tool are able to communicate with the API in any of the blocking
|
||||
Linux all applications running as root are able to reach the API in blocking states.
|
||||
|
||||
All API connections use TLS 1.3 with certificate pinning. The app comes bundled with the
|
||||
[Let's encrypt root certificate](../mullvad-api/le_root_cert.pem) and only accepts connections
|
||||
[Let's encrypt root certificate](../mullvad-tls-client/le_root_cert.pem) and only accepts connections
|
||||
with servers having a valid certificate issued to `api.mullvad.net` and signed with this
|
||||
bundled certificate. The post-quantum X25519MLKEM768 key exchange is required.
|
||||
|
||||
|
||||
@@ -267,5 +267,5 @@ Reference: https://docs.travis-ci.com/user/common-build-problems/#mac-macos-sier
|
||||
# SSL pinning
|
||||
|
||||
The iOS app utilizes SSL pinning via the `mullvad-api` client.
|
||||
Root certificates can be updated by using the source certificates shipped along with `mullvad-api` found at `../mullvad-api/le_root_cert.pem`
|
||||
Root certificates can be updated by using the source certificates shipped along with `mullvad-tls-client` found at `../mullvad-tls-client/le_root_cert.pem`
|
||||
|
||||
@@ -26,9 +26,9 @@ log = { workspace = true }
|
||||
mullvad-api-constants = { path = "./mullvad-api-constants" }
|
||||
mullvad-encrypted-dns-proxy = { path = "../mullvad-encrypted-dns-proxy" }
|
||||
mullvad-fs = { path = "../mullvad-fs" }
|
||||
mullvad-tls-client = { path = "../mullvad-tls-client" }
|
||||
mullvad-types = { path = "../mullvad-types" }
|
||||
mullvad-version = { path = "../mullvad-version" }
|
||||
rustls-pki-types.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
shadowsocks = { workspace = true, features = ["stream-cipher"] }
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Provides a TLS 1.3 stream, accepting only LE for root cert.
|
||||
//! SNI is disabled. The only allowed key exchange group is the
|
||||
//! post-quantum hybrid X25519MLKEM768, so handshakes fail if the
|
||||
//! server will not negotiate it.
|
||||
//! Provides a TLS stream to the Mullvad API. See [`mullvad_tls_client::api`]
|
||||
//! for the parameters it is locked down to.
|
||||
use std::{
|
||||
io::{self, ErrorKind},
|
||||
pin::Pin,
|
||||
@@ -10,56 +8,30 @@ use std::{
|
||||
};
|
||||
|
||||
use hyper_util::client::legacy::connect::{Connected, Connection};
|
||||
use rustls_pki_types::{CertificateDer, pem::PemObject};
|
||||
use std::sync::LazyLock;
|
||||
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
|
||||
use tokio_rustls::{
|
||||
TlsConnector,
|
||||
rustls::{self, ClientConfig, pki_types::ServerName},
|
||||
rustls::{ClientConfig, pki_types::ServerName},
|
||||
};
|
||||
|
||||
const LE_ROOT_CERT: &[u8] = include_bytes!("../le_root_cert.pem");
|
||||
|
||||
pub struct TlsStream<S: AsyncRead + AsyncWrite + Unpin> {
|
||||
stream: tokio_rustls::client::TlsStream<S>,
|
||||
}
|
||||
|
||||
static TLS_CONFIG: LazyLock<Arc<ClientConfig>> = LazyLock::new(|| {
|
||||
// Restrict the key exchange to X25519MLKEM768. Offering only this group
|
||||
// means the server must select it, otherwise the handshake aborts.
|
||||
let provider = rustls::crypto::CryptoProvider {
|
||||
kx_groups: vec![rustls::crypto::aws_lc_rs::kx_group::X25519MLKEM768],
|
||||
..rustls::crypto::aws_lc_rs::default_provider()
|
||||
};
|
||||
let config = {
|
||||
let mut config = ClientConfig::builder_with_provider(Arc::new(provider))
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.expect("aws-lc-rs crypto provider should support TLS 1.3")
|
||||
.with_root_certificates(read_cert_store().expect("Failed to parse pem file"))
|
||||
.with_no_client_auth();
|
||||
// This assumes that the server hello/certificates will include certificate for the domain.
|
||||
config.enable_sni = false;
|
||||
// Disable TLS tickets to reduce ability to track clients over time
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
config
|
||||
};
|
||||
Arc::new(config)
|
||||
});
|
||||
|
||||
impl<S> TlsStream<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
{
|
||||
pub async fn connect_https(stream: S, domain: &str) -> io::Result<TlsStream<S>> {
|
||||
Self::connect_https_with_client_config(stream, domain, Arc::clone(&TLS_CONFIG)).await
|
||||
Self::connect_https_with_client_config(stream, domain, mullvad_tls_client::api()).await
|
||||
}
|
||||
|
||||
pub async fn connect_https_with_client_config(
|
||||
stream: S,
|
||||
domain: &str,
|
||||
client_config: Arc<ClientConfig>,
|
||||
client_config: &ClientConfig,
|
||||
) -> io::Result<TlsStream<S>> {
|
||||
let connector = TlsConnector::from(client_config);
|
||||
let connector = TlsConnector::from(Arc::new(client_config.clone()));
|
||||
|
||||
let host = match ServerName::try_from(domain.to_owned()) {
|
||||
Ok(n) => n,
|
||||
@@ -77,19 +49,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cert_store() -> Result<rustls::RootCertStore, rustls_pki_types::pem::Error> {
|
||||
let mut cert_store = rustls::RootCertStore::empty();
|
||||
|
||||
let certs = CertificateDer::pem_reader_iter(&mut std::io::BufReader::new(LE_ROOT_CERT))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let (num_certs_added, num_failures) = cert_store.add_parsable_certificates(certs);
|
||||
if num_failures > 0 || num_certs_added != 1 {
|
||||
panic!("Failed to add root cert");
|
||||
}
|
||||
|
||||
Ok(cert_store)
|
||||
}
|
||||
|
||||
impl<S> AsyncRead for TlsStream<S>
|
||||
where
|
||||
S: AsyncRead + AsyncWrite + Unpin,
|
||||
@@ -132,13 +91,3 @@ where
|
||||
Connected::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cert_loading() {
|
||||
let _certs = read_cert_store();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ exceptions = [
|
||||
{ crate = "mullvad-ios", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-logging", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-masque-proxy", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-tls-client", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-types", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-update", allow = ["GPL-3.0-or-later"] },
|
||||
{ crate = "mullvad-version", allow = ["GPL-3.0-or-later"] },
|
||||
|
||||
@@ -11,10 +11,11 @@ anyhow = "1.0"
|
||||
clap = { workspace = true }
|
||||
mullvad-api = { path = "../mullvad-api" }
|
||||
mullvad-api-constants = { path = "../mullvad-api/mullvad-api-constants" }
|
||||
mullvad-tls-client = { path = "../mullvad-tls-client", features = [
|
||||
"releases-cdn",
|
||||
] }
|
||||
mullvad-version = { path = "../mullvad-version", features = ["serde"] }
|
||||
reqwest = { workspace = true }
|
||||
rustls = { workspace = true, features = ["aws-lc-rs", "std"] }
|
||||
rustls-pki-types = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! This module implements fetching of information about app versions
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::defaults;
|
||||
use anyhow::Context;
|
||||
use rustls::{ClientConfig, RootCertStore, crypto::aws_lc_rs};
|
||||
|
||||
use mullvad_api_constants::*;
|
||||
use mullvad_tls_client::ClientConfig;
|
||||
|
||||
use mullvad_api::version::android::AndroidReleases;
|
||||
|
||||
@@ -33,7 +32,7 @@ impl HttpVersionInfoProvider {
|
||||
}
|
||||
|
||||
async fn get_releases_inner(&self) -> anyhow::Result<AndroidReleases> {
|
||||
let raw_json = Self::get(&self.url, self.resolve).await?;
|
||||
let raw_json = Self::get(&self.url, self.resolve, mullvad_tls_client::api()).await?;
|
||||
serde_json::from_slice(&raw_json).context("Failed to deserialize Android releases")
|
||||
}
|
||||
|
||||
@@ -42,6 +41,7 @@ impl HttpVersionInfoProvider {
|
||||
Self::get(
|
||||
&format!("{}{}", defaults::METADATA_URL, "latest.json"),
|
||||
None,
|
||||
mullvad_tls_client::releases_cdn(),
|
||||
)
|
||||
.await
|
||||
.and_then(|raw_json: Vec<u8>| Ok(String::from_utf8(raw_json)?))
|
||||
@@ -56,23 +56,13 @@ impl HttpVersionInfoProvider {
|
||||
/// # Arguments
|
||||
/// `url` - URL to fetch
|
||||
/// `resolve` - Optional host to resolve (to the IP) without DNS
|
||||
async fn get(url: &str, resolve: Option<(&'static str, IpAddr)>) -> anyhow::Result<Vec<u8>> {
|
||||
// reqwest is built without a bundled crypto provider, so feed it a
|
||||
// preconfigured aws-lc-rs rustls ClientConfig with TLS 1.3 enforced
|
||||
// and the pinned certificate as the only trust anchor.
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots
|
||||
.add(defaults::PINNED_CERTIFICATE.clone())
|
||||
.expect("pinned certificate should be a valid trust anchor");
|
||||
let mut tls_config =
|
||||
ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.expect("aws-lc-rs crypto provider should support TLS 1.3")
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
// Disable TLS tickets to reduce ability to track clients over time
|
||||
tls_config.resumption = rustls::client::Resumption::disabled();
|
||||
let mut req_builder = reqwest::Client::builder().use_preconfigured_tls(tls_config);
|
||||
/// `tls_config` - TLS configuration for the host `url` points at
|
||||
async fn get(
|
||||
url: &str,
|
||||
resolve: Option<(&'static str, IpAddr)>,
|
||||
tls_config: &ClientConfig,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut req_builder = reqwest::Client::builder().use_preconfigured_tls(tls_config.clone());
|
||||
|
||||
// Resolve name without DNS
|
||||
if let Some((host, addr)) = resolve {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use rustls_pki_types::{CertificateDer, pem::PemObject};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Default URL for the `releases`-API.
|
||||
///
|
||||
/// Note that this is just a proxy to _some_ of the files in [METADATA_URL].
|
||||
@@ -8,12 +5,3 @@ pub const RELEASES_URL: &str = "https://api.mullvad.net/app/releases/";
|
||||
|
||||
/// Default URL for version metadata repository.
|
||||
pub const METADATA_URL: &str = "https://releases.mullvad.net/android/metadata/";
|
||||
|
||||
/// Accepted root certificate for the hosts above. Only this certificate is
|
||||
/// trusted when fetching version metadata.
|
||||
///
|
||||
/// This is the Let's Encrypt root-certificate.
|
||||
pub static PINNED_CERTIFICATE: LazyLock<CertificateDer<'static>> = LazyLock::new(|| {
|
||||
const CERT_BYTES: &[u8] = include_bytes!("../../../mullvad-api/le_root_cert.pem");
|
||||
CertificateDer::from_pem_slice(CERT_BYTES).expect("invalid cert")
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "mullvad-tls-client"
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "TLS client configuration for the Mullvad app and its tooling"
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rustls = { workspace = true, features = ["aws-lc-rs", "std"] }
|
||||
rustls-pki-types.workspace = true
|
||||
|
||||
[features]
|
||||
# The configuration for `releases.mullvad.net`. Only the release tooling reaches
|
||||
# that host, so the app does not build it.
|
||||
releases-cdn = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File renamed without changes.
@@ -0,0 +1,33 @@
|
||||
//! Trust anchors for the configurations in this crate.
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use rustls::RootCertStore;
|
||||
use rustls_pki_types::{CertificateDer, pem::PemObject};
|
||||
|
||||
/// The only trust anchor accepted for Mullvad endpoints: ISRG Root X1, the
|
||||
/// Let's Encrypt root certificate.
|
||||
const LE_ROOT_CERT: &[u8] = include_bytes!("../le_root_cert.pem");
|
||||
|
||||
/// A trust store containing only [`LE_ROOT_CERT`]. Parsed once, and shared by
|
||||
/// every configuration that pins it.
|
||||
pub static LE_ROOT_STORE: LazyLock<Arc<RootCertStore>> = LazyLock::new(|| {
|
||||
let cert = CertificateDer::from_pem_slice(LE_ROOT_CERT)
|
||||
.expect("bundled Let's Encrypt root certificate should be valid PEM");
|
||||
let mut store = RootCertStore::empty();
|
||||
store
|
||||
.add(cert)
|
||||
.expect("bundled Let's Encrypt root certificate should be a valid trust anchor");
|
||||
Arc::new(store)
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The bundled root certificate parses and is accepted as a trust anchor.
|
||||
#[test]
|
||||
fn root_store_holds_exactly_one_anchor() {
|
||||
assert_eq!(LE_ROOT_STORE.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! TLS client configuration for the Mullvad app and its surrounding tooling.
|
||||
//!
|
||||
//! Every component that opens a TLS connection takes its configuration from
|
||||
//! here, so that the properties we insist on are decided once rather than
|
||||
//! restated at each call site. Helpers are named after the destination only
|
||||
//! because that is what limits which properties are available; choosing them
|
||||
//! is not the caller's job.
|
||||
|
||||
mod cert;
|
||||
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
/// Re-exported so that consumers can name the type in their own
|
||||
/// signatures without depending on `rustls` themselves.
|
||||
pub use rustls::ClientConfig;
|
||||
|
||||
/// TLS configuration for `api.mullvad.net`.
|
||||
///
|
||||
/// * TLS 1.3 only.
|
||||
/// * Certificate pinning to ISRG Root X1, the Let's Encrypt root.
|
||||
/// * Post-quantum safe. Offers only `X25519MLKEM768` as key exchange.
|
||||
/// * SNI disabled.
|
||||
/// * No TLS session tickets.
|
||||
pub fn api() -> &'static ClientConfig {
|
||||
static CONFIG: LazyLock<ClientConfig> = LazyLock::new(|| {
|
||||
let provider = rustls::crypto::CryptoProvider {
|
||||
kx_groups: vec![rustls::crypto::aws_lc_rs::kx_group::X25519MLKEM768],
|
||||
..rustls::crypto::aws_lc_rs::default_provider()
|
||||
};
|
||||
let mut config = client_config(
|
||||
&[&rustls::version::TLS13],
|
||||
Some(provider),
|
||||
Arc::clone(&cert::LE_ROOT_STORE),
|
||||
);
|
||||
// The server presents a certificate for the domain without being asked.
|
||||
config.enable_sni = false;
|
||||
config
|
||||
});
|
||||
|
||||
&CONFIG
|
||||
}
|
||||
|
||||
/// TLS configuration for `releases.mullvad.net`.
|
||||
///
|
||||
/// * TLS 1.3 only.
|
||||
/// * Certificate pinning to ISRG Root X1, the Let's Encrypt root.
|
||||
/// * SNI disabled.
|
||||
/// * No TLS session tickets.
|
||||
#[cfg(feature = "releases-cdn")]
|
||||
pub fn releases_cdn() -> &'static ClientConfig {
|
||||
static CONFIG: LazyLock<ClientConfig> = LazyLock::new(|| {
|
||||
let mut config = client_config(
|
||||
&[&rustls::version::TLS13],
|
||||
None,
|
||||
Arc::clone(&cert::LE_ROOT_STORE),
|
||||
);
|
||||
// The server presents a certificate for the domain without being asked.
|
||||
config.enable_sni = false;
|
||||
config
|
||||
});
|
||||
|
||||
&CONFIG
|
||||
}
|
||||
|
||||
/// Helper for creating TLS client configs.
|
||||
///
|
||||
/// It fixes what does not vary:
|
||||
/// * No client certificate
|
||||
/// * No session tickets
|
||||
///
|
||||
/// And makes it easy to set the TLS protocol versions, crypto provider and
|
||||
/// trust anchors. `crypto_provider` falls back to the aws-lc-rs default, which
|
||||
/// offers every key exchange group the provider supports.
|
||||
fn client_config(
|
||||
protocol_versions: &[&'static rustls::SupportedProtocolVersion],
|
||||
crypto_provider: Option<rustls::crypto::CryptoProvider>,
|
||||
root_store: Arc<rustls::RootCertStore>,
|
||||
) -> ClientConfig {
|
||||
let provider = crypto_provider.unwrap_or_else(rustls::crypto::aws_lc_rs::default_provider);
|
||||
let mut config = ClientConfig::builder_with_provider(Arc::new(provider))
|
||||
.with_protocol_versions(protocol_versions)
|
||||
.expect("aws-lc-rs crypto provider should support the requested TLS versions")
|
||||
.with_root_certificates(root_store)
|
||||
.with_no_client_auth();
|
||||
// Disable TLS tickets to reduce ability to track clients over time
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
config
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The API configuration keeps the hostname out of the ClientHello.
|
||||
#[test]
|
||||
fn api_config_disables_sni() {
|
||||
assert!(!api().enable_sni);
|
||||
}
|
||||
|
||||
/// No ALPN is set. `hyper_rustls::with_tls_config` panics if any is, and
|
||||
/// each consumer negotiates its own protocols.
|
||||
#[test]
|
||||
fn api_config_sets_no_alpn() {
|
||||
assert!(api().alpn_protocols.is_empty());
|
||||
}
|
||||
|
||||
/// The API accepts nothing but the post-quantum hybrid.
|
||||
#[test]
|
||||
fn api_config_offers_only_post_quantum_kx() {
|
||||
let config = api();
|
||||
let kx_groups = &config.crypto_provider().kx_groups;
|
||||
assert_eq!(kx_groups.len(), 1);
|
||||
assert_eq!(
|
||||
kx_groups[0].name(),
|
||||
rustls::crypto::aws_lc_rs::kx_group::X25519MLKEM768.name()
|
||||
);
|
||||
}
|
||||
|
||||
/// Both pinned configurations reach hosts that need no SNI.
|
||||
#[cfg(feature = "releases-cdn")]
|
||||
#[test]
|
||||
fn releases_config_disables_sni() {
|
||||
assert!(!releases_cdn().enable_sni);
|
||||
}
|
||||
|
||||
/// No helper hands out a configuration that caches session tickets. A
|
||||
/// resumed session identifies the client to the server as one it has
|
||||
/// served before, linking connections made from different tunnels.
|
||||
#[test]
|
||||
fn configs_disable_session_resumption() {
|
||||
for (name, config) in [("api", api()), ("releases_cdn", releases_cdn())] {
|
||||
let resumption = format!("{:?}", config.resumption);
|
||||
assert!(
|
||||
resumption.contains("NoClientSessionStorage"),
|
||||
"{name} caches sessions: {resumption}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ itertools = { workspace = true }
|
||||
json-canon = "0.1"
|
||||
log = { workspace = true }
|
||||
mullvad-api-constants = { path = "../mullvad-api/mullvad-api-constants" }
|
||||
mullvad-tls-client = { path = "../mullvad-tls-client" }
|
||||
mullvad-version = { path = "../mullvad-version", features = ["serde"] }
|
||||
# If a consumer enable the `proptest` feature, then we expose proptest generators in the public API
|
||||
proptest = { workspace = true, optional = true }
|
||||
@@ -51,6 +52,9 @@ tokio = { workspace = true, features = ["fs", "macros", "test-util", "time"] }
|
||||
default = []
|
||||
# Downloading, verifying and running app installers. Without it, only version metadata handling.
|
||||
client = ["reqwest", "rustls", "rustls-pki-types", "thiserror", "tokio", "webpki-roots"]
|
||||
# Fetching metadata straight from `releases.mullvad.net`, which only the release
|
||||
# tooling does. The app goes through the API instead.
|
||||
release-tooling = ["client", "mullvad-tls-client/releases-cdn"]
|
||||
sign = ["clap", "rand_core"]
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -11,7 +11,7 @@ anyhow = "1.0"
|
||||
chrono = { workspace = true, features = ["now", "serde"] }
|
||||
clap = { workspace = true }
|
||||
hex = { version = "0.4" }
|
||||
mullvad-update = { path = "../", features = ["client", "sign", "strum"] }
|
||||
mullvad-update = { path = "../", features = ["client", "release-tooling", "sign", "strum"] }
|
||||
mullvad-version = { path = "../../mullvad-version", features = ["serde"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -12,10 +12,10 @@ use crate::defaults;
|
||||
use crate::format::response::SignedResponse;
|
||||
use crate::version::{VersionInfo, VersionParameters};
|
||||
|
||||
use super::tls::build_client_config;
|
||||
use super::version_provider::VersionInfoProvider;
|
||||
|
||||
use mullvad_api_constants::*;
|
||||
use mullvad_tls_client::ClientConfig;
|
||||
|
||||
/// Available platforms in the default metadata repository
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -53,6 +53,8 @@ pub struct HttpVersionInfoProvider {
|
||||
resolve: Option<(&'static str, IpAddr)>,
|
||||
/// If set, the response metadata will be serialized and written to this path
|
||||
dump_to_path: Option<PathBuf>,
|
||||
/// TLS configuration for the host `url` points at
|
||||
tls_config: &'static ClientConfig,
|
||||
}
|
||||
|
||||
impl VersionInfoProvider for HttpVersionInfoProvider {
|
||||
@@ -75,7 +77,7 @@ impl HttpVersionInfoProvider {
|
||||
/// This will fetch the metadata from `api.mullvad.net`, and reject anything except the LE root
|
||||
/// certificate. The domain name is resolved to [`API_IP_DEFAULT`] without using DNS.
|
||||
pub fn api(platform: MetaRepositoryPlatform) -> Self {
|
||||
Self::new(defaults::RELEASES_URL, platform)
|
||||
Self::new(defaults::RELEASES_URL, platform, mullvad_tls_client::api())
|
||||
}
|
||||
|
||||
/// Create a repository using the base URL [defaults::METADATA_URL].
|
||||
@@ -85,16 +87,26 @@ impl HttpVersionInfoProvider {
|
||||
/// likely to be stale.
|
||||
///
|
||||
/// You most likely want to use [Self::api] instead.
|
||||
#[cfg(feature = "release-tooling")]
|
||||
pub fn releases(platform: MetaRepositoryPlatform) -> Self {
|
||||
Self::new(defaults::METADATA_URL, platform)
|
||||
Self::new(
|
||||
defaults::METADATA_URL,
|
||||
platform,
|
||||
mullvad_tls_client::releases_cdn(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return repository based on the `base_url`.
|
||||
fn new(base_url: &str, platform: MetaRepositoryPlatform) -> Self {
|
||||
fn new(
|
||||
base_url: &str,
|
||||
platform: MetaRepositoryPlatform,
|
||||
tls_config: &'static ClientConfig,
|
||||
) -> Self {
|
||||
HttpVersionInfoProvider {
|
||||
url: format!("{}/{}", base_url, platform.filename()),
|
||||
resolve: Some((API_HOST_DEFAULT, API_IP_DEFAULT)),
|
||||
dump_to_path: None,
|
||||
tls_config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +150,7 @@ impl HttpVersionInfoProvider {
|
||||
&self,
|
||||
deserialize_fn: impl FnOnce(&[u8]) -> anyhow::Result<SignedResponse>,
|
||||
) -> anyhow::Result<SignedResponse> {
|
||||
let raw_json = Self::get(&self.url, self.resolve).await?;
|
||||
let raw_json = Self::get(&self.url, self.resolve, self.tls_config).await?;
|
||||
let signed_response = deserialize_fn(&raw_json)?;
|
||||
if let Some(path) = &self.dump_to_path {
|
||||
fs::write(path, raw_json)
|
||||
@@ -152,8 +164,13 @@ impl HttpVersionInfoProvider {
|
||||
///
|
||||
/// - DNS will be used to look up the URL.
|
||||
/// - The JSON response is not signed.
|
||||
#[cfg(feature = "release-tooling")]
|
||||
pub async fn get_latest_versions_file() -> anyhow::Result<String> {
|
||||
Self::get(&format!("{}/latest.json", defaults::METADATA_URL), None)
|
||||
Self::get(
|
||||
&format!("{}/latest.json", defaults::METADATA_URL),
|
||||
None,
|
||||
mullvad_tls_client::releases_cdn(),
|
||||
)
|
||||
.await
|
||||
.and_then(|raw_json: Vec<u8>| Ok(String::from_utf8(raw_json)?))
|
||||
.context("Failed to get latest.json file")
|
||||
@@ -167,11 +184,13 @@ impl HttpVersionInfoProvider {
|
||||
/// # Arguments
|
||||
/// `url` - URL to fetch
|
||||
/// `resolve` - Optional host to resolve (to the IP) without DNS
|
||||
async fn get(url: &str, resolve: Option<(&'static str, IpAddr)>) -> anyhow::Result<Vec<u8>> {
|
||||
// reqwest is built without a bundled crypto provider, so feed it a
|
||||
// preconfigured aws-lc-rs rustls ClientConfig.
|
||||
let tls_config = build_client_config(Some(defaults::PINNED_CERTIFICATE.clone()), true);
|
||||
let mut req_builder = reqwest::Client::builder().use_preconfigured_tls(tls_config);
|
||||
/// `tls_config` - TLS configuration for the host `url` points at
|
||||
async fn get(
|
||||
url: &str,
|
||||
resolve: Option<(&'static str, IpAddr)>,
|
||||
tls_config: &ClientConfig,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut req_builder = reqwest::Client::builder().use_preconfigured_tls(tls_config.clone());
|
||||
|
||||
// Resolve name without DNS
|
||||
if let Some((host, addr)) = resolve {
|
||||
@@ -257,6 +276,8 @@ mod test {
|
||||
url,
|
||||
resolve: Some(resolve),
|
||||
dump_to_path: Some(temp_dump.clone()),
|
||||
// Unused: the mock server speaks plain http
|
||||
tls_config: mullvad_tls_client::api(),
|
||||
};
|
||||
|
||||
let info = info_provider
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
//! Default keys and certificates that may be used for verifying data
|
||||
|
||||
use crate::format::key::VerifyingKey;
|
||||
#[cfg(feature = "client")]
|
||||
use rustls_pki_types::{CertificateDer, pem::PemObject};
|
||||
use std::sync::LazyLock;
|
||||
use vec1::Vec1;
|
||||
|
||||
@@ -13,18 +11,9 @@ use vec1::Vec1;
|
||||
pub const RELEASES_URL: &str = "https://api.mullvad.net/app/releases/";
|
||||
|
||||
/// Default URL for version metadata repository.
|
||||
#[cfg(feature = "client")]
|
||||
#[cfg(feature = "release-tooling")]
|
||||
pub const METADATA_URL: &str = "https://releases.mullvad.net/desktop/metadata/";
|
||||
|
||||
/// Default TLS certificate to pin to.
|
||||
///
|
||||
/// This is the Let's Encrypt root-certificate.
|
||||
#[cfg(feature = "client")]
|
||||
pub static PINNED_CERTIFICATE: LazyLock<CertificateDer<'static>> = LazyLock::new(|| {
|
||||
const CERT_BYTES: &[u8] = include_bytes!("../../mullvad-api/le_root_cert.pem");
|
||||
CertificateDer::from_pem_slice(CERT_BYTES).expect("invalid cert")
|
||||
});
|
||||
|
||||
/// Pubkeys used to verify metadata from the Mullvad API (production)
|
||||
pub static TRUSTED_METADATA_SIGNING_PUBKEYS: LazyLock<Vec1<VerifyingKey>> =
|
||||
LazyLock::new(|| parse_keys(include_str!("../trusted-metadata-signing-pubkeys")));
|
||||
|
||||
Generated
+11
-2
@@ -65,7 +65,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"mullvad-tls-client",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2277,10 +2277,10 @@ dependencies = [
|
||||
"mullvad-api-constants",
|
||||
"mullvad-encrypted-dns-proxy",
|
||||
"mullvad-fs",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-types",
|
||||
"mullvad-update",
|
||||
"mullvad-version",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shadowsocks",
|
||||
@@ -2388,6 +2388,14 @@ dependencies = [
|
||||
"vec1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mullvad-tls-client"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mullvad-types"
|
||||
version = "0.0.0"
|
||||
@@ -2417,6 +2425,7 @@ dependencies = [
|
||||
"json-canon",
|
||||
"log",
|
||||
"mullvad-api-constants",
|
||||
"mullvad-tls-client",
|
||||
"mullvad-version",
|
||||
"rand 0.9.4",
|
||||
"serde",
|
||||
|
||||
@@ -37,7 +37,6 @@ log = "0.4"
|
||||
nix = { version = "0.30.1", features = ["ioctl", "net", "signal", "socket"] }
|
||||
ping = "0.7.1"
|
||||
rand = "0.9.3"
|
||||
rustls = { version = "0.23", default-features = false }
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
socket2 = "0.5.7"
|
||||
|
||||
@@ -12,7 +12,7 @@ http-body-util = { workspace = true }
|
||||
hyper = { workspace = true }
|
||||
hyper-rustls = { workspace = true, features = ["aws-lc-rs", "http2"] }
|
||||
hyper-util = { workspace = true }
|
||||
rustls = { workspace = true, features = ["aws-lc-rs"] }
|
||||
mullvad-tls-client = { path = "../../mullvad-tls-client" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
//! Minimal async client for the am.i.mullvad.net geoip endpoint.
|
||||
//!
|
||||
//! Pins the Let's Encrypt root certificate, enforces TLS 1.3 with X25519MLKEM768 only,
|
||||
//! and disables SNI.
|
||||
//! The connection is configured by [`mullvad_tls_client::api`], the same way
|
||||
//! the daemon configures its own connection check to these hosts.
|
||||
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::{Request, Uri, header};
|
||||
use hyper_util::client::legacy::Client;
|
||||
use rustls::{
|
||||
ClientConfig,
|
||||
pki_types::{CertificateDer, pem::PemObject},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::{Arc, LazyLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const LE_ROOT_CERT: &[u8] = include_bytes!("../../../mullvad-api/le_root_cert.pem");
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
|
||||
const USER_AGENT: &str = "mullvad-app-testing";
|
||||
|
||||
@@ -84,29 +74,9 @@ pub async fn geoip_lookup(
|
||||
.map_err(|_| Error::Timeout)?
|
||||
}
|
||||
|
||||
/// A lazily computed TLS client config with the settings we want
|
||||
/// for TLS connections to mullvad https endpoints in general.
|
||||
static CLIENT_CONFIG: LazyLock<ClientConfig> = LazyLock::new(|| {
|
||||
let provider = rustls::crypto::CryptoProvider {
|
||||
kx_groups: vec![rustls::crypto::aws_lc_rs::kx_group::X25519MLKEM768],
|
||||
..rustls::crypto::aws_lc_rs::default_provider()
|
||||
};
|
||||
let mut config = ClientConfig::builder_with_provider(Arc::new(provider))
|
||||
.with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.expect("aws-lc-rs crypto provider should support TLS 1.3")
|
||||
.with_root_certificates(create_pinned_cert_store())
|
||||
.with_no_client_auth();
|
||||
// The server certificate covers the relevant am.i.mullvad.net hostnames; SNI is omitted
|
||||
// so the destination subdomain is not visible in the ClientHello.
|
||||
config.enable_sni = false;
|
||||
// Disable TLS tickets to reduce ability to track clients over time
|
||||
config.resumption = rustls::client::Resumption::disabled();
|
||||
config
|
||||
});
|
||||
|
||||
async fn http_get(url: Uri) -> Result<AmIMullvadResponse, Error> {
|
||||
let https = hyper_rustls::HttpsConnectorBuilder::new()
|
||||
.with_tls_config(CLIENT_CONFIG.clone())
|
||||
.with_tls_config(mullvad_tls_client::api().clone())
|
||||
.https_only()
|
||||
.enable_http2()
|
||||
.build();
|
||||
@@ -125,14 +95,3 @@ async fn http_get(url: Uri) -> Result<AmIMullvadResponse, Error> {
|
||||
let bytes = response.into_body().collect().await?.to_bytes();
|
||||
Ok(serde_json::from_slice(&bytes)?)
|
||||
}
|
||||
|
||||
/// Creates and returns a certificate store with the single trusted bundled CA
|
||||
fn create_pinned_cert_store() -> rustls::RootCertStore {
|
||||
let cert = CertificateDer::from_pem_slice(LE_ROOT_CERT)
|
||||
.expect("Bundled LE root cert PEM is malformed");
|
||||
let mut cert_store = rustls::RootCertStore::empty();
|
||||
cert_store
|
||||
.add(cert)
|
||||
.expect("Bundled LE root cert is invalid");
|
||||
cert_store
|
||||
}
|
||||
Reference in new issue
Block a user