gen_vectors.rs
⎇
Raw
1//! Regenerates `tests/vectors.json` — the golden vectors that are the Rust ↔
2//! Kotlin contract for OTP/1.
3//!
4//! ```sh
5//! cargo run -p otproto --features serde --example gen_vectors
6//! ```
7//!
8//! The output is committed. `tests/vectors.rs` verifies every entry against a
9//! freshly built datagram, so a protocol change that is not reflected here fails
10//! the Rust test suite; the Android build decodes the same file in
11//! `./gradlew test`, so a change reflected here but not implemented in Kotlin
12//! fails there. Between them, the wire format cannot drift on one side only.
13//!
14//! Everything is deterministic: fixed token key, fixed token id, nonces derived
15//! from the case index. Nothing here calls an RNG or a clock.
16
17use std::collections::BTreeMap;
18use std::path::PathBuf;
19
20use otproto::msg::Direction;
21use otproto::point::Flags;
22use otproto::{
23 Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, MAX_POINTS, Message,
24 MsgType, Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf,
25};
26use serde::Serialize;
27
28/// Bytes 0x00..0x1F. Chosen to be obviously synthetic.
29const TOKEN_KEY: [u8; 32] = {
30 let mut k = [0u8; 32];
31 let mut i = 0;
32 while i < 32 {
33 k[i] = i as u8;
34 i += 1;
35 }
36 k
37};
38
39const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF;
40
41/// Stands in for the server's revocation master. Bytes 0xE0..0xFF, so it is
42/// visibly distinct from [`TOKEN_KEY`] in a hex dump.
43const REVOCATION_MASTER: [u8; 32] = {
44 let mut k = [0u8; 32];
45 let mut i = 0;
46 while i < 32 {
47 k[i] = 0xE0 + i as u8;
48 i += 1;
49 }
50 k
51};
52
53/// A fixed reference instant, 2026-08-19T09:20:42Z, used everywhere a timestamp
54/// is needed so the vectors never depend on when they were generated.
55const T0: u32 = 1_785_000_042;
56
57#[derive(Serialize)]
58struct Vectors {
59 protocol: &'static str,
60 version: u8,
61 note: &'static str,
62 header_len: usize,
63 tag_len: usize,
64 max_datagram: usize,
65 max_points: usize,
66 token_id: u64,
67 token_key_hex: String,
68 k_up_hex: String,
69 k_down_hex: String,
70 revocation_master_hex: String,
71 /// `K_rev` for [`TOKEN_ID`]. Derived from the master and the id, not from
72 /// the token key, so it outlives the token's row.
73 k_rev_hex: String,
74 /// Record-level vectors: the 24-byte point encoding on its own.
75 points: Vec<PointVector>,
76 /// Full datagram vectors, one per interesting message.
77 datagrams: Vec<DatagramVector>,
78}
79
80#[derive(Serialize)]
81struct PointVector {
82 name: &'static str,
83 point: Point,
84 bytes_hex: String,
85}
86
87#[derive(Serialize)]
88struct DatagramVector {
89 name: &'static str,
90 direction: &'static str,
91 /// Which key seals this datagram: `up`, `down`, or `rev`. Not implied by
92 /// `direction`: `REVOKED` travels downlink but is sealed under `K_rev`.
93 key: &'static str,
94 msg_type: u8,
95 nonce_hex: String,
96 /// The 21 cleartext header bytes, which are also the AAD.
97 header_hex: String,
98 payload_hex: String,
99 datagram_hex: String,
100 datagram_len: usize,
101 message: Message,
102}
103
104/// Distinct, obviously-synthetic nonce per case.
105fn nonce_for(idx: usize) -> Nonce {
106 let mut n = [0u8; 12];
107 for (j, b) in n.iter_mut().enumerate() {
108 *b = (idx as u8) << 4 | j as u8;
109 }
110 n
111}
112
113fn point_vectors() -> Vec<PointVector> {
114 let cases: Vec<(&'static str, Point)> = vec![
115 ("all_unknown", Point::new(T0, 525_200_080, 134_050_000)),
116 (
117 "fully_populated",
118 Point {
119 ts: T0,
120 lat_e7: 525_200_080,
121 lon_e7: 134_050_000,
122 acc_dm: Some(80),
123 alt_m: Some(34),
124 spd_cms: Some(450),
125 brg_cdeg: Some(21_400),
126 bat_pct: Some(76),
127 flags: Flags::NETWORK_FIX,
128 },
129 ),
130 (
131 "southern_western_hemisphere",
132 Point {
133 acc_dm: Some(1_200),
134 alt_m: Some(-31),
135 spd_cms: Some(0),
136 brg_cdeg: Some(0),
137 bat_pct: Some(0),
138 flags: Flags::CHARGING | Flags::LOW_ACCURACY,
139 ..Point::new(T0, -338_688_000, -1_754_500_000)
140 },
141 ),
142 (
143 "extremes",
144 Point {
145 ts: u32::MAX,
146 lat_e7: 900_000_000,
147 lon_e7: -1_800_000_000,
148 acc_dm: Some(65_534),
149 alt_m: Some(-32_767),
150 spd_cms: Some(65_534),
151 brg_cdeg: Some(35_999),
152 bat_pct: Some(100),
153 flags: Flags(Flags::KNOWN),
154 },
155 ),
156 ("epoch_zero", Point::new(0, 0, 0)),
157 ];
158 cases
159 .into_iter()
160 .map(|(name, point)| {
161 let point = point.canonical();
162 PointVector {
163 name,
164 bytes_hex: hex::encode(point.to_bytes()),
165 point,
166 }
167 })
168 .collect()
169}
170
171fn messages() -> Vec<(&'static str, Message)> {
172 let pv = point_vectors();
173 let populated = pv[1].point;
174 let mut cases = vec![
175 ("loc_single", Message::Loc(vec![populated])),
176 (
177 "loc_three_independent",
178 Message::Loc(vec![
179 Point::new(T0 - 120, 525_200_080, 134_050_000),
180 populated,
181 Point {
182 acc_dm: Some(2_500),
183 flags: Flags::NETWORK_FIX | Flags::LOW_ACCURACY,
184 ..Point::new(T0 + 60, 525_201_000, 134_051_000)
185 },
186 ]),
187 ),
188 (
189 "loc_max_points",
190 Message::Loc(
191 (0..MAX_POINTS)
192 .map(|i| Point {
193 acc_dm: Some(50 + i as u16),
194 bat_pct: Some(100 - i as u8),
195 ..Point::new(
196 T0 + i as u32 * 30,
197 525_200_080 + i as i32 * 100,
198 134_050_000,
199 )
200 })
201 .collect(),
202 ),
203 ),
204 ("ack_single", Message::Ack(Ack::single(nonce_for(0)))),
205 (
206 "ack_config_pending",
207 Message::Ack(Ack {
208 nonces: vec![nonce_for(1), nonce_for(2)],
209 flags: AckFlags::CONFIG_PENDING,
210 }),
211 ),
212 (
213 "ack_max_throttle",
214 Message::Ack(Ack {
215 nonces: (0..MAX_POINTS).map(nonce_for).collect(),
216 flags: AckFlags::CONFIG_PENDING,
217 }),
218 ),
219 (
220 "hello",
221 Message::Hello(Hello {
222 app_version_code: 17,
223 os_api_level: 34,
224 flags: HelloFlags::FIRST_LAUNCH,
225 config_version: 1,
226 }),
227 ),
228 ("config_balanced", Message::Config(Config::default())),
229 (
230 "config_battery_saver_paused",
231 Message::Config(Config {
232 config_version: 9,
233 profile: Profile::BatterySaver,
234 flags: ConfigFlags::REQUEST_HELLO,
235 heartbeat_s: 1_800,
236 interval_scale_pct: 250,
237 min_distance_m: 100,
238 max_points_per_loc: 20,
239 }),
240 ),
241 (
242 "config_high_accuracy",
243 Message::Config(Config {
244 config_version: 2,
245 profile: Profile::HighAccuracy,
246 flags: ConfigFlags::TRACKING_ENABLED,
247 heartbeat_s: 600,
248 interval_scale_pct: 50,
249 min_distance_m: 10,
250 max_points_per_loc: MAX_POINTS as u8,
251 }),
252 ),
253 (
254 "config_get",
255 Message::ConfigGet(ConfigGet { have_version: 1 }),
256 ),
257 (
258 "ping",
259 Message::Ping(Ping {
260 echo: 0xDEAD_BEEF,
261 seq: 7,
262 }),
263 ),
264 (
265 "pong",
266 Message::Pong(Pong {
267 echo: 0xDEAD_BEEF,
268 seq: 7,
269 }),
270 ),
271 ];
272
273 // One REVOKED per reason. Each drives the same client behaviour — clear
274 // state, show the login screen — but they are what the user is told, so a
275 // silently renumbered reason would be a real regression.
276 for (name, reason) in [
277 ("revoked_explicit", RevokeReason::Revoked),
278 ("revoked_expired", RevokeReason::Expired),
279 ("revoked_unknown", RevokeReason::Unknown),
280 ] {
281 cases.push((name, Message::Revoked(Revoked { reason })));
282 }
283
284 // One NACK per reason: each is a distinct client behaviour, so each is worth
285 // pinning byte-for-byte.
286 for (name, reason, retry) in [
287 ("nack_unknown_token", NackReason::UnknownToken, 0),
288 ("nack_malformed", NackReason::Malformed, 0),
289 ("nack_rate_limited", NackReason::RateLimited, 30),
290 ("nack_storage_full", NackReason::StorageFull, 255),
291 ] {
292 cases.push((
293 name,
294 Message::Nack(Nack {
295 nonce: nonce_for(3),
296 reason,
297 retry_after_s: retry,
298 }),
299 ));
300 }
301 cases
302}
303
304fn main() {
305 let (k_up, k_down) = kdf::derive_both(&TOKEN_KEY);
306 let k_rev = kdf::revocation_key(&REVOCATION_MASTER, TOKEN_ID);
307
308 let datagrams = messages()
309 .into_iter()
310 .enumerate()
311 .map(|(idx, (name, message))| {
312 let ty = message.msg_type();
313 let dir = ty.direction();
314 let (key, key_name) = match ty {
315 MsgType::Revoked => (&k_rev, "rev"),
316 _ => match dir {
317 Direction::Up => (&k_up, "up"),
318 Direction::Down => (&k_down, "down"),
319 },
320 };
321 let nonce = nonce_for(idx);
322 let header = Header::new(ty, TOKEN_ID, nonce);
323 let payload = message.encode_payload();
324 let datagram = otproto::seal(key, header, &payload);
325 DatagramVector {
326 name,
327 direction: match dir {
328 Direction::Up => "up",
329 Direction::Down => "down",
330 },
331 key: key_name,
332 msg_type: ty as u8,
333 nonce_hex: hex::encode(nonce),
334 header_hex: hex::encode(header.to_bytes()),
335 payload_hex: hex::encode(&payload),
336 datagram_len: datagram.len(),
337 datagram_hex: hex::encode(&datagram),
338 message,
339 }
340 })
341 .collect::<Vec<_>>();
342
343 // Distinct names are what let the Kotlin side address a single case.
344 let mut seen = BTreeMap::new();
345 for d in &datagrams {
346 assert!(
347 seen.insert(d.name, ()).is_none(),
348 "duplicate vector name {}",
349 d.name
350 );
351 }
352
353 let vectors = Vectors {
354 protocol: "OTP/1",
355 version: otproto::VERSION,
356 note: "Generated by `cargo run -p otproto --features serde --example gen_vectors`. \
357 Do not edit by hand.",
358 header_len: otproto::HEADER_LEN,
359 tag_len: otproto::TAG_LEN,
360 max_datagram: otproto::MAX_DATAGRAM,
361 max_points: MAX_POINTS,
362 token_id: TOKEN_ID,
363 token_key_hex: hex::encode(TOKEN_KEY),
364 k_up_hex: hex::encode(k_up),
365 k_down_hex: hex::encode(k_down),
366 revocation_master_hex: hex::encode(REVOCATION_MASTER),
367 k_rev_hex: hex::encode(k_rev),
368 points: point_vectors(),
369 datagrams,
370 };
371
372 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors.json");
373 let mut json = serde_json::to_string_pretty(&vectors).expect("vectors serialize");
374 json.push('\n');
375 std::fs::write(&path, json).expect("write vectors.json");
376 println!(
377 "wrote {} ({} cases)",
378 path.display(),
379 vectors.datagrams.len()
380 );
381}
382