feat: add traffic analysis resistance (Phase 7.7 + F8)

- Core: add pad_uniform/unpad_uniform for configurable boundary padding
  and generate_decoy for indistinguishable decoy messages
- Server: add traffic_resistance module with payload padding, timing
  jitter, and background decoy traffic generator (feature-gated)
- P2P: add mesh traffic_resistance module with padded envelopes and
  mesh decoy injection (feature-gated)
- All gated behind --features traffic-resistance
- 22 new tests across core (8), server (4), and P2P (5)
This commit is contained in:
2026-03-04 20:50:19 +01:00
parent c401caec60
commit f4621b3425
7 changed files with 590 additions and 0 deletions

View File

@@ -11,11 +11,20 @@
//!
//! The total padded output is always one of the bucket sizes: 256, 1024, 4096, 16384 bytes.
//! For payloads larger than 16380 bytes, rounds up to the nearest 16384-byte multiple.
//!
//! ## Uniform boundary padding (traffic analysis resistance)
//!
//! [`pad_uniform`] / [`unpad_uniform`] pad to a configurable byte boundary
//! (default 256) instead of exponential buckets. This produces more uniform
//! ciphertext sizes at the cost of slightly more padding overhead.
use rand::RngCore;
use crate::error::CoreError;
/// Default uniform padding boundary in bytes.
pub const DEFAULT_PADDING_BOUNDARY: usize = 256;
/// Bucket sizes in bytes. The smallest (256) accommodates a sealed sender
/// envelope (99 bytes overhead) plus a short message.
const BUCKETS: &[usize] = &[256, 1024, 4096, 16384];
@@ -61,6 +70,46 @@ pub fn unpad(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
Ok(padded[4..4 + real_len].to_vec())
}
/// Pad a payload to the nearest multiple of `boundary` bytes.
///
/// Uses the same wire format as [`pad`]: `[real_length: 4 bytes LE][payload][random padding]`.
/// The total output length is always a multiple of `boundary`. A `boundary` of 0 is
/// treated as [`DEFAULT_PADDING_BOUNDARY`].
pub fn pad_uniform(payload: &[u8], boundary: usize) -> Vec<u8> {
let boundary = if boundary == 0 { DEFAULT_PADDING_BOUNDARY } else { boundary };
let total = payload.len() + 4; // 4-byte length prefix
let padded_len = total.div_ceil(boundary) * boundary;
let mut out = Vec::with_capacity(padded_len);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
let pad_len = padded_len - total;
if pad_len > 0 {
let mut padding = vec![0u8; pad_len];
rand::rngs::OsRng.fill_bytes(&mut padding);
out.extend_from_slice(&padding);
}
out
}
/// Remove uniform padding. Wire format is identical to [`unpad`].
pub fn unpad_uniform(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
unpad(padded)
}
/// Generate a decoy payload that looks identical to a real padded message.
///
/// Returns random bytes of length equal to a `boundary`-aligned padded message.
/// The 4-byte length prefix is set to 0, so [`unpad_uniform`] returns an empty payload.
pub fn generate_decoy(boundary: usize) -> Vec<u8> {
let boundary = if boundary == 0 { DEFAULT_PADDING_BOUNDARY } else { boundary };
let mut out = vec![0u8; boundary];
// Length prefix = 0 (decoy carries no real payload).
// Fill the rest with random bytes so it is indistinguishable from padding.
rand::rngs::OsRng.fill_bytes(&mut out[4..]);
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
@@ -142,4 +191,75 @@ mod tests {
bad.extend_from_slice(&[0u8; 10]);
assert!(unpad(&bad).is_err());
}
// ── Uniform padding tests ──────────────────────────────────────────────
#[test]
fn uniform_round_trip_default_boundary() {
let msg = b"uniform padding test";
let padded = pad_uniform(msg, DEFAULT_PADDING_BOUNDARY);
assert_eq!(padded.len() % DEFAULT_PADDING_BOUNDARY, 0);
assert_eq!(padded.len(), 256); // 20 + 4 = 24, rounds up to 256
let unpadded = unpad_uniform(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn uniform_custom_boundary() {
let msg = vec![0xAA; 100];
let padded = pad_uniform(&msg, 128);
assert_eq!(padded.len() % 128, 0);
assert_eq!(padded.len(), 128); // 100 + 4 = 104, rounds up to 128
let unpadded = unpad_uniform(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn uniform_exact_boundary() {
// 252 + 4 = 256, exactly on boundary
let msg = vec![0xBB; 252];
let padded = pad_uniform(&msg, 256);
assert_eq!(padded.len(), 256);
let unpadded = unpad_uniform(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn uniform_one_over_boundary() {
// 253 + 4 = 257, rounds up to 512
let msg = vec![0xCC; 253];
let padded = pad_uniform(&msg, 256);
assert_eq!(padded.len(), 512);
let unpadded = unpad_uniform(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn uniform_zero_boundary_uses_default() {
let msg = b"zero boundary";
let padded = pad_uniform(msg, 0);
assert_eq!(padded.len() % DEFAULT_PADDING_BOUNDARY, 0);
let unpadded = unpad_uniform(&padded).unwrap();
assert_eq!(unpadded, msg);
}
#[test]
fn decoy_is_boundary_aligned() {
let decoy = generate_decoy(256);
assert_eq!(decoy.len(), 256);
assert_eq!(decoy.len() % 256, 0);
}
#[test]
fn decoy_unpads_to_empty() {
let decoy = generate_decoy(256);
let payload = unpad_uniform(&decoy).unwrap();
assert!(payload.is_empty());
}
#[test]
fn decoy_default_boundary() {
let decoy = generate_decoy(0);
assert_eq!(decoy.len(), DEFAULT_PADDING_BOUNDARY);
}
}