chore: rename quicproquo → quicprochat in Rust workspace
Rename all crate directories, package names, binary names, proto package/module paths, ALPN strings, env var prefixes, config filenames, mDNS service names, and plugin ABI symbols from quicproquo/qpq to quicprochat/qpc.
This commit is contained in:
265
crates/quicprochat-core/src/padding.rs
Normal file
265
crates/quicprochat-core/src/padding.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
//! Message padding to hide plaintext lengths from the server.
|
||||
//!
|
||||
//! Pads payloads to fixed bucket sizes before MLS encryption so that the
|
||||
//! ciphertext does not reveal the actual message length.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! ```text
|
||||
//! [real_length: 4 bytes LE (u32)][payload: real_length bytes][random padding]
|
||||
//! ```
|
||||
//!
|
||||
//! 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];
|
||||
|
||||
/// Select the smallest bucket that fits `content_len + 4` (the 4-byte length prefix).
|
||||
fn bucket_for(content_len: usize) -> usize {
|
||||
let total = content_len + 4;
|
||||
for &b in BUCKETS {
|
||||
if total <= b {
|
||||
return b;
|
||||
}
|
||||
}
|
||||
// Larger than biggest bucket: round up to nearest 16384-byte multiple.
|
||||
total.div_ceil(16384) * 16384
|
||||
}
|
||||
|
||||
/// Pad a payload to the next bucket boundary with cryptographic random bytes.
|
||||
pub fn pad(payload: &[u8]) -> Vec<u8> {
|
||||
let bucket = bucket_for(payload.len());
|
||||
let mut out = Vec::with_capacity(bucket);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
let pad_len = bucket - 4 - payload.len();
|
||||
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 padding and return the original payload.
|
||||
pub fn unpad(padded: &[u8]) -> Result<Vec<u8>, CoreError> {
|
||||
if padded.len() < 4 {
|
||||
return Err(CoreError::AppMessage("padded message too short".into()));
|
||||
}
|
||||
let real_len = u32::from_le_bytes([padded[0], padded[1], padded[2], padded[3]]) as usize;
|
||||
if 4 + real_len > padded.len() {
|
||||
return Err(CoreError::AppMessage(
|
||||
"padded real_length exceeds buffer".into(),
|
||||
));
|
||||
}
|
||||
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 {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_small() {
|
||||
let msg = b"hello";
|
||||
let padded = pad(msg);
|
||||
assert_eq!(padded.len(), 256); // smallest bucket
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_medium() {
|
||||
let msg = vec![0xAB; 300];
|
||||
let padded = pad(&msg);
|
||||
assert_eq!(padded.len(), 1024); // second bucket
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_large() {
|
||||
let msg = vec![0xCD; 2000];
|
||||
let padded = pad(&msg);
|
||||
assert_eq!(padded.len(), 4096); // third bucket
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_very_large() {
|
||||
let msg = vec![0xEF; 10000];
|
||||
let padded = pad(&msg);
|
||||
assert_eq!(padded.len(), 16384); // largest bucket
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_oversized() {
|
||||
let msg = vec![0xFF; 20000];
|
||||
let padded = pad(&msg);
|
||||
assert_eq!(padded.len(), 32768); // 2 * 16384
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_empty() {
|
||||
let msg = b"";
|
||||
let padded = pad(msg);
|
||||
assert_eq!(padded.len(), 256); // smallest bucket
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exactly_at_bucket_boundary() {
|
||||
// 252 + 4 = 256 → fits in 256 bucket exactly
|
||||
let msg = vec![0x42; 252];
|
||||
let padded = pad(&msg);
|
||||
assert_eq!(padded.len(), 256);
|
||||
let unpadded = unpad(&padded).unwrap();
|
||||
assert_eq!(unpadded, msg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpad_too_short_fails() {
|
||||
assert!(unpad(&[0, 0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpad_invalid_length_fails() {
|
||||
// Claims 1000 bytes but only has 10
|
||||
let mut bad = (1000u32).to_le_bytes().to_vec();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user