frame.rs
⎇
Raw
1//! Datagram framing: a 21-byte cleartext header authenticated as AAD, followed
2//! by a ChaCha20-Poly1305 sealed payload.
3//!
4//! ```text
5//! off size field
6//! 0 1 ver_type — high nibble version (1), low nibble message type
7//! 1 8 token_id u64 BE — random, server-assigned at login
8//! 9 12 nonce — random; also this message's id
9//! ```
10//!
11//! The header is cleartext because the server must read `token_id` to select a
12//! key before it can decrypt anything; it is authenticated as AAD so a
13//! ciphertext cannot be retargeted to another token or another message type.
14//!
15//! `datagram = header || ChaCha20Poly1305(K_dir, nonce, payload, aad = header)`
16
17use chacha20poly1305::aead::{AeadInPlace, KeyInit};
18use chacha20poly1305::{ChaCha20Poly1305, Tag};
19
20use crate::error::DecodeError;
21use crate::kdf::Key;
22use crate::msg::{Message, MsgType, Nonce};
23
24pub const VERSION: u8 = 1;
25pub const HEADER_LEN: usize = 21;
26pub const TAG_LEN: usize = 16;
27
28/// Smallest possible datagram: header, empty payload, tag. No message type
29/// actually has an empty payload, but this is the floor a length check can use
30/// before it knows the type.
31pub const MIN_DATAGRAM: usize = HEADER_LEN + TAG_LEN;
32
33/// Path-MTU-safe ceiling for both IPv4 and IPv6. The 40-point `LOC` cap keeps
34/// the largest real datagram at 998 bytes.
35pub const MAX_DATAGRAM: usize = 1200;
36
37/// Total datagram size for a payload of `payload_len` bytes.
38#[must_use]
39pub const fn datagram_len(payload_len: usize) -> usize {
40 HEADER_LEN + payload_len + TAG_LEN
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct Header {
45 pub msg_type: MsgType,
46 /// The u64 assigned at login. A *credential*, not an identity: it
47 /// authorises writing into its owner's position stream and never appears in
48 /// a stored point's key.
49 pub token_id: u64,
50 pub nonce: Nonce,
51}
52
53impl Header {
54 #[must_use]
55 pub const fn new(msg_type: MsgType, token_id: u64, nonce: Nonce) -> Self {
56 Self {
57 msg_type,
58 token_id,
59 nonce,
60 }
61 }
62
63 #[must_use]
64 pub fn to_bytes(self) -> [u8; HEADER_LEN] {
65 let mut b = [0u8; HEADER_LEN];
66 b[0] = (VERSION << 4) | (self.msg_type as u8);
67 b[1..9].copy_from_slice(&self.token_id.to_be_bytes());
68 b[9..21].copy_from_slice(&self.nonce);
69 b
70 }
71
72 /// Read the header of a datagram without decrypting it.
73 ///
74 /// This is the server's first look at a packet: it yields the `token_id`
75 /// needed to pick a key, and it is where length, version and type filtering
76 /// happen — all before any crypto work is spent on the packet.
77 pub fn peek(datagram: &[u8]) -> Result<Self, DecodeError> {
78 if datagram.len() < MIN_DATAGRAM {
79 return Err(DecodeError::TooShort(datagram.len()));
80 }
81 if datagram.len() > MAX_DATAGRAM {
82 return Err(DecodeError::TooLong(datagram.len()));
83 }
84 let version = datagram[0] >> 4;
85 if version != VERSION {
86 return Err(DecodeError::BadVersion(version));
87 }
88 Ok(Self {
89 msg_type: MsgType::try_from(datagram[0] & 0x0F)?,
90 token_id: u64::from_be_bytes(datagram[1..9].try_into().expect("length checked")),
91 nonce: datagram[9..HEADER_LEN].try_into().expect("length checked"),
92 })
93 }
94}
95
96/// Seal a message into a datagram.
97///
98/// The nonce is passed in rather than generated here: this crate does no I/O and
99/// owns no RNG, which is what makes it deterministically testable and lets the
100/// golden vectors be reproducible. Callers must supply 12 fresh random bytes per
101/// message — at 12 bytes the collision probability after 16 million messages is
102/// about 2⁻⁴⁹, so a counter buys nothing and costs persistence.
103#[must_use]
104pub fn seal(key: &Key, header: Header, payload: &[u8]) -> Vec<u8> {
105 debug_assert!(
106 datagram_len(payload.len()) <= MAX_DATAGRAM,
107 "payload of {} bytes exceeds the datagram budget",
108 payload.len()
109 );
110 let aad = header.to_bytes();
111 let mut out = Vec::with_capacity(datagram_len(payload.len()));
112 out.extend_from_slice(&aad);
113 out.extend_from_slice(payload);
114
115 let cipher = ChaCha20Poly1305::new(key.into());
116 let tag = cipher
117 .encrypt_in_place_detached((&header.nonce).into(), &aad, &mut out[HEADER_LEN..])
118 .expect("in-place detached encryption of a bounded buffer cannot fail");
119 out.extend_from_slice(&tag);
120 out
121}
122
123/// Seal an already-typed message, deriving the header from it.
124#[must_use]
125pub fn seal_message(key: &Key, token_id: u64, nonce: Nonce, msg: &Message) -> Vec<u8> {
126 seal(
127 key,
128 Header::new(msg.msg_type(), token_id, nonce),
129 &msg.encode_payload(),
130 )
131}
132
133/// Open a datagram, returning its header and decrypted payload.
134///
135/// A [`DecodeError::AuthFailed`] must never be answered — not with a `NACK`, not
136/// with anything. The server cannot know who sent it, and replying would make
137/// the open port both a forgery oracle and a reflector.
138pub fn open(key: &Key, datagram: &[u8]) -> Result<(Header, Vec<u8>), DecodeError> {
139 let header = Header::peek(datagram)?;
140 let (aad, rest) = datagram.split_at(HEADER_LEN);
141 let (ciphertext, tag) = rest.split_at(rest.len() - TAG_LEN);
142
143 let mut payload = ciphertext.to_vec();
144 ChaCha20Poly1305::new(key.into())
145 .decrypt_in_place_detached(
146 (&header.nonce).into(),
147 aad,
148 &mut payload,
149 Tag::from_slice(tag),
150 )
151 .map_err(|_| DecodeError::AuthFailed)?;
152 Ok((header, payload))
153}
154
155/// Open a datagram and parse its payload.
156pub fn open_message(key: &Key, datagram: &[u8]) -> Result<(Header, Message), DecodeError> {
157 let (header, payload) = open(key, datagram)?;
158 let msg = Message::decode_payload(header.msg_type, &payload)?;
159 Ok((header, msg))
160}
161
162/// Ceiling on any datagram the server sends in reply to one it received.
163///
164/// This is the anti-amplification control, and it replaces an earlier rule that
165/// `len(response) <= len(request)` for every message type. That rule was
166/// achievable only by padding requests with reserved bytes — paying real bytes on
167/// every `HELLO` and `PING` to make replies "fit" — and it protected against a
168/// threat that authentication already eliminates:
169///
170/// **Nearly every reply is sent only to a datagram that passed AEAD
171/// verification.** A bad tag gets silence. So a reflection attacker must already
172/// hold a live token key, and the leverage they gain is the ratio below — against
173/// DNS at ~50× and NTP `monlist` at ~550×, which is the scale at which reflection
174/// is worth doing at all.
175///
176/// [`Revoked`] is the one exception, and it is deliberately the smallest message
177/// in the protocol. The server cannot verify a datagram naming a token it has no
178/// record of, yet that is exactly the device it must tell to log in again. Two
179/// rules keep it from being useful: it is sent only when the request was at least
180/// as long (see [`may_answer_unverified`], so the ratio never exceeds 1.0), and
181/// the server rate-limits it per *destination* address, which for a spoofed
182/// packet is the victim. Whether it is sent at all is a config switch.
183///
184/// | request | bytes | reply | bytes | ratio |
185/// |---|---|---|---|---|
186/// | `LOC`, 1 point | 62 | `ACK` | 51 | 0.82 |
187/// | `LOC`, 40 points | 998 | `ACK` | 51 | 0.05 |
188/// | `HELLO` | 43 | `ACK` | 51 | 1.19 |
189/// | `PING` | 43 | `PONG` | 43 | 1.00 |
190/// | `CONFIG_GET` | 39 | `CONFIG` | 49 | 1.26 |
191/// | any, ≥ 38 B | 38 | `REVOKED` | 38 | ≤ 1.00 |
192///
193/// An absolute ceiling is also a stronger statement than a relative one: however
194/// the protocol grows, the open port cannot be made to emit more than this many
195/// bytes for one received datagram. A multi-nonce `ACK` is the one reply that
196/// scales, and it scales with the number of datagrams *already received* from that
197/// token, so it cannot amplify either — but nothing in this build emits one, and
198/// [`fits_reply_budget`] holds for every reply it does emit.
199pub const MAX_REPLY: usize = 64;
200
201/// Whether a reply respects [`MAX_REPLY`].
202#[must_use]
203pub const fn fits_reply_budget(response_len: usize) -> bool {
204 response_len <= MAX_REPLY
205}
206
207/// Size of a sealed [`Revoked`] notice: the smallest datagram this protocol can
208/// produce, since its payload is one byte.
209pub const REVOKED_DATAGRAM_LEN: usize = HEADER_LEN + 1 + TAG_LEN;
210
211/// Whether a request is long enough to earn an unverifiable [`Revoked`] reply.
212///
213/// The server cannot authenticate a datagram naming a token it does not know, so
214/// answering one means answering an address the sender merely claimed. That is a
215/// reflector. It is a tolerable one only while it can never be an *amplifier*, so
216/// the reply must not exceed the request — which for a fixed 38-byte reply is
217/// just this length test.
218///
219/// [`MIN_DATAGRAM`] is 37, one byte short, so a minimum-size datagram earns
220/// nothing. Every real message is far larger: the smallest a device ever sends is
221/// a 39-byte `CONFIG_GET`.
222#[must_use]
223pub const fn may_answer_unverified(request_len: usize) -> bool {
224 request_len >= REVOKED_DATAGRAM_LEN
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::kdf;
231 use crate::msg::*;
232 use crate::point::Point;
233
234 const TOKEN_KEY: Key = [0x5A; 32];
235 const TOKEN_ID: u64 = 0x1122_3344_5566_7788;
236 const NONCE: Nonce = [0xA0; NONCE_LEN];
237
238 fn keys() -> (Key, Key) {
239 kdf::derive_both(&TOKEN_KEY)
240 }
241
242 #[test]
243 fn header_round_trips() {
244 let h = Header::new(MsgType::Loc, TOKEN_ID, NONCE);
245 assert_eq!(
246 Header::peek(&[h.to_bytes().as_slice(), &[0; TAG_LEN]].concat()),
247 Ok(h)
248 );
249 }
250
251 #[test]
252 fn seal_open_round_trips_every_type() {
253 let (up, down) = keys();
254 let messages = [
255 Message::Loc(vec![Point::new(1_785_000_042, 525_200_080, 134_050_000)]),
256 Message::Ack(Ack::single(NONCE)),
257 Message::Nack(Nack {
258 nonce: NONCE,
259 reason: NackReason::UnknownToken,
260 retry_after_s: 0,
261 }),
262 Message::Hello(Hello {
263 app_version_code: 2,
264 os_api_level: 34,
265 flags: HelloFlags::NONE,
266 config_version: 1,
267 }),
268 Message::Config(Config::default()),
269 Message::ConfigGet(ConfigGet { have_version: 1 }),
270 Message::Ping(Ping { echo: 1, seq: 2 }),
271 Message::Pong(Pong { echo: 1, seq: 3 }),
272 ];
273 for msg in messages {
274 let key = if msg.msg_type().is_uplink() {
275 &up
276 } else {
277 &down
278 };
279 let dg = seal_message(key, TOKEN_ID, NONCE, &msg);
280 assert_eq!(dg.len(), datagram_len(msg.payload_len()));
281 assert!(dg.len() <= MAX_DATAGRAM);
282 let (h, back) = open_message(key, &dg).expect("opens");
283 assert_eq!(h.token_id, TOKEN_ID);
284 assert_eq!(h.msg_type, msg.msg_type());
285 assert_eq!(back, msg);
286 }
287 }
288
289 #[test]
290 fn the_wrong_direction_key_cannot_open_a_datagram() {
291 let (up, down) = keys();
292 let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
293 assert_eq!(open(&down, &dg), Err(DecodeError::AuthFailed));
294 }
295
296 #[test]
297 fn every_header_byte_is_authenticated() {
298 let (up, _) = keys();
299 let dg = seal_message(
300 &up,
301 TOKEN_ID,
302 NONCE,
303 &Message::Ping(Ping { echo: 9, seq: 1 }),
304 );
305 for i in 0..HEADER_LEN {
306 let mut bad = dg.clone();
307 bad[i] ^= 0x01;
308 // A flipped version or type nibble fails the header check; anything
309 // else fails the tag. Either way it never yields a message.
310 assert!(
311 open(&up, &bad).is_err(),
312 "byte {i} of the header was not authenticated"
313 );
314 }
315 }
316
317 #[test]
318 fn a_flipped_ciphertext_or_tag_byte_fails() {
319 let (up, _) = keys();
320 let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
321 for i in HEADER_LEN..dg.len() {
322 let mut bad = dg.clone();
323 bad[i] ^= 0x80;
324 assert_eq!(open(&up, &bad), Err(DecodeError::AuthFailed), "byte {i}");
325 }
326 }
327
328 #[test]
329 fn truncation_is_caught_before_any_crypto() {
330 let (up, _) = keys();
331 let dg = seal_message(&up, TOKEN_ID, NONCE, &Message::Ping(Ping::default()));
332 for cut in 0..MIN_DATAGRAM {
333 assert_eq!(Header::peek(&dg[..cut]), Err(DecodeError::TooShort(cut)));
334 }
335 // Long enough to look like a header, short enough to be corrupt.
336 for cut in MIN_DATAGRAM..dg.len() {
337 assert!(
338 open(&up, &dg[..cut]).is_err(),
339 "truncation to {cut} accepted"
340 );
341 }
342 }
343
344 #[test]
345 fn oversized_and_misversioned_datagrams_are_rejected() {
346 assert_eq!(
347 Header::peek(&vec![0x11; MAX_DATAGRAM + 1]),
348 Err(DecodeError::TooLong(MAX_DATAGRAM + 1))
349 );
350 let mut dg = vec![0u8; MIN_DATAGRAM];
351 dg[0] = 0x21; // version 2
352 assert_eq!(Header::peek(&dg), Err(DecodeError::BadVersion(2)));
353 dg[0] = 0x1F; // version 1, type 0xF
354 assert_eq!(Header::peek(&dg), Err(DecodeError::BadMsgType(0xF)));
355 }
356
357 /// The control that keeps the open UDP port useless as a reflector.
358 #[test]
359 fn every_reply_fits_the_budget() {
360 // Every message the server can send downstream.
361 let replies = [
362 Message::Ack(Ack::single(NONCE)),
363 Message::Nack(Nack {
364 nonce: NONCE,
365 reason: NackReason::Malformed,
366 retry_after_s: 0,
367 }),
368 Message::Config(Config::default()),
369 Message::Pong(Pong::default()),
370 Message::Revoked(Revoked {
371 reason: RevokeReason::Revoked,
372 }),
373 ];
374 for reply in &replies {
375 let len = datagram_len(reply.payload_len());
376 assert!(
377 fits_reply_budget(len),
378 "{:?} is {len} B, over the {MAX_REPLY} B reply budget",
379 reply.msg_type(),
380 );
381 }
382 }
383
384 /// The unverifiable reply can never be an amplifier.
385 ///
386 /// This is the whole justification for REVOKED being one byte of payload.
387 /// A datagram short enough to be profitable to reflect is short enough to be
388 /// refused an answer.
389 #[test]
390 fn an_unverified_notice_never_amplifies() {
391 let revoked = Message::Revoked(Revoked {
392 reason: RevokeReason::Unknown,
393 });
394 assert_eq!(datagram_len(revoked.payload_len()), REVOKED_DATAGRAM_LEN);
395 assert!(
396 !may_answer_unverified(MIN_DATAGRAM),
397 "the smallest possible datagram must not earn a reply"
398 );
399 for request_len in MIN_DATAGRAM..=MAX_DATAGRAM {
400 if may_answer_unverified(request_len) {
401 assert!(
402 REVOKED_DATAGRAM_LEN <= request_len,
403 "a {request_len} B request drew a {REVOKED_DATAGRAM_LEN} B reply"
404 );
405 }
406 }
407 }
408
409 /// The ratios the reply budget actually permits, pinned so a future field
410 /// cannot quietly turn the port into a useful reflector. Nothing here is
411 /// remotely in DNS (~50x) or NTP monlist (~550x) territory, and every one of
412 /// them still requires the sender to hold a valid token key.
413 #[test]
414 fn amplification_ratios_stay_near_one() {
415 let hello = Message::Hello(Hello {
416 app_version_code: 0,
417 os_api_level: 0,
418 flags: HelloFlags::NONE,
419 config_version: 0,
420 });
421 let nack = Message::Nack(Nack {
422 nonce: NONCE,
423 reason: NackReason::Malformed,
424 retry_after_s: 0,
425 });
426 let exchanges = [
427 (
428 Message::Loc(vec![Point::new(0, 0, 0)]),
429 Message::Ack(Ack::single(NONCE)),
430 ),
431 (
432 Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]),
433 Message::Ack(Ack::single(NONCE)),
434 ),
435 (hello, Message::Ack(Ack::single(NONCE))),
436 (
437 Message::Ping(Ping::default()),
438 Message::Pong(Pong::default()),
439 ),
440 (
441 Message::ConfigGet(ConfigGet::default()),
442 Message::Config(Config::default()),
443 ),
444 (Message::Loc(vec![Point::new(0, 0, 0)]), nack),
445 ];
446
447 for (req, resp) in &exchanges {
448 let req_len = datagram_len(req.payload_len()) as f64;
449 let resp_len = datagram_len(resp.payload_len()) as f64;
450 let ratio = resp_len / req_len;
451 assert!(
452 ratio <= 1.5,
453 "{:?} -> {:?} amplifies {ratio:.2}x, more leverage than this design allows",
454 req.msg_type(),
455 resp.msg_type(),
456 );
457 }
458 }
459
460 /// The sizes the ratio table in the module documentation is computed from.
461 #[test]
462 fn documented_message_sizes_hold() {
463 let sizes = [
464 (Message::Ack(Ack::single(NONCE)), 51),
465 (
466 Message::Nack(Nack {
467 nonce: NONCE,
468 reason: NackReason::Malformed,
469 retry_after_s: 0,
470 }),
471 51,
472 ),
473 (Message::Config(Config::default()), 49),
474 (
475 Message::Revoked(Revoked {
476 reason: RevokeReason::Revoked,
477 }),
478 REVOKED_DATAGRAM_LEN,
479 ),
480 (Message::ConfigGet(ConfigGet::default()), 39),
481 (Message::Ping(Ping::default()), 43),
482 (Message::Pong(Pong::default()), 43),
483 (
484 Message::Hello(Hello {
485 app_version_code: 0,
486 os_api_level: 0,
487 flags: HelloFlags::NONE,
488 config_version: 0,
489 }),
490 43,
491 ),
492 ];
493 for (msg, want) in &sizes {
494 assert_eq!(
495 datagram_len(msg.payload_len()),
496 *want,
497 "{:?} changed size, which moves the amplification ratios",
498 msg.msg_type()
499 );
500 }
501 }
502
503 #[test]
504 fn documented_wire_sizes_hold() {
505 let one = datagram_len(Message::Loc(vec![Point::new(0, 0, 0)]).payload_len());
506 let twenty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); 20]).payload_len());
507 let forty = datagram_len(Message::Loc(vec![Point::new(0, 0, 0); MAX_POINTS]).payload_len());
508 assert_eq!((one, twenty, forty), (62, 518, 998));
509 }
510}
511