diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-02-07 13:37:06 +0100 |
|---|---|---|
| committer | David Lönnhager <david.l@mullvad.net> | 2025-03-05 23:31:56 +0100 |
| commit | 1081a6d582a6390f41788ad2c88238f1f087e80b (patch) | |
| tree | e8c1a663d0b177e158ef9f3faf4a74b76f5c68e8 | |
| parent | 5f24b9e9ce4161ac0cefb05bfc19388554acb357 (diff) | |
| download | mullvadvpn-1081a6d582a6390f41788ad2c88238f1f087e80b.tar.xz mullvadvpn-1081a6d582a6390f41788ad2c88238f1f087e80b.zip | |
Add mullvad-version-metadata tool
`format` was also updated to support signing
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | mullvad-update/Cargo.toml | 12 | ||||
| -rw-r--r-- | mullvad-update/src/api.rs | 18 | ||||
| -rw-r--r-- | mullvad-update/src/bin/mullvad-version-metadata.rs | 79 | ||||
| -rw-r--r-- | mullvad-update/src/deserializer.rs | 235 | ||||
| -rw-r--r-- | mullvad-update/src/format/deserializer.rs | 116 | ||||
| -rw-r--r-- | mullvad-update/src/format/key.rs | 184 | ||||
| -rw-r--r-- | mullvad-update/src/format/mod.rs | 2 | ||||
| -rw-r--r-- | mullvad-update/src/format/serializer.rs | 97 | ||||
| -rw-r--r-- | mullvad-update/src/lib.rs | 4 |
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; |
