vectors.rs
⎇
Raw
1//! Verifies the committed golden vectors against a freshly built codec.
2//!
3//! This test deliberately reads `vectors.json` as untyped JSON and rebuilds each
4//! message field by field, rather than deserializing straight into
5//! [`otproto::Message`]. Two reasons:
6//!
7//! 1. It runs without the `serde` feature, so `cargo test` covers it by default.
8//! 2. It is the same exercise the Kotlin test performs, so this file doubles as
9//! the reference for that implementation. A shared `Deserialize` impl would
10//! let a renamed field pass here and fail on the phone.
11
12use std::collections::BTreeSet;
13
14use otproto::msg::Direction;
15use otproto::point::Flags;
16use otproto::{
17 Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, Message, MsgType,
18 Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
19};
20use serde_json::Value;
21
22const VECTORS: &str = include_str!("vectors.json");
23
24fn load() -> Value {
25 serde_json::from_str(VECTORS).expect("vectors.json is valid JSON")
26}
27
28fn hex(v: &Value, key: &str) -> Vec<u8> {
29 hex_str(
30 v[key]
31 .as_str()
32 .unwrap_or_else(|| panic!("{key} is not a string")),
33 )
34}
35
36fn hex_str(s: &str) -> Vec<u8> {
37 assert!(s.len().is_multiple_of(2), "odd-length hex string {s:?}");
38 (0..s.len())
39 .step_by(2)
40 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
41 .collect()
42}
43
44fn u32f(v: &Value, key: &str) -> u32 {
45 v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u32
46}
47
48fn u16f(v: &Value, key: &str) -> u16 {
49 v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u16
50}
51
52fn u8f(v: &Value, key: &str) -> u8 {
53 v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u8
54}
55
56fn opt<T, F: Fn(u64) -> T>(v: &Value, key: &str, f: F) -> Option<T> {
57 match &v[key] {
58 Value::Null => None,
59 other => Some(f(other.as_u64().unwrap_or_else(|| {
60 // Negative values (altitude) arrive as i64.
61 other.as_i64().expect("numeric") as u64
62 }))),
63 }
64}
65
66fn point_from_json(v: &Value) -> Point {
67 Point {
68 ts: u32f(v, "ts"),
69 lat_e7: v["lat_e7"].as_i64().expect("lat_e7") as i32,
70 lon_e7: v["lon_e7"].as_i64().expect("lon_e7") as i32,
71 acc_dm: opt(v, "acc_dm", |n| n as u16),
72 alt_m: match &v["alt_m"] {
73 Value::Null => None,
74 other => Some(other.as_i64().expect("alt_m") as i16),
75 },
76 spd_cms: opt(v, "spd_cms", |n| n as u16),
77 brg_cdeg: opt(v, "brg_cdeg", |n| n as u16),
78 bat_pct: opt(v, "bat_pct", |n| n as u8),
79 flags: Flags(u8f(v, "flags")),
80 }
81}
82
83fn nonce_from_hex(s: &str) -> Nonce {
84 hex_str(s).try_into().expect("12-byte nonce")
85}
86
87fn nonces_from_json(v: &Value) -> Vec<Nonce> {
88 v.as_array()
89 .expect("nonces array")
90 .iter()
91 .map(|n| {
92 let bytes: Vec<u8> = n
93 .as_array()
94 .expect("nonce is an array of bytes")
95 .iter()
96 .map(|b| b.as_u64().expect("byte") as u8)
97 .collect();
98 bytes.try_into().expect("12-byte nonce")
99 })
100 .collect()
101}
102
103fn message_from_json(v: &Value) -> Message {
104 let ty = v["type"].as_str().expect("message type");
105 let val = &v["value"];
106 match ty {
107 "loc" => Message::Loc(
108 val.as_array()
109 .expect("points")
110 .iter()
111 .map(point_from_json)
112 .collect(),
113 ),
114 "ack" => Message::Ack(Ack {
115 nonces: nonces_from_json(&val["nonces"]),
116 flags: AckFlags(u8f(val, "flags")),
117 }),
118 "nack" => Message::Nack(Nack {
119 nonce: {
120 let bytes: Vec<u8> = val["nonce"]
121 .as_array()
122 .expect("nonce bytes")
123 .iter()
124 .map(|b| b.as_u64().expect("byte") as u8)
125 .collect();
126 bytes.try_into().expect("12-byte nonce")
127 },
128 reason: match val["reason"].as_str().expect("reason") {
129 "unknown_token" => NackReason::UnknownToken,
130 "malformed" => NackReason::Malformed,
131 "rate_limited" => NackReason::RateLimited,
132 "storage_full" => NackReason::StorageFull,
133 other => panic!("unknown NACK reason {other:?}"),
134 },
135 retry_after_s: u8f(val, "retry_after_s"),
136 }),
137 "hello" => Message::Hello(Hello {
138 app_version_code: u16f(val, "app_version_code"),
139 os_api_level: u8f(val, "os_api_level"),
140 flags: HelloFlags(u8f(val, "flags")),
141 config_version: u16f(val, "config_version"),
142 }),
143 "config" => Message::Config(Config {
144 config_version: u16f(val, "config_version"),
145 profile: match val["profile"].as_str().expect("profile") {
146 "battery_saver" => Profile::BatterySaver,
147 "balanced" => Profile::Balanced,
148 "high_accuracy" => Profile::HighAccuracy,
149 other => panic!("unknown profile {other:?}"),
150 },
151 flags: ConfigFlags(u8f(val, "flags")),
152 heartbeat_s: u16f(val, "heartbeat_s"),
153 interval_scale_pct: u16f(val, "interval_scale_pct"),
154 min_distance_m: u16f(val, "min_distance_m"),
155 max_points_per_loc: u8f(val, "max_points_per_loc"),
156 }),
157 "config_get" => Message::ConfigGet(ConfigGet {
158 have_version: u16f(val, "have_version"),
159 }),
160 "ping" => Message::Ping(Ping {
161 echo: u32f(val, "echo"),
162 seq: u16f(val, "seq"),
163 }),
164 "pong" => Message::Pong(Pong {
165 echo: u32f(val, "echo"),
166 seq: u16f(val, "seq"),
167 }),
168 "revoked" => Message::Revoked(Revoked {
169 reason: match val["reason"].as_str().expect("reason") {
170 "revoked" => RevokeReason::Revoked,
171 "expired" => RevokeReason::Expired,
172 "unknown" => RevokeReason::Unknown,
173 other => panic!("unknown revoke reason {other:?}"),
174 },
175 }),
176 other => panic!("unknown message type {other:?}"),
177 }
178}
179
180#[test]
181fn file_level_constants_match_this_build() {
182 let v = load();
183 assert_eq!(v["protocol"], "OTP/1");
184 assert_eq!(v["version"].as_u64(), Some(u64::from(otproto::VERSION)));
185 assert_eq!(v["header_len"].as_u64(), Some(otproto::HEADER_LEN as u64));
186 assert_eq!(v["tag_len"].as_u64(), Some(otproto::TAG_LEN as u64));
187 assert_eq!(
188 v["max_datagram"].as_u64(),
189 Some(otproto::MAX_DATAGRAM as u64)
190 );
191 assert_eq!(v["max_points"].as_u64(), Some(otproto::MAX_POINTS as u64));
192}
193
194#[test]
195fn key_derivation_matches_the_vectors() {
196 let v = load();
197 let token_key: [u8; 32] = hex(&v, "token_key_hex")
198 .try_into()
199 .expect("32-byte token key");
200 let (up, down) = kdf::derive_both(&token_key);
201 assert_eq!(hex(&v, "k_up_hex"), up, "K_up drifted");
202 assert_eq!(hex(&v, "k_down_hex"), down, "K_down drifted");
203 assert_ne!(up, down);
204
205 let master: [u8; 32] = hex(&v, "revocation_master_hex")
206 .try_into()
207 .expect("32-byte master");
208 let token_id = v["token_id"].as_u64().expect("token_id");
209 let rev = otproto::revocation_key(&master, token_id);
210 assert_eq!(hex(&v, "k_rev_hex"), rev, "K_rev drifted");
211 // Independent of the token key, which is the property that lets a REVOKED
212 // notice outlive the token's row.
213 assert_ne!(rev, up);
214 assert_ne!(rev, down);
215 assert_ne!(rev, token_key);
216}
217
218#[test]
219fn point_records_encode_exactly_as_recorded() {
220 let v = load();
221 let points = v["points"].as_array().expect("points array");
222 assert!(!points.is_empty());
223 for case in points {
224 let name = case["name"].as_str().expect("name");
225 let expected = hex(case, "bytes_hex");
226 assert_eq!(
227 expected.len(),
228 otproto::POINT_LEN,
229 "{name}: wrong record length"
230 );
231
232 let point = point_from_json(&case["point"]);
233 assert_eq!(
234 point.to_bytes().as_slice(),
235 expected.as_slice(),
236 "{name}: encode drifted"
237 );
238
239 let decoded = Point::from_bytes(expected.as_slice().try_into().expect("length checked"));
240 assert_eq!(decoded, point, "{name}: decode drifted");
241 }
242}
243
244#[test]
245fn every_datagram_vector_reproduces_byte_for_byte() {
246 let v = load();
247 let token_key: [u8; 32] = hex(&v, "token_key_hex")
248 .try_into()
249 .expect("32-byte token key");
250 let token_id = v["token_id"].as_u64().expect("token_id");
251 let (k_up, k_down) = kdf::derive_both(&token_key);
252 let master: [u8; 32] = hex(&v, "revocation_master_hex")
253 .try_into()
254 .expect("32-byte master");
255 let k_rev = otproto::revocation_key(&master, token_id);
256
257 let cases = v["datagrams"].as_array().expect("datagrams array");
258 assert!(cases.len() >= 9, "vectors must cover every message type");
259 let mut covered = BTreeSet::new();
260
261 for case in cases {
262 let name = case["name"].as_str().expect("name");
263 let message = message_from_json(&case["message"]);
264 let ty = message.msg_type();
265 covered.insert(ty as u8);
266
267 // The recorded type, direction and key must agree with what this build
268 // derives from the message itself.
269 assert_eq!(
270 u8f(case, "msg_type"),
271 ty as u8,
272 "{name}: msg_type disagrees"
273 );
274 let dir = ty.direction();
275 assert_eq!(
276 case["direction"].as_str(),
277 Some(match dir {
278 Direction::Up => "up",
279 Direction::Down => "down",
280 }),
281 "{name}: direction disagrees"
282 );
283 // Which key seals this datagram is recorded explicitly, because it is
284 // not implied by the direction: REVOKED travels downlink but is sealed
285 // under K_rev so it survives the token's row being deleted.
286 let (key, key_name) = match ty {
287 MsgType::Revoked => (&k_rev, "rev"),
288 _ => match dir {
289 Direction::Up => (&k_up, "up"),
290 Direction::Down => (&k_down, "down"),
291 },
292 };
293 assert_eq!(
294 case["key"].as_str(),
295 Some(key_name),
296 "{name}: sealing key disagrees"
297 );
298
299 let nonce = nonce_from_hex(case["nonce_hex"].as_str().expect("nonce_hex"));
300 let header = Header::new(ty, token_id, nonce);
301 assert_eq!(
302 header.to_bytes().as_slice(),
303 hex(case, "header_hex"),
304 "{name}: header drifted"
305 );
306
307 let payload = message.encode_payload();
308 assert_eq!(payload, hex(case, "payload_hex"), "{name}: payload drifted");
309
310 let datagram = otproto::seal(key, header, &payload);
311 assert_eq!(
312 datagram,
313 hex(case, "datagram_hex"),
314 "{name}: datagram drifted"
315 );
316 assert_eq!(
317 datagram.len(),
318 case["datagram_len"].as_u64().expect("datagram_len") as usize,
319 "{name}: recorded length is wrong"
320 );
321 assert!(
322 datagram.len() <= otproto::MAX_DATAGRAM,
323 "{name}: exceeds the datagram budget"
324 );
325
326 // And the other direction: the recorded bytes must open to the recorded
327 // message. Encoding agreeing with itself would not prove that.
328 let (h, back) =
329 otproto::open_message(key, &datagram).unwrap_or_else(|e| panic!("{name}: {e}"));
330 assert_eq!(h, header, "{name}: header did not survive a round trip");
331 assert_eq!(
332 back, message,
333 "{name}: message did not survive a round trip"
334 );
335 }
336
337 let all: BTreeSet<u8> = MsgType::ALL.iter().map(|t| *t as u8).collect();
338 assert_eq!(covered, all, "some message type has no golden vector");
339}
340
341#[test]
342fn vectors_only_open_under_the_key_that_sealed_them() {
343 let v = load();
344 let token_key: [u8; 32] = hex(&v, "token_key_hex")
345 .try_into()
346 .expect("32-byte token key");
347 let token_id = v["token_id"].as_u64().expect("token_id");
348 let (k_up, k_down) = kdf::derive_both(&token_key);
349 let master: [u8; 32] = hex(&v, "revocation_master_hex")
350 .try_into()
351 .expect("32-byte master");
352 let k_rev = otproto::revocation_key(&master, token_id);
353
354 for case in v["datagrams"].as_array().expect("datagrams") {
355 let name = case["name"].as_str().expect("name");
356 let datagram = hex(case, "datagram_hex");
357 let sealed_with = case["key"].as_str().expect("key");
358 // Every key except the right one must fail. Uplink versus downlink is
359 // the classic reflection guard; K_rev matters for a different reason —
360 // if a REVOKED opened under K_down, a device could be told it was
361 // revoked by anyone holding the token key.
362 for (name_of, key) in [("up", &k_up), ("down", &k_down), ("rev", &k_rev)] {
363 if name_of == sealed_with {
364 continue;
365 }
366 assert!(
367 otproto::open(key, &datagram).is_err(),
368 "{name}: opened under K_{name_of}, which did not seal it"
369 );
370 }
371 }
372
373 // A revocation notice for another token must not open here either: K_rev is
374 // per-id precisely so one device cannot forge one for another.
375 let other = otproto::revocation_key(&master, token_id ^ 1);
376 for case in v["datagrams"].as_array().expect("datagrams") {
377 if case["key"] != "rev" {
378 continue;
379 }
380 let datagram = hex(case, "datagram_hex");
381 assert!(
382 otproto::open(&other, &datagram).is_err(),
383 "a REVOKED opened under another token's K_rev"
384 );
385 }
386}
387