//! Regenerates `tests/vectors.json` — the golden vectors that are the Rust ↔ //! Kotlin contract for OTP/1. //! //! ```sh //! cargo run -p otproto --features serde --example gen_vectors //! ``` //! //! The output is committed. `tests/vectors.rs` verifies every entry against a //! freshly built datagram, so a protocol change that is not reflected here fails //! the Rust test suite; the Android build decodes the same file in //! `./gradlew test`, so a change reflected here but not implemented in Kotlin //! fails there. Between them, the wire format cannot drift on one side only. //! //! Everything is deterministic: fixed token key, fixed token id, nonces derived //! from the case index. Nothing here calls an RNG or a clock. use std::collections::BTreeMap; use std::path::PathBuf; use otproto::msg::Direction; use otproto::point::Flags; use otproto::{ Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, MAX_POINTS, Message, MsgType, Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf, }; use serde::Serialize; /// Bytes 0x00..0x1F. Chosen to be obviously synthetic. const TOKEN_KEY: [u8; 32] = { let mut k = [0u8; 32]; let mut i = 0; while i < 32 { k[i] = i as u8; i += 1; } k }; const TOKEN_ID: u64 = 0x0123_4567_89AB_CDEF; /// Stands in for the server's revocation master. Bytes 0xE0..0xFF, so it is /// visibly distinct from [`TOKEN_KEY`] in a hex dump. const REVOCATION_MASTER: [u8; 32] = { let mut k = [0u8; 32]; let mut i = 0; while i < 32 { k[i] = 0xE0 + i as u8; i += 1; } k }; /// A fixed reference instant, 2026-08-19T09:20:42Z, used everywhere a timestamp /// is needed so the vectors never depend on when they were generated. const T0: u32 = 1_785_000_042; #[derive(Serialize)] struct Vectors { protocol: &'static str, version: u8, note: &'static str, header_len: usize, tag_len: usize, max_datagram: usize, max_points: usize, token_id: u64, token_key_hex: String, k_up_hex: String, k_down_hex: String, revocation_master_hex: String, /// `K_rev` for [`TOKEN_ID`]. Derived from the master and the id, not from /// the token key, so it outlives the token's row. k_rev_hex: String, /// Record-level vectors: the 24-byte point encoding on its own. points: Vec, /// Full datagram vectors, one per interesting message. datagrams: Vec, } #[derive(Serialize)] struct PointVector { name: &'static str, point: Point, bytes_hex: String, } #[derive(Serialize)] struct DatagramVector { name: &'static str, direction: &'static str, /// Which key seals this datagram: `up`, `down`, or `rev`. Not implied by /// `direction`: `REVOKED` travels downlink but is sealed under `K_rev`. key: &'static str, msg_type: u8, nonce_hex: String, /// The 21 cleartext header bytes, which are also the AAD. header_hex: String, payload_hex: String, datagram_hex: String, datagram_len: usize, message: Message, } /// Distinct, obviously-synthetic nonce per case. fn nonce_for(idx: usize) -> Nonce { let mut n = [0u8; 12]; for (j, b) in n.iter_mut().enumerate() { *b = (idx as u8) << 4 | j as u8; } n } fn point_vectors() -> Vec { let cases: Vec<(&'static str, Point)> = vec![ ("all_unknown", Point::new(T0, 525_200_080, 134_050_000)), ( "fully_populated", Point { ts: T0, lat_e7: 525_200_080, lon_e7: 134_050_000, acc_dm: Some(80), alt_m: Some(34), spd_cms: Some(450), brg_cdeg: Some(21_400), bat_pct: Some(76), flags: Flags::NETWORK_FIX, }, ), ( "southern_western_hemisphere", Point { acc_dm: Some(1_200), alt_m: Some(-31), spd_cms: Some(0), brg_cdeg: Some(0), bat_pct: Some(0), flags: Flags::CHARGING | Flags::LOW_ACCURACY, ..Point::new(T0, -338_688_000, -1_754_500_000) }, ), ( "extremes", Point { ts: u32::MAX, lat_e7: 900_000_000, lon_e7: -1_800_000_000, acc_dm: Some(65_534), alt_m: Some(-32_767), spd_cms: Some(65_534), brg_cdeg: Some(35_999), bat_pct: Some(100), flags: Flags(Flags::KNOWN), }, ), ("epoch_zero", Point::new(0, 0, 0)), ]; cases .into_iter() .map(|(name, point)| { let point = point.canonical(); PointVector { name, bytes_hex: hex::encode(point.to_bytes()), point, } }) .collect() } fn messages() -> Vec<(&'static str, Message)> { let pv = point_vectors(); let populated = pv[1].point; let mut cases = vec![ ("loc_single", Message::Loc(vec![populated])), ( "loc_three_independent", Message::Loc(vec![ Point::new(T0 - 120, 525_200_080, 134_050_000), populated, Point { acc_dm: Some(2_500), flags: Flags::NETWORK_FIX | Flags::LOW_ACCURACY, ..Point::new(T0 + 60, 525_201_000, 134_051_000) }, ]), ), ( "loc_max_points", Message::Loc( (0..MAX_POINTS) .map(|i| Point { acc_dm: Some(50 + i as u16), bat_pct: Some(100 - i as u8), ..Point::new( T0 + i as u32 * 30, 525_200_080 + i as i32 * 100, 134_050_000, ) }) .collect(), ), ), ("ack_single", Message::Ack(Ack::single(nonce_for(0)))), ( "ack_config_pending", Message::Ack(Ack { nonces: vec![nonce_for(1), nonce_for(2)], flags: AckFlags::CONFIG_PENDING, }), ), ( "ack_max_throttle", Message::Ack(Ack { nonces: (0..MAX_POINTS).map(nonce_for).collect(), flags: AckFlags::CONFIG_PENDING, }), ), ( "hello", Message::Hello(Hello { app_version_code: 17, os_api_level: 34, flags: HelloFlags::FIRST_LAUNCH, config_version: 1, }), ), ("config_balanced", Message::Config(Config::default())), ( "config_battery_saver_paused", Message::Config(Config { config_version: 9, profile: Profile::BatterySaver, flags: ConfigFlags::REQUEST_HELLO, heartbeat_s: 1_800, interval_scale_pct: 250, min_distance_m: 100, max_points_per_loc: 20, }), ), ( "config_high_accuracy", Message::Config(Config { config_version: 2, profile: Profile::HighAccuracy, flags: ConfigFlags::TRACKING_ENABLED, heartbeat_s: 600, interval_scale_pct: 50, min_distance_m: 10, max_points_per_loc: MAX_POINTS as u8, }), ), ( "config_get", Message::ConfigGet(ConfigGet { have_version: 1 }), ), ( "ping", Message::Ping(Ping { echo: 0xDEAD_BEEF, seq: 7, }), ), ( "pong", Message::Pong(Pong { echo: 0xDEAD_BEEF, seq: 7, }), ), ]; // One REVOKED per reason. Each drives the same client behaviour — clear // state, show the login screen — but they are what the user is told, so a // silently renumbered reason would be a real regression. for (name, reason) in [ ("revoked_explicit", RevokeReason::Revoked), ("revoked_expired", RevokeReason::Expired), ("revoked_unknown", RevokeReason::Unknown), ] { cases.push((name, Message::Revoked(Revoked { reason }))); } // One NACK per reason: each is a distinct client behaviour, so each is worth // pinning byte-for-byte. for (name, reason, retry) in [ ("nack_unknown_token", NackReason::UnknownToken, 0), ("nack_malformed", NackReason::Malformed, 0), ("nack_rate_limited", NackReason::RateLimited, 30), ("nack_storage_full", NackReason::StorageFull, 255), ] { cases.push(( name, Message::Nack(Nack { nonce: nonce_for(3), reason, retry_after_s: retry, }), )); } cases } fn main() { let (k_up, k_down) = kdf::derive_both(&TOKEN_KEY); let k_rev = kdf::revocation_key(&REVOCATION_MASTER, TOKEN_ID); let datagrams = messages() .into_iter() .enumerate() .map(|(idx, (name, message))| { let ty = message.msg_type(); let dir = ty.direction(); let (key, key_name) = match ty { MsgType::Revoked => (&k_rev, "rev"), _ => match dir { Direction::Up => (&k_up, "up"), Direction::Down => (&k_down, "down"), }, }; let nonce = nonce_for(idx); let header = Header::new(ty, TOKEN_ID, nonce); let payload = message.encode_payload(); let datagram = otproto::seal(key, header, &payload); DatagramVector { name, direction: match dir { Direction::Up => "up", Direction::Down => "down", }, key: key_name, msg_type: ty as u8, nonce_hex: hex::encode(nonce), header_hex: hex::encode(header.to_bytes()), payload_hex: hex::encode(&payload), datagram_len: datagram.len(), datagram_hex: hex::encode(&datagram), message, } }) .collect::>(); // Distinct names are what let the Kotlin side address a single case. let mut seen = BTreeMap::new(); for d in &datagrams { assert!( seen.insert(d.name, ()).is_none(), "duplicate vector name {}", d.name ); } let vectors = Vectors { protocol: "OTP/1", version: otproto::VERSION, note: "Generated by `cargo run -p otproto --features serde --example gen_vectors`. \ Do not edit by hand.", header_len: otproto::HEADER_LEN, tag_len: otproto::TAG_LEN, max_datagram: otproto::MAX_DATAGRAM, max_points: MAX_POINTS, token_id: TOKEN_ID, token_key_hex: hex::encode(TOKEN_KEY), k_up_hex: hex::encode(k_up), k_down_hex: hex::encode(k_down), revocation_master_hex: hex::encode(REVOCATION_MASTER), k_rev_hex: hex::encode(k_rev), points: point_vectors(), datagrams, }; let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/vectors.json"); let mut json = serde_json::to_string_pretty(&vectors).expect("vectors serialize"); json.push('\n'); std::fs::write(&path, json).expect("write vectors.json"); println!( "wrote {} ({} cases)", path.display(), vectors.datagrams.len() ); }