summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-02-21 16:22:02 +0100
committerDavid Lönnhager <david.l@mullvad.net>2025-03-06 00:09:14 +0100
commit7f6d6c9df3676829d8d45e78df557934de537b3b (patch)
tree8e56eece175584172ecfa4750d14ff3e1046dc15
parentacbecc4a0b7ffea72031fadf5bc1456820d10a1f (diff)
downloadmullvadvpn-7f6d6c9df3676829d8d45e78df557934de537b3b.tar.xz
mullvadvpn-7f6d6c9df3676829d8d45e78df557934de537b3b.zip
Extend meta tool and move to own package
-rw-r--r--Cargo.lock56
-rw-r--r--Cargo.toml1
-rw-r--r--mullvad-update/Cargo.toml4
-rw-r--r--mullvad-update/meta/.gitignore3
-rw-r--r--mullvad-update/meta/Cargo.toml25
-rw-r--r--mullvad-update/meta/src/artifacts.rs42
-rw-r--r--mullvad-update/meta/src/github.rs16
-rw-r--r--mullvad-update/meta/src/io_util.rs52
-rw-r--r--mullvad-update/meta/src/main.rs212
-rw-r--r--mullvad-update/meta/src/platform.rs438
-rw-r--r--mullvad-update/src/bin/mullvad-version-metadata.rs237
-rw-r--r--mullvad-update/src/client/api.rs26
-rw-r--r--mullvad-update/src/format/deserializer.rs3
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(&params, 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")?;