Skip to main content

phantom_protocol/transport/
fragmentation.rs

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