feat(transport,tunnel): implement Wave 3 — QUIC transport + split-tunnel router
aura-transport: quinn 0.11 endpoint with HTTP/3 mimicry (ALPN h3/h3-29, Chrome-like transport params), outer-TLS accept-any (real auth is the inner Aura handshake), packet padding to HTTPS sizes; AuraServer/AuraClient drive the proto handshake over a QUIC bidi stream; AuraConnection impls aura_proto::PacketConnection (full-duplex via Session::split + per-half mutex). 14 tests incl. a real-QUIC loopback end-to-end (crypto+pki+proto+transport). aura-tunnel: RouteTable (longest-prefix split-tunnel classify), AuraDns (hickory) host-route registration, AuraRouter over a PacketIo TUN seam + Arc<dyn PacketConnection>, AuraTun (tun 0.8 unix; wintun cfg-gated Windows). 10 tests (route classify/priority, dst-IP parse, mock router). send_direct is a v1 stub. Whole workspace: tests green, clippy clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
//! Traffic-shaping padding helpers (project §7.2).
|
||||
//!
|
||||
//! Aura's outer wire is QUIC dressed up as HTTP/3 (see [`crate::mimicry`]). Real HTTPS/H3 traffic
|
||||
//! tends to cluster at a handful of record/datagram sizes; padding an Aura payload up to one of
|
||||
//! those buckets makes a passive size-only classifier less able to single it out. These helpers are
|
||||
//! deliberately simple, allocation-light, and fully unit-tested so the higher layers can call them
|
||||
//! with confidence.
|
||||
//!
|
||||
//! Note these operate on the *application* payload before it is handed to the proto/QUIC layers.
|
||||
//! They do not, and cannot, hide QUIC's own framing — they only normalize plaintext lengths so the
|
||||
//! ciphertext lands in a common size class.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
/// The HTTPS/H3-like size buckets a payload is rounded up to, in ascending order.
|
||||
///
|
||||
/// The first five (64 / 128 / 256 / 512 / 1024) are common small-record sizes; 1280 is the IPv6
|
||||
/// minimum-MTU "safe" QUIC datagram size; 1460 is a typical Ethernet TCP/QUIC payload (1500-byte
|
||||
/// MTU minus IP+UDP headers). Keep this sorted ascending: [`pad_to_https_size`] relies on it.
|
||||
pub const HTTPS_SIZE_BUCKETS: [usize; 7] = [64, 128, 256, 512, 1024, 1280, 1460];
|
||||
|
||||
/// The largest bucket in [`HTTPS_SIZE_BUCKETS`]; payloads at or above this are left unpadded.
|
||||
pub const MAX_BUCKET: usize = HTTPS_SIZE_BUCKETS[HTTPS_SIZE_BUCKETS.len() - 1];
|
||||
|
||||
/// Pad `packet` (in place, appending zero bytes) up to the next HTTPS-like size bucket.
|
||||
///
|
||||
/// Behavior:
|
||||
/// * If `packet.len()` is already exactly a bucket, it is left unchanged (idempotent).
|
||||
/// * Otherwise it grows to the smallest bucket strictly larger than its current length.
|
||||
/// * **At or over the largest bucket ([`MAX_BUCKET`] = 1460):** the packet is left unchanged. We do
|
||||
/// not pad to a multiple of 1460, because a single Aura payload is expected to fit within one
|
||||
/// datagram; over-MTU payloads are the caller's concern (e.g. they will be split by QUIC anyway),
|
||||
/// and rounding them up would only waste bandwidth without improving the size-class disguise.
|
||||
///
|
||||
/// Padding is appended as zero bytes; this is a length-shaping primitive, not an authenticated
|
||||
/// framing scheme — the proto layer seals the whole (already-padded) payload with AEAD, so the pad
|
||||
/// bytes are encrypted on the wire. Callers that need to *recover* the original length must carry it
|
||||
/// themselves (e.g. an inner length prefix); for Aura's IP-packet payloads the IP total-length field
|
||||
/// already bounds the real data, so trailing zeros are simply ignored by the receiver.
|
||||
pub fn pad_to_https_size(packet: &mut Vec<u8>) {
|
||||
let len = packet.len();
|
||||
// Smallest bucket that can already hold `len` (>=, so an exact-bucket length is a no-op and the
|
||||
// operation is idempotent). If `len` exceeds every bucket, no padding is applied.
|
||||
if let Some(&target) = HTTPS_SIZE_BUCKETS.iter().find(|&&b| b >= len) {
|
||||
packet.resize(target, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the bucket `len` would be padded *up to* by [`pad_to_https_size`], or `len` itself if it
|
||||
/// is at/over [`MAX_BUCKET`]. Useful for sizing buffers ahead of time and for tests.
|
||||
#[must_use]
|
||||
pub fn next_https_bucket(len: usize) -> usize {
|
||||
HTTPS_SIZE_BUCKETS
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&b| b >= len)
|
||||
.unwrap_or(len)
|
||||
}
|
||||
|
||||
/// Best-effort random padding: append between `min_pad` and `max_pad` (inclusive) zero bytes to
|
||||
/// `packet`, capping the result at `max_total` bytes so a hard size ceiling is never exceeded.
|
||||
///
|
||||
/// Signature rationale: callers want jitter on the wire without blowing a datagram budget, so the
|
||||
/// knobs are an inclusive `[min_pad, max_pad]` range plus an absolute `max_total` clamp. Returns the
|
||||
/// number of pad bytes actually appended (which may be **less** than `min_pad` if `max_total` was
|
||||
/// already nearly reached, including `0` when `packet.len() >= max_total`).
|
||||
///
|
||||
/// "Best-effort" = if the requested padding does not fit under `max_total`, as much as fits is added
|
||||
/// rather than erroring. If `min_pad > max_pad` the arguments are swapped so the call still does
|
||||
/// something sensible instead of panicking.
|
||||
pub fn inject_padding_frames(
|
||||
packet: &mut Vec<u8>,
|
||||
min_pad: usize,
|
||||
max_pad: usize,
|
||||
max_total: usize,
|
||||
) -> usize {
|
||||
let (lo, hi) = if min_pad <= max_pad {
|
||||
(min_pad, max_pad)
|
||||
} else {
|
||||
(max_pad, min_pad)
|
||||
};
|
||||
|
||||
let headroom = max_total.saturating_sub(packet.len());
|
||||
if headroom == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Pick a random amount in [lo, hi], then clamp to whatever headroom remains.
|
||||
let want = if lo == hi {
|
||||
lo
|
||||
} else {
|
||||
rand::thread_rng().gen_range(lo..=hi)
|
||||
};
|
||||
let pad = want.min(headroom);
|
||||
packet.resize(packet.len() + pad, 0);
|
||||
pad
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pads_up_to_each_bucket() {
|
||||
// One below / one into each bucket lands on that bucket.
|
||||
let cases = [
|
||||
(0usize, 64usize),
|
||||
(1, 64),
|
||||
(63, 64),
|
||||
(65, 128),
|
||||
(127, 128),
|
||||
(200, 256),
|
||||
(300, 512),
|
||||
(513, 1024),
|
||||
(1025, 1280),
|
||||
(1281, 1460),
|
||||
];
|
||||
for (input, expected) in cases {
|
||||
let mut v = vec![0xAB; input];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), expected, "padding {input} should reach {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_bucket_is_unchanged_and_idempotent() {
|
||||
for &b in &HTTPS_SIZE_BUCKETS {
|
||||
let mut v = vec![1u8; b];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), b, "exact bucket {b} must not grow");
|
||||
// Idempotence: padding again changes nothing.
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), b, "re-padding bucket {b} must be a no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_or_over_max_bucket_is_left_alone() {
|
||||
for len in [MAX_BUCKET, MAX_BUCKET + 1, 2000, 9000] {
|
||||
let mut v = vec![7u8; len];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), len, "len {len} >= MAX_BUCKET must be unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padding_preserves_original_prefix() {
|
||||
let mut v: Vec<u8> = (0..50u8).collect();
|
||||
let original = v.clone();
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(v.len(), 64);
|
||||
assert_eq!(&v[..50], &original[..], "real bytes must be preserved");
|
||||
assert!(v[50..].iter().all(|&b| b == 0), "pad must be zero bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_bucket_matches_padding() {
|
||||
for len in [0usize, 1, 64, 65, 1024, 1459, 1460, 5000] {
|
||||
let predicted = next_https_bucket(len);
|
||||
let mut v = vec![0u8; len];
|
||||
pad_to_https_size(&mut v);
|
||||
assert_eq!(predicted, v.len(), "next_https_bucket disagrees at {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_respects_range_and_cap() {
|
||||
// Plenty of headroom: result grows by something in [4, 8].
|
||||
let mut v = vec![0u8; 10];
|
||||
let added = inject_padding_frames(&mut v, 4, 8, 1000);
|
||||
assert!((4..=8).contains(&added), "added {added} outside [4,8]");
|
||||
assert_eq!(v.len(), 10 + added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_clamps_to_max_total() {
|
||||
// Only 3 bytes of headroom even though we ask for 10..=10.
|
||||
let mut v = vec![0u8; 97];
|
||||
let added = inject_padding_frames(&mut v, 10, 10, 100);
|
||||
assert_eq!(added, 3, "should add only what fits under max_total");
|
||||
assert_eq!(v.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_zero_headroom_is_noop() {
|
||||
let mut v = vec![0u8; 100];
|
||||
let added = inject_padding_frames(&mut v, 1, 50, 100);
|
||||
assert_eq!(added, 0);
|
||||
assert_eq!(v.len(), 100);
|
||||
|
||||
// Already over the cap: still a no-op, never truncates.
|
||||
let mut v2 = vec![0u8; 120];
|
||||
let added2 = inject_padding_frames(&mut v2, 1, 50, 100);
|
||||
assert_eq!(added2, 0);
|
||||
assert_eq!(v2.len(), 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_padding_swapped_bounds_dont_panic() {
|
||||
let mut v = vec![0u8; 10];
|
||||
let added = inject_padding_frames(&mut v, 8, 4, 1000); // min > max
|
||||
assert!((4..=8).contains(&added));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user