feat(crypto,cli,transport): daily protocol-mask rotation at 05:00 MSK
Both server and client deterministically rotate the on-wire obfuscation mask (SNI, HTTP Host/User-Agent/Server headers, UDP padding profile) at 05:00 Moscow time (02:00 UTC) every day, derived from the CA fingerprint + UTC date — no network coordination needed. - aura-crypto::masks: MaskSet + 4 palettes (16 SNI, 10 UA, 5 Server, 4 padding profiles); derive_mask_for_msk_date via HKDF-SHA256(salt="aura-mask-v1-salt", ikm=ca_fp||"YYYY-MM-DD", info="aura-mask-v1"); ca_fingerprint with built-in base64 PEM decode (no new deps). - aura-cli::masks: MaskRotator (Arc<RwLock<MaskSet>>) + Hinnant's civil_from_days for manual UTC date math; scheduler picks next 02:00 UTC strictly (avoids busy-loop at boundary); spawned at startup in server::run/client::run. - aura-transport: PADDING_PROFILES + next_bucket_for_profile (profile 0 byte-for- byte equals legacy pad_to_https_size); TcpOpts gains user_agent/server_header; UdpOpts gains padding_profile; MultiServer holds Arc<UdpServer>/Arc<TcpServer> with set_udp_opts/set_tcp_opts so rotation propagates without restart. - Backward-compatible: defaults preserve previous behavior; existing 97 tests unchanged. 17 new tests (derive determinism + date variation, civil-from-days known points incl. 1970-01-01/2000-02-29/2024->2025, next-rotation boundary, msk_today offset, profile equivalence, base64 round-trip, full mask-driven UDP loopback). Total: 114 passed, clippy/fmt clean. No new workspace deps. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,11 +17,40 @@ use rand::Rng;
|
||||
/// 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.
|
||||
///
|
||||
/// This is **profile 0** of [`PADDING_PROFILES`]; the standalone `const` is preserved as a
|
||||
/// backwards-compatible alias so existing transport tests and direct callers keep working.
|
||||
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];
|
||||
|
||||
/// Padding-profile palettes used by the daily mask rotation
|
||||
/// ([`aura_crypto::MaskSet::padding_profile_id`]).
|
||||
///
|
||||
/// Each profile is a strictly-ascending list of size buckets; [`pad_to_bucket`] rounds a packet up
|
||||
/// to the smallest bucket of the chosen profile that is `>= len`, and leaves packets at/over the
|
||||
/// profile's maximum unchanged (same semantics as the original [`pad_to_https_size`]).
|
||||
///
|
||||
/// * Profile **0** = [`HTTPS_SIZE_BUCKETS`] — the historical default; identical wire behaviour to
|
||||
/// pre-rotation so legacy tests pass.
|
||||
/// * Profiles **1..3** — alternative bucket shapes drawn from common MTU/TLS-record sizes; their
|
||||
/// total bandwidth overhead is similar but the on-wire size distribution is different.
|
||||
///
|
||||
/// The mask layer expects exactly [`aura_crypto::PADDING_PROFILE_COUNT`] entries here; the
|
||||
/// transport reduces a profile id modulo this length defensively so an out-of-range id is silently
|
||||
/// reinterpreted instead of panicking.
|
||||
pub const PADDING_PROFILES: &[&[usize]] = &[
|
||||
// 0: backwards-compatible default (same as HTTPS_SIZE_BUCKETS).
|
||||
&[64, 128, 256, 512, 1024, 1280, 1460],
|
||||
// 1: small-record + larger-MTU profile (rounds the largest payloads to a full 1500 frame).
|
||||
&[128, 256, 512, 1024, 1280, 1500],
|
||||
// 2: coarser buckets, deliberately fewer steps to obscure fine length variations.
|
||||
&[200, 400, 800, 1200, 1500],
|
||||
// 3: TLS-record-ish (256 / 512) plus three larger buckets.
|
||||
&[256, 512, 768, 1024, 1280, 1500],
|
||||
];
|
||||
|
||||
/// Pad `packet` (in place, appending zero bytes) up to the next HTTPS-like size bucket.
|
||||
///
|
||||
/// Behavior:
|
||||
@@ -57,6 +86,38 @@ pub fn next_https_bucket(len: usize) -> usize {
|
||||
.unwrap_or(len)
|
||||
}
|
||||
|
||||
/// Pad `packet` up to the next bucket of [`PADDING_PROFILES`]`[profile_id]` (same semantics as
|
||||
/// [`pad_to_https_size`], but selecting the bucket palette by profile id).
|
||||
///
|
||||
/// `profile_id` is reduced modulo `PADDING_PROFILES.len()` so an out-of-range id silently maps
|
||||
/// back into the valid range instead of panicking (the higher mask layer derives it from HKDF and
|
||||
/// has its own range guard, but the transport-layer defence here keeps misuse non-fatal).
|
||||
///
|
||||
/// Profile `0` reproduces the original [`pad_to_https_size`] palette exactly, so a deployment that
|
||||
/// happens to derive profile 0 is byte-identical to the pre-rotation wire behaviour.
|
||||
pub fn pad_to_bucket(packet: &mut Vec<u8>, profile_id: u8) {
|
||||
let buckets = profile_buckets(profile_id);
|
||||
let len = packet.len();
|
||||
if let Some(&target) = buckets.iter().find(|&&b| b >= len) {
|
||||
packet.resize(target, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the bucket `len` would be padded *up to* by [`pad_to_bucket`] for the given
|
||||
/// `profile_id`, or `len` itself if it is at/over the profile's maximum bucket.
|
||||
#[must_use]
|
||||
pub fn next_bucket_for_profile(len: usize, profile_id: u8) -> usize {
|
||||
let buckets = profile_buckets(profile_id);
|
||||
buckets.iter().copied().find(|&b| b >= len).unwrap_or(len)
|
||||
}
|
||||
|
||||
/// Look up the bucket palette for `profile_id`, reducing the id modulo the palette count so an
|
||||
/// out-of-range id is harmless.
|
||||
fn profile_buckets(profile_id: u8) -> &'static [usize] {
|
||||
let idx = (profile_id as usize) % PADDING_PROFILES.len();
|
||||
PADDING_PROFILES[idx]
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
@@ -201,4 +262,64 @@ mod tests {
|
||||
let added = inject_padding_frames(&mut v, 8, 4, 1000); // min > max
|
||||
assert!((4..=8).contains(&added));
|
||||
}
|
||||
|
||||
/// Profile 0 must exactly reproduce the historical [`pad_to_https_size`] behaviour, so any
|
||||
/// deployment that derives profile 0 is wire-compatible with pre-rotation Aura.
|
||||
#[test]
|
||||
fn profile_zero_matches_pad_to_https_size() {
|
||||
for len in [0usize, 1, 63, 64, 65, 200, 1280, 1459, 1460, 1461, 9999] {
|
||||
let mut a = vec![0u8; len];
|
||||
let mut b = vec![0u8; len];
|
||||
pad_to_https_size(&mut a);
|
||||
pad_to_bucket(&mut b, 0);
|
||||
assert_eq!(
|
||||
a.len(),
|
||||
b.len(),
|
||||
"profile 0 must match the legacy palette at {len}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every profile must be strictly ascending and pad correctly to its first-`>= len` bucket
|
||||
/// (matching `next_bucket_for_profile` and leaving overlarge payloads unchanged).
|
||||
#[test]
|
||||
fn pad_to_bucket_respects_each_profile() {
|
||||
for (pid, palette) in PADDING_PROFILES.iter().enumerate() {
|
||||
// Ascending?
|
||||
for w in palette.windows(2) {
|
||||
assert!(w[0] < w[1], "profile {pid} buckets must be ascending");
|
||||
}
|
||||
let max = *palette.last().unwrap();
|
||||
for &target_len in *palette {
|
||||
// Just-under-bucket lands on bucket; exactly-bucket is unchanged.
|
||||
let mut v = vec![0u8; target_len.saturating_sub(1)];
|
||||
pad_to_bucket(&mut v, pid as u8);
|
||||
assert_eq!(v.len(), target_len);
|
||||
let mut v2 = vec![0u8; target_len];
|
||||
pad_to_bucket(&mut v2, pid as u8);
|
||||
assert_eq!(
|
||||
v2.len(),
|
||||
target_len,
|
||||
"exact bucket {target_len} unchanged in profile {pid}"
|
||||
);
|
||||
}
|
||||
// Over the max stays unchanged.
|
||||
let mut v = vec![0u8; max + 1];
|
||||
pad_to_bucket(&mut v, pid as u8);
|
||||
assert_eq!(v.len(), max + 1, "over-max unchanged in profile {pid}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Out-of-range `profile_id` is reduced modulo the palette count instead of panicking.
|
||||
#[test]
|
||||
fn pad_to_bucket_handles_out_of_range_id() {
|
||||
let mut a = vec![0u8; 100];
|
||||
let mut b = vec![0u8; 100];
|
||||
// 0 == PADDING_PROFILES.len() * k for any k; e.g. 8 wraps to 0 for a 4-entry palette.
|
||||
pad_to_bucket(&mut a, 0);
|
||||
pad_to_bucket(&mut b, PADDING_PROFILES.len() as u8 * 2);
|
||||
assert_eq!(a.len(), b.len(), "id wraps modulo palette count");
|
||||
let predicted = next_bucket_for_profile(100, 0);
|
||||
assert_eq!(a.len(), predicted);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user