//! Message types and their payload layouts. //! //! Every payload is fixed-width except `LOC` (which is a count plus that many //! point records) and `ACK` (a count plus that many nonces). //! //! **There is no clock anywhere in this protocol except `Point::ts`.** No message //! carries the server's time, nothing measures or reports clock skew, and nothing //! corrects a timestamp. The client's `ts` is stored and displayed exactly as //! sent. That is the whole of the timestamp design. //! //! Reserved bytes, where they exist, are written as zero and ignored on decode, //! so a later version can populate them without this build rejecting the packet. use crate::error::DecodeError; use crate::point::{POINT_LEN, Point}; /// Number of nonce bytes, which is also the length of a message id. pub const NONCE_LEN: usize = 12; /// A message id: the random AEAD nonce, reused as the identifier that `ACK` and /// `NACK` echo. It is unique and already on the wire, so a separate id field /// would be pure overhead. pub type Nonce = [u8; NONCE_LEN]; /// Most points in one `LOC`, and most nonces in one `ACK`. /// /// 40 points is `21 + 1 + 40*24 + 16` = 998 bytes, comfortably inside the /// 1200-byte datagram budget. pub const MAX_POINTS: usize = 40; const HELLO_LEN: usize = 6; const CONFIG_LEN: usize = 12; const CONFIG_GET_LEN: usize = 2; const PING_LEN: usize = 6; const PONG_LEN: usize = 6; const NACK_LEN: usize = NONCE_LEN + 2; const REVOKED_LEN: usize = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[repr(u8)] pub enum MsgType { Loc = 0x1, Ack = 0x2, Nack = 0x3, Hello = 0x4, Config = 0x5, ConfigGet = 0x6, Ping = 0x7, Pong = 0x8, /// Sealed under `K_rev`, not `K_down`. See [`Revoked`]. Revoked = 0x9, } impl MsgType { pub const ALL: [Self; 9] = [ Self::Loc, Self::Ack, Self::Nack, Self::Hello, Self::Config, Self::ConfigGet, Self::Ping, Self::Pong, Self::Revoked, ]; /// True for messages a device sends to the server, which are sealed under /// `K_up`. The two directions have separate keys, so a captured uplink /// datagram can never be replayed back as a downlink one. #[must_use] pub const fn is_uplink(self) -> bool { matches!(self, Self::Loc | Self::Hello | Self::ConfigGet | Self::Ping) } #[must_use] pub const fn direction(self) -> Direction { if self.is_uplink() { Direction::Up } else { Direction::Down } } } impl TryFrom for MsgType { type Error = DecodeError; fn try_from(v: u8) -> Result { Ok(match v { 0x1 => Self::Loc, 0x2 => Self::Ack, 0x3 => Self::Nack, 0x4 => Self::Hello, 0x5 => Self::Config, 0x6 => Self::ConfigGet, 0x7 => Self::Ping, 0x8 => Self::Pong, 0x9 => Self::Revoked, other => return Err(DecodeError::BadMsgType(other)), }) } } /// Which of the two derived keys seals a message. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Direction { /// Device → server, `K_up`. Up, /// Server → device, `K_down`. Down, } impl Direction { /// HKDF info string. Distinct strings are what make the two keys /// independent. #[must_use] pub const fn info(self) -> &'static [u8] { match self { Self::Up => b"otp/1/up", Self::Down => b"otp/1/down", } } } // --------------------------------------------------------------------------- // ACK // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] pub struct AckFlags(pub u8); impl AckFlags { pub const NONE: Self = Self(0); /// The server has a newer config; the device should send `CONFIG_GET`. /// Config delivery is pull-based so it never depends on a live NAT binding. pub const CONFIG_PENDING: Self = Self(1 << 0); /// The write path is saturated; back off before the next flush. pub const THROTTLE: Self = Self(1 << 1); #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } } /// Retires one or more messages. One `ACK` can cover a whole flush burst. #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Ack { pub nonces: Vec, pub flags: AckFlags, } impl Ack { #[must_use] pub fn single(nonce: Nonce) -> Self { Self { nonces: vec![nonce], flags: AckFlags::NONE, } } const fn payload_len_for(count: usize) -> usize { 1 + count * NONCE_LEN + 1 } } // --------------------------------------------------------------------------- // NACK // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[repr(u8)] pub enum NackReason { /// Token unknown, revoked, or expired. The device clears local state and /// shows the login screen. UnknownToken = 1, /// Decrypted cleanly but the payload made no sense. Malformed = 2, RateLimited = 3, /// Per-account storage quota exhausted. StorageFull = 4, } impl TryFrom for NackReason { type Error = DecodeError; fn try_from(v: u8) -> Result { Ok(match v { 1 => Self::UnknownToken, 2 => Self::Malformed, 3 => Self::RateLimited, 4 => Self::StorageFull, value => { return Err(DecodeError::BadEnum { field: "NackReason", value, }); } }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Nack { pub nonce: Nonce, pub reason: NackReason, /// Seconds to wait before retrying. 0 means "no advice". pub retry_after_s: u8, } // --------------------------------------------------------------------------- // REVOKED // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[repr(u8)] pub enum RevokeReason { /// Explicitly revoked: "log out all other devices", or a password change. Revoked = 1, /// Deleted by the staleness sweep after a long silence. Expired = 2, /// The server has no record of this token at all. A restored backup or a /// rotated server key looks like this. Unknown = 3, } impl TryFrom for RevokeReason { type Error = DecodeError; fn try_from(v: u8) -> Result { Ok(match v { 1 => Self::Revoked, 2 => Self::Expired, 3 => Self::Unknown, value => { return Err(DecodeError::BadEnum { field: "RevokeReason", value, }); } }) } } /// "This token is dead; log in again." /// /// The one message sealed under `K_rev` rather than `K_down`, which is the whole /// reason it exists as a separate type. `K_down` derives from the token key, so /// it dies with the token's row — and the moment the server most needs to speak /// is exactly when that row is gone. `K_rev` is derived from a server master key /// and the `token_id` (see [`crate::kdf::revocation_key`]), so the server can /// recompute it for any id, including one it has never seen. /// /// Two properties follow from deriving per `token_id` rather than sharing one /// server key: a third party cannot forge this message, and neither can another /// legitimate device — it only ever learns its own `K_rev`. /// /// Replay needs no counter. `token_id` lives in the header, and the header is the /// AEAD's associated data, so a captured `REVOKED` names the token it was issued /// against. After the user logs in again the device holds a different id and the /// rule "act only on my current `token_id`" discards it. /// /// One byte of payload, deliberately: at 38 bytes on the wire this is the /// smallest reply in the protocol, and the server refuses to send it in answer to /// anything shorter, so it can never amplify. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Revoked { pub reason: RevokeReason, } // --------------------------------------------------------------------------- // HELLO // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] pub struct HelloFlags(pub u8); impl HelloFlags { pub const NONE: Self = Self(0); /// First run after an install or reinstall. pub const FIRST_LAUNCH: Self = Self(1 << 0); } /// Sent once per service start, so the server has app and OS versions to show /// alongside a token. Carries no time: the server has nothing to compare it to /// that it would be allowed to act on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Hello { pub app_version_code: u16, pub os_api_level: u8, pub flags: HelloFlags, /// The config version the device currently holds. pub config_version: u16, } // --------------------------------------------------------------------------- // CONFIG / CONFIG_GET // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[repr(u8)] pub enum Profile { BatterySaver = 0, #[default] Balanced = 1, HighAccuracy = 2, } impl TryFrom for Profile { type Error = DecodeError; fn try_from(v: u8) -> Result { Ok(match v { 0 => Self::BatterySaver, 1 => Self::Balanced, 2 => Self::HighAccuracy, value => { return Err(DecodeError::BadEnum { field: "Profile", value, }); } }) } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(transparent))] pub struct ConfigFlags(pub u8); impl ConfigFlags { pub const NONE: Self = Self(0); /// Master switch. Clearing it stops sharing without revoking the token. pub const TRACKING_ENABLED: Self = Self(1 << 0); /// Server wants a fresh `HELLO` (e.g. it has no version info on record). pub const REQUEST_HELLO: Self = Self(1 << 1); #[must_use] pub const fn contains(self, other: Self) -> bool { self.0 & other.0 == other.0 } } /// The server's view of how this device should behave. /// /// `profile` names a parameter set the *client* owns; the numeric fields are /// server-side overrides on top of it. That keeps the message small and means a /// profile can be retuned by shipping an app update, without a protocol change. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Config { pub config_version: u16, pub profile: Profile, pub flags: ConfigFlags, /// Stationary heartbeat period. Clamped by the client to what inexact /// alarms can actually deliver in doze (~9 min floor). pub heartbeat_s: u16, /// Scales the profile's intervals, in percent. 100 = profile default. pub interval_scale_pct: u16, pub min_distance_m: u16, pub max_points_per_loc: u8, } impl Default for Config { fn default() -> Self { Self { config_version: 1, profile: Profile::Balanced, flags: ConfigFlags::TRACKING_ENABLED, heartbeat_s: 900, interval_scale_pct: 100, min_distance_m: 20, max_points_per_loc: MAX_POINTS as u8, } } } /// Asks for the current [`Config`]. Padded to `CONFIG`'s size so the reply is /// never larger than the request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ConfigGet { /// The version the device already has, so the server can skip a no-op push. pub have_version: u16, } // --------------------------------------------------------------------------- // PING / PONG // --------------------------------------------------------------------------- /// Probes whether UDP works on the current network. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Ping { /// Opaque to the server, echoed verbatim in the `PONG`. The client puts /// whatever lets it match up a reply and measure a round trip — a reading of /// its own monotonic clock, typically. Deliberately *not* a wall-clock time /// the server is invited to interpret. pub echo: u32, pub seq: u16, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Pong { /// Copied from the `PING`, so the client needs no per-probe state. pub echo: u32, pub seq: u16, } // --------------------------------------------------------------------------- // Message // --------------------------------------------------------------------------- #[derive(Debug, Clone, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] // Adjacently tagged rather than internally tagged: `Loc` carries a sequence, // which an internally-tagged representation cannot express. #[cfg_attr( feature = "serde", serde(tag = "type", content = "value", rename_all = "snake_case") )] pub enum Message { /// One or more independent points. Loc(Vec), Ack(Ack), Nack(Nack), Hello(Hello), Config(Config), ConfigGet(ConfigGet), Ping(Ping), Pong(Pong), Revoked(Revoked), } impl Message { #[must_use] pub const fn msg_type(&self) -> MsgType { match self { Self::Loc(_) => MsgType::Loc, Self::Ack(_) => MsgType::Ack, Self::Nack(_) => MsgType::Nack, Self::Hello(_) => MsgType::Hello, Self::Config(_) => MsgType::Config, Self::ConfigGet(_) => MsgType::ConfigGet, Self::Ping(_) => MsgType::Ping, Self::Pong(_) => MsgType::Pong, Self::Revoked(_) => MsgType::Revoked, } } /// Exact payload length, without allocating. #[must_use] pub fn payload_len(&self) -> usize { match self { Self::Loc(points) => 1 + points.len() * POINT_LEN, Self::Ack(ack) => Ack::payload_len_for(ack.nonces.len()), Self::Nack(_) => NACK_LEN, Self::Hello(_) => HELLO_LEN, Self::Config(_) => CONFIG_LEN, Self::ConfigGet(_) => CONFIG_GET_LEN, Self::Ping(_) => PING_LEN, Self::Pong(_) => PONG_LEN, Self::Revoked(_) => REVOKED_LEN, } } /// Append this message's payload (the part that gets encrypted). /// /// # Panics /// If a `Loc` or `Ack` holds more than [`MAX_POINTS`] elements, or a `Loc` /// holds none. Those are caller bugs, not wire conditions: nothing outside /// this process can trigger them. pub fn encode_payload_into(&self, out: &mut Vec) { out.reserve(self.payload_len()); match self { Self::Loc(points) => { assert!( (1..=MAX_POINTS).contains(&points.len()), "LOC must carry 1..={MAX_POINTS} points, got {}", points.len() ); out.push(points.len() as u8); for p in points { out.extend_from_slice(&p.to_bytes()); } } Self::Ack(ack) => { assert!( (1..=MAX_POINTS).contains(&ack.nonces.len()), "ACK must carry 1..={MAX_POINTS} nonces, got {}", ack.nonces.len() ); out.push(ack.nonces.len() as u8); for n in &ack.nonces { out.extend_from_slice(n); } out.push(ack.flags.0); } Self::Nack(nack) => { out.extend_from_slice(&nack.nonce); out.push(nack.reason as u8); out.push(nack.retry_after_s); } Self::Hello(h) => { out.extend_from_slice(&h.app_version_code.to_be_bytes()); out.push(h.os_api_level); out.push(h.flags.0); out.extend_from_slice(&h.config_version.to_be_bytes()); } Self::Config(c) => { out.extend_from_slice(&c.config_version.to_be_bytes()); out.push(c.profile as u8); out.push(c.flags.0); out.extend_from_slice(&c.heartbeat_s.to_be_bytes()); out.extend_from_slice(&c.interval_scale_pct.to_be_bytes()); out.extend_from_slice(&c.min_distance_m.to_be_bytes()); out.push(c.max_points_per_loc); out.push(0); // reserved } Self::ConfigGet(g) => { out.extend_from_slice(&g.have_version.to_be_bytes()); } Self::Ping(p) => { out.extend_from_slice(&p.echo.to_be_bytes()); out.extend_from_slice(&p.seq.to_be_bytes()); } Self::Pong(p) => { out.extend_from_slice(&p.echo.to_be_bytes()); out.extend_from_slice(&p.seq.to_be_bytes()); } Self::Revoked(r) => out.push(r.reason as u8), } debug_assert_eq!( out.len(), self.payload_len(), "payload_len disagrees with the encoder" ); } #[must_use] pub fn encode_payload(&self) -> Vec { let mut out = Vec::with_capacity(self.payload_len()); self.encode_payload_into(&mut out); out } /// Parse a decrypted payload. pub fn decode_payload(ty: MsgType, p: &[u8]) -> Result { let exact = |what: &'static str, expected: usize| -> Result<(), DecodeError> { if p.len() == expected { Ok(()) } else { Err(DecodeError::BadPayloadLen { what, expected, actual: p.len(), }) } }; Ok(match ty { MsgType::Loc => { let count = *p.first().ok_or(DecodeError::BadPayloadLen { what: "LOC", expected: 1 + POINT_LEN, actual: 0, })? as usize; if !(1..=MAX_POINTS).contains(&count) { return Err(DecodeError::BadPointCount(count)); } exact("LOC", 1 + count * POINT_LEN)?; let points = p[1..] .chunks_exact(POINT_LEN) .map(|c| { Point::from_bytes(c.try_into().expect("chunks_exact yields POINT_LEN")) }) .collect(); Self::Loc(points) } MsgType::Ack => { let count = *p.first().ok_or(DecodeError::BadPayloadLen { what: "ACK", expected: Ack::payload_len_for(1), actual: 0, })? as usize; if !(1..=MAX_POINTS).contains(&count) { return Err(DecodeError::BadNonceCount(count)); } exact("ACK", Ack::payload_len_for(count))?; let nonces = p[1..1 + count * NONCE_LEN] .chunks_exact(NONCE_LEN) .map(|c| -> Nonce { c.try_into().expect("chunks_exact yields NONCE_LEN") }) .collect(); let tail = 1 + count * NONCE_LEN; Self::Ack(Ack { nonces, flags: AckFlags(p[tail]), }) } MsgType::Nack => { exact("NACK", NACK_LEN)?; Self::Nack(Nack { nonce: p[..NONCE_LEN].try_into().expect("length checked"), reason: NackReason::try_from(p[NONCE_LEN])?, retry_after_s: p[NONCE_LEN + 1], }) } MsgType::Hello => { exact("HELLO", HELLO_LEN)?; Self::Hello(Hello { app_version_code: be16(p), os_api_level: p[2], flags: HelloFlags(p[3]), config_version: be16(&p[4..]), }) } MsgType::Config => { exact("CONFIG", CONFIG_LEN)?; Self::Config(Config { config_version: be16(p), profile: Profile::try_from(p[2])?, flags: ConfigFlags(p[3]), heartbeat_s: be16(&p[4..]), interval_scale_pct: be16(&p[6..]), min_distance_m: be16(&p[8..]), max_points_per_loc: p[10], }) } MsgType::ConfigGet => { exact("CONFIG_GET", CONFIG_GET_LEN)?; Self::ConfigGet(ConfigGet { have_version: be16(p), }) } MsgType::Ping => { exact("PING", PING_LEN)?; Self::Ping(Ping { echo: be32(p), seq: be16(&p[4..]), }) } MsgType::Pong => { exact("PONG", PONG_LEN)?; Self::Pong(Pong { echo: be32(p), seq: be16(&p[4..]), }) } MsgType::Revoked => { exact("REVOKED", REVOKED_LEN)?; Self::Revoked(Revoked { reason: RevokeReason::try_from(p[0])?, }) } }) } } fn be16(b: &[u8]) -> u16 { u16::from_be_bytes([b[0], b[1]]) } fn be32(b: &[u8]) -> u32 { u32::from_be_bytes([b[0], b[1], b[2], b[3]]) } #[cfg(test)] mod tests { use super::*; fn round_trip(m: &Message) { let bytes = m.encode_payload(); assert_eq!(bytes.len(), m.payload_len()); let back = Message::decode_payload(m.msg_type(), &bytes).expect("decodes"); assert_eq!(&back, m); } #[test] fn every_type_round_trips() { round_trip(&Message::Loc(vec![Point::new( 1_785_000_042, 525_200_080, 134_050_000, )])); round_trip(&Message::Loc( (0..MAX_POINTS as u32) .map(|i| Point::new(i, i as i32, -(i as i32))) .collect(), )); round_trip(&Message::Ack(Ack { nonces: vec![[7; NONCE_LEN], [9; NONCE_LEN]], flags: AckFlags::CONFIG_PENDING, })); round_trip(&Message::Nack(Nack { nonce: [3; NONCE_LEN], reason: NackReason::RateLimited, retry_after_s: 30, })); round_trip(&Message::Hello(Hello { app_version_code: 17, os_api_level: 34, flags: HelloFlags::FIRST_LAUNCH, config_version: 2, })); round_trip(&Message::Config(Config::default())); round_trip(&Message::ConfigGet(ConfigGet { have_version: 2 })); round_trip(&Message::Ping(Ping { echo: 0xDEAD_BEEF, seq: 5, })); round_trip(&Message::Pong(Pong { echo: 0xDEAD_BEEF, seq: 5, })); } #[test] fn loc_rejects_degenerate_counts() { assert_eq!( Message::decode_payload(MsgType::Loc, &[0]), Err(DecodeError::BadPointCount(0)) ); let mut too_many = vec![(MAX_POINTS + 1) as u8]; too_many.extend(std::iter::repeat_n(0u8, (MAX_POINTS + 1) * POINT_LEN)); assert_eq!( Message::decode_payload(MsgType::Loc, &too_many), Err(DecodeError::BadPointCount(MAX_POINTS + 1)) ); } #[test] fn loc_rejects_a_count_that_disagrees_with_the_length() { // Claims two points, carries one. The classic truncation bug. let mut p = vec![2u8]; p.extend_from_slice(&Point::new(1, 2, 3).to_bytes()); assert!(matches!( Message::decode_payload(MsgType::Loc, &p), Err(DecodeError::BadPayloadLen { .. }) )); } #[test] fn fixed_payload_sizes_are_what_the_spec_says() { assert_eq!( Message::Nack(Nack { nonce: [0; NONCE_LEN], reason: NackReason::Malformed, retry_after_s: 0 }) .payload_len(), 14 ); assert_eq!( Message::Hello(Hello { app_version_code: 0, os_api_level: 0, flags: HelloFlags::NONE, config_version: 0 }) .payload_len(), 6 ); assert_eq!(Message::Config(Config::default()).payload_len(), 12); assert_eq!(Message::ConfigGet(ConfigGet::default()).payload_len(), 2); assert_eq!(Message::Ping(Ping::default()).payload_len(), 6); assert_eq!(Message::Pong(Pong::default()).payload_len(), 6); assert_eq!(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len(), 25); } /// CONFIG holds the only reserved byte left in the protocol. A later version /// may populate it, and this build must ignore it rather than reject the /// packet. #[test] fn reserved_bytes_do_not_break_decoding() { let config = Config::default(); let mut p = Message::Config(config).encode_payload(); p[11] = 0xAB; assert_eq!( Message::decode_payload(MsgType::Config, &p), Ok(Message::Config(config)) ); } #[test] fn unknown_discriminants_are_rejected() { assert!(MsgType::try_from(0).is_err()); assert!(MsgType::try_from(10).is_err()); assert!(Profile::try_from(3).is_err()); assert!(NackReason::try_from(0).is_err()); assert!(NackReason::try_from(5).is_err()); assert!(RevokeReason::try_from(0).is_err()); assert!(RevokeReason::try_from(4).is_err()); } #[test] fn direction_split_matches_the_key_schedule() { for ty in MsgType::ALL { assert_eq!( ty.direction() == Direction::Up, matches!( ty, MsgType::Loc | MsgType::Hello | MsgType::ConfigGet | MsgType::Ping ) ); } assert_ne!(Direction::Up.info(), Direction::Down.info()); } }