props.rs
| 1 | //! Property tests for the OTP/1 codec. |
| 2 | //! |
| 3 | //! Two shapes of property, and both matter for different reasons: |
| 4 | //! |
| 5 | //! * **Round-trip** — `decode(encode(x)) == x` and `encode(decode(b)) == b`. |
| 6 | //! These pin the codec's meaning. |
| 7 | //! * **Totality** — arbitrary bytes either fail cleanly or produce a message; |
| 8 | //! they never panic. The decoder faces the open internet, so a panic is a |
| 9 | //! remote denial of service. `cargo fuzz run decode` covers the same ground |
| 10 | //! with better coverage guidance; this keeps it honest on every `cargo test`. |
| 11 | |
| 12 | use otproto::msg::Direction; |
| 13 | use otproto::point::{Flags, POINT_LEN}; |
| 14 | use otproto::{Ack, AckFlags, Header, MAX_POINTS, Message, Nonce, Point, kdf}; |
| 15 | use proptest::prelude::*; |
| 16 | |
| 17 | /// Any point that survives encoding unchanged, i.e. no field needs clamping. |
| 18 | fn canonical_point() -> impl Strategy<Value = Point> { |
| 19 | ( |
| 20 | any::<u32>(), |
| 21 | -otproto::point::LAT_MAX_E7..=otproto::point::LAT_MAX_E7, |
| 22 | -otproto::point::LON_MAX_E7..=otproto::point::LON_MAX_E7, |
| 23 | proptest::option::of(0u16..=65_534), |
| 24 | proptest::option::of(-32_767i16..=32_767), |
| 25 | proptest::option::of(0u16..=65_534), |
| 26 | proptest::option::of(0u16..=35_999), |
| 27 | proptest::option::of(0u8..=100), |
| 28 | any::<u8>(), |
| 29 | ) |
| 30 | .prop_map( |
| 31 | |(ts, lat_e7, lon_e7, acc_dm, alt_m, spd_cms, brg_cdeg, bat_pct, flags)| Point { |
| 32 | ts, |
| 33 | lat_e7, |
| 34 | lon_e7, |
| 35 | acc_dm, |
| 36 | alt_m, |
| 37 | spd_cms, |
| 38 | brg_cdeg, |
| 39 | bat_pct, |
| 40 | flags: Flags(flags), |
| 41 | }, |
| 42 | ) |
| 43 | } |
| 44 | |
| 45 | /// Any point at all, including values the encoder will clamp. |
| 46 | fn wild_point() -> impl Strategy<Value = Point> { |
| 47 | proptest::array::uniform24(any::<u8>()).prop_map(|b| Point::from_bytes(&b)) |
| 48 | } |
| 49 | |
| 50 | fn nonce() -> impl Strategy<Value = Nonce> { |
| 51 | proptest::array::uniform12(any::<u8>()) |
| 52 | } |
| 53 | |
| 54 | proptest! { |
| 55 | #[test] |
| 56 | fn canonical_points_round_trip(p in canonical_point()) { |
| 57 | prop_assert!(p.is_canonical()); |
| 58 | prop_assert_eq!(Point::from_bytes(&p.to_bytes()), p); |
| 59 | } |
| 60 | |
| 61 | /// The other direction. Not every 24-byte string is a canonical record: |
| 62 | /// battery 101..=254 and bearing 36000..=65534 parse fine but re-encode |
| 63 | /// clamped, since only their sentinel is reserved, not the whole tail of |
| 64 | /// their range. Those two fields are normalised here, and the clamping |
| 65 | /// itself is covered by `canonicalisation_is_idempotent`. |
| 66 | #[test] |
| 67 | fn canonical_point_bytes_round_trip(mut b in proptest::array::uniform24(any::<u8>())) { |
| 68 | let brg = u16::from_be_bytes([b[18], b[19]]); |
| 69 | if brg > 35_999 && brg != 0xFFFF { |
| 70 | b[18..20].copy_from_slice(&35_999u16.to_be_bytes()); |
| 71 | } |
| 72 | if b[20] > 100 && b[20] != 0xFF { |
| 73 | b[20] = 100; |
| 74 | } |
| 75 | b[22] = 0; |
| 76 | b[23] = 0; |
| 77 | prop_assert_eq!(Point::from_bytes(&b).to_bytes(), b); |
| 78 | } |
| 79 | |
| 80 | /// Clamping is idempotent: canonicalising twice changes nothing more. |
| 81 | #[test] |
| 82 | fn canonicalisation_is_idempotent(p in wild_point()) { |
| 83 | let once = p.canonical(); |
| 84 | prop_assert!(once.is_canonical()); |
| 85 | prop_assert_eq!(once.canonical(), once); |
| 86 | } |
| 87 | |
| 88 | #[test] |
| 89 | fn loc_messages_round_trip(points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS)) { |
| 90 | let msg = Message::Loc(points); |
| 91 | let payload = msg.encode_payload(); |
| 92 | prop_assert_eq!(payload.len(), msg.payload_len()); |
| 93 | prop_assert_eq!(Message::decode_payload(otproto::MsgType::Loc, &payload).unwrap(), msg); |
| 94 | } |
| 95 | |
| 96 | #[test] |
| 97 | fn ack_messages_round_trip( |
| 98 | nonces in prop::collection::vec(nonce(), 1..=MAX_POINTS), |
| 99 | flags in any::<u8>(), |
| 100 | ) { |
| 101 | let msg = Message::Ack(Ack { nonces, flags: AckFlags(flags) }); |
| 102 | let payload = msg.encode_payload(); |
| 103 | prop_assert_eq!(Message::decode_payload(otproto::MsgType::Ack, &payload).unwrap(), msg); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn seal_open_round_trips( |
| 108 | points in prop::collection::vec(canonical_point(), 1..=MAX_POINTS), |
| 109 | token_key in proptest::array::uniform32(any::<u8>()), |
| 110 | token_id in any::<u64>(), |
| 111 | n in nonce(), |
| 112 | ) { |
| 113 | let k_up = kdf::derive(&token_key, Direction::Up); |
| 114 | let msg = Message::Loc(points); |
| 115 | let dg = otproto::seal_message(&k_up, token_id, n, &msg); |
| 116 | prop_assert!(dg.len() <= otproto::MAX_DATAGRAM); |
| 117 | let (h, back) = otproto::open_message(&k_up, &dg).unwrap(); |
| 118 | prop_assert_eq!(h.token_id, token_id); |
| 119 | prop_assert_eq!(h.nonce, n); |
| 120 | prop_assert_eq!(back, msg); |
| 121 | } |
| 122 | |
| 123 | /// Any single bit flipped anywhere must be caught. The header is covered |
| 124 | /// because it is the AAD, not because it is separately checksummed. |
| 125 | #[test] |
| 126 | fn any_single_bit_flip_is_detected( |
| 127 | token_key in proptest::array::uniform32(any::<u8>()), |
| 128 | token_id in any::<u64>(), |
| 129 | n in nonce(), |
| 130 | point in canonical_point(), |
| 131 | bit in 0usize..(otproto::HEADER_LEN + 1 + POINT_LEN + otproto::TAG_LEN) * 8, |
| 132 | ) { |
| 133 | let k_up = kdf::derive(&token_key, Direction::Up); |
| 134 | let mut dg = otproto::seal_message(&k_up, token_id, n, &Message::Loc(vec![point])); |
| 135 | dg[bit / 8] ^= 1 << (bit % 8); |
| 136 | prop_assert!(otproto::open_message(&k_up, &dg).is_err()); |
| 137 | } |
| 138 | |
| 139 | /// Totality: arbitrary bytes never panic the header parser. |
| 140 | #[test] |
| 141 | fn peek_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..1300)) { |
| 142 | let _ = Header::peek(&bytes); |
| 143 | } |
| 144 | |
| 145 | /// Totality: arbitrary bytes never panic the AEAD layer either. |
| 146 | #[test] |
| 147 | fn open_never_panics( |
| 148 | bytes in prop::collection::vec(any::<u8>(), 0..1300), |
| 149 | token_key in proptest::array::uniform32(any::<u8>()), |
| 150 | ) { |
| 151 | let k = kdf::derive(&token_key, Direction::Up); |
| 152 | let _ = otproto::open_message(&k, &bytes); |
| 153 | } |
| 154 | |
| 155 | /// Totality on the payload decoder specifically, reached without having to |
| 156 | /// forge a valid tag first — the interesting half of the decoder is behind |
| 157 | /// the AEAD, so fuzzing the datagram alone would almost never get here. |
| 158 | #[test] |
| 159 | fn decode_payload_never_panics( |
| 160 | ty in 1u8..=8, |
| 161 | payload in prop::collection::vec(any::<u8>(), 0..1200), |
| 162 | ) { |
| 163 | let ty = otproto::MsgType::try_from(ty).unwrap(); |
| 164 | if let Ok(msg) = Message::decode_payload(ty, &payload) { |
| 165 | // Anything that decodes must re-encode to the same length, and for |
| 166 | // types without reserved padding, to the same bytes. |
| 167 | prop_assert_eq!(msg.payload_len(), payload.len()); |
| 168 | prop_assert_eq!(msg.msg_type(), ty); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /// A datagram sealed for one token must not open under another token's key, |
| 173 | /// even with the ciphertext untouched — the header is AAD, so the token id |
| 174 | /// is bound into the tag. |
| 175 | #[test] |
| 176 | fn a_datagram_cannot_be_retargeted( |
| 177 | token_key in proptest::array::uniform32(any::<u8>()), |
| 178 | a in any::<u64>(), |
| 179 | b in any::<u64>(), |
| 180 | n in nonce(), |
| 181 | point in canonical_point(), |
| 182 | ) { |
| 183 | prop_assume!(a != b); |
| 184 | let k_up = kdf::derive(&token_key, Direction::Up); |
| 185 | let mut dg = otproto::seal_message(&k_up, a, n, &Message::Loc(vec![point])); |
| 186 | dg[1..9].copy_from_slice(&b.to_be_bytes()); |
| 187 | prop_assert!(otproto::open_message(&k_up, &dg).is_err()); |
| 188 | } |
| 189 | } |
| 190 |