Skip to main content

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}