Skip to main content

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}