Skip to content

Commit 580f7b1

Browse files
committed
Test version format verification
1 parent e1f0bbe commit 580f7b1

File tree

4 files changed

+102
-8
lines changed

4 files changed

+102
-8
lines changed

Cargo.lock

+18
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mullvad-update/Cargo.toml

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ workspace = true
1212

1313
[dependencies]
1414
anyhow = "1.0"
15+
json-canon = "0.1"
1516
chrono = { workspace = true, features = ["serde"] }
1617
ed25519-dalek = { version = "2.1", default-features = false }
1718
hex = { version = "0.4", default-features = false }

mullvad-update/src/deserializer.rs

+81-6
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,81 @@
11
//! Deserializer for version API response format
22
3+
use anyhow::Context;
34
use serde::Deserialize;
45

56
/// JSON response including signature and signed content
6-
/// Note that signature verification isn't accomplished by deserializing
7-
#[derive(Deserialize)]
7+
/// This type does not implement [serde::Deserialize] to prevent accidental deserialization without
8+
/// signature verification.
89
pub struct SignedResponse {
910
/// Signature of the canonicalized JSON of `signed`
1011
pub signature: ResponseSignature,
1112
/// Content signed by `signature`
1213
pub signed: Response,
1314
}
1415

16+
/// Helper class that leaves the signed data untouched
17+
/// Note that deserializing doesn't verify anything
18+
#[derive(serde::Deserialize)]
19+
struct PartialSignedResponse {
20+
/// Signature of the canonicalized JSON of `signed`
21+
pub signature: ResponseSignature,
22+
/// Content signed by `signature`
23+
pub signed: serde_json::Value,
24+
}
25+
26+
impl SignedResponse {
27+
/// Deserialize some bytes to JSON, and verify them, including signature and expiry.
28+
/// If successful, the deserialized data is returned.
29+
pub fn deserialize_and_verify(key: VerifyingKey, bytes: &[u8]) -> Result<Self, anyhow::Error> {
30+
Self::deserialize_and_verify_at_time(key, bytes, chrono::Utc::now())
31+
}
32+
33+
/// Deserialize some bytes to JSON, and verify them, including signature and expiry.
34+
/// If successful, the deserialized data is returned.
35+
fn deserialize_and_verify_at_time(
36+
key: VerifyingKey,
37+
bytes: &[u8],
38+
current_time: chrono::DateTime<chrono::Utc>,
39+
) -> Result<Self, anyhow::Error> {
40+
let partial_data: PartialSignedResponse =
41+
serde_json::from_slice(bytes).context("Invalid version JSON")?;
42+
43+
// Check if the key matches
44+
if partial_data.signature.keyid.0 != key.0 {
45+
anyhow::bail!("Unrecognized key");
46+
}
47+
48+
// Serialize to canonical json format
49+
let canon_data = json_canon::to_vec(&partial_data.signed)
50+
.context("Failed to serialize to canonical JSON")?;
51+
52+
// Check if the data is signed by our key
53+
partial_data
54+
.signature
55+
.keyid
56+
.0
57+
.verify_strict(&canon_data, &partial_data.signature.sig.0)
58+
.context("Signature verification failed")?;
59+
60+
// Deserialize the canonical JSON to structured representation
61+
let signed_response: Response =
62+
serde_json::from_slice(&canon_data).context("Failed to deserialize response")?;
63+
64+
// Reject time if the data has expired
65+
if current_time >= signed_response.expires {
66+
anyhow::bail!(
67+
"Version metadata has expired: valid until {}",
68+
signed_response.expires
69+
);
70+
}
71+
72+
Ok(SignedResponse {
73+
signature: partial_data.signature,
74+
signed: signed_response,
75+
})
76+
}
77+
}
78+
1579
/// JSON response signature
1680
#[derive(Deserialize)]
1781
pub struct ResponseSignature {
@@ -137,10 +201,21 @@ pub struct SpecificVersionArchitectureResponse {
137201
mod test {
138202
use super::*;
139203

140-
/// Test that a valid version response is successfully deserialized
204+
/// Test that a valid signed version response is successfully deserialized and verified
141205
#[test]
142-
fn test_response_deserialization() {
143-
let _: SignedResponse =
144-
serde_json::from_str(include_str!("../test-version-response.json")).unwrap();
206+
fn test_response_deserialization_and_verification() {
207+
const TEST_PUBKEY: &str =
208+
"AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8";
209+
let pubkey = hex::decode(TEST_PUBKEY).unwrap();
210+
let verifying_key =
211+
ed25519_dalek::VerifyingKey::from_bytes(&pubkey.try_into().unwrap()).unwrap();
212+
213+
SignedResponse::deserialize_and_verify_at_time(
214+
VerifyingKey(verifying_key),
215+
include_bytes!("../test-version-response.json"),
216+
// It's 1970 again
217+
chrono::DateTime::UNIX_EPOCH,
218+
)
219+
.expect("expected valid signed version metadata");
145220
}
146221
}

mullvad-update/test-version-response.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"signature": {
3-
"keyid": "8B84D57D8E94DC03D9E3A17DA77358FD8BA21D2C65B0C63B580F32A79332F727",
4-
"sig": "085672c70dffe26610e58542ee552843633cfed973abdad94c56138dbf0cd991644f2d3f27e4dda3098e08ab676e7f52627b587947ae69db1012d59a6da18e0c"
3+
"keyid": "AEC24A08466F3D6A1EDCDB2AD3C234428AB9D991B6BEA7F53CB9F172E6CB40D8",
4+
"sig": "d68ba75006ea3ac249e56849022a7d93603effe26ec0385bac42cf6675fc6e31322cae018a60428d5c670baedd46b59fa2b35a412f1ed285256c64dbafbcb905"
55
},
66
"signed": {
77
"expires": "2025-07-02T15:33:00Z",

0 commit comments

Comments
 (0)