diff --git a/Cargo.lock b/Cargo.lock index 6ff1e06ce477..8c658ed27839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2577,6 +2577,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "json-canon" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "447ae153a2bd47d61acc0d131295408e32ef87ed9785825a6f4ecef85afc0edb" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "json5" version = "0.4.1" @@ -3281,6 +3292,7 @@ dependencies = [ "chrono", "ed25519-dalek", "hex", + "json-canon", "mockito", "mullvad-version", "pgp", @@ -5127,6 +5139,12 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "same-file" version = "1.0.6" diff --git a/mullvad-update/Cargo.toml b/mullvad-update/Cargo.toml index e2b9756fe995..f3a067c3afef 100644 --- a/mullvad-update/Cargo.toml +++ b/mullvad-update/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] anyhow = "1.0" +json-canon = "0.1" chrono = { workspace = true, features = ["serde"] } ed25519-dalek = { version = "2.1", default-features = false } hex = { version = "0.4", default-features = false } diff --git a/mullvad-update/src/deserializer.rs b/mullvad-update/src/deserializer.rs index fdab49943434..5ddb4a45d8e9 100644 --- a/mullvad-update/src/deserializer.rs +++ b/mullvad-update/src/deserializer.rs @@ -1,10 +1,11 @@ //! Deserializer for version API response format +use anyhow::Context; use serde::Deserialize; /// JSON response including signature and signed content -/// Note that signature verification isn't accomplished by deserializing -#[derive(Deserialize)] +/// 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, @@ -12,6 +13,69 @@ pub struct SignedResponse { 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::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now()) + } + + /// 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, + ) -> Result { + 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 { @@ -137,10 +201,21 @@ pub struct SpecificVersionArchitectureResponse { mod test { use super::*; - /// Test that a valid version response is successfully deserialized + /// Test that a valid signed version response is successfully deserialized and verified #[test] - fn test_response_deserialization() { - let _: SignedResponse = - serde_json::from_str(include_str!("../test-version-response.json")).unwrap(); + 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/test-version-response.json b/mullvad-update/test-version-response.json index 1f52f9faaf83..1dfd39770604 100644 --- a/mullvad-update/test-version-response.json +++ b/mullvad-update/test-version-response.json @@ -1,7 +1,7 @@ { "signature": { - "keyid": "8B84D57D8E94DC03D9E3A17DA77358FD8BA21D2C65B0C63B580F32A79332F727", - "sig": "085672c70dffe26610e58542ee552843633cfed973abdad94c56138dbf0cd991644f2d3f27e4dda3098e08ab676e7f52627b587947ae69db1012d59a6da18e0c" + "keyid": "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8", + "sig": "d68ba75006ea3ac249e56849022a7d93603effe26ec0385bac42cf6675fc6e31322cae018a60428d5c670baedd46b59fa2b35a412f1ed285256c64dbafbcb905" }, "signed": { "expires": "2025-07-02T15:33:00Z",