phantom_protocol/transport/shaping.rs
1//! Traffic shaping — anti-fingerprint size padding (WIRE v6, deliverable (c)).
2//!
3//! The data-plane datagram size otherwise tracks the application payload size
4//! (even with the v6 length-prefix diet, the datagram *length* is observable).
5//! This module hides it by padding each packet up to a size **bucket** before it
6//! is sealed, so an on-path observer sees only a small set of sizes.
7//!
8//! Padding lives **inside** the AEAD plaintext (encrypted + authenticated), so a
9//! network attacker can neither see it, strip it, nor forge it — only the bucketed
10//! datagram size is observable. A padded packet sets
11//! [`PacketFlags::PADDED`](crate::transport::types::PacketFlags::PADDED); its
12//! plaintext gains a trailer `‹pad_n zero bytes› ‖ pad_n:u16be`, which the receiver
13//! strips after a successful decrypt.
14//!
15//! The policy that picks the bucket is **PADÉ** (Nikitin et al., "PURBs", 2019):
16//! a length is rounded up so its low `E−S` bits are zero (`E = ⌊log2 L⌋`,
17//! `S = ⌊log2 E⌋+1`), which caps the overhead at ≈ `1/E` (≤ ~12% for small
18//! packets, →0 for large) while collapsing the size distribution to O(log) values
19//! per magnitude. Far cheaper than pad-to-MTU, far better than fixed buckets near
20//! their edges.
21//!
22//! Pure + `no_std`-friendly (no allocation in the size math; `append`/`strip`
23//! operate on caller buffers). Default policy is [`PaddingPolicy::None`] — shaping
24//! is fully opt-in (it costs bandwidth).
25
26use crate::crypto::adaptive_crypto::AEAD_OVERHEAD;
27use crate::transport::types::{PacketHeader, WireError};
28use std::time::Duration;
29
30/// Upper bound on the padded on-wire packet size (the `PhantomPacket` wire image:
31/// 15-byte header + ciphertext). Capped below the 1200-byte path MTU with margin
32/// for the largest transport envelope (the 8-byte UDP `ConnId`, plus slack), so a
33/// padded datagram never fragments. A packet already larger than this is not
34/// padded (it is near-MTU already — low size entropy).
35pub const MAX_SHAPED_WIRE: usize = 1184;
36
37/// Size of the in-plaintext padding length field (`pad_n: u16be`). The minimum
38/// trailer a padded packet carries (with `pad_n == 0` zero bytes).
39pub const PAD_LEN_FIELD: usize = 2;
40
41/// How a packet's on-wire size is chosen before sealing.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43#[cfg_attr(feature = "bindings", derive(uniffi::Enum))]
44pub enum PaddingPolicy {
45 /// No padding — the wire size is the natural payload size (default).
46 #[default]
47 None,
48 /// PADÉ bucketing (bounded ≈12% worst-case overhead).
49 Padme,
50}
51
52/// PADÉ: round `l` up so its low `E−S` bits are zero, where `E = ⌊log2 l⌋` and
53/// `S = ⌊log2 E⌋ + 1`. Returns `l` unchanged for `l ≤ 2` (nothing to round).
54/// Monotone non-decreasing and idempotent; overhead `(padme(l) − l)/l < 2^−S`.
55pub fn padme(l: usize) -> usize {
56 if l <= 2 {
57 return l;
58 }
59 // E = ⌊log2 l⌋ (≥ 1 here); S = ⌊log2 E⌋ + 1 = bits needed to represent E.
60 let e = l.ilog2() as usize;
61 let s = if e == 0 { 1 } else { e.ilog2() as usize + 1 };
62 let mask_bits = e.saturating_sub(s);
63 // Round `l` up, clearing its low `mask_bits` bits.
64 let mask = (1usize << mask_bits) - 1;
65 (l + mask) & !mask
66}
67
68/// The number of trailer bytes to append to the AEAD **plaintext** to bring the
69/// resulting on-wire packet to a PADÉ bucket. `0` when no padding applies (policy
70/// `None`, or the packet is already too large to pad under [`MAX_SHAPED_WIRE`]).
71/// When non-zero it is `≥ PAD_LEN_FIELD` (the 2-byte length field plus zero fill).
72///
73/// `plaintext_len` is the length of the inner AEAD plaintext *before* padding; the
74/// on-wire size is `header(15) + plaintext_len + AEAD_OVERHEAD`.
75pub fn padding_trailer_len(plaintext_len: usize, policy: PaddingPolicy) -> usize {
76 match policy {
77 PaddingPolicy::None => 0,
78 PaddingPolicy::Padme => {
79 let wire = PacketHeader::SIZE + plaintext_len + AEAD_OVERHEAD;
80 // Target a bucket at least `PAD_LEN_FIELD` above the current wire size
81 // (we must have room for the mandatory pad-length field), capped so the
82 // datagram cannot exceed the MTU.
83 let target = padme(wire + PAD_LEN_FIELD).min(MAX_SHAPED_WIRE);
84 if target >= wire + PAD_LEN_FIELD {
85 target - wire
86 } else {
87 0
88 }
89 }
90 }
91}
92
93/// Append a padding trailer of `trailer` total bytes to `plaintext`:
94/// `(trailer − PAD_LEN_FIELD)` zero bytes, then the big-endian `u16` count of
95/// those zero bytes. `trailer` must be `≥ PAD_LEN_FIELD` and `≤ u16::MAX +
96/// PAD_LEN_FIELD` (callers get `trailer` from [`padding_trailer_len`], which
97/// respects both). A `trailer < PAD_LEN_FIELD` is a no-op (defensive).
98pub fn append_padding(plaintext: &mut Vec<u8>, trailer: usize) {
99 if trailer < PAD_LEN_FIELD {
100 return;
101 }
102 let pad_n = (trailer - PAD_LEN_FIELD) as u16;
103 plaintext.resize(plaintext.len() + pad_n as usize, 0);
104 plaintext.extend_from_slice(&pad_n.to_be_bytes());
105}
106
107/// Strip a padding trailer from a decrypted, `PADDED`-flagged plaintext: read the
108/// trailing `u16be` pad length, then drop that many zero bytes plus the 2-byte
109/// field. Returns the inner plaintext slice. A trailer that claims more bytes than
110/// are present is [`WireError::Truncated`] (an authenticated peer cannot reach this
111/// with a malformed trailer — the AEAD already verified — but a buggy peer must not
112/// panic the receiver).
113pub fn strip_padding(plaintext: &[u8]) -> Result<&[u8], WireError> {
114 if plaintext.len() < PAD_LEN_FIELD {
115 return Err(WireError::Truncated);
116 }
117 let split = plaintext.len() - PAD_LEN_FIELD;
118 let pad_n = u16::from_be_bytes([plaintext[split], plaintext[split + 1]]) as usize;
119 let inner_len = split.checked_sub(pad_n).ok_or(WireError::Truncated)?;
120 Ok(&plaintext[..inner_len])
121}
122
123/// Anti-fingerprint timing jitter (WIRE v6, deliverable (d)): a **uniform random**
124/// delay in `[0, max_ms]` milliseconds to add before a send, so the inter-packet
125/// timing no longer tracks the application's write pattern. Returns
126/// `Duration::ZERO` when `max_ms == 0` (jitter off). Opt-in; it trades up to
127/// `max_ms` of added latency per packet for timing-correlation resistance.
128pub fn random_jitter(max_ms: u32) -> Duration {
129 if max_ms == 0 {
130 return Duration::ZERO;
131 }
132 use rand::Rng;
133 // Inclusive `[0, max_ms]` so both endpoints are reachable.
134 let ms = rand::thread_rng().gen_range(0..=max_ms);
135 Duration::from_millis(ms as u64)
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 /// PADÉ rounds to the expected buckets (hand-computed): the low `E−S` bits are
143 /// zeroed, so powers of two and just-below-power values collapse together.
144 #[test]
145 fn padme_rounds_to_expected_buckets() {
146 assert_eq!(padme(0), 0);
147 assert_eq!(padme(1), 1);
148 assert_eq!(padme(2), 2);
149 assert_eq!(padme(3), 3); // E=1,S=1 → 0 low bits → unchanged
150 assert_eq!(padme(63), 64); // E=5,S=3 → clear 2 bits → 64
151 assert_eq!(padme(64), 64); // power of two → unchanged
152 assert_eq!(padme(100), 104); // E=6,S=3 → clear 3 bits → 104
153 assert_eq!(padme(1000), 1024); // E=9,S=4 → clear 5 bits → 1024
154 }
155
156 /// PADÉ is monotone non-decreasing, never shrinks a length, is idempotent, and
157 /// the overhead is bounded (≤ ~12% above 8 bytes).
158 #[test]
159 fn padme_is_monotone_idempotent_and_bounded() {
160 let mut prev = 0;
161 for l in 0..4096usize {
162 let p = padme(l);
163 assert!(p >= l, "padme never shrinks: padme({l})={p}");
164 assert!(p >= prev, "padme is monotone: padme({l})={p} < prev {prev}");
165 assert_eq!(padme(p), p, "padme is idempotent at the bucket {p}");
166 if l >= 8 {
167 assert!(
168 (p - l) * 100 <= l * 12,
169 "overhead bound: padme({l})={p} exceeds ~12%"
170 );
171 }
172 prev = p;
173 }
174 }
175
176 /// A padding trailer round-trips: stripping `plaintext ‖ append(trailer)`
177 /// recovers exactly `plaintext`, for every inner length and every trailer the
178 /// policy can pick.
179 #[test]
180 fn append_then_strip_is_identity() {
181 for inner_len in [0usize, 1, 15, 16, 17, 100, 500, 1100] {
182 let inner: Vec<u8> = (0..inner_len).map(|i| (i % 251) as u8).collect();
183 let trailer = padding_trailer_len(inner_len, PaddingPolicy::Padme);
184 let mut padded = inner.clone();
185 append_padding(&mut padded, trailer);
186 // The padded wire size lands on a PADÉ bucket (or is capped).
187 let wire = PacketHeader::SIZE + padded.len() + AEAD_OVERHEAD;
188 if trailer > 0 {
189 assert!(wire <= MAX_SHAPED_WIRE);
190 assert_eq!(
191 wire,
192 padme(PacketHeader::SIZE + inner_len + AEAD_OVERHEAD + PAD_LEN_FIELD)
193 .min(MAX_SHAPED_WIRE)
194 );
195 }
196 let recovered = strip_padding(&padded).expect("strip");
197 assert_eq!(recovered, &inner[..], "strip(append(x)) == x");
198 }
199 }
200
201 /// `None` policy never pads; near-MTU packets are not padded (would fragment).
202 #[test]
203 fn policy_none_and_mtu_cap() {
204 assert_eq!(padding_trailer_len(500, PaddingPolicy::None), 0);
205 // A plaintext whose wire size is already at/above the cap → no padding.
206 let big = MAX_SHAPED_WIRE; // wire would exceed the cap once header+tag added
207 assert_eq!(padding_trailer_len(big, PaddingPolicy::Padme), 0);
208 }
209
210 /// Timing jitter is bounded to `[0, max_ms]` and actually varies (so it isn't
211 /// the trivial zero implementation); `max_ms == 0` disables it.
212 #[test]
213 fn random_jitter_is_bounded_and_varies() {
214 assert_eq!(random_jitter(0), Duration::ZERO, "0 disables jitter");
215 let max = 15u32;
216 let cap = Duration::from_millis(max as u64);
217 let mut saw_nonzero = false;
218 for _ in 0..1000 {
219 let d = random_jitter(max);
220 assert!(d <= cap, "jitter {d:?} exceeds the {max}ms ceiling");
221 if d > Duration::ZERO {
222 saw_nonzero = true;
223 }
224 }
225 assert!(
226 saw_nonzero,
227 "jitter must actually add delay (not always zero)"
228 );
229 }
230
231 /// A malformed trailer (claims more pad than present) is a typed error, never a
232 /// panic — a buggy authenticated peer must not crash the receiver.
233 #[test]
234 fn strip_rejects_malformed_trailer() {
235 // pad_n = 0xFFFF but only a few bytes present.
236 let bad = vec![0u8, 0u8, 0xFF, 0xFF];
237 assert_eq!(strip_padding(&bad), Err(WireError::Truncated));
238 // Shorter than the length field.
239 assert_eq!(strip_padding(&[0u8]), Err(WireError::Truncated));
240 }
241}