//! Verifies the committed golden vectors against a freshly built codec. //! //! This test deliberately reads `vectors.json` as untyped JSON and rebuilds each //! message field by field, rather than deserializing straight into //! [`otproto::Message`]. Two reasons: //! //! 1. It runs without the `serde` feature, so `cargo test` covers it by default. //! 2. It is the same exercise the Kotlin test performs, so this file doubles as //! the reference for that implementation. A shared `Deserialize` impl would //! let a renamed field pass here and fail on the phone. use std::collections::BTreeSet; use otproto::msg::Direction; use otproto::point::Flags; use otproto::{ Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, Message, MsgType, Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf, }; use serde_json::Value; const VECTORS: &str = include_str!("vectors.json"); fn load() -> Value { serde_json::from_str(VECTORS).expect("vectors.json is valid JSON") } fn hex(v: &Value, key: &str) -> Vec { hex_str( v[key] .as_str() .unwrap_or_else(|| panic!("{key} is not a string")), ) } fn hex_str(s: &str) -> Vec { assert!(s.len().is_multiple_of(2), "odd-length hex string {s:?}"); (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit")) .collect() } fn u32f(v: &Value, key: &str) -> u32 { v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u32 } fn u16f(v: &Value, key: &str) -> u16 { v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u16 } fn u8f(v: &Value, key: &str) -> u8 { v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u8 } fn opt T>(v: &Value, key: &str, f: F) -> Option { match &v[key] { Value::Null => None, other => Some(f(other.as_u64().unwrap_or_else(|| { // Negative values (altitude) arrive as i64. other.as_i64().expect("numeric") as u64 }))), } } fn point_from_json(v: &Value) -> Point { Point { ts: u32f(v, "ts"), lat_e7: v["lat_e7"].as_i64().expect("lat_e7") as i32, lon_e7: v["lon_e7"].as_i64().expect("lon_e7") as i32, acc_dm: opt(v, "acc_dm", |n| n as u16), alt_m: match &v["alt_m"] { Value::Null => None, other => Some(other.as_i64().expect("alt_m") as i16), }, spd_cms: opt(v, "spd_cms", |n| n as u16), brg_cdeg: opt(v, "brg_cdeg", |n| n as u16), bat_pct: opt(v, "bat_pct", |n| n as u8), flags: Flags(u8f(v, "flags")), } } fn nonce_from_hex(s: &str) -> Nonce { hex_str(s).try_into().expect("12-byte nonce") } fn nonces_from_json(v: &Value) -> Vec { v.as_array() .expect("nonces array") .iter() .map(|n| { let bytes: Vec = n .as_array() .expect("nonce is an array of bytes") .iter() .map(|b| b.as_u64().expect("byte") as u8) .collect(); bytes.try_into().expect("12-byte nonce") }) .collect() } fn message_from_json(v: &Value) -> Message { let ty = v["type"].as_str().expect("message type"); let val = &v["value"]; match ty { "loc" => Message::Loc( val.as_array() .expect("points") .iter() .map(point_from_json) .collect(), ), "ack" => Message::Ack(Ack { nonces: nonces_from_json(&val["nonces"]), flags: AckFlags(u8f(val, "flags")), }), "nack" => Message::Nack(Nack { nonce: { let bytes: Vec = val["nonce"] .as_array() .expect("nonce bytes") .iter() .map(|b| b.as_u64().expect("byte") as u8) .collect(); bytes.try_into().expect("12-byte nonce") }, reason: match val["reason"].as_str().expect("reason") { "unknown_token" => NackReason::UnknownToken, "malformed" => NackReason::Malformed, "rate_limited" => NackReason::RateLimited, "storage_full" => NackReason::StorageFull, other => panic!("unknown NACK reason {other:?}"), }, retry_after_s: u8f(val, "retry_after_s"), }), "hello" => Message::Hello(Hello { app_version_code: u16f(val, "app_version_code"), os_api_level: u8f(val, "os_api_level"), flags: HelloFlags(u8f(val, "flags")), config_version: u16f(val, "config_version"), }), "config" => Message::Config(Config { config_version: u16f(val, "config_version"), profile: match val["profile"].as_str().expect("profile") { "battery_saver" => Profile::BatterySaver, "balanced" => Profile::Balanced, "high_accuracy" => Profile::HighAccuracy, other => panic!("unknown profile {other:?}"), }, flags: ConfigFlags(u8f(val, "flags")), heartbeat_s: u16f(val, "heartbeat_s"), interval_scale_pct: u16f(val, "interval_scale_pct"), min_distance_m: u16f(val, "min_distance_m"), max_points_per_loc: u8f(val, "max_points_per_loc"), }), "config_get" => Message::ConfigGet(ConfigGet { have_version: u16f(val, "have_version"), }), "ping" => Message::Ping(Ping { echo: u32f(val, "echo"), seq: u16f(val, "seq"), }), "pong" => Message::Pong(Pong { echo: u32f(val, "echo"), seq: u16f(val, "seq"), }), "revoked" => Message::Revoked(Revoked { reason: match val["reason"].as_str().expect("reason") { "revoked" => RevokeReason::Revoked, "expired" => RevokeReason::Expired, "unknown" => RevokeReason::Unknown, other => panic!("unknown revoke reason {other:?}"), }, }), other => panic!("unknown message type {other:?}"), } } #[test] fn file_level_constants_match_this_build() { let v = load(); assert_eq!(v["protocol"], "OTP/1"); assert_eq!(v["version"].as_u64(), Some(u64::from(otproto::VERSION))); assert_eq!(v["header_len"].as_u64(), Some(otproto::HEADER_LEN as u64)); assert_eq!(v["tag_len"].as_u64(), Some(otproto::TAG_LEN as u64)); assert_eq!( v["max_datagram"].as_u64(), Some(otproto::MAX_DATAGRAM as u64) ); assert_eq!(v["max_points"].as_u64(), Some(otproto::MAX_POINTS as u64)); } #[test] fn key_derivation_matches_the_vectors() { let v = load(); let token_key: [u8; 32] = hex(&v, "token_key_hex") .try_into() .expect("32-byte token key"); let (up, down) = kdf::derive_both(&token_key); assert_eq!(hex(&v, "k_up_hex"), up, "K_up drifted"); assert_eq!(hex(&v, "k_down_hex"), down, "K_down drifted"); assert_ne!(up, down); let master: [u8; 32] = hex(&v, "revocation_master_hex") .try_into() .expect("32-byte master"); let token_id = v["token_id"].as_u64().expect("token_id"); let rev = otproto::revocation_key(&master, token_id); assert_eq!(hex(&v, "k_rev_hex"), rev, "K_rev drifted"); // Independent of the token key, which is the property that lets a REVOKED // notice outlive the token's row. assert_ne!(rev, up); assert_ne!(rev, down); assert_ne!(rev, token_key); } #[test] fn point_records_encode_exactly_as_recorded() { let v = load(); let points = v["points"].as_array().expect("points array"); assert!(!points.is_empty()); for case in points { let name = case["name"].as_str().expect("name"); let expected = hex(case, "bytes_hex"); assert_eq!( expected.len(), otproto::POINT_LEN, "{name}: wrong record length" ); let point = point_from_json(&case["point"]); assert_eq!( point.to_bytes().as_slice(), expected.as_slice(), "{name}: encode drifted" ); let decoded = Point::from_bytes(expected.as_slice().try_into().expect("length checked")); assert_eq!(decoded, point, "{name}: decode drifted"); } } #[test] fn every_datagram_vector_reproduces_byte_for_byte() { let v = load(); let token_key: [u8; 32] = hex(&v, "token_key_hex") .try_into() .expect("32-byte token key"); let token_id = v["token_id"].as_u64().expect("token_id"); let (k_up, k_down) = kdf::derive_both(&token_key); let master: [u8; 32] = hex(&v, "revocation_master_hex") .try_into() .expect("32-byte master"); let k_rev = otproto::revocation_key(&master, token_id); let cases = v["datagrams"].as_array().expect("datagrams array"); assert!(cases.len() >= 9, "vectors must cover every message type"); let mut covered = BTreeSet::new(); for case in cases { let name = case["name"].as_str().expect("name"); let message = message_from_json(&case["message"]); let ty = message.msg_type(); covered.insert(ty as u8); // The recorded type, direction and key must agree with what this build // derives from the message itself. assert_eq!( u8f(case, "msg_type"), ty as u8, "{name}: msg_type disagrees" ); let dir = ty.direction(); assert_eq!( case["direction"].as_str(), Some(match dir { Direction::Up => "up", Direction::Down => "down", }), "{name}: direction disagrees" ); // Which key seals this datagram is recorded explicitly, because it is // not implied by the direction: REVOKED travels downlink but is sealed // under K_rev so it survives the token's row being deleted. let (key, key_name) = match ty { MsgType::Revoked => (&k_rev, "rev"), _ => match dir { Direction::Up => (&k_up, "up"), Direction::Down => (&k_down, "down"), }, }; assert_eq!( case["key"].as_str(), Some(key_name), "{name}: sealing key disagrees" ); let nonce = nonce_from_hex(case["nonce_hex"].as_str().expect("nonce_hex")); let header = Header::new(ty, token_id, nonce); assert_eq!( header.to_bytes().as_slice(), hex(case, "header_hex"), "{name}: header drifted" ); let payload = message.encode_payload(); assert_eq!(payload, hex(case, "payload_hex"), "{name}: payload drifted"); let datagram = otproto::seal(key, header, &payload); assert_eq!( datagram, hex(case, "datagram_hex"), "{name}: datagram drifted" ); assert_eq!( datagram.len(), case["datagram_len"].as_u64().expect("datagram_len") as usize, "{name}: recorded length is wrong" ); assert!( datagram.len() <= otproto::MAX_DATAGRAM, "{name}: exceeds the datagram budget" ); // And the other direction: the recorded bytes must open to the recorded // message. Encoding agreeing with itself would not prove that. let (h, back) = otproto::open_message(key, &datagram).unwrap_or_else(|e| panic!("{name}: {e}")); assert_eq!(h, header, "{name}: header did not survive a round trip"); assert_eq!( back, message, "{name}: message did not survive a round trip" ); } let all: BTreeSet = MsgType::ALL.iter().map(|t| *t as u8).collect(); assert_eq!(covered, all, "some message type has no golden vector"); } #[test] fn vectors_only_open_under_the_key_that_sealed_them() { let v = load(); let token_key: [u8; 32] = hex(&v, "token_key_hex") .try_into() .expect("32-byte token key"); let token_id = v["token_id"].as_u64().expect("token_id"); let (k_up, k_down) = kdf::derive_both(&token_key); let master: [u8; 32] = hex(&v, "revocation_master_hex") .try_into() .expect("32-byte master"); let k_rev = otproto::revocation_key(&master, token_id); for case in v["datagrams"].as_array().expect("datagrams") { let name = case["name"].as_str().expect("name"); let datagram = hex(case, "datagram_hex"); let sealed_with = case["key"].as_str().expect("key"); // Every key except the right one must fail. Uplink versus downlink is // the classic reflection guard; K_rev matters for a different reason — // if a REVOKED opened under K_down, a device could be told it was // revoked by anyone holding the token key. for (name_of, key) in [("up", &k_up), ("down", &k_down), ("rev", &k_rev)] { if name_of == sealed_with { continue; } assert!( otproto::open(key, &datagram).is_err(), "{name}: opened under K_{name_of}, which did not seal it" ); } } // A revocation notice for another token must not open here either: K_rev is // per-id precisely so one device cannot forge one for another. let other = otproto::revocation_key(&master, token_id ^ 1); for case in v["datagrams"].as_array().expect("datagrams") { if case["key"] != "rev" { continue; } let datagram = hex(case, "datagram_hex"); assert!( otproto::open(&other, &datagram).is_err(), "a REVOKED opened under another token's K_rev" ); } }