props.rs
⎇
Raw
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
12use otproto::msg::Direction;
13use otproto::point::{Flags, POINT_LEN};
14use otproto::{Ack, AckFlags, Header, MAX_POINTS, Message, Nonce, Point, kdf};
15use proptest::prelude::*;
16
17/// Any point that survives encoding unchanged, i.e. no field needs clamping.
18fn 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.
46fn wild_point() -> impl Strategy<Value = Point> {
47 proptest::array::uniform24(any::<u8>()).prop_map(|b| Point::from_bytes(&b))
48}
49
50fn nonce() -> impl Strategy<Value = Nonce> {
51 proptest::array::uniform12(any::<u8>())
52}
53
54proptest! {
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