diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-02-21 16:22:02 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-03-06 00:09:14 +0100 |
| commit | 7f6d6c9df3676829d8d45e78df557934de537b3b (patch) | |
| tree | 8e56eece175584172ecfa4750d14ff3e1046dc15 | |
| parent | acbecc4a0b7ffea72031fadf5bc1456820d10a1f (diff) | |
| download | mullvadvpn-7f6d6c9df3676829d8d45e78df557934de537b3b.tar.xz mullvadvpn-7f6d6c9df3676829d8d45e78df557934de537b3b.zip | |
Extend meta tool and move to own package
| -rw-r--r-- | Cargo.lock | 56 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | mullvad-update/Cargo.toml | 4 | ||||
| -rw-r--r-- | mullvad-update/meta/.gitignore | 3 | ||||
| -rw-r--r-- | mullvad-update/meta/Cargo.toml | 25 | ||||
| -rw-r--r-- | mullvad-update/meta/src/artifacts.rs | 42 | ||||
| -rw-r--r-- | mullvad-update/meta/src/github.rs | 16 | ||||
| -rw-r--r-- | mullvad-update/meta/src/io_util.rs | 52 | ||||
| -rw-r--r-- | mullvad-update/meta/src/main.rs | 212 | ||||
| -rw-r--r-- | mullvad-update/meta/src/platform.rs | 438 | ||||
| -rw-r--r-- | mullvad-update/src/bin/mullvad-version-metadata.rs | 237 | ||||
| -rw-r--r-- | mullvad-update/src/client/api.rs | 26 | ||||
| -rw-r--r-- | mullvad-update/src/format/deserializer.rs | 3 |
13 files changed, 889 insertions, 226 deletions
diff --git a/Cargo.lock b/Cargo.lock index c8d0716510..7bb0305295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,6 +1074,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] name = "enum-as-inner" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2441,6 +2450,23 @@ dependencies = [ ] [[package]] +name = "meta" +version = "0.0.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "hex", + "mullvad-update", + "mullvad-version", + "rand 0.8.5", + "reqwest", + "serde_json", + "sha2", + "tokio", +] + +[[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4057,8 +4083,10 @@ checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2 0.4.4", "http 1.1.0", "http-body", "http-body-util", @@ -4080,6 +4108,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 1.0.1", + "system-configuration 0.6.1", "tokio", "tokio-rustls 0.26.0", "tower-service", @@ -4752,7 +4781,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "system-configuration-sys 0.6.0", ] [[package]] @@ -4766,6 +4806,16 @@ dependencies = [ ] [[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "talpid-core" version = "0.0.0" dependencies = [ @@ -4795,7 +4845,7 @@ dependencies = [ "resolv-conf", "serde", "serde_json", - "system-configuration", + "system-configuration 0.5.1", "talpid-dbus", "talpid-macos", "talpid-net", @@ -4936,7 +4986,7 @@ dependencies = [ "netlink-sys", "nix 0.28.0", "rtnetlink", - "system-configuration", + "system-configuration 0.5.1", "talpid-types", "talpid-windows", "thiserror 2.0.9", diff --git a/Cargo.toml b/Cargo.toml index 16baf26854..5dae521483 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ members = [ "mullvad-types", "mullvad-types/intersection-derive", "mullvad-update", + "mullvad-update/meta", "mullvad-version", "talpid-core", "talpid-dbus", diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index cf2a396796..229d813d1f 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -41,8 +41,8 @@ async-tempfile = "0.6" insta = { workspace = true } mockito = "1.6.1" rand = "0.8.5" -tokio = { workspace = true, features = ["test-util", "time", "macros"] } +tokio = { workspace = true, features = ["fs", "test-util", "time", "macros"] } [[bin]] name = "mullvad-version-metadata" -required-features = ["sign"]
\ No newline at end of file +required-features = ["sign"] diff --git a/mullvad-update/meta/.gitignore b/mullvad-update/meta/.gitignore new file mode 100644 index 0000000000..47ecd8473f --- /dev/null +++ b/mullvad-update/meta/.gitignore @@ -0,0 +1,3 @@ +/signed +/work +/artifacts
\ No newline at end of file diff --git a/mullvad-update/meta/Cargo.toml b/mullvad-update/meta/Cargo.toml new file mode 100644 index 0000000000..37dd6beb24 --- /dev/null +++ b/mullvad-update/meta/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "meta" +description = "Tools for managing Mullvad version metadata" +authors.workspace = true +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = "1.0" +chrono = { workspace = true, features = ["serde", "now"] } +clap = { workspace = true } +hex = { version = "0.4" } +rand = { version = "0.8.5" } +reqwest = { version = "0.12.9", features = ["rustls-tls"] } +serde_json = { workspace = true } +sha2 = "0.10" +tokio = { version = "1", features = ["full"] } + +mullvad-version = { path = "../../mullvad-version", features = ["serde"] } +mullvad-update = { path = "../", features = ["client", "sign"] } diff --git a/mullvad-update/meta/src/artifacts.rs b/mullvad-update/meta/src/artifacts.rs new file mode 100644 index 0000000000..2c0a76b28e --- /dev/null +++ b/mullvad-update/meta/src/artifacts.rs @@ -0,0 +1,42 @@ +//! Generate metadata for installer artifacts +use anyhow::Context; +use std::path::Path; +use tokio::{ + fs, + io::{AsyncSeekExt, BufReader}, +}; + +use mullvad_update::{format, verify::Sha256Verifier}; + +/// Generate `format::Installer` +pub async fn generate_installer_details( + architecture: format::Architecture, + artifact: &Path, +) -> anyhow::Result<format::Installer> { + let mut file = fs::File::open(artifact) + .await + .context(format!("Failed to open file at {}", artifact.display()))?; + file.seek(std::io::SeekFrom::End(0)) + .await + .context("Failed to seek to end")?; + let file_size = file + .stream_position() + .await + .context("Failed to get file size")?; + file.seek(std::io::SeekFrom::Start(0)) + .await + .context("Failed to reset file pos")?; + let file = BufReader::new(file); + + let checksum = Sha256Verifier::generate_hash(file) + .await + .context("Failed to compute checksum")?; + + Ok(format::Installer { + architecture, + // TODO: fetch cdns from config + urls: vec![], + size: file_size.try_into().context("Invalid file size")?, + sha256: hex::encode(checksum), + }) +} diff --git a/mullvad-update/meta/src/github.rs b/mullvad-update/meta/src/github.rs new file mode 100644 index 0000000000..136ffe21a8 --- /dev/null +++ b/mullvad-update/meta/src/github.rs @@ -0,0 +1,16 @@ +use anyhow::Context; + +// Obtain changes.txt for a given version/tag from the GitHub repository +pub async fn fetch_changes_text(version: &mullvad_version::Version) -> anyhow::Result<String> { + let github_changes_url = format!("https://raw.githubusercontent.com/mullvad/mullvadvpn-app/refs/tags/{version}/desktop/packages/mullvad-vpn/changes.txt"); + let changes = reqwest::get(github_changes_url) + .await + .context("Failed to retrieve changes.txt (tag missing?)")?; + if let Err(err) = changes.error_for_status_ref() { + return Err(err).context("Error status returned when downloading changes.txt"); + } + changes + .text() + .await + .context("Failed to retrieve text for changes.txt (tag missing?)") +} diff --git a/mullvad-update/meta/src/io_util.rs b/mullvad-update/meta/src/io_util.rs new file mode 100644 index 0000000000..adc24e753d --- /dev/null +++ b/mullvad-update/meta/src/io_util.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +use anyhow::Context; +use tokio::fs; + +/// Wait for user to respond with yes or no +/// This returns `false` if reading from stdin fails +pub async fn wait_for_confirm(prompt: &str) -> bool { + const DEFAULT: bool = true; + + print!("{prompt}"); + if DEFAULT { + println!(" [Y/n]"); + } else { + println!(" [y/N]"); + } + + tokio::task::spawn_blocking(|| { + let mut s = String::new(); + let stdin = std::io::stdin(); + + loop { + stdin.read_line(&mut s).context("Failed to read line")?; + + match s.trim().to_ascii_lowercase().as_str() { + "" => break Ok::<bool, anyhow::Error>(DEFAULT), + "y" | "ye" | "yes" => break Ok(true), + "n" | "no" => break Ok(false), + _ => (), + } + } + }) + .await + .unwrap() + .unwrap_or(false) +} + +/// Recursively create directories and write to 'file' +pub async fn create_dir_and_write( + path: impl AsRef<Path>, + contents: impl AsRef<[u8]>, +) -> anyhow::Result<()> { + let path = path.as_ref(); + + let parent_dir = path.parent().context("Missing parent directory")?; + fs::create_dir_all(parent_dir) + .await + .context("Failed to create directories")?; + + fs::write(path, contents).await?; + Ok(()) +} diff --git a/mullvad-update/meta/src/main.rs b/mullvad-update/meta/src/main.rs new file mode 100644 index 0000000000..f0f600db53 --- /dev/null +++ b/mullvad-update/meta/src/main.rs @@ -0,0 +1,212 @@ +//! See [Opt]. + +use anyhow::{bail, Context}; +use clap::Parser; +use io_util::create_dir_and_write; + +use mullvad_update::format::{self, key, SignedResponse}; + +use platform::Platform; + +mod artifacts; +mod github; +mod io_util; +mod platform; + +/// Metadata expiry to use when not specified (months from now) +const DEFAULT_EXPIRY_MONTHS: usize = 6; + +/// Rollout to use when not specified +const DEFAULT_ROLLOUT: f32 = 1.; + +/// Lowest version to accept using 'verify' +const MIN_VERIFY_METADATA_VERSION: usize = 0; + +/// Verification public key +const VERIFYING_PUBKEY: &str = include_str!("../../test-pubkey"); + +/// A tool that generates signed Mullvad version metadata. +/// +/// Unsigned work is stored in `work/`, and signed work is stored in `signed/` +#[derive(Parser)] +pub enum Opt { + /// Generate an ed25519 secret key + GenerateKey, + + /// Create empty metadata files in work directory + CreateMetadataFile { + /// Platforms to write template for + platforms: Vec<Platform>, + }, + + /// Download version metadata from releases.mullvad.net or API endpoint and store it in + /// `signed/` + Pull { + /// Platforms to write template for + platforms: Vec<Platform>, + + /// Replace signed files without user input + #[arg(long, short = 'y')] + assume_yes: bool, + }, + + /// List releases in `work/` + ListReleases { + /// Platforms to list releases for. All if none are specified + platforms: Vec<Platform>, + }, + + /// Add release to `work/` + AddRelease { + /// Version to add + version: mullvad_version::Version, + /// Platforms to add releases for. All if none are specified + platforms: Vec<Platform>, + /// Rollout fraction 0-1. The default is 1, i.e. 100% + #[arg(long, default_value_t = DEFAULT_ROLLOUT)] + rollout: f32, + }, + + /// Remove release from `work/` + RemoveRelease { + /// Version to remove + version: mullvad_version::Version, + /// Platforms to remove releases for. All if none are specified + platforms: Vec<Platform>, + }, + + /// Modify release in `work/` + ModifyRelease { + /// Version to modify + version: mullvad_version::Version, + /// Platforms to remove releases for. All if none are specified + platforms: Vec<Platform>, + /// Rollout percentage. The default is 1 + #[arg(long)] + rollout: Option<f32>, + }, + + /// Sign using an ed25519 key and output the signed metadata to `signed/` + Sign { + /// Secret ed25519 key used for signing, as hexadecimal string + secret: key::SecretKey, + /// Platforms to remove releases for. All if none are specified + platforms: Vec<Platform>, + /// When the metadata expires, in months from now + #[arg(long, default_value_t = DEFAULT_EXPIRY_MONTHS)] + expiry: usize, + /// Replace signed files without user input + #[arg(long, short = 'y')] + assume_yes: bool, + }, + + /// Verify that payloads are signed by a given ed25519 pubkey + Verify { + /// Platforms to remove releases for. All if none are specified + platforms: Vec<Platform>, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let opt = Opt::parse(); + + match opt { + Opt::GenerateKey => { + println!("{}", key::SecretKey::generate().to_string()); + Ok(()) + } + Opt::CreateMetadataFile { platforms } => { + let json = serde_json::to_string_pretty(&SignedResponse { + signatures: vec![], + signed: format::Response::default(), + }) + .expect("Failed to serialize empty response"); + for platform in all_platforms_if_empty(platforms) { + let work_path = platform.work_path(); + println!("Adding empty template to {}", work_path.display()); + create_dir_and_write(work_path, &json).await?; + } + Ok(()) + } + Opt::Pull { + platforms, + assume_yes, + } => { + for platform in all_platforms_if_empty(platforms) { + platform.pull(assume_yes).await?; + } + Ok(()) + } + Opt::Sign { + secret, + platforms, + expiry, + assume_yes, + } => { + for platform in all_platforms_if_empty(platforms) { + platform + .sign(secret.clone(), expiry, assume_yes) + .await + .context("Failed to sign file")?; + } + Ok(()) + } + Opt::ListReleases { platforms } => { + for platform in all_platforms_if_empty(platforms) { + platform.list_releases().await?; + println!(); + } + Ok(()) + } + Opt::AddRelease { + version, + platforms, + rollout, + } => { + let changes = github::fetch_changes_text(&version).await?; + println!("\nchanges.txt for tag {version}:\n\n-- begin\n{changes}\n--end\n\n"); + + for platform in all_platforms_if_empty(platforms) { + platform.add_release(&version, &changes, rollout).await?; + } + Ok(()) + } + Opt::RemoveRelease { version, platforms } => { + for platform in all_platforms_if_empty(platforms) { + platform.remove_release(&version).await?; + } + Ok(()) + } + Opt::ModifyRelease { + version, + platforms, + rollout, + } => { + for platform in all_platforms_if_empty(platforms) { + platform.modify_release(&version, rollout).await?; + } + Ok(()) + } + Opt::Verify { platforms } => { + let mut any_failed = false; + for platform in all_platforms_if_empty(platforms) { + if let Err(err) = platform.verify().await { + any_failed = true; + eprintln!("Error for {platform}: {err:?}"); + } + } + if any_failed { + bail!("Some signatures failed to be verified"); + } + Ok(()) + } + } +} + +fn all_platforms_if_empty(platforms: Vec<Platform>) -> Vec<Platform> { + if platforms.is_empty() { + return Platform::all().to_vec(); + } + platforms +} diff --git a/mullvad-update/meta/src/platform.rs b/mullvad-update/meta/src/platform.rs new file mode 100644 index 0000000000..f5cae74dea --- /dev/null +++ b/mullvad-update/meta/src/platform.rs @@ -0,0 +1,438 @@ +//! Types for handling per-platform metadata + +use anyhow::{anyhow, bail, Context}; +use mullvad_update::{ + api::HttpVersionInfoProvider, + format::{self, key}, +}; +use std::{ + cmp::Ordering, + fmt, + path::{Path, PathBuf}, + str::FromStr, +}; +use tokio::{fs, io}; + +use crate::{ + artifacts, + io_util::{create_dir_and_write, wait_for_confirm}, +}; + +/// Base URL for metadata found with `meta pull`. +/// Actual JSON files should be stored at `<base url>/updates-<platform>.json`. +const META_REPOSITORY_URL: &str = "https://releases.mullvad.net/desktop/metadata/"; + +#[derive(Clone, Copy)] +pub enum Platform { + Windows, + Linux, + Macos, +} + +impl fmt::Display for Platform { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Platform::Windows => f.write_str("Windows"), + Platform::Linux => f.write_str("Linux"), + Platform::Macos => f.write_str("macOS"), + } + } +} + +impl FromStr for Platform { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s.to_ascii_lowercase().as_str() { + "windows" => Ok(Platform::Windows), + "linux" => Ok(Platform::Linux), + "macos" => Ok(Platform::Macos), + other => Err(anyhow!("Invalid platform: {other}")), + } + } +} + +/// Artifacts paths +pub struct Artifacts { + pub x86_artifacts: Vec<PathBuf>, + pub arm64_artifacts: Vec<PathBuf>, +} + +impl Platform { + /// Return array of all platforms + pub fn all() -> [Self; 3] { + [Platform::Windows, Platform::Linux, Platform::Macos] + } + + /// Path to WIP file in `work/` for this platform + pub fn work_path(&self) -> PathBuf { + Path::new("work").join(self.local_filename()) + } + + /// Path to signed file in `signed/` for this platform + pub fn signed_path(&self) -> PathBuf { + Path::new("signed").join(self.local_filename()) + } + + /// URL that stores the latest published metadata + pub fn published_url(&self) -> String { + format!("{META_REPOSITORY_URL}/{}", self.published_filename()) + } + + /// Expected artifacts in `artifacts/` directory + pub fn artifact_filenames(&self, version: &mullvad_version::Version) -> Artifacts { + let artifacts_dir = Path::new("artifacts"); + match self { + Platform::Windows => Artifacts { + x86_artifacts: vec![artifacts_dir.join(format!("MullvadVPN-{version}_x64.exe"))], + arm64_artifacts: vec![artifacts_dir.join(format!("MullvadVPN-{version}_arm64.exe"))], + }, + Platform::Linux => Artifacts { + x86_artifacts: vec![], + arm64_artifacts: vec![], + }, + Platform::Macos => Artifacts { + x86_artifacts: vec![artifacts_dir.join(format!("MullvadVPN-{version}.pkg"))], + arm64_artifacts: vec![artifacts_dir.join(format!("MullvadVPN-{version}.pkg"))], + }, + } + } + + fn published_filename(&self) -> &str { + match self { + Platform::Windows => "updates-windows.json", + Platform::Linux => "updates-linux.json", + Platform::Macos => "updates-macos.json", + } + } + + fn local_filename(&self) -> &str { + match self { + Platform::Windows => "windows.json", + Platform::Linux => "linux.json", + Platform::Macos => "macos.json", + } + } + + /// Pull latest metadata from repository and store it in `signed/` + pub async fn pull(&self, assume_yes: bool) -> anyhow::Result<()> { + let url = self.published_url(); + + println!("Pulling {self} metadata from {url}..."); + + // Pull latest metadata + let verifying_key = + key::VerifyingKey::from_hex(crate::VERIFYING_PUBKEY).expect("Invalid pubkey"); + + let version_provider = HttpVersionInfoProvider { + // TODO: pin + pinned_certificate: None, + url, + verifying_key, + }; + let response = version_provider + .get_versions(crate::MIN_VERIFY_METADATA_VERSION) + .await + .context("Failed to retrieve versions")?; + + let json = serde_json::to_string_pretty(&response) + .context("Failed to serialize updated metadata")?; + + let signed_path = self.signed_path(); + + // Confirm if file exists + if !assume_yes && signed_path.exists() { + let msg = format!( + "This will replace the existing file at {}. Continue?", + signed_path.display() + ); + if !wait_for_confirm(&msg).await { + bail!("Aborted signing"); + } + } + + println!("Writing metadata to {}", signed_path.display()); + + create_dir_and_write(&signed_path, &json).await?; + + println!("Updated {}", signed_path.display()); + Ok(()) + } + + /// Sign version metadata for `platform`. + /// This will replace the file at `self.signed_path()` with a signed version of + /// `self.work_path()`. + pub async fn sign( + &self, + secret: key::SecretKey, + expires_months: usize, + assume_yes: bool, + ) -> anyhow::Result<()> { + let work_path = self.work_path(); + let signed_path = self.signed_path(); + + println!( + "Signing {} and writing it to {}...", + work_path.display(), + signed_path.display() + ); + + // Confirm if file exists + if !assume_yes && signed_path.exists() { + let msg = format!( + "This will replace the existing file at {}. Continue?", + signed_path.display() + ); + if !wait_for_confirm(&msg).await { + bail!("Aborted signing"); + } + } + + // Read unsigned JSON data + let data = fs::read(work_path).await?; + let mut response = format::SignedResponse::deserialize_and_verify_insecure(&data)?; + + // Update the expiration date + response.signed.metadata_expiry = chrono::Utc::now() + .checked_add_months(chrono::Months::new( + expires_months.try_into().context("Invalid months")?, + )) + .context("Invalid expiry")?; + + println!( + "Setting metadata expiry to {}", + response.signed.metadata_expiry + ); + + // Increment metadata version + let new_version = response.signed.metadata_version + 1; + + println!("Incrementing metadata version to {new_version}"); + + // Sign it + let signed_response = format::SignedResponse::sign(secret, response.signed)?; + + // Update signed data + let signed_bytes = serde_json::to_string_pretty(&signed_response) + .context("Failed to serialize signed version")?; + create_dir_and_write(&signed_path, signed_bytes) + .await + .context("Failed to write signed data")?; + println!("Wrote signed response to {}", signed_path.display()); + + Ok(()) + } + + /// Verify the integrity of the platform in `signed/` + pub async fn verify(&self) -> anyhow::Result<()> { + let signed_path = self.signed_path(); + println!("Verifying signature of {}...", signed_path.display()); + let bytes = fs::read(signed_path).await.context("Failed to read file")?; + + // TODO: Actual key + let public_key = + key::VerifyingKey::from_hex(include_str!("../../test-pubkey")).expect("Invalid pubkey"); + + format::SignedResponse::deserialize_and_verify( + &public_key, + &bytes, + crate::MIN_VERIFY_METADATA_VERSION, + ) + .context("Failed to verify metadata for {platform}: {error}")?; + + Ok(()) + } + + /// Add release to platform in `work/` + pub async fn add_release( + &self, + version: &mullvad_version::Version, + changes: &str, + rollout: f32, + ) -> anyhow::Result<()> { + let installers = self.installers(version).await?; + + // Fetch WIP versions and verify that release does not exist + let work_path = self.work_path(); + println!("Adding {version} from {}", work_path.display()); + + let mut work_response = self.read_work().await?; + if work_response + .signed + .releases + .iter() + .any(|release| &release.version == version) + { + // If it doesn't exist, treat as success + bail!("Version {version} already exists"); + } + + // Make release + let new_release = format::Release { + changelog: changes.to_owned(), + version: version.clone(), + installers: installers.to_owned(), + rollout, + }; + + print_release_info(&new_release); + + work_response.signed.releases.push(new_release); + + let json = serde_json::to_string_pretty(&work_response) + .context("Failed to serialize updated metadata")?; + create_dir_and_write(&work_path, &json).await?; + + println!("Added {version} to {}", work_path.display()); + + Ok(()) + } + + /// Obtain artifacts checksums and lengths for a given version of this platform in `artifacts/` + async fn installers( + &self, + version: &mullvad_version::Version, + ) -> anyhow::Result<Vec<format::Installer>> { + let mut installers = vec![]; + let artifacts = self.artifact_filenames(version); + for artifact in artifacts.arm64_artifacts { + installers.push( + artifacts::generate_installer_details(format::Architecture::Arm64, &artifact) + .await?, + ); + } + for artifact in artifacts.x86_artifacts { + installers.push( + artifacts::generate_installer_details(format::Architecture::X86, &artifact).await?, + ); + } + Ok(installers) + } + + /// List releases for platforms in `work/` + pub async fn list_releases(&self) -> anyhow::Result<()> { + let work_path = self.work_path(); + println!("Releases for file {}", work_path.display()); + + let mut response = self.read_work().await?; + + if response.signed.releases.is_empty() { + println!("No releases"); + return Ok(()); + } + + response + .signed + .releases + .sort_by(|a, b| b.version.partial_cmp(&a.version).unwrap_or(Ordering::Equal)); + + for release in response.signed.releases { + print_release_info(&release); + } + Ok(()) + } + + /// Remove version/release in `work/` + pub async fn remove_release(&self, version: &mullvad_version::Version) -> anyhow::Result<()> { + let work_path = self.work_path(); + println!("Removing {version} from {}", work_path.display()); + + let mut work_response = self.read_work().await?; + + let Some(found_release_ind) = work_response + .signed + .releases + .iter() + .position(|release| &release.version == version) + else { + // If it doesn't exist, treat as success + return Ok(()); + }; + + let removed_release = work_response.signed.releases.swap_remove(found_release_ind); + + print_release_info(&removed_release); + + let json = serde_json::to_string_pretty(&work_response) + .context("Failed to serialize updated metadata")?; + create_dir_and_write(&work_path, &json).await?; + + println!("Removed {version} in {}", work_path.display()); + + Ok(()) + } + + /// Modify version/release in `work/` + pub async fn modify_release( + &self, + version: &mullvad_version::Version, + rollout: Option<f32>, + ) -> anyhow::Result<()> { + let work_path = self.work_path(); + println!("Modifying {version} in {}", work_path.display()); + + let mut work_response = self.read_work().await?; + + let Some(release) = work_response + .signed + .releases + .iter_mut() + .find(|release| &release.version == version) + else { + bail!("{version} not found in {}", work_path.display()); + }; + + if let Some(new_rollout) = rollout { + release.rollout = new_rollout; + } + + print_release_info(&release); + + let json = serde_json::to_string_pretty(&work_response) + .context("Failed to serialize updated metadata")?; + create_dir_and_write(&work_path, &json).await?; + + println!("Updated {version} in {}", work_path.display()); + + Ok(()) + } + + /// Reads the metadata for `platform` in the work directory. + /// If the file doesn't exist, this returns a new, empty response. + async fn read_work(&self) -> anyhow::Result<format::SignedResponse> { + let work_path = self.work_path(); + let bytes = match fs::read(&work_path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + // Return empty response + return Ok(format::SignedResponse { + signatures: vec![], + signed: format::Response::default(), + }); + } + Err(error) => bail!("Failed to read {}: {error}", work_path.display()), + }; + // Note: We don't need to verify the signature here + format::SignedResponse::deserialize_and_verify_insecure(&bytes) + } +} + +/// Print release info: +/// Version: 2025.3 (arm, x86) (50%) +/// <Changelog> +fn print_release_info(release: &format::Release) { + let mut architectures: Vec<_> = release + .installers + .iter() + .map(|installer| installer.architecture.to_string()) + .collect(); + architectures.dedup(); + let architectures = architectures.join(", "); + + println!( + "- {} ({}) ({}%)", + release.version, + architectures, + (release.rollout * 100.) as u32 + ); +} diff --git a/mullvad-update/src/bin/mullvad-version-metadata.rs b/mullvad-update/src/bin/mullvad-version-metadata.rs index df922a58ca..b65eee837f 100644 --- a/mullvad-update/src/bin/mullvad-version-metadata.rs +++ b/mullvad-update/src/bin/mullvad-version-metadata.rs @@ -1,11 +1,8 @@ //! See [Opt]. -use anyhow::{anyhow, Context}; +use anyhow::Context; use clap::Parser; -use std::{ - io::Read, - path::{Path, PathBuf}, -}; +use std::io::Read; use tokio::{fs, io}; use mullvad_update::format::{self, key}; @@ -13,87 +10,22 @@ use mullvad_update::format::{self, key}; #[allow(dead_code)] const DEFAULT_EXPIRY_MONTHS: u32 = 6; -// TODO: fail whenever 'files' doesn't contain at least one file - /// A tool that generates signed Mullvad version metadata. #[derive(Parser)] pub enum Opt { /// Generate an ed25519 secret key GenerateKey, - /// Download version metadata from releases.mullvad.net or API endpoint - /// meta download - DownloadMetadataFiles, - - /// Create empty metadata files - /// meta create-metadata-file metadata/{windows,macos,linux}-metadata.json - CreateMetadataFile { - /// Files to write template to - files: Vec<PathBuf>, - }, - - /// List releases in the given metadata files - /// meta list metadata/windows-metadata.json - /// meta list metadata/* - ListReleases { - /// Files to list releases for - files: Vec<PathBuf>, - }, - - /// Add release to the given files - /// meta add-release 2025.4 [--rollout <rate>] metadata/windows-metadata.json - /// meta add-release 2025.4 [--rollout <rate>] metadata/* - AddRelease { - /// Version to add - version: mullvad_version::Version, - /// Rollout percentage (default is 1) - /// TODO: must be 0..=1 - - #[arg(long, short = 'w')] - rollout: Option<f32>, - /// Files to change `version` for - files: Vec<PathBuf>, - // TODO: installers - }, - - /// Remove release from the given files - /// meta remove-release 2025.3 metadata/windows-metadata.json - /// meta remove-release 2025.3 metadata/* - RemoveRelease { - /// Version to remove - version: mullvad_version::Version, - /// Files to remove `version` from - files: Vec<PathBuf>, - }, - - /// Modify release in the given files - /// meta modify-release 2025.4 [--rollout <rate>] metadata/windows-metadata.json - /// meta modify-release 2025.4 [--rollout <rate>] metadata/* - ModifyRelease { - /// Version to modify - version: mullvad_version::Version, - /// Rollout percentage. The default is 1 - /// TODO: must be 0..=1 - #[arg(long, short = 'w')] - rollout: Option<f32>, - /// Files to remove `version` from - files: Vec<PathBuf>, - }, - /// Sign a JSON payload using an ed25519 key and output the signed metadata - /// meta sign metadata/* + /// This data is typically generated by 'generate-unsigned-metadata' Sign { + /// File to sign. Use "-" to read from stdin. + #[clap(short, long)] + file: String, + /// Secret ed25519 key used for signing, as hexadecimal string + #[clap(short, long)] secret: key::SecretKey, - /// Files to sign - files: Vec<PathBuf>, - }, - - /// Verify that payloads are signed by a given ed25519 pubkey - /// meta verify metadata/* - Verify { - /// Files to verify - files: Vec<PathBuf>, }, } @@ -106,146 +38,17 @@ async fn main() -> anyhow::Result<()> { println!("{}", key::SecretKey::generate()); Ok(()) } - Opt::CreateMetadataFile { files } => { - let mut response = format::Response::default(); - let json = serde_json::to_string_pretty(&response) - .expect("Failed to serialize empty response"); - for file in files { - fs::write(file, &json).await?; - } - Ok(()) - } - Opt::Sign { secret, files } => { - for file in files { - println!("Signing file {}...", file.display()); - sign(&file, secret.clone()) - .await - .context("Failed to sign file")?; - } - Ok(()) - } - Opt::DownloadMetadataFiles => { - /*const VERSION_METADATA_URLS: &[&str] = [ - - ];*/ - // TODO - //reqwest::get("https://releases.mullvad.net/") - - // TODO: verify - Ok(()) - } - Opt::ListReleases { files } => { - for file in files { - println!("Releases for file {}", file.display()); - - let bytes = fs::read(file).await.context("Failed to read file")?; - let mut response: format::Response = - serde_json::from_slice(&bytes).context("Failed to deserialize file")?; - - response.releases.sort_by(|a, b| { - mullvad_version::Version::version_ordering(&b.version, &a.version) - }); - - // Version: 2025.3 (arm, x86) (50%) - // <Changelog> - for release in response.releases { - print_release_info(&release); - } - } - Ok(()) - } - Opt::AddRelease { - version, - rollout, - files, - } => { - // add-release <version-number> [--rollout <rate>] <changelog-path> <artifact-dir> <url-base> <metadata-files> - /*for file in files { - let bytes = fs::read(file).await.context("Failed to read file")?; - let response: format::Response = serde_json::from_slice(&bytes).context("Failed to deserialize file")?; - }*/ - Ok(()) - } - Opt::RemoveRelease { version, files } => { - for file in files { - let bytes = fs::read(&file).await.context("Failed to read file")?; - let mut response: format::Response = - serde_json::from_slice(&bytes).context("Failed to deserialize file")?; - - let Some(found_release_ind) = response - .releases - .iter() - .position(|release| release.version == version) - else { - continue; - }; - - let removed_release = response.releases.swap_remove(found_release_ind); - - println!("Removed release in {}", file.display()); - print_release_info(&removed_release); - - let json = serde_json::to_string_pretty(&response) - .context("Failed to serialize updated metadata")?; - fs::write(file, &json).await?; - } - Ok(()) - } - Opt::ModifyRelease { - version, - rollout, - files, - } => { - for file in files { - let bytes = fs::read(&file).await.context("Failed to read file")?; - let mut response: format::Response = - serde_json::from_slice(&bytes).context("Failed to deserialize file")?; - - let Some(release) = response - .releases - .iter_mut() - .find(|release| release.version == version) - else { - continue; - }; - - if let Some(new_rollout) = rollout { - release.rollout = new_rollout; - } - - println!("Updated release in {}", file.display()); - print_release_info(&release); - - let json = serde_json::to_string_pretty(&response) - .context("Failed to serialize updated metadata")?; - fs::write(file, &json).await?; - } - Ok(()) - } - Opt::Verify { files } => todo!(), + Opt::Sign { file, secret } => sign(file, secret).await, } } -fn print_release_info(release: &format::Release) { - let mut architectures: Vec<_> = release - .installers - .iter() - .map(|installer| installer.architecture.to_string()) - .collect(); - architectures.dedup(); - let architectures = architectures.join(", "); - - println!( - "- {} ({}) ({}%)", - release.version, - architectures, - (release.rollout * 100.) as u32 - ); -} - -async fn sign(file: &Path, secret: key::SecretKey) -> anyhow::Result<()> { +async fn sign(file: String, secret: key::SecretKey) -> anyhow::Result<()> { // Read unsigned JSON data - let data: Vec<u8> = fs::read(file).await?; + let data = if file == "-" { + get_stdin().await? + } else { + fs::read(file).await? + }; // Deserialize version data let response: format::Response = @@ -263,3 +66,13 @@ async fn sign(file: &Path, secret: key::SecretKey) -> anyhow::Result<()> { Ok(()) } + +async fn get_stdin() -> io::Result<Vec<u8>> { + tokio::task::spawn_blocking(|| { + let mut buf = vec![]; + std::io::stdin().read_to_end(&mut buf)?; + Ok(buf) + }) + .await + .unwrap() +} diff --git a/mullvad-update/src/client/api.rs b/mullvad-update/src/client/api.rs index 37f54c8c62..62b550603f 100644 --- a/mullvad-update/src/client/api.rs +++ b/mullvad-update/src/client/api.rs @@ -25,13 +25,7 @@ pub struct HttpVersionInfoProvider { #[async_trait::async_trait] impl VersionInfoProvider for HttpVersionInfoProvider { async fn get_version_info(&self, params: VersionParameters) -> anyhow::Result<VersionInfo> { - let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; - let response = format::SignedResponse::deserialize_and_verify( - &self.verifying_key, - &raw_json, - params.lowest_metadata_version, - )?; - + let response = self.get_versions(params.lowest_metadata_version).await?; VersionInfo::try_from_response(¶ms, response.signed) } } @@ -40,6 +34,20 @@ impl HttpVersionInfoProvider { /// Maximum size of the GET response, in bytes const SIZE_LIMIT: usize = 1024 * 1024; + /// Download and verify signed data + pub async fn get_versions( + &self, + lowest_metadata_version: usize, + ) -> anyhow::Result<format::SignedResponse> { + let raw_json = Self::get(&self.url, self.pinned_certificate.clone()).await?; + let response = format::SignedResponse::deserialize_and_verify( + &self.verifying_key, + &raw_json, + lowest_metadata_version, + )?; + Ok(response) + } + /// Perform a simple GET request, with a size limit, and return it as bytes async fn get( url: &str, @@ -68,6 +76,10 @@ impl HttpVersionInfoProvider { anyhow::bail!("Version info exceeded limit: {} bytes", Self::SIZE_LIMIT); } + if let Err(err) = req.error_for_status_ref() { + return Err(err).context("GET request failed"); + } + let mut read_n = 0; let mut data = vec![]; diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs index f79aab6537..dec1e64f47 100644 --- a/mullvad-update/src/format/deserializer.rs +++ b/mullvad-update/src/format/deserializer.rs @@ -17,9 +17,8 @@ impl SignedResponse { Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now(), min_metadata_version) } - /// This method is used for testing, and skips all verification. + /// This method is used mostly for testing, and skips all verification. /// Own method to prevent accidental misuse. - #[cfg(test)] pub fn deserialize_and_verify_insecure(bytes: &[u8]) -> Result<Self, anyhow::Error> { let partial_data: PartialSignedResponse = serde_json::from_slice(bytes).context("Invalid version JSON")?; |
