phantom_protocol/transport/fragmentation.rs
1//! UDP fragmentation and reassembly with anti-DoS bounds.
2//!
3//! A logical packet larger than `MAX_UDP_PAYLOAD` is split into [`CryptoFrame`] chunks
4//! by [`fragment_payload`] and reassembled by [`FragmentAssembler`]. Because the wire is
5//! unreliable, unordered UDP, the assembler buffers partial assemblies keyed on
6//! `(session_id, packet_id)` and completes a packet once every chunk index has arrived.
7//!
8//! The reassembler is the only stateful, attacker-reachable buffer in the fragmentation
9//! path, so every input is bounds-checked: a bad fragment is silently dropped (never an
10//! error). The three caps below (`MAX_REASSEMBLED_LEN`, `MAX_TOTAL_CHUNKS`,
11//! `MAX_CONCURRENT_ASSEMBLIES`) plus the M-7 insert-if-absent rule bound the memory a
12//! peer can pin and stop a spoofed-key attacker from corrupting a victim's reassembly.
13//!
14//! This module is target-agnostic; the production PhantomUDP transport reuses it via
15//! `transport::phantom_udp::datagram`.
16
17use borsh::{BorshDeserialize, BorshSerialize};
18use std::collections::HashMap;
19use std::time::{Duration, Instant};
20
21const MAX_UDP_PAYLOAD: usize = 1200; // Leave room for IP/UDP headers and protocol overhead
22
23/// Largest logical packet the assembler will reassemble. Bounds the memory a
24/// single `(session_id, packet_id)` assembly can pin: at most
25/// `MAX_TOTAL_CHUNKS` chunks of `MAX_UDP_PAYLOAD` bytes each.
26pub const MAX_REASSEMBLED_LEN: usize = 256 * 1024;
27
28/// Maximum fragments per logical packet, derived from the reassembled-size cap.
29/// A frame declaring more than this (up to the `u16::MAX` the wire allows) is
30/// dropped, so an attacker cannot force a 65 535-entry chunk map.
31pub const MAX_TOTAL_CHUNKS: u16 = (MAX_REASSEMBLED_LEN / MAX_UDP_PAYLOAD + 1) as u16;
32
33/// Maximum number of in-flight (incomplete) assemblies tracked at once. Caps
34/// the memory an attacker can pin by spraying chunks across many distinct
35/// `(session_id, packet_id)` keys without ever completing a packet. The
36/// worst-case resident memory is therefore bounded by
37/// `MAX_CONCURRENT_ASSEMBLIES * MAX_REASSEMBLED_LEN` (≈ 64 MiB).
38pub const MAX_CONCURRENT_ASSEMBLIES: usize = 256;
39
40/// A single chunk of a fragmented logical packet.
41///
42/// `(session_id, packet_id)` is the reassembly key. It is an opaque demux key, not a
43/// security boundary: a chunk's authenticity is established later by the inner AEAD, not
44/// here. In the production PhantomUDP path the assembler is fed `session_id` =
45/// the rotating 8-byte outer ConnId zero-extended to 16 bytes (see `phantom_udp::datagram`),
46/// which is cleartext and guessable — hence the insert-if-absent (M-7) and anti-DoS bounds
47/// in `FragmentAssembler::process_chunk`.
48#[derive(BorshSerialize, BorshDeserialize, Debug, Clone)]
49pub struct CryptoFrame {
50 pub session_id: [u8; 16],
51 pub packet_id: u32,
52 pub chunk_index: u16,
53 pub total_chunks: u16,
54 pub payload: Vec<u8>,
55}
56
57pub struct FragmentAssembler {
58 // Map of (SessionId, PacketId) -> (Received Chunks, Total Chunks, Last Update Time)
59 assemblies: HashMap<([u8; 16], u32), AssemblyState>,
60}
61
62struct AssemblyState {
63 chunks: HashMap<u16, Vec<u8>>,
64 total_chunks: u16,
65 last_update: Instant,
66}
67
68impl Default for FragmentAssembler {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl FragmentAssembler {
75 pub fn new() -> Self {
76 Self {
77 assemblies: HashMap::new(),
78 }
79 }
80
81 /// Process a new CryptoFrame chunk.
82 /// Returns Some(reassembled_packet) if this chunk completes the packet.
83 pub fn process_chunk(&mut self, frame: CryptoFrame) -> Option<Vec<u8>> {
84 // Reject malformed or abusive fragments up front — for a UDP reassembler
85 // a bad fragment is simply dropped. Without these bounds a peer could
86 // pin unbounded memory: a huge `total_chunks` (up to 65 535) inflates
87 // the per-assembly chunk map, an out-of-range `chunk_index` parks bytes
88 // in a slot completion never reaches, and an oversized `payload`
89 // (borsh-decoded, so not implicitly capped at the datagram MTU)
90 // amplifies each chunk.
91 if frame.total_chunks == 0
92 || frame.total_chunks > MAX_TOTAL_CHUNKS
93 || frame.chunk_index >= frame.total_chunks
94 || frame.payload.len() > MAX_UDP_PAYLOAD
95 {
96 return None;
97 }
98
99 let key = (frame.session_id, frame.packet_id);
100
101 // Bound the number of concurrent assemblies. If this frame would open a
102 // NEW assembly while the table is full, evict the stalest one first —
103 // dropping the most-abandoned partial (typically an attacker's spray or
104 // a dead transfer) rather than letting the table grow without limit or
105 // permanently locking out fresh packets.
106 if !self.assemblies.contains_key(&key) && self.assemblies.len() >= MAX_CONCURRENT_ASSEMBLIES
107 {
108 self.evict_stalest();
109 }
110
111 let is_complete = {
112 let state = self.assemblies.entry(key).or_insert_with(|| AssemblyState {
113 chunks: HashMap::new(),
114 total_chunks: frame.total_chunks,
115 last_update: Instant::now(),
116 });
117
118 state.last_update = Instant::now();
119 // Insert-if-absent (M-7): a duplicate or poisoned chunk for an already-received
120 // index must NOT overwrite the first-seen payload — an on-path attacker who
121 // guessed the cleartext `(session_id, packet_id)` could otherwise corrupt a
122 // victim's reassembly (which then fails the victim's AEAD). The first chunk wins.
123 state
124 .chunks
125 .entry(frame.chunk_index)
126 .or_insert(frame.payload);
127
128 state.chunks.len() == state.total_chunks as usize
129 };
130
131 if is_complete {
132 // PANIC-SAFETY: the `is_complete` branch above just inserted the
133 // entry under `key` via `entry(key).or_insert_with(...)` and we
134 // hold `&mut self` — nothing else can have removed it.
135 #[allow(clippy::unwrap_used, clippy::disallowed_methods)]
136 let state = self.assemblies.remove(&key).unwrap();
137 let mut total_size = 0;
138 for i in 0..state.total_chunks {
139 if let Some(chunk) = state.chunks.get(&i) {
140 total_size += chunk.len();
141 } else {
142 return None;
143 }
144 }
145
146 let mut packet = Vec::with_capacity(total_size);
147 for i in 0..state.total_chunks {
148 // PANIC-SAFETY: the preceding loop returned early if any
149 // chunk `i` was missing; reaching this loop proves every
150 // index in `0..total_chunks` is present.
151 #[allow(clippy::unwrap_used, clippy::disallowed_methods)]
152 packet.extend_from_slice(state.chunks.get(&i).unwrap());
153 }
154
155 return Some(packet);
156 }
157
158 None
159 }
160
161 /// Evict the single least-recently-updated assembly. Used to keep the table
162 /// at or below [`MAX_CONCURRENT_ASSEMBLIES`] when a new assembly arrives at
163 /// capacity (the periodic `get_nacks_and_evict` sweep only reclaims dead
164 /// entries on a timer, which is too slow under a deliberate spray).
165 fn evict_stalest(&mut self) {
166 if let Some((&stalest_key, _)) = self
167 .assemblies
168 .iter()
169 .min_by_key(|(_, state)| state.last_update)
170 {
171 self.assemblies.remove(&stalest_key);
172 }
173 }
174
175 /// Number of in-flight (incomplete) assemblies currently tracked.
176 pub fn len(&self) -> usize {
177 self.assemblies.len()
178 }
179
180 /// Whether there are no in-flight assemblies.
181 pub fn is_empty(&self) -> bool {
182 self.assemblies.is_empty()
183 }
184
185 /// Check for timed out assemblies and return a list of missing chunks (NACK)
186 /// Also evicts purely dead assemblies (> 5000ms)
187 pub fn get_nacks_and_evict(&mut self) -> Vec<([u8; 16], u32, Vec<u16>)> {
188 let now = Instant::now();
189 let mut nacks = Vec::new();
190 let mut to_remove = Vec::new();
191
192 for (key, state) in self.assemblies.iter() {
193 let elapsed = now.duration_since(state.last_update);
194
195 if elapsed > Duration::from_millis(5000) {
196 // Dead
197 to_remove.push(*key);
198 } else if elapsed > Duration::from_millis(50) {
199 // NACK condition
200 let mut missing = Vec::new();
201 for i in 0..state.total_chunks {
202 if !state.chunks.contains_key(&i) {
203 missing.push(i);
204 }
205 }
206 if !missing.is_empty() {
207 nacks.push((key.0, key.1, missing));
208 }
209 }
210 }
211
212 for k in to_remove {
213 self.assemblies.remove(&k);
214 }
215
216 nacks
217 }
218}
219
220/// Split a large payload into CryptoFrame chunks
221pub fn fragment_payload(session_id: [u8; 16], packet_id: u32, payload: &[u8]) -> Vec<CryptoFrame> {
222 let mut frames = Vec::new();
223 let chunks = payload.chunks(MAX_UDP_PAYLOAD);
224 let total_chunks = chunks.len() as u16;
225
226 for (i, chunk) in chunks.enumerate() {
227 frames.push(CryptoFrame {
228 session_id,
229 packet_id,
230 chunk_index: i as u16,
231 total_chunks,
232 payload: chunk.to_vec(),
233 });
234 }
235
236 frames
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 fn frame(packet_id: u32, idx: u16, total: u16, payload_len: usize) -> CryptoFrame {
244 CryptoFrame {
245 session_id: [0u8; 16],
246 packet_id,
247 chunk_index: idx,
248 total_chunks: total,
249 payload: vec![0xABu8; payload_len],
250 }
251 }
252
253 #[test]
254 fn fragment_reassemble_round_trip() {
255 let payload: Vec<u8> = (0..3000u32).map(|i| i as u8).collect();
256 let frames = fragment_payload([1u8; 16], 42, &payload);
257 assert!(frames.len() > 1, "3000 bytes must fragment");
258 let mut asm = FragmentAssembler::new();
259 let mut out = None;
260 for f in frames {
261 if let Some(p) = asm.process_chunk(f) {
262 out = Some(p);
263 }
264 }
265 assert_eq!(out.as_deref(), Some(payload.as_slice()));
266 assert!(asm.is_empty(), "completed assembly is removed");
267 }
268
269 #[test]
270 fn rejects_zero_total_chunks() {
271 let mut asm = FragmentAssembler::new();
272 assert!(asm.process_chunk(frame(1, 0, 0, 10)).is_none());
273 assert!(asm.is_empty(), "malformed frame must not open an assembly");
274 }
275
276 #[test]
277 fn rejects_out_of_range_chunk_index() {
278 let mut asm = FragmentAssembler::new();
279 // chunk_index == total_chunks is out of the valid 0..total range.
280 assert!(asm.process_chunk(frame(1, 2, 2, 10)).is_none());
281 assert!(asm.is_empty());
282 }
283
284 #[test]
285 fn rejects_excessive_total_chunks() {
286 let mut asm = FragmentAssembler::new();
287 assert!(asm
288 .process_chunk(frame(1, 0, MAX_TOTAL_CHUNKS.saturating_add(1), 10))
289 .is_none());
290 assert!(asm.is_empty());
291 }
292
293 #[test]
294 fn rejects_oversized_fragment_payload() {
295 let mut asm = FragmentAssembler::new();
296 assert!(asm
297 .process_chunk(frame(1, 0, 4, MAX_UDP_PAYLOAD + 1))
298 .is_none());
299 assert!(asm.is_empty());
300 }
301
302 #[test]
303 fn caps_concurrent_assemblies() {
304 let mut asm = FragmentAssembler::new();
305 // Open far more distinct (never-completed, total_chunks=4) assemblies
306 // than the cap; the table must never exceed MAX_CONCURRENT_ASSEMBLIES.
307 for packet_id in 0..(MAX_CONCURRENT_ASSEMBLIES as u32 * 4) {
308 assert!(asm.process_chunk(frame(packet_id, 0, 4, 10)).is_none());
309 assert!(
310 asm.len() <= MAX_CONCURRENT_ASSEMBLIES,
311 "assembly table exceeded its cap: {}",
312 asm.len()
313 );
314 }
315 assert_eq!(asm.len(), MAX_CONCURRENT_ASSEMBLIES);
316 }
317
318 /// M-7: chunk insertion is insert-if-absent. An on-path attacker who guessed the
319 /// cleartext `(session_id, packet_id)` of a victim's in-flight reassembly must not be
320 /// able to overwrite an already-received chunk with a poisoned payload (which would
321 /// corrupt the reassembly so it fails the victim's AEAD). The first chunk seen at an
322 /// index wins; a later duplicate at that index is ignored.
323 #[test]
324 fn duplicate_chunk_does_not_overwrite_first_seen_payload() {
325 let mut asm = FragmentAssembler::new();
326 let mk = |idx: u16, byte: u8| CryptoFrame {
327 session_id: [1u8; 16],
328 packet_id: 7,
329 chunk_index: idx,
330 total_chunks: 2,
331 payload: vec![byte; 4],
332 };
333 // Real chunk 0, then a poisoned duplicate of chunk 0 (different bytes).
334 assert!(asm.process_chunk(mk(0, 0xAA)).is_none());
335 assert!(
336 asm.process_chunk(mk(0, 0xFF)).is_none(),
337 "a duplicate index must not complete or overwrite"
338 );
339 // Chunk 1 completes the packet; chunk 0 must still be the first-seen (0xAA) payload.
340 let out = asm
341 .process_chunk(mk(1, 0xBB))
342 .expect("completes the 2-chunk packet");
343 assert_eq!(
344 out,
345 vec![0xAA, 0xAA, 0xAA, 0xAA, 0xBB, 0xBB, 0xBB, 0xBB],
346 "first-seen chunk 0 must win, not the poisoned duplicate"
347 );
348 }
349}