summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Lönnhager <david.l@mullvad.net>2025-02-07 13:37:06 +0100
committerDavid Lönnhager <david.l@mullvad.net>2025-03-05 23:31:56 +0100
commit1081a6d582a6390f41788ad2c88238f1f087e80b (patch)
treee8c1a663d0b177e158ef9f3faf4a74b76f5c68e8
parent5f24b9e9ce4161ac0cefb05bfc19388554acb357 (diff)
downloadmullvadvpn-1081a6d582a6390f41788ad2c88238f1f087e80b.tar.xz
mullvadvpn-1081a6d582a6390f41788ad2c88238f1f087e80b.zip
Add mullvad-version-metadata tool
`format` was also updated to support signing
-rw-r--r--Cargo.lock1
-rw-r--r--mullvad-update/Cargo.toml12
-rw-r--r--mullvad-update/src/api.rs18
-rw-r--r--mullvad-update/src/bin/mullvad-version-metadata.rs79
-rw-r--r--mullvad-update/src/deserializer.rs235
-rw-r--r--mullvad-update/src/format/deserializer.rs116
-rw-r--r--mullvad-update/src/format/key.rs184
-rw-r--r--mullvad-update/src/format/mod.rs2
-rw-r--r--mullvad-update/src/format/serializer.rs97
-rw-r--r--mullvad-update/src/lib.rs4
10 files changed, 502 insertions, 246 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 2f59ea0fbe..e7416e7779 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2990,6 +2990,7 @@ dependencies = [
"async-tempfile",
"async-trait",
"chrono",
+ "clap",
"ed25519-dalek",
"hex",
"json-canon",
diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml
index f858f0d917..9ad66787d4 100644
--- a/mullvad-update/Cargo.toml
+++ b/mullvad-update/Cargo.toml
@@ -10,6 +10,10 @@ rust-version.workspace = true
[lints]
workspace = true
+[features]
+default = []
+sign = ["rand", "clap"]
+
[dependencies]
anyhow = "1.0"
json-canon = "0.1"
@@ -25,7 +29,15 @@ async-trait = "0.1"
mullvad-version = { path = "../mullvad-version", features = ["serde"] }
+# features required by binaries
+clap = { workspace = true, optional = true }
+rand = { version = "0.8.5", optional = true }
+
[dev-dependencies]
async-tempfile = "0.6"
mockito = "1.6.1"
rand = "0.8.5"
+
+[[bin]]
+name = "mullvad-version-metadata"
+required-features = ["sign"] \ No newline at end of file
diff --git a/mullvad-update/src/api.rs b/mullvad-update/src/api.rs
index d4fbd0e91c..c17973900c 100644
--- a/mullvad-update/src/api.rs
+++ b/mullvad-update/src/api.rs
@@ -2,7 +2,7 @@
use anyhow::Context;
-use crate::deserializer;
+use crate::format;
/// Parameters for [VersionInfoProvider]
#[derive(Debug)]
@@ -60,7 +60,7 @@ pub struct ApiVersionInfoProvider;
impl VersionInfoProvider for ApiVersionInfoProvider {
async fn get_version_info(params: VersionParameters) -> anyhow::Result<VersionInfo> {
// FIXME: Replace with actual API response
- use deserializer::*;
+ use format::*;
const TEST_PUBKEY: &str =
"AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8";
@@ -69,7 +69,7 @@ impl VersionInfoProvider for ApiVersionInfoProvider {
ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap();
let response = SignedResponse::deserialize_and_verify(
- VerifyingKey(verifying_key),
+ format::key::VerifyingKey(verifying_key),
include_bytes!("../test-version-response.json"),
)?;
@@ -82,7 +82,7 @@ impl VersionInfo {
/// NOTE: `response` is assumed to be verified and untampered. It is not verified.
fn try_from_signed_response(
params: &VersionParameters,
- response: deserializer::SignedResponse,
+ response: format::SignedResponse,
) -> anyhow::Result<Self> {
let stable = Version::try_from_signed_response(params, response.signed.stable)?;
let beta = response
@@ -100,7 +100,7 @@ impl Version {
/// Convert response data to public version type
fn try_from_signed_response(
params: &VersionParameters,
- response: deserializer::VersionResponse,
+ response: format::VersionResponse,
) -> anyhow::Result<Self> {
// Check if the rollout version is acceptable according to threshold
if let Some(next) = response.next {
@@ -118,7 +118,7 @@ impl Version {
/// This may fail if the current architecture isn't included in the response
fn try_for_arch(
params: &VersionParameters,
- response: deserializer::SpecificVersionResponse,
+ response: format::SpecificVersionResponse,
) -> anyhow::Result<Self> {
let installer = match params.architecture {
VersionArchitecture::X86 => response.installers.x86,
@@ -147,9 +147,9 @@ mod test {
/// Test API version responses can be parsed
#[test]
fn test_api_version_info_provider_parser() -> anyhow::Result<()> {
- let response = deserializer::SignedResponse::deserialize_and_verify_insecure(
- include_bytes!("../test-version-response.json"),
- )?;
+ let response = format::SignedResponse::deserialize_and_verify_insecure(include_bytes!(
+ "../test-version-response.json"
+ ))?;
let params = VersionParameters {
architecture: VersionArchitecture::X86,
diff --git a/mullvad-update/src/bin/mullvad-version-metadata.rs b/mullvad-update/src/bin/mullvad-version-metadata.rs
new file mode 100644
index 0000000000..fbfc0ed51b
--- /dev/null
+++ b/mullvad-update/src/bin/mullvad-version-metadata.rs
@@ -0,0 +1,79 @@
+//! See [Opt].
+
+use anyhow::Context;
+use clap::Parser;
+use std::io::Read;
+use tokio::{fs, io};
+
+use mullvad_update::format::{self, key};
+
+#[allow(dead_code)]
+const DEFAULT_EXPIRY_MONTHS: u32 = 6;
+
+/// A tool that generates signed Mullvad version metadata.
+#[derive(Parser)]
+pub enum Opt {
+ /// Generate an ed25519 secret key
+ GenerateKey,
+
+ /// Sign a JSON payload using an ed25519 key and output the signed 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,
+ },
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ let opt = Opt::parse();
+
+ match opt {
+ Opt::GenerateKey => {
+ println!("{}", key::SecretKey::generate().to_string());
+ Ok(())
+ }
+ Opt::Sign { file, secret } => sign(file, secret).await,
+ }
+}
+
+async fn sign(file: String, secret: key::SecretKey) -> anyhow::Result<()> {
+ // Read unsigned JSON data
+ let data = if file == "-" {
+ get_stdin().await?
+ } else {
+ fs::read(file).await?
+ };
+
+ // Deserialize version data
+ // TODO: Make sure this never ignores missing fields
+ let response: format::Response =
+ serde_json::from_slice(&data).context("Failed to deserialize version metadata")?;
+
+ // Sign it
+ let signed_response = format::SignedResponse::sign(secret, response)?;
+
+ // Print it
+ println!(
+ "{}",
+ serde_json::to_string_pretty(&signed_response)
+ .context("Failed to serialize signed version")?
+ );
+
+ 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/deserializer.rs b/mullvad-update/src/deserializer.rs
deleted file mode 100644
index 5f1a0326b8..0000000000
--- a/mullvad-update/src/deserializer.rs
+++ /dev/null
@@ -1,235 +0,0 @@
-//! Deserializer for version API response format
-
-use anyhow::Context;
-use serde::Deserialize;
-
-/// JSON response including signature and signed content
-/// This type does not implement [serde::Deserialize] to prevent accidental deserialization without
-/// signature verification.
-pub struct SignedResponse {
- /// Signature of the canonicalized JSON of `signed`
- pub signature: ResponseSignature,
- /// Content signed by `signature`
- pub signed: Response,
-}
-
-/// Helper class that leaves the signed data untouched
-/// Note that deserializing doesn't verify anything
-#[derive(serde::Deserialize)]
-struct PartialSignedResponse {
- /// Signature of the canonicalized JSON of `signed`
- pub signature: ResponseSignature,
- /// Content signed by `signature`
- pub signed: serde_json::Value,
-}
-
-impl SignedResponse {
- /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
- /// If successful, the deserialized data is returned.
- pub fn deserialize_and_verify(key: VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> {
- Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now())
- }
-
- /// This method is used 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")?;
- let signed = serde_json::from_value(partial_data.signed)
- .context("Failed to deserialize response")?;
- Ok(Self {
- signature: partial_data.signature,
- signed,
- })
- }
-
- /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
- /// If successful, the deserialized data is returned.
- fn deserialize_and_verify_at_time(
- key: VerifyingKey,
- bytes: &[u8],
- current_time: chrono::DateTime<chrono::Utc>,
- ) -> Result<Self, anyhow::Error> {
- let partial_data: PartialSignedResponse =
- serde_json::from_slice(bytes).context("Invalid version JSON")?;
-
- // Check if the key matches
- if partial_data.signature.keyid.0 != key.0 {
- anyhow::bail!("Unrecognized key");
- }
-
- // Serialize to canonical json format
- let canon_data = json_canon::to_vec(&partial_data.signed)
- .context("Failed to serialize to canonical JSON")?;
-
- // Check if the data is signed by our key
- partial_data
- .signature
- .keyid
- .0
- .verify_strict(&canon_data, &partial_data.signature.sig.0)
- .context("Signature verification failed")?;
-
- // Deserialize the canonical JSON to structured representation
- let signed_response: Response =
- serde_json::from_slice(&canon_data).context("Failed to deserialize response")?;
-
- // Reject time if the data has expired
- if current_time >= signed_response.expires {
- anyhow::bail!(
- "Version metadata has expired: valid until {}",
- signed_response.expires
- );
- }
-
- Ok(SignedResponse {
- signature: partial_data.signature,
- signed: signed_response,
- })
- }
-}
-
-/// JSON response signature
-#[derive(Deserialize)]
-pub struct ResponseSignature {
- pub keyid: VerifyingKey,
- pub sig: Signature,
-}
-
-/// ed25519 verifying key
-pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey);
-
-impl<'de> Deserialize<'de> for VerifyingKey {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: serde::Deserializer<'de>,
- {
- let bytes = String::deserialize(deserializer).and_then(|string| {
- bytes_from_hex::<D, { ed25519_dalek::PUBLIC_KEY_LENGTH }>(&string)
- })?;
- let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|_err| {
- serde::de::Error::invalid_value(
- serde::de::Unexpected::Other("invalid verifying key"),
- &"valid ed25519 key",
- )
- })?;
- Ok(VerifyingKey(key))
- }
-}
-
-/// ed25519 signature
-pub struct Signature(pub ed25519_dalek::Signature);
-
-impl<'de> Deserialize<'de> for Signature {
- fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
- where
- D: serde::Deserializer<'de>,
- {
- let bytes = String::deserialize(deserializer)
- .and_then(|string| bytes_from_hex::<D, { ed25519_dalek::SIGNATURE_LENGTH }>(&string))?;
- Ok(Signature(ed25519_dalek::Signature::from_bytes(&bytes)))
- }
-}
-
-/// Deserialize a hex-encoded string to a bytes array of an exact size
-fn bytes_from_hex<'de, D, const SIZE: usize>(key: &str) -> Result<[u8; SIZE], D::Error>
-where
- D: serde::Deserializer<'de>,
-{
- let bytes = hex::decode(key).map_err(|_err| {
- serde::de::Error::invalid_value(
- serde::de::Unexpected::Other("hex-encoded string"),
- &"valid hex",
- )
- })?;
- if bytes.len() != SIZE {
- let expected = format!("hex-encoded string of {SIZE} bytes");
- return Err(serde::de::Error::invalid_length(
- bytes.len(),
- &expected.as_str(),
- ));
- }
- let mut key = [0u8; SIZE];
- key.copy_from_slice(&bytes);
- Ok(key)
-}
-
-/// Signed JSON response, not including the signature
-#[derive(Deserialize)]
-pub struct Response {
- /// When the signature expires
- pub expires: chrono::DateTime<chrono::Utc>,
- /// Stable version response
- pub stable: VersionResponse,
- /// Beta version response
- pub beta: Option<VersionResponse>,
-}
-
-#[derive(Deserialize)]
-pub struct VersionResponse {
- /// The current version in this channel
- pub current: SpecificVersionResponse,
- /// The version being rolled out in this channel
- pub next: Option<NextSpecificVersionResponse>,
-}
-
-#[derive(Deserialize)]
-pub struct NextSpecificVersionResponse {
- /// The percentage of users that should receive the new version.
- pub rollout: f32,
- #[serde(flatten)]
- pub version: SpecificVersionResponse,
-}
-
-#[derive(Deserialize)]
-pub struct SpecificVersionResponse {
- /// Mullvad app version
- pub version: mullvad_version::Version,
- /// Changelog entries
- pub changelog: String,
- /// Installer details for different architectures
- pub installers: SpecificVersionArchitectureResponses,
-}
-
-/// Version details for supported architectures
-#[derive(Deserialize)]
-pub struct SpecificVersionArchitectureResponses {
- /// Details for x86 installer
- pub x86: Option<SpecificVersionArchitectureResponse>,
- /// Details for ARM64 installer
- pub arm64: Option<SpecificVersionArchitectureResponse>,
-}
-
-#[derive(Deserialize)]
-pub struct SpecificVersionArchitectureResponse {
- /// Mirrors that host the artifact
- pub urls: Vec<String>,
- /// Size of the installer, in bytes
- pub size: usize,
- /// Hash of the installer, hexadecimal string
- pub sha256: String,
-}
-
-#[cfg(test)]
-mod test {
- use super::*;
-
- /// Test that a valid signed version response is successfully deserialized and verified
- #[test]
- fn test_response_deserialization_and_verification() {
- const TEST_PUBKEY: &str =
- "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8";
- let pubkey = hex::decode(TEST_PUBKEY).unwrap();
- let verifying_key =
- ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap();
-
- SignedResponse::deserialize_and_verify_at_time(
- VerifyingKey(verifying_key),
- include_bytes!("../test-version-response.json"),
- // It's 1970 again
- chrono::DateTime::UNIX_EPOCH,
- )
- .expect("expected valid signed version metadata");
- }
-}
diff --git a/mullvad-update/src/format/deserializer.rs b/mullvad-update/src/format/deserializer.rs
new file mode 100644
index 0000000000..35eeb09e66
--- /dev/null
+++ b/mullvad-update/src/format/deserializer.rs
@@ -0,0 +1,116 @@
+//! Deserializer and verifier of version metadata
+
+use anyhow::Context;
+
+use super::key::*;
+use super::Response;
+use super::{PartialSignedResponse, SignedResponse};
+
+impl SignedResponse {
+ /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
+ /// If successful, the deserialized data is returned.
+ pub fn deserialize_and_verify(key: VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> {
+ Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now())
+ }
+
+ /// This method is used 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")?;
+ let signed = serde_json::from_value(partial_data.signed)
+ .context("Failed to deserialize response")?;
+ Ok(Self {
+ signature: partial_data.signature,
+ signed,
+ })
+ }
+
+ /// Deserialize some bytes to JSON, and verify them, including signature and expiry.
+ /// If successful, the deserialized data is returned.
+ fn deserialize_and_verify_at_time(
+ key: VerifyingKey,
+ bytes: &[u8],
+ current_time: chrono::DateTime<chrono::Utc>,
+ ) -> Result<Self, anyhow::Error> {
+ // Deserialize and verify signature
+ let partial_data = deserialize_and_verify(&key, bytes)?;
+
+ // Deserialize the canonical JSON to structured representation
+ let signed_response: Response = serde_json::from_value(partial_data.signed)
+ .context("Failed to deserialize response")?;
+
+ // Reject time if the data has expired
+ if current_time >= signed_response.expires {
+ anyhow::bail!(
+ "Version metadata has expired: valid until {}",
+ signed_response.expires
+ );
+ }
+
+ Ok(SignedResponse {
+ signature: partial_data.signature,
+ signed: signed_response,
+ })
+ }
+}
+
+/// Deserialize arbitrary JSON object with a signature attached.
+/// WARNING: This only verifies the signature, not expiration.
+///
+/// On success, this returns verified data and signature
+pub(super) fn deserialize_and_verify(
+ key: &VerifyingKey,
+ bytes: &[u8],
+) -> anyhow::Result<PartialSignedResponse> {
+ let partial_data: PartialSignedResponse =
+ serde_json::from_slice(bytes).context("Invalid version JSON")?;
+
+ // Check if the key matches
+ if partial_data.signature.keyid.0 != key.0 {
+ anyhow::bail!("Unrecognized key");
+ }
+
+ // Serialize to canonical json format
+ let canon_data = json_canon::to_vec(&partial_data.signed)
+ .context("Failed to serialize to canonical JSON")?;
+
+ // Check if the data is signed by our key
+ partial_data
+ .signature
+ .keyid
+ .0
+ .verify_strict(&canon_data, &partial_data.signature.sig.0)
+ .context("Signature verification failed")?;
+
+ Ok(PartialSignedResponse {
+ signature: partial_data.signature,
+ // Serialize back in case something was lost during deserialization
+ signed: serde_json::from_slice(&canon_data)
+ .context("Failed to serialize canonical JSON")?,
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ /// Test that a valid signed version response is successfully deserialized and verified
+ #[test]
+ fn test_response_deserialization_and_verification() {
+ const TEST_PUBKEY: &str =
+ "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8";
+ let pubkey = hex::decode(TEST_PUBKEY).unwrap();
+ let verifying_key =
+ ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap();
+
+ SignedResponse::deserialize_and_verify_at_time(
+ VerifyingKey(verifying_key),
+ include_bytes!("../../test-version-response.json"),
+ // It's 1970 again
+ chrono::DateTime::UNIX_EPOCH,
+ )
+ .expect("expected valid signed version metadata");
+ }
+}
diff --git a/mullvad-update/src/format/key.rs b/mullvad-update/src/format/key.rs
new file mode 100644
index 0000000000..3fe308f903
--- /dev/null
+++ b/mullvad-update/src/format/key.rs
@@ -0,0 +1,184 @@
+//! Key and signature types for API version response format
+
+use std::str::FromStr;
+
+use anyhow::{bail, Context};
+use ed25519_dalek::ed25519::signature::SignerMut;
+#[cfg(feature = "sign")]
+use rand::RngCore;
+use serde::{Deserialize, Serialize};
+
+/// ed25519 secret/signing key
+#[derive(Debug, Clone, PartialEq)]
+pub struct SecretKey(pub ed25519_dalek::SecretKey);
+
+impl SecretKey {
+ /// Generate a new secret ed25519 key
+ #[cfg(feature = "sign")]
+ pub fn generate() -> Self {
+ // Using OsRng is suggested by the docs
+ let mut bytes = ed25519_dalek::SecretKey::default();
+ rand::rngs::OsRng.fill_bytes(&mut bytes);
+ SecretKey(bytes)
+ }
+
+ pub fn pubkey(&self) -> VerifyingKey {
+ let sign_key = ed25519_dalek::SigningKey::from_bytes(&self.0);
+ VerifyingKey(sign_key.verifying_key())
+ }
+
+ /// Sign data using this key
+ pub fn sign(&self, msg: &[u8]) -> Signature {
+ let mut secret = ed25519_dalek::SigningKey::from_bytes(&self.0);
+ Signature(secret.sign(msg))
+ }
+}
+
+impl ToString for SecretKey {
+ fn to_string(&self) -> String {
+ hex::encode(self.0)
+ }
+}
+
+impl<'de> Deserialize<'de> for SecretKey {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let key = String::deserialize(deserializer)?;
+ let key = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(&key)
+ .map_err(|err| serde::de::Error::custom(err.to_string()))?;
+ Ok(SecretKey(key))
+ }
+}
+
+impl FromStr for SecretKey {
+ type Err = anyhow::Error;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let bytes = bytes_from_hex::<{ ed25519_dalek::SECRET_KEY_LENGTH }>(&s)?;
+ Ok(SecretKey(bytes))
+ }
+}
+
+impl Serialize for SecretKey {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&hex::encode(self.0))
+ }
+}
+
+/// ed25519 verifying key
+#[derive(Debug, PartialEq, Eq)]
+pub struct VerifyingKey(pub ed25519_dalek::VerifyingKey);
+
+impl<'de> Deserialize<'de> for VerifyingKey {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = String::deserialize(deserializer)?;
+ let bytes = bytes_from_hex::<{ ed25519_dalek::PUBLIC_KEY_LENGTH }>(&bytes)
+ .map_err(|err| serde::de::Error::custom(err.to_string()))?;
+ let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|_err| {
+ serde::de::Error::invalid_value(
+ serde::de::Unexpected::Other("invalid verifying key"),
+ &"valid ed25519 key",
+ )
+ })?;
+ Ok(VerifyingKey(key))
+ }
+}
+
+impl Serialize for VerifyingKey {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&hex::encode(self.0.as_bytes()))
+ }
+}
+
+/// ed25519 signature
+pub struct Signature(pub ed25519_dalek::Signature);
+
+impl<'de> Deserialize<'de> for Signature {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let bytes = String::deserialize(deserializer)?;
+ let bytes = bytes_from_hex::<{ ed25519_dalek::SIGNATURE_LENGTH }>(&bytes)
+ .map_err(|err| serde::de::Error::custom(err.to_string()))?;
+ Ok(Signature(ed25519_dalek::Signature::from_bytes(&bytes)))
+ }
+}
+
+impl Serialize for Signature {
+ fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ serializer.serialize_str(&hex::encode(self.0.to_bytes()))
+ }
+}
+
+/// Deserialize a hex-encoded string to a bytes array of an exact size
+fn bytes_from_hex<const SIZE: usize>(key: &str) -> anyhow::Result<[u8; SIZE]> {
+ let bytes = hex::decode(key).context("invalid hex")?;
+ if bytes.len() != SIZE {
+ bail!(
+ "hex-encoded string of {SIZE} bytes, found {} bytes",
+ bytes.len()
+ );
+ }
+ let mut key = [0u8; SIZE];
+ key.copy_from_slice(&bytes);
+ Ok(key)
+}
+
+#[cfg(test)]
+mod test {
+ use rand::RngCore;
+
+ use super::*;
+
+ #[test]
+ fn test_serialization_and_deserialization() {
+ let mut secret = [0u8; 32];
+ rand::thread_rng().fill_bytes(&mut secret);
+
+ let secret_hex = hex::encode(secret);
+ let secret = SecretKey::from_str(&hex::encode(secret)).unwrap();
+
+ let pubkey = secret.pubkey();
+ let pubkey_hex = hex::encode(pubkey.0);
+
+ // Test serialization
+ let actual = serde_json::json!({
+ "secret": secret,
+ "key": pubkey,
+ });
+ let expected: serde_json::Value = serde_json::from_str(&format!(
+ r#"{{
+ "secret": "{secret_hex}",
+ "key": "{pubkey_hex}"
+ }}"#
+ ))
+ .unwrap();
+
+ assert_eq!(actual, expected);
+
+ // Test deserialization
+ let secret_obj = actual.as_object().unwrap().get("secret").unwrap().clone();
+ let deserialized_secret: SecretKey = serde_json::from_value(secret_obj).unwrap();
+
+ let pubkey_obj = actual.as_object().unwrap().get("key").unwrap().clone();
+ let deserialized_pubkey: VerifyingKey = serde_json::from_value(pubkey_obj).unwrap();
+
+ assert_eq!(deserialized_secret, secret);
+ assert_eq!(deserialized_pubkey, pubkey);
+ }
+}
diff --git a/mullvad-update/src/format/mod.rs b/mullvad-update/src/format/mod.rs
index 98428babf4..1c7bdb1114 100644
--- a/mullvad-update/src/format/mod.rs
+++ b/mullvad-update/src/format/mod.rs
@@ -16,7 +16,7 @@ pub struct SignedResponse {
pub signed: Response,
}
-/// Helper class that leaves the signed data untouched
+/// Helper type that leaves the signed data untouched
/// Note that deserializing doesn't verify anything
#[derive(Deserialize, Serialize)]
struct PartialSignedResponse {
diff --git a/mullvad-update/src/format/serializer.rs b/mullvad-update/src/format/serializer.rs
new file mode 100644
index 0000000000..4c40037e2d
--- /dev/null
+++ b/mullvad-update/src/format/serializer.rs
@@ -0,0 +1,97 @@
+//! Serializer for signed version response data
+//!
+//! Signing attaches a signature, and leaves the original JSON data in the "signed" key:
+//!
+//! ```ignore
+//! {
+//! "signature": {
+//! "keyid": "...",
+//! "sig": "..."
+//! }
+//! "signed": {
+//! ...
+//! }
+//! }
+//! ```
+
+use anyhow::Context;
+use serde::Serialize;
+
+use super::{key, PartialSignedResponse, Response, ResponseSignature, SignedResponse};
+
+impl SignedResponse {
+ pub fn sign(key: key::SecretKey, response: Response) -> anyhow::Result<SignedResponse> {
+ // Refuse to sign expired data
+ if response.expires < chrono::Utc::now() {
+ anyhow::bail!("Signing failed since the data has expired");
+ }
+
+ // Sign it
+ let partial_signed = sign(&key, &response)?;
+
+ // Attempt to deserialize signed part as response
+ // Probably unnecessary; mostly in case canonical JSON lost something
+ let response: Response = serde_json::from_value(partial_signed.signed)?;
+
+ Ok(SignedResponse {
+ signature: partial_signed.signature,
+ signed: response,
+ })
+ }
+}
+
+/// Serialize JSON to bytes, with a signature attached, signed using `key`
+fn sign<T: Serialize>(
+ key: &key::SecretKey,
+ unsigned_value: &T,
+) -> anyhow::Result<PartialSignedResponse> {
+ // Serialize unsigned data to canonical JSON
+ let unsigned_canon =
+ json_canon::to_vec(&unsigned_value).context("Failed to canonicalize JSON")?;
+
+ // Generate signature for the canonical JSON
+ let sig = key.sign(&unsigned_canon);
+
+ // Deserialize in case something was lost during serialization
+ let signed =
+ serde_json::from_slice(&unsigned_canon).context("Failed to deserialize canonical JSON")?;
+
+ // Attach signature
+ Ok(PartialSignedResponse {
+ signature: ResponseSignature {
+ keyid: key.pubkey(),
+ sig,
+ },
+ // Attach now-signed data
+ signed,
+ })
+}
+
+#[cfg(test)]
+mod test {
+ use super::*;
+ use crate::format::deserializer::deserialize_and_verify;
+ use serde_json::json;
+
+ #[test]
+ fn test_sign() -> anyhow::Result<()> {
+ // Generate key and data
+ let key = key::SecretKey::generate();
+ let pubkey = key.pubkey();
+
+ let data = json!({
+ "stuff": "I can prove that I wrote this"
+ });
+
+ // Verify that we can deserialize and verify the data
+ let partial = sign(&key, &data).context("Signing failed")?;
+
+ assert_eq!(partial.signature.keyid, pubkey);
+
+ let bytes = serde_json::to_vec(&partial)?;
+
+ deserialize_and_verify(&pubkey, &bytes)?;
+
+ Ok(())
+ }
+}
diff --git a/mullvad-update/src/lib.rs b/mullvad-update/src/lib.rs
index 08069710e0..27184e59d5 100644
--- a/mullvad-update/src/lib.rs
+++ b/mullvad-update/src/lib.rs
@@ -2,6 +2,8 @@
pub mod api;
pub mod app;
-mod deserializer;
pub mod fetch;
pub mod verify;
+
+/// Parser and serializer for version metadata
+pub mod format;