vectors.rs
| 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 | |
| 12 | use std::collections::BTreeSet; |
| 13 | |
| 14 | use otproto::msg::Direction; |
| 15 | use otproto::point::Flags; |
| 16 | use otproto::{ |
| 17 | Ack, AckFlags, Config, ConfigFlags, ConfigGet, Header, Hello, HelloFlags, Message, MsgType, |
| 18 | Nack, NackReason, Nonce, Ping, Point, Pong, Profile, RevokeReason, Revoked, kdf, |
| 19 | }; |
| 20 | use serde_json::Value; |
| 21 | |
| 22 | const VECTORS: &str = include_str!("vectors.json"); |
| 23 | |
| 24 | fn load() -> Value { |
| 25 | serde_json::from_str(VECTORS).expect("vectors.json is valid JSON") |
| 26 | } |
| 27 | |
| 28 | fn 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 | |
| 36 | fn 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 | |
| 44 | fn u32f(v: &Value, key: &str) -> u32 { |
| 45 | v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u32 |
| 46 | } |
| 47 | |
| 48 | fn u16f(v: &Value, key: &str) -> u16 { |
| 49 | v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u16 |
| 50 | } |
| 51 | |
| 52 | fn u8f(v: &Value, key: &str) -> u8 { |
| 53 | v[key].as_u64().unwrap_or_else(|| panic!("{key} missing")) as u8 |
| 54 | } |
| 55 | |
| 56 | fn 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 | |
| 66 | fn 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 | |
| 83 | fn nonce_from_hex(s: &str) -> Nonce { |
| 84 | hex_str(s).try_into().expect("12-byte nonce") |
| 85 | } |
| 86 | |
| 87 | fn 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 | |
| 103 | fn 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] |
| 181 | fn 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] |
| 195 | fn 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] |
| 219 | fn 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] |
| 245 | fn 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] |
| 342 | fn 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 |