diff --git a/config/client.toml.example b/config/client.toml.example index 7c590d9..57dd8b3 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -67,3 +67,12 @@ quic_port = 444 obfuscate = true # TCP: prepend a minimal HTTP/1.1 preamble (Host = [client] sni) so the open resembles plain HTTP. masquerade = true + +[transport.masks] +# Daily protocol-mask rotation. When `true`, every day at 05:00 MSK (= 02:00 UTC) the client +# derives a new (SNI, User-Agent, Server-header, padding-profile) tuple from +# HKDF-SHA256(CA-fingerprint, MSK-date) and uses it for any subsequent connect — the server derives +# the same tuple independently from the same CA fingerprint, so no wire coordination is needed. +# Existing connections keep the mask they connected with. Default: true. +# When `false`, the static values above ([client] sni, [transport] obfuscate, ...) are used as-is. +enabled = true diff --git a/config/server.toml.example b/config/server.toml.example index 15fdcdd..436d1a2 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -45,3 +45,12 @@ quic_port = 444 obfuscate = true # TCP: prepend a minimal HTTP/1.1 preamble (Host = [mimicry] sni) so the open resembles plain HTTP. masquerade = true + +[transport.masks] +# Daily protocol-mask rotation. When `true`, every day at 05:00 MSK (= 02:00 UTC) the server +# derives a new (SNI, User-Agent, Server-header, padding-profile) tuple from +# HKDF-SHA256(CA-fingerprint, MSK-date) and applies it to new connections — the client derives the +# same tuple independently from the CA fingerprint it already trusts, so no wire coordination is +# needed. Existing connections keep the mask they accepted with. Default: true. +# When `false`, the static values above ([mimicry] sni, [transport] obfuscate, ...) are used as-is. +enabled = true diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 5866e59..03fb42f 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -28,15 +28,47 @@ use tokio::sync::RwLock; use crate::admin::{self, AdminState, Stats}; use crate::config::ClientConfigFile; +use crate::masks::MaskRotator; /// Entry point for `aura client --config ` (and optional `--admin-socket`). pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { let cfg = ClientConfigFile::load(config_path)?; let local_ip = cfg.local_ip()?; let proto_cfg = cfg.to_proto()?; - let dial_cfg = cfg.dial_config()?; + let mut dial_cfg = cfg.dial_config()?; let (table, domains) = cfg.build_route_table()?; + // Build the daily mask rotator (HKDF over the CA fingerprint + MSK date). When enabled, the + // current mask overrides the static `[client] sni` / `[transport] obfuscate` values on the + // `DialConfig` so the connect we are about to do already uses today's mask. The rotator's + // background task keeps `rot.handle()` updated for any future re-dials. + let masks_enabled = cfg.transport.masks.enabled; + let mask_rotator = if masks_enabled { + let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); + let initial = rot.current().await; + dial_cfg.sni = initial.sni.clone(); + dial_cfg.tcp.host = initial.http_host.clone(); + dial_cfg.tcp.user_agent = initial.user_agent.clone(); + dial_cfg.tcp.server_header = initial.server_header.clone(); + dial_cfg.udp.padding_profile = initial.padding_profile_id; + tracing::info!( + sni = %initial.sni, + padding_profile = initial.padding_profile_id, + "mask rotation enabled; initial mask applied to dial" + ); + // Keep the rotation task running in the background; v1's client only dials once, so the + // background rotation is informational here — but it is the same wiring the server uses + // and keeps the abstraction symmetric for future per-event re-dials. + let _bg = rot.spawn(); + Some(rot) + } else { + tracing::info!("mask rotation disabled in config; using static TOML values"); + None + }; + // Suppress the unused-binding warning when the rotator is `Some` but the local does not get + // read again (the spawn keeps the rotator alive via its own Arc clone). + let _ = &mask_rotator; + tracing::info!( name = %cfg.client.name, server_addr = %cfg.client.server_addr, diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index 8ccbc58..6dfa609 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -216,6 +216,8 @@ pub struct TransportSection { pub obfuscate: bool, /// TCP transport: prepend a minimal HTTP/1.1 preamble so the open resembles plain HTTP. pub masquerade: bool, + /// `[transport.masks]`: daily protocol-mask rotation knobs. + pub masks: MasksSection, } impl Default for TransportSection { @@ -227,10 +229,31 @@ impl Default for TransportSection { quic_port: 444, obfuscate: true, masquerade: true, + masks: MasksSection::default(), } } } +/// `[transport.masks]` section: automatic daily rotation of the obfuscation surface (SNI, HTTP +/// preamble headers, padding profile) at 05:00 MSK. +/// +/// Both peers derive the current mask from `(CA fingerprint, MSK date)`; no wire coordination is +/// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` / +/// `[mimicry] sni` are used as before (pre-rotation behaviour). +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct MasksSection { + /// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`: + /// use the static TOML values verbatim. + pub enabled: bool, +} + +impl Default for MasksSection { + fn default() -> Self { + Self { enabled: true } + } +} + impl TransportSection { /// Parse `order` into [`TransportMode`]s, rejecting unknown names and duplicates. pub fn modes(&self) -> anyhow::Result> { @@ -475,6 +498,7 @@ impl ClientConfigFile { tcp: TcpOpts { masquerade: self.transport.masquerade, host: self.client.sni.clone(), + ..TcpOpts::default() }, attempt_timeout: Duration::from_secs(8), }) diff --git a/crates/aura-cli/src/lib.rs b/crates/aura-cli/src/lib.rs index 2580005..7c9b48e 100644 --- a/crates/aura-cli/src/lib.rs +++ b/crates/aura-cli/src/lib.rs @@ -16,5 +16,6 @@ pub mod admin; pub mod bench; pub mod client; pub mod config; +pub mod masks; pub mod pki; pub mod server; diff --git a/crates/aura-cli/src/masks.rs b/crates/aura-cli/src/masks.rs new file mode 100644 index 0000000..b79bafd --- /dev/null +++ b/crates/aura-cli/src/masks.rs @@ -0,0 +1,282 @@ +//! Daily protocol-mask rotator: keeps a shared [`MaskSet`] up to date at **05:00 MSK** (= 02:00 +//! UTC) without any network coordination. +//! +//! Both server and client run their own [`MaskRotator`] right after loading the PKI. The rotator: +//! +//! 1. derives the current day's [`MaskSet`] from `(CA fingerprint, MSK date)` at startup; +//! 2. sleeps until the next 02:00 UTC (today's if still in the future, else tomorrow's); +//! 3. derives the new day's mask and swaps it into the shared `RwLock`; +//! 4. logs the rotation and loops. +//! +//! Each new connection (`UdpServer::accept`, `UdpClient::connect`, `TcpClient::connect`, ...) +//! reads the **current** mask once when constructing its [`UdpOpts`] / [`TcpOpts`] / QUIC SNI, so +//! already-established connections keep their original mask and only fresh connections see the +//! rotation. There is no need to coordinate with the peer: each side independently derived the same +//! set from the CA fingerprint it already trusts. +//! +//! ## Date arithmetic (no external date crate) +//! +//! We compute the calendar date from a Unix timestamp using Howard Hinnant's `civil_from_days` +//! algorithm (`days_from_epoch -> (year, month, day)`), so we do not pull in `chrono` / `time` for +//! this crate. MSK is UTC+3 with no DST since 2014, so the MSK date is simply +//! `floor((unix + 3*3600) / 86400)` days since the epoch. +//! +//! ## What rotates and what does not +//! +//! The mask only controls the **obfuscation surface**: SNI / HTTP header strings, padding +//! profile. The Aura cryptographic handshake (hybrid X25519 + ML-KEM-768 + mutual X.509) is +//! unchanged; rotation does not weaken or alter authentication, only the on-wire fingerprint a +//! passive observer can latch onto. + +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use aura_crypto::{ca_fingerprint, derive_mask_for_msk_date, MaskSet}; +use tokio::sync::RwLock; +use tokio::task::JoinHandle; + +/// Shared current mask handle: server and client both clone this `Arc>` into the +/// code that builds per-connection transport options. +pub type MaskHandle = Arc>; + +/// Daily protocol-mask rotator. Spawn one in `aura server` / `aura client`'s `run` after loading +/// the PKI; share its [`handle`](MaskRotator::handle) with the transport-options builders. +pub struct MaskRotator { + active: MaskHandle, + ca_fp: [u8; 32], +} + +impl MaskRotator { + /// Build a rotator from the CA PEM the rest of the stack already trusts. + /// + /// The initial mask is the one current at the calling instant (today's MSK day). Use + /// [`Self::spawn`] to start the daily rotation task that updates the shared handle. + /// + /// # Errors + /// Propagates [`aura_crypto::CryptoError`] from [`aura_crypto::ca_fingerprint`] (typically a + /// malformed CA PEM). + pub fn new(ca_cert_pem: &str) -> anyhow::Result { + let ca_fp = ca_fingerprint(ca_cert_pem)?; + let now = unix_now_utc(); + let (y, m, d) = msk_today(now); + let initial = derive_mask_for_msk_date(&ca_fp, y, m, d); + Ok(Self { + active: Arc::new(RwLock::new(initial)), + ca_fp, + }) + } + + /// A snapshot of the current mask. This locks the inner `RwLock` briefly and clones; suitable + /// for the once-per-`connect`/`accept` use case (not for hot per-packet paths). + pub async fn current(&self) -> MaskSet { + self.active.read().await.clone() + } + + /// Blocking-friendly current-mask snapshot. Uses `blocking_read` so it is safe to call from + /// synchronous CLI bootstrap code that has not yet entered a tokio context. + #[must_use] + pub fn current_blocking(&self) -> MaskSet { + self.active.blocking_read().clone() + } + + /// Cloneable handle into the shared current-mask `RwLock`. Pass this to the transport layer so + /// each new connection reads the latest mask under one cheap async lock. + #[must_use] + pub fn handle(&self) -> MaskHandle { + Arc::clone(&self.active) + } + + /// Spawn the background rotation task: sleeps until the next 02:00 UTC, then loops forever + /// updating the shared handle once per MSK day. + /// + /// Returns the [`JoinHandle`] so the caller can keep ownership (the task aborts when the + /// rotator's handle is dropped only because the inner `Arc` keeps the `RwLock` alive while the + /// task holds it; in normal operation the task lives for the program's lifetime). + #[must_use] + pub fn spawn(self: &Arc) -> JoinHandle<()> { + let this = Arc::clone(self); + tokio::spawn(async move { + loop { + let now = unix_now_utc(); + let next = next_rotation_unix(now); + let wait = (next - now).max(0) as u64; + tracing::debug!( + in_secs = wait, + "mask rotator: sleeping until next 02:00 UTC" + ); + tokio::time::sleep(Duration::from_secs(wait)).await; + + // Recompute "today's MSK date" right after waking so we pick the post-rotation day + // (the alarm fires at 02:00 UTC = 05:00 MSK, which is the new MSK day). + let after = unix_now_utc(); + let (y, m, d) = msk_today(after); + let new_mask = derive_mask_for_msk_date(&this.ca_fp, y, m, d); + { + let mut guard = this.active.write().await; + if *guard != new_mask { + tracing::info!( + year = y, month = m, day = d, + sni = %new_mask.sni, + padding_profile = new_mask.padding_profile_id, + "rotated protocol mask (05:00 MSK)" + ); + *guard = new_mask; + } + } + } + }) + } +} + +// --------------------------------------------------------------------------------------------- +// Date / time helpers (no external date crate; pure stdlib). +// --------------------------------------------------------------------------------------------- + +/// Current Unix time in UTC seconds. Saturates to 0 if the clock is somehow before the epoch +/// (impossible on a normal system). +pub fn unix_now_utc() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// `civil_from_days` (Howard Hinnant): convert days-since-1970-01-01 into `(year, month, day)`. +/// +/// Handles dates from year ~-5.8M to ~+5.8M correctly; for our purposes any 32-bit days-since-epoch +/// is fine (covers ±5.8M years around 1970). +/// +/// Returns `(year, month[1..=12], day[1..=31])`. +#[must_use] +pub fn ymd_from_days(days: i32) -> (i32, u32, u32) { + // Shift the epoch to 0000-03-01 (Hinnant's reference) so the leap-cycle math is uniform. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097); + // doe ("day of era") is in [0, 146096]; the wrapping_sub-style subtraction ends up positive + // because `era * 146_097 <= z` by construction, so the cast to u32 is safe. + let doe = (z - era * 146_097) as u32; + // yoe ("year of era") in [0, 399]. + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe as i32 + era * 400; + // doy ("day of year") in [0, 365], where the year begins on March 1 for the algorithm. + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + // mp ("month pivot") in [0, 11], where 0 = March. + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y_real = if m <= 2 { y + 1 } else { y }; + (y_real, m, d) +} + +/// Today's MSK calendar date for a given Unix-UTC timestamp. MSK = UTC+3, no DST since 2014. +#[must_use] +pub fn msk_today(unix: i64) -> (i32, u32, u32) { + let msk_secs = unix + 3 * 3600; + let days = msk_secs.div_euclid(86_400) as i32; + ymd_from_days(days) +} + +/// The next 02:00 UTC (= 05:00 MSK) instant strictly *after* `now`, as a Unix timestamp. +/// +/// If `now` lands exactly at 02:00:00 UTC we still return *tomorrow's* 02:00 (the comparison is +/// strict on `>`). That keeps the sleep monotonic — we never schedule a zero-wait rotation that +/// would re-derive the same mask in a tight loop. +#[must_use] +pub fn next_rotation_unix(now: i64) -> i64 { + let today_utc_midnight = now.div_euclid(86_400) * 86_400; + let today_0200_utc = today_utc_midnight + 2 * 3600; + if today_0200_utc > now { + today_0200_utc + } else { + today_0200_utc + 86_400 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Days-since-epoch for a handful of fixed, known calendar dates. The Hinnant algorithm has to + /// match a manual cross-check at the boundaries (Y2K, leap year, year transitions, before + /// epoch). + #[test] + fn ymd_from_days_known_points() { + // 1970-01-01 == days 0. + assert_eq!(ymd_from_days(0), (1970, 1, 1)); + // 1969-12-31 == days -1. + assert_eq!(ymd_from_days(-1), (1969, 12, 31)); + // 2000-02-29 (a leap day): days from 1970-01-01. + // 30 years between 1970 and 2000, with leap years 1972, 76, 80, 84, 88, 92, 96 = 7 leaps. + // Jan + Feb of 2000 == 31 + 29 = 60 days, so day 29 of Feb is day 59 (0-indexed) of 2000. + // 1970..2000 has 30 years * 365 + 7 leap days = 10957 days. 2000-02-29 == 10957 + 59 = 11016. + assert_eq!(ymd_from_days(11016), (2000, 2, 29)); + // 2024-12-31 -> 2025-01-01 transition. + // 1970..2024 = 54 years; leap years strictly *before* 2024 are 1972,76,80,84,88,92,96,2000, + // 04,08,12,16,20 = 13 (2100 is not a leap and is out of range anyway). So 2024-01-01 lives + // at day 54*365 + 13 = 19723. 2024 itself is leap (366 days), so 2024-12-31 is 19723 + 365 + // = 20088 and 2025-01-01 is 20089. + assert_eq!(ymd_from_days(20088), (2024, 12, 31)); + assert_eq!(ymd_from_days(20089), (2025, 1, 1)); + } + + /// Rotation should land exactly at the next 02:00 UTC, with the "==" boundary biased + /// strictly forward to avoid a zero-wait rotate loop. + #[test] + fn next_rotation_at_boundaries() { + // Exact 02:00:00 UTC on a known day (2025-01-01 00:00:00 UTC = 1735689600). + let jan1_2025_00 = 1735689600i64; + let jan1_2025_02 = jan1_2025_00 + 2 * 3600; + assert_eq!( + next_rotation_unix(jan1_2025_02), + jan1_2025_02 + 86_400, + "exactly at 02:00 UTC -> tomorrow's 02:00" + ); + // One second before 02:00 -> today's 02:00. + assert_eq!( + next_rotation_unix(jan1_2025_02 - 1), + jan1_2025_02, + "01:59:59 UTC -> today's 02:00" + ); + // One second after 02:00 -> tomorrow's 02:00. + assert_eq!( + next_rotation_unix(jan1_2025_02 + 1), + jan1_2025_02 + 86_400, + "02:00:01 UTC -> tomorrow's 02:00" + ); + } + + /// MSK = UTC+3: a UTC moment around midnight reports the *next* MSK day already, so the mask + /// rolls over at 21:00 UTC every day (= 00:00 MSK). + #[test] + fn msk_today_handles_offset() { + // 2025-01-01 01:00 UTC == 2025-01-01 04:00 MSK => still the 1st in MSK. + let utc_0100 = 1735689600i64 + 3600; + assert_eq!(msk_today(utc_0100), (2025, 1, 1)); + // 2025-01-01 22:00 UTC == 2025-01-02 01:00 MSK => already the 2nd in MSK. + let utc_2200 = 1735689600i64 + 22 * 3600; + assert_eq!(msk_today(utc_2200), (2025, 1, 2)); + // 2025-01-01 00:00 UTC == 2025-01-01 03:00 MSK => still the 1st in MSK. + assert_eq!(msk_today(1735689600), (2025, 1, 1)); + // 2025-01-01 21:00 UTC == 2025-01-02 00:00 MSK => first instant of the 2nd in MSK. + let utc_2100 = 1735689600i64 + 21 * 3600; + assert_eq!(msk_today(utc_2100), (2025, 1, 2)); + } + + /// End-to-end on the rotator API: derived mask is deterministic and the handle reflects the + /// current snapshot. + #[tokio::test] + async fn mask_rotator_new_yields_today_mask() { + // Build a tiny CA PEM so the fingerprint pipeline runs realistically. + let ca = aura_pki::AuraCa::generate("aura-mask-test-ca").expect("generate CA"); + let pem = ca.ca_cert_pem(); + + let rotator = MaskRotator::new(&pem).expect("rotator"); + let m1 = rotator.current().await; + // Re-derive directly and assert equality (same `(ca_fp, MSK today)`). + let now = unix_now_utc(); + let (y, mo, d) = msk_today(now); + let fp = ca_fingerprint(&pem).expect("fp"); + let m2 = derive_mask_for_msk_date(&fp, y, mo, d); + assert_eq!(m1, m2); + } +} diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index da9e0aa..09bc5d3 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -21,6 +21,7 @@ use std::path::Path; use std::sync::Arc; +use std::time::Duration; use anyhow::Context; use aura_transport::MultiServer; @@ -30,6 +31,7 @@ use tokio::sync::RwLock; use crate::admin::{self, AdminState, Stats}; use crate::config::ServerConfigFile; +use crate::masks::MaskRotator; /// Entry point for `aura server --config ` (and optional `--admin-socket`). pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { @@ -40,8 +42,31 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Per-transport endpoints (UDP/TCP/QUIC) derived from the listen IP + `[transport]` ports. let endpoints = cfg.transport_endpoints()?; - let udp_opts = cfg.udp_opts(); - let tcp_opts = cfg.tcp_opts(); + let mut udp_opts = cfg.udp_opts(); + let mut tcp_opts = cfg.tcp_opts(); + + // Build the daily mask rotator (HKDF over the CA fingerprint + MSK date). When enabled in the + // config, the *initial* mask overrides the static SNI / padding-profile / header values from + // the TOML so the first accepts already use today's mask; the rotator's background task then + // updates the bound MultiServer's opts each day at 02:00 UTC (= 05:00 MSK). + let masks_enabled = cfg.transport.masks.enabled; + let mask_rotator = if masks_enabled { + let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); + let initial = rot.current().await; + udp_opts.padding_profile = initial.padding_profile_id; + tcp_opts.host = initial.http_host.clone(); + tcp_opts.user_agent = initial.user_agent.clone(); + tcp_opts.server_header = initial.server_header.clone(); + tracing::info!( + sni = %initial.sni, + padding_profile = initial.padding_profile_id, + "mask rotation enabled; initial mask applied" + ); + Some(rot) + } else { + tracing::info!("mask rotation disabled in config; using static TOML values"); + None + }; tracing::info!( name = %cfg.server.name, @@ -62,11 +87,50 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Bind every enabled transport at once. The QUIC outer (mimicry) cert reuses the Aura server // leaf inside `proto_cfg`, matching the transport's guidance. - let mut server = MultiServer::bind(endpoints, proto_cfg.clone(), udp_opts, tcp_opts) + let server = MultiServer::bind(endpoints, proto_cfg.clone(), udp_opts, tcp_opts.clone()) .await .context("binding Aura multi-transport server")?; tracing::info!("Aura server bound on all enabled transports"); + // Spawn the mask rotation loop AFTER bind so the rotator can push new opts into the live + // server each day. Existing connections keep their accept-time snapshot. + let server = Arc::new(tokio::sync::Mutex::new(server)); + if let Some(rot) = &mask_rotator { + let _bg = rot.spawn(); + let server_for_apply = Arc::clone(&server); + let rot_for_apply = Arc::clone(rot); + let base_udp = udp_opts; + let base_tcp = tcp_opts.clone(); + tokio::spawn(async move { + // Poll the rotator's handle for a change once a minute, and push it into the live + // MultiServer when it changes. The actual rotation timer lives inside the rotator's + // spawn; this loop is just the "apply to bound sockets" bridge. + let handle = rot_for_apply.handle(); + let mut last = rot_for_apply.current().await; + loop { + tokio::time::sleep(Duration::from_secs(60)).await; + let current = handle.read().await.clone(); + if current != last { + let mut new_udp = base_udp; + new_udp.padding_profile = current.padding_profile_id; + let mut new_tcp = base_tcp.clone(); + new_tcp.host = current.http_host.clone(); + new_tcp.user_agent = current.user_agent.clone(); + new_tcp.server_header = current.server_header.clone(); + let srv = server_for_apply.lock().await; + srv.set_udp_opts(new_udp).await; + srv.set_tcp_opts(new_tcp).await; + tracing::info!( + sni = %current.sni, + padding_profile = current.padding_profile_id, + "applied rotated mask to bound MultiServer" + ); + last = current; + } + } + }); + } + // Shared routing table (server-side classification is trivial in v1: everything via VPN) + // stats, exposed over the admin socket. let routes = Arc::new(RwLock::new(RouteTable::new(RouteAction::Vpn))); @@ -87,7 +151,12 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // Accept loop. Each accepted connection (from any transport) gets a server-side TUN and a router // task. `MultiServer::accept` yields `None` only when every transport's accept loop has stopped. let mtu = cfg.tunnel.mtu; - while let Some(accepted) = server.accept().await { + loop { + let next = { + let mut srv = server.lock().await; + srv.accept().await + }; + let Some(accepted) = next else { break }; let peer = accepted.peer_id.clone(); let mode = accepted.mode; let conn = accepted.conn; diff --git a/crates/aura-cli/tests/mask_loopback.rs b/crates/aura-cli/tests/mask_loopback.rs new file mode 100644 index 0000000..e292678 --- /dev/null +++ b/crates/aura-cli/tests/mask_loopback.rs @@ -0,0 +1,97 @@ +//! End-to-end smoke test for the daily mask rotator: build a CA, derive today's [`MaskSet`], plug +//! its `padding_profile_id` into the server / client `UdpOpts`, run a UDP loopback handshake, and +//! exchange a packet. This proves: +//! +//! * The crypto-layer derivation produces values that the transport layer accepts. +//! * The padding profile id derived from `MaskSet` is a valid argument for `pad_to_bucket` / +//! `next_bucket_for_profile`. +//! * Wire compatibility is preserved when both ends use the same mask. +//! +//! It does NOT exercise the time-based rotation (that runs at 05:00 MSK and would require freezing +//! the clock); the algorithm itself is unit-tested in `aura_cli::masks::tests`. + +use std::sync::Arc; + +use aura_cli::masks::MaskRotator; +use aura_crypto::derive_mask_for_msk_date; +use aura_pki::AuraCa; +use aura_proto::{ClientConfig, PacketConnection, ServerConfig}; +use aura_transport::{UdpClient, UdpOpts, UdpServer}; + +const SERVER_NAME: &str = "localhost"; +const CLIENT_ID: &str = "cli-mask-client"; + +fn make_configs(ca: &AuraCa) -> (ServerConfig, ClientConfig) { + let server_cert = ca + .issue_server_cert(SERVER_NAME) + .expect("issue server cert"); + let client_cert = ca.issue_client_cert(CLIENT_ID).expect("issue client cert"); + let ca_pem = ca.ca_cert_pem(); + let server_cfg = ServerConfig { + ca_cert_pem: ca_pem.clone(), + server_cert_pem: server_cert.cert_pem.clone(), + server_key_pem: server_cert.key_pem.clone(), + }; + let client_cfg = ClientConfig { + ca_cert_pem: ca_pem, + client_cert_pem: client_cert.cert_pem, + client_key_pem: client_cert.key_pem, + server_name: SERVER_NAME.to_string(), + }; + (server_cfg, client_cfg) +} + +#[tokio::test] +async fn mask_drives_udp_loopback_with_obfuscation() { + // Real CA → real CA PEM → real fingerprint → today's MaskSet, both sides. + let ca = AuraCa::generate("aura-mask-loopback-ca").expect("CA"); + let pem = ca.ca_cert_pem(); + + // Rotator drives the *current* mask (matches a direct derive for today). + let rotator = MaskRotator::new(&pem).expect("rotator"); + let current = rotator.current().await; + + // Cross-check against a direct crypto-layer derive for today's MSK day. + let now = aura_cli::masks::unix_now_utc(); + let (y, m, d) = aura_cli::masks::msk_today(now); + let fp = aura_crypto::ca_fingerprint(&pem).expect("fp"); + let direct = derive_mask_for_msk_date(&fp, y, m, d); + assert_eq!(current, direct, "rotator should match direct derivation"); + + // Build UdpOpts with obfuscation on and the *mask's* padding profile id. Both sides agree + // because they derived from the same CA and date. + let opts = UdpOpts { + obfuscate: true, + padding_profile: current.padding_profile_id, + ..UdpOpts::default() + }; + + let (server_cfg, client_cfg) = make_configs(&ca); + + let server = + UdpServer::bind("127.0.0.1:0".parse().unwrap(), server_cfg, opts).expect("bind udp server"); + let server_addr = server.local_addr().expect("server addr"); + + let accept_task = tokio::spawn(async move { server.accept().await }); + let connect_task = + tokio::spawn(async move { UdpClient::connect(server_addr, client_cfg, opts).await }); + + let server_conn = accept_task.await.expect("accept join").expect("accept"); + let client_conn = connect_task.await.expect("connect join").expect("connect"); + + assert_eq!(server_conn.peer_id(), Some(CLIENT_ID)); + assert_eq!(client_conn.peer_id(), Some(SERVER_NAME)); + + let server_conn: Arc = Arc::new(server_conn); + let client_conn: Arc = Arc::new(client_conn); + + for pkt in [ + b"hello-mask".to_vec(), + vec![0xA5u8; 1300], + (0..200u8).collect::>(), + ] { + client_conn.send_packet(&pkt).await.expect("client send"); + let got = server_conn.recv_packet().await.expect("server recv"); + assert_eq!(got, pkt, "padded round trip preserves the payload"); + } +} diff --git a/crates/aura-crypto/src/lib.rs b/crates/aura-crypto/src/lib.rs index 2e510a7..2902174 100644 --- a/crates/aura-crypto/src/lib.rs +++ b/crates/aura-crypto/src/lib.rs @@ -15,10 +15,15 @@ pub mod aead; pub mod kdf; pub mod kem; +pub mod masks; pub use aead::{AeadKey, AeadSession}; pub use kdf::{derive_session_keys, SessionKeys}; pub use kem::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret}; +pub use masks::{ + ca_fingerprint, derive_mask_for_msk_date, MaskSet, PADDING_PROFILE_COUNT, + SERVER_HEADER_PALETTE, SNI_PALETTE, USER_AGENT_PALETTE, +}; use thiserror::Error; diff --git a/crates/aura-crypto/src/masks.rs b/crates/aura-crypto/src/masks.rs new file mode 100644 index 0000000..898720d --- /dev/null +++ b/crates/aura-crypto/src/masks.rs @@ -0,0 +1,357 @@ +//! Daily-rotated **protocol masks** (project §7 / v2): the SNI, HTTP headers, and padding profile +//! that the wire-level obfuscation presents to a passive observer. +//! +//! Both peers derive the same `MaskSet` from a shared seed (the CA certificate's SHA-256 +//! fingerprint) combined with the current calendar date in Moscow time (UTC+3). This means: +//! +//! * **No coordination on the wire.** A new connection just picks the current `MaskSet`; the peer +//! independently derived the identical set from the CA fingerprint it already trusts. +//! * **Daily rotation.** At 05:00 MSK (= 02:00 UTC) both sides recompute the set for the new MSK +//! day, so the on-wire fingerprint (SNI, HTTP `User-Agent` / `Server` headers, the padding bucket +//! palette) changes once per day. +//! * **Connection stickiness.** A `MaskSet` is taken once per `connect`/`accept` and stored in the +//! connection's options; already-established connections keep their original mask, only new ones +//! pick up the rotated set. +//! +//! The derivation is HKDF-SHA256 with a fixed salt and `info`, and indices into fixed palettes +//! (SNIs, User-Agents, Server headers, padding profiles). The palettes intentionally include +//! plausible real-world values so any single sample resembles a CDN-like fingerprint. +//! +//! ## Wire compatibility +//! Padding profile id `0` reproduces the original +//! [`crate::HTTPS_SIZE_BUCKETS`](../../aura_transport/padding/constant.HTTPS_SIZE_BUCKETS.html) +//! palette `{64,128,256,512,1024,1280,1460}` exactly — so when a fresh deployment happens to +//! derive profile 0 the wire behaviour is byte-identical to the pre-rotation default, and the +//! existing transport tests stay valid. + +use hkdf::Hkdf; +use sha2::{Digest, Sha256}; + +use crate::CryptoError; + +/// Palette of SNI / HTTP `Host` values rotated through daily. Plausible CDN-like names so a single +/// observed connection resembles ordinary HTTPS traffic. +pub const SNI_PALETTE: &[&str] = &[ + "cdn.cloudflare.com", + "ajax.googleapis.com", + "static.fbcdn.net", + "s3.amazonaws.com", + "code.jquery.com", + "fonts.googleapis.com", + "i.ytimg.com", + "edge.microsoft.com", + "cdnjs.cloudflare.com", + "static.cloudflareinsights.com", + "assets.gitlab-static.net", + "raw.githubusercontent.com", + "media.licdn.com", + "static.licdn.com", + "cdn.jsdelivr.net", + "ssl.gstatic.com", +]; + +/// Palette of `User-Agent` strings used by the TCP transport's client masquerade preamble. +pub const USER_AGENT_PALETTE: &[&str] = &[ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.0.0", + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", +]; + +/// Palette of HTTP `Server` header values used by the TCP transport's server masquerade preamble. +pub const SERVER_HEADER_PALETTE: &[&str] = + &["nginx", "nginx/1.25.3", "Apache", "cloudflare", "AmazonS3"]; + +/// Number of padding profiles available. Mirrors +/// [`crate::PADDING_PROFILES`](../../aura_transport/padding/constant.PADDING_PROFILES.html) in +/// `aura-transport`; this is a constant here so the crypto crate can index into a palette without +/// depending on the transport crate. +/// +/// The transport-side palette is the source of truth for the actual bucket lists; this constant +/// only sizes the modulo on the derived id. +pub const PADDING_PROFILE_COUNT: u8 = 4; + +/// HKDF salt for daily mask derivation (versioned so we can rotate the derivation itself later). +const HKDF_SALT: &[u8] = b"aura-mask-v1-salt"; +/// HKDF info string for daily mask derivation (versioned alongside the salt). +const HKDF_INFO: &[u8] = b"aura-mask-v1"; + +/// One day's worth of masking parameters: SNI / HTTP headers / padding profile. +/// +/// Derived deterministically by [`derive_mask_for_msk_date`] from `(ca_fingerprint, msk_date)` so +/// the server and the client always agree on the current set without any wire coordination. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MaskSet { + /// Outer TLS SNI (QUIC) and also the TCP masquerade `Host:` header source. + pub sni: String, + /// HTTP `Host:` header value used in the TCP client preamble. Typically equals [`Self::sni`]; + /// kept as a separate field so deployments can decouple them later without changing the type. + pub http_host: String, + /// `User-Agent:` value used in the TCP client preamble. + pub user_agent: String, + /// `Server:` header value used in the TCP server preamble. + pub server_header: String, + /// Index into the padding-profile palette in `aura-transport::padding`; modulo the actual + /// palette length on the transport side (`% PADDING_PROFILES.len()`). + pub padding_profile_id: u8, +} + +/// SHA-256 fingerprint of the **DER** bytes of the first `CERTIFICATE` block in `ca_cert_pem`. +/// +/// PEM is parsed without bringing in a full X.509 dependency: we look for the `-----BEGIN +/// CERTIFICATE-----` / `-----END CERTIFICATE-----` markers and base64-decode the content between +/// them. +/// +/// # Errors +/// * [`CryptoError::InvalidLength`] with `what = "ca-pem"` if no CERTIFICATE block is found or the +/// base64 payload is malformed. +pub fn ca_fingerprint(ca_cert_pem: &str) -> Result<[u8; 32], CryptoError> { + let der = pem_first_cert_der(ca_cert_pem).ok_or(CryptoError::InvalidLength { + what: "ca-pem", + expected: 1, + got: 0, + })?; + let mut h = Sha256::new(); + h.update(&der); + let digest = h.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + Ok(out) +} + +/// Derive the daily [`MaskSet`] for `(ca_fingerprint, year-month-day)`, where the date is the +/// **MSK** calendar day (UTC+3) the mask is current on. +/// +/// HKDF-SHA256 with `ikm = ca_fp || '|' || "YYYY-MM-DD"`, fixed salt, fixed info. The 64-byte OKM +/// is sliced into four 2-byte big-endian indices, each taken `mod len(palette)`. +#[must_use] +pub fn derive_mask_for_msk_date(ca_fp: &[u8; 32], year: i32, month: u32, day: u32) -> MaskSet { + // Build IKM = ca_fp || "|" || "YYYY-MM-DD" (zero-padded). No allocations beyond this small Vec. + let mut ikm = Vec::with_capacity(32 + 1 + 10); + ikm.extend_from_slice(ca_fp); + ikm.push(b'|'); + ikm.extend_from_slice(format_ymd(year, month, day).as_bytes()); + + // 4 indices * 2 bytes = 8 bytes minimum; we draw 64 bytes so future fields have headroom. + let hk = Hkdf::::new(Some(HKDF_SALT), &ikm); + let mut okm = [0u8; 64]; + // `expand` on this hk type with 64-byte output cannot fail (well under 255 * HashLen). + hk.expand(HKDF_INFO, &mut okm) + .expect("HKDF expand of 64 bytes cannot fail for SHA-256"); + + let sni_idx = u16::from_be_bytes([okm[0], okm[1]]) as usize % SNI_PALETTE.len(); + let ua_idx = u16::from_be_bytes([okm[2], okm[3]]) as usize % USER_AGENT_PALETTE.len(); + let srv_idx = u16::from_be_bytes([okm[4], okm[5]]) as usize % SERVER_HEADER_PALETTE.len(); + let pad_idx = u16::from_be_bytes([okm[6], okm[7]]) as u8 % PADDING_PROFILE_COUNT; + + let sni = SNI_PALETTE[sni_idx].to_string(); + MaskSet { + http_host: sni.clone(), + sni, + user_agent: USER_AGENT_PALETTE[ua_idx].to_string(), + server_header: SERVER_HEADER_PALETTE[srv_idx].to_string(), + padding_profile_id: pad_idx, + } +} + +// --------------------------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------------------------- + +/// Zero-pad a `(year, month, day)` into `"YYYY-MM-DD"`. Years outside `[0, 9999]` are still printed +/// in their natural width (only the four-zero pad is the requirement here, and any historical CA +/// will land in the 21st century). +fn format_ymd(year: i32, month: u32, day: u32) -> String { + format!("{year:04}-{month:02}-{day:02}") +} + +/// Locate the first `-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----` block in `pem` and +/// return its base64-decoded DER bytes. Returns `None` if no block is found or the base64 payload +/// is malformed. +fn pem_first_cert_der(pem: &str) -> Option> { + const BEGIN: &str = "-----BEGIN CERTIFICATE-----"; + const END: &str = "-----END CERTIFICATE-----"; + let start = pem.find(BEGIN)? + BEGIN.len(); + let end = pem[start..].find(END)? + start; + let body = &pem[start..end]; + // Strip whitespace (newlines, CRLF, spaces) before base64-decoding. + let mut compact = String::with_capacity(body.len()); + for c in body.chars() { + if !c.is_whitespace() { + compact.push(c); + } + } + base64_decode(&compact) +} + +/// Minimal standard-base64 decoder (no padding-only chars, ignores final `=` padding). Returns +/// `None` if the input has an invalid character or length. +/// +/// We avoid a dependency on a base64 crate (the workspace ships none in `aura-crypto`); the input +/// is a CA certificate, so a few KB at most, and this runs once at startup. +fn base64_decode(s: &str) -> Option> { + fn val(b: u8) -> Option { + match b { + b'A'..=b'Z' => Some(b - b'A'), + b'a'..=b'z' => Some(b - b'a' + 26), + b'0'..=b'9' => Some(b - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes = s.as_bytes(); + // Strip trailing '=' padding. + let mut end = bytes.len(); + while end > 0 && bytes[end - 1] == b'=' { + end -= 1; + } + let body = &bytes[..end]; + let mut out = Vec::with_capacity(body.len() * 3 / 4 + 2); + + let mut buf: u32 = 0; + let mut bits: u32 = 0; + for &b in body { + let v = val(b)? as u32; + buf = (buf << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((buf >> bits) & 0xFF) as u8); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derive_mask_deterministic_same_inputs() { + let ca_fp = [7u8; 32]; + let a = derive_mask_for_msk_date(&ca_fp, 2026, 1, 15); + let b = derive_mask_for_msk_date(&ca_fp, 2026, 1, 15); + assert_eq!(a, b, "same (ca_fp, date) must yield identical MaskSet"); + } + + #[test] + fn derive_mask_changes_with_date() { + let ca_fp = [3u8; 32]; + let day1 = derive_mask_for_msk_date(&ca_fp, 2026, 5, 27); + let day2 = derive_mask_for_msk_date(&ca_fp, 2026, 5, 28); + // The HKDF output is different, so the *indices* are different, but the indexing is + // modulo small palettes — so any individual field could coincide. Assert at least one + // field differs (the palettes all have >= 4 entries, so this is overwhelmingly likely). + assert!( + day1.sni != day2.sni + || day1.user_agent != day2.user_agent + || day1.server_header != day2.server_header + || day1.padding_profile_id != day2.padding_profile_id, + "consecutive days produced identical MaskSet across all fields: {day1:?}" + ); + } + + #[test] + fn derive_mask_changes_with_ca_fp() { + let fp1 = [1u8; 32]; + let fp2 = [2u8; 32]; + let m1 = derive_mask_for_msk_date(&fp1, 2026, 5, 27); + let m2 = derive_mask_for_msk_date(&fp2, 2026, 5, 27); + assert!( + m1.sni != m2.sni + || m1.user_agent != m2.user_agent + || m1.server_header != m2.server_header + || m1.padding_profile_id != m2.padding_profile_id, + "different CA fingerprints produced identical MaskSet: {m1:?}" + ); + } + + #[test] + fn mask_fields_are_within_palettes() { + let m = derive_mask_for_msk_date(&[42u8; 32], 2026, 5, 27); + assert!(SNI_PALETTE.iter().any(|s| *s == m.sni)); + assert!(USER_AGENT_PALETTE.iter().any(|s| *s == m.user_agent)); + assert!(SERVER_HEADER_PALETTE.iter().any(|s| *s == m.server_header)); + assert!(m.padding_profile_id < PADDING_PROFILE_COUNT); + assert_eq!(m.http_host, m.sni, "http_host mirrors sni by default"); + } + + #[test] + fn format_ymd_zero_pads() { + assert_eq!(format_ymd(2026, 1, 5), "2026-01-05"); + assert_eq!(format_ymd(2026, 12, 31), "2026-12-31"); + assert_eq!(format_ymd(1, 1, 1), "0001-01-01"); + } + + #[test] + fn base64_decode_round_trips_simple() { + // "hello world" -> "aGVsbG8gd29ybGQ=" + let out = base64_decode("aGVsbG8gd29ybGQ=").expect("decode"); + assert_eq!(&out, b"hello world"); + // Trailing pad is optional in our decoder. + let out2 = base64_decode("aGVsbG8gd29ybGQ").expect("decode no pad"); + assert_eq!(&out2, b"hello world"); + } + + #[test] + fn base64_rejects_invalid_char() { + assert!(base64_decode("hello!world").is_none()); + } + + #[test] + fn ca_fingerprint_rejects_missing_block() { + let err = ca_fingerprint("no certificate here").unwrap_err(); + match err { + CryptoError::InvalidLength { what, .. } => assert_eq!(what, "ca-pem"), + e => panic!("unexpected error: {e:?}"), + } + } + + #[test] + fn ca_fingerprint_matches_direct_sha256() { + // Build a fake "PEM" by base64-encoding a known DER payload; assert the resulting + // fingerprint equals SHA-256 of that payload. + let der: Vec = (0..=255u8).collect(); + let b64 = simple_base64_encode(&der); + let pem = format!("-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n"); + let fp = ca_fingerprint(&pem).expect("fingerprint"); + let mut h = Sha256::new(); + h.update(&der); + let want = h.finalize(); + assert_eq!(&fp, want.as_slice()); + } + + /// Tiny base64 encoder used by the round-trip test (we ship our own decoder above to avoid a + /// new dependency, and the encoder is just as small). + fn simple_base64_encode(data: &[u8]) -> String { + const ALPHA: &[u8; 64] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity((data.len() * 4).div_ceil(3)); + let chunks = data.chunks(3); + for chunk in chunks { + let b0 = chunk[0]; + let b1 = chunk.get(1).copied().unwrap_or(0); + let b2 = chunk.get(2).copied().unwrap_or(0); + out.push(ALPHA[(b0 >> 2) as usize] as char); + out.push(ALPHA[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char); + if chunk.len() >= 2 { + out.push(ALPHA[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char); + } else { + out.push('='); + } + if chunk.len() >= 3 { + out.push(ALPHA[(b2 & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + out + } +} diff --git a/crates/aura-transport/src/dial.rs b/crates/aura-transport/src/dial.rs index 8933fc1..2a5a06a 100644 --- a/crates/aura-transport/src/dial.rs +++ b/crates/aura-transport/src/dial.rs @@ -174,9 +174,19 @@ pub struct Accepted { /// TCP and QUIC accept loops handle many clients. The custom-UDP backend is single-peer-per-accept /// in v1 (a multi-client UDP demux is a documented follow-up), so with several clients prefer TCP or /// QUIC, or run one UDP server per client. +/// +/// The UDP and TCP servers are kept behind shared [`Arc`] handles so the daily mask rotator can +/// update their accept-time options (padding profile, masquerade preamble strings) without +/// disturbing in-flight connections — see [`MultiServer::set_udp_opts`] / [`MultiServer::set_tcp_opts`]. pub struct MultiServer { rx: mpsc::Receiver, tasks: Vec>, + /// Live UDP server handle (shared with the accept loop), used by the mask rotator to update + /// the accept-time options. `None` when the UDP transport was not enabled. + udp: Option>, + /// Live TCP server handle (shared with the accept loop), used by the mask rotator to update + /// the accept-time options. `None` when the TCP transport was not enabled. + tcp: Option>, } impl MultiServer { @@ -194,14 +204,26 @@ impl MultiServer { let (txc, rx) = mpsc::channel::(32); let mut tasks = Vec::new(); - if let Some(addr) = endpoints.udp { - let server = UdpServer::bind(addr, proto_cfg.clone(), udp)?; - tasks.push(tokio::spawn(udp_accept_loop(server, txc.clone()))); - } - if let Some(addr) = endpoints.tcp { - let server = TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?; - tasks.push(tokio::spawn(tcp_accept_loop(server, txc.clone()))); - } + let udp_handle = if let Some(addr) = endpoints.udp { + let server = Arc::new(UdpServer::bind(addr, proto_cfg.clone(), udp)?); + tasks.push(tokio::spawn(udp_accept_loop( + Arc::clone(&server), + txc.clone(), + ))); + Some(server) + } else { + None + }; + let tcp_handle = if let Some(addr) = endpoints.tcp { + let server = Arc::new(TcpServer::bind(addr, proto_cfg.clone(), tcp.clone()).await?); + tasks.push(tokio::spawn(tcp_accept_loop( + Arc::clone(&server), + txc.clone(), + ))); + Some(server) + } else { + None + }; if let Some(addr) = endpoints.quic { let server = AuraServer::bind( addr, @@ -215,7 +237,28 @@ impl MultiServer { if tasks.is_empty() { anyhow::bail!("MultiServer: no transports enabled"); } - Ok(Self { rx, tasks }) + Ok(Self { + rx, + tasks, + udp: udp_handle, + tcp: tcp_handle, + }) + } + + /// Update the UDP accept-time options. The next [`Self::accept`] of a UDP connection will use + /// the new options; existing connections keep theirs. No-op if the UDP transport is disabled. + pub async fn set_udp_opts(&self, new_opts: UdpOpts) { + if let Some(s) = &self.udp { + s.set_opts(new_opts).await; + } + } + + /// Update the TCP accept-time options. The next [`Self::accept`] of a TCP connection will use + /// the new options; existing connections keep theirs. No-op if the TCP transport is disabled. + pub async fn set_tcp_opts(&self, new_opts: TcpOpts) { + if let Some(s) = &self.tcp { + s.set_opts(new_opts).await; + } } /// Wait for the next accepted connection from any enabled transport. Returns `None` when all @@ -233,7 +276,7 @@ impl Drop for MultiServer { } } -async fn udp_accept_loop(server: UdpServer, tx: mpsc::Sender) { +async fn udp_accept_loop(server: Arc, tx: mpsc::Sender) { loop { match server.accept().await { Ok(conn) => { @@ -255,7 +298,7 @@ async fn udp_accept_loop(server: UdpServer, tx: mpsc::Sender) { } } -async fn tcp_accept_loop(server: TcpServer, tx: mpsc::Sender) { +async fn tcp_accept_loop(server: Arc, tx: mpsc::Sender) { loop { match server.accept().await { Ok(conn) => { diff --git a/crates/aura-transport/src/lib.rs b/crates/aura-transport/src/lib.rs index 8e3b4c5..7715375 100644 --- a/crates/aura-transport/src/lib.rs +++ b/crates/aura-transport/src/lib.rs @@ -74,7 +74,10 @@ pub mod udp; pub use conn::AuraConnection; pub use dial::{dial, Accepted, DialConfig, Endpoints, MultiServer, TransportMode}; pub use mimicry::{alpn_protocols, chrome_quic_transport_config, ALPN_H3, DEFAULT_SNI}; -pub use padding::{inject_padding_frames, pad_to_https_size, HTTPS_SIZE_BUCKETS}; +pub use padding::{ + inject_padding_frames, next_bucket_for_profile, pad_to_bucket, pad_to_https_size, + HTTPS_SIZE_BUCKETS, PADDING_PROFILES, +}; pub use quic::{client_endpoint, server_endpoint, AcceptAnyServerCert}; pub use tcp::{TcpClient, TcpConnection, TcpOpts, TcpServer}; pub use udp::{UdpClient, UdpConnection, UdpOpts, UdpServer}; diff --git a/crates/aura-transport/src/padding.rs b/crates/aura-transport/src/padding.rs index e9233bc..43b4777 100644 --- a/crates/aura-transport/src/padding.rs +++ b/crates/aura-transport/src/padding.rs @@ -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, 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); + } } diff --git a/crates/aura-transport/src/tcp.rs b/crates/aura-transport/src/tcp.rs index 5d6e998..c99aad3 100644 --- a/crates/aura-transport/src/tcp.rs +++ b/crates/aura-transport/src/tcp.rs @@ -31,6 +31,10 @@ use aura_proto::{ }; /// Tunables for the TCP transport. +/// +/// `user_agent` / `server_header` defaults match the original hard-coded preamble strings, so a +/// pre-rotation deployment that constructs `TcpOpts::default()` retains exact wire compatibility +/// with previous Aura builds (used by existing TCP loopback tests). #[derive(Clone, Debug)] pub struct TcpOpts { /// When `true`, exchange a minimal HTTP/1.1 preamble before the Aura handshake so the connection @@ -38,6 +42,12 @@ pub struct TcpOpts { pub masquerade: bool, /// `Host:` header value used in the client's masquerade preamble. pub host: String, + /// `User-Agent:` header value used in the client's masquerade preamble; the daily mask + /// rotation supplies this from [`aura_crypto::MaskSet::user_agent`]. + pub user_agent: String, + /// `Server:` header value used in the server's masquerade preamble; the daily mask rotation + /// supplies this from [`aura_crypto::MaskSet::server_header`]. + pub server_header: String, } impl Default for TcpOpts { @@ -45,6 +55,10 @@ impl Default for TcpOpts { Self { masquerade: false, host: "cdn.example.com".to_string(), + // Match the pre-rotation hard-coded preamble strings exactly so existing loopback tests + // (which build `TcpOpts::default()`) keep observing identical wire bytes. + user_agent: "Mozilla/5.0".to_string(), + server_header: "nginx".to_string(), } } } @@ -129,18 +143,23 @@ impl PacketConnection for TcpConnection { // --------------------------------------------------------------------------------------------- /// Write a plausible HTTP/1.1 request line + headers (client side of the masquerade). -async fn write_client_preamble(stream: &mut TcpStream, host: &str) -> io::Result<()> { +async fn write_client_preamble( + stream: &mut TcpStream, + host: &str, + user_agent: &str, +) -> io::Result<()> { let req = format!( - "GET / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: Mozilla/5.0\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n" + "GET / HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {user_agent}\r\nAccept: */*\r\nConnection: keep-alive\r\n\r\n" ); stream.write_all(req.as_bytes()).await?; stream.flush().await } /// Write a plausible HTTP/1.1 response head (server side of the masquerade). -async fn write_server_preamble(stream: &mut TcpStream) -> io::Result<()> { - let resp = - "HTTP/1.1 200 OK\r\nServer: nginx\r\nContent-Type: application/octet-stream\r\nConnection: keep-alive\r\n\r\n"; +async fn write_server_preamble(stream: &mut TcpStream, server_header: &str) -> io::Result<()> { + let resp = format!( + "HTTP/1.1 200 OK\r\nServer: {server_header}\r\nContent-Type: application/octet-stream\r\nConnection: keep-alive\r\n\r\n" + ); stream.write_all(resp.as_bytes()).await?; stream.flush().await } @@ -184,7 +203,11 @@ async fn read_until_headers_end(stream: &mut TcpStream) -> io::Result<()> { pub struct TcpServer { listener: TcpListener, proto_cfg: Arc, - opts: TcpOpts, + /// Live options: kept behind an `Arc` so the daily mask rotator can update the + /// masquerade `Server:` header (and `host` if a deployment cares to) and the next + /// [`Self::accept`] picks it up. In-flight connections already exchanged their preamble bytes, + /// so the rotation only changes what *the next handshake* writes. + opts: Arc>, } impl TcpServer { @@ -202,10 +225,21 @@ impl TcpServer { Ok(Self { listener, proto_cfg: Arc::new(proto_cfg), - opts, + opts: Arc::new(tokio::sync::RwLock::new(opts)), }) } + /// Replace the server's accept-time options. The next [`Self::accept`] picks up the change; + /// in-flight connections keep what they exchanged at their own accept. + pub async fn set_opts(&self, new_opts: TcpOpts) { + *self.opts.write().await = new_opts; + } + + /// A snapshot of the current accept-time options. + pub async fn opts(&self) -> TcpOpts { + self.opts.read().await.clone() + } + /// The local address (incl. the OS-assigned port) this server is bound to. /// /// # Errors @@ -222,9 +256,12 @@ impl TcpServer { pub async fn accept(&self) -> anyhow::Result { let (mut stream, _peer) = self.listener.accept().await?; stream.set_nodelay(true).ok(); - if self.opts.masquerade { + // Snapshot once: the preamble writes immediately, and we want a consistent view in case a + // rotation lands mid-accept. + let opts = self.opts.read().await.clone(); + if opts.masquerade { read_until_headers_end(&mut stream).await?; - write_server_preamble(&mut stream).await?; + write_server_preamble(&mut stream, &opts.server_header).await?; } let (reader, writer) = stream.into_split(); let session = server_handshake(reader, writer, &self.proto_cfg).await?; @@ -250,7 +287,7 @@ impl TcpClient { let mut stream = TcpStream::connect(server).await?; stream.set_nodelay(true).ok(); if opts.masquerade { - write_client_preamble(&mut stream, &opts.host).await?; + write_client_preamble(&mut stream, &opts.host, &opts.user_agent).await?; read_until_headers_end(&mut stream).await?; } let (reader, writer) = stream.into_split(); diff --git a/crates/aura-transport/src/udp.rs b/crates/aura-transport/src/udp.rs index ca3618c..06920de 100644 --- a/crates/aura-transport/src/udp.rs +++ b/crates/aura-transport/src/udp.rs @@ -100,13 +100,19 @@ const RECV_BUF: usize = 2048; /// Tunables for the UDP transport (handshake reliability timers and obfuscation). /// /// [`UdpOpts::default`] is a sensible production default: obfuscation off, a 250 ms retransmit -/// timeout, and a 10 s overall handshake deadline. +/// timeout, a 10 s overall handshake deadline, and padding profile `0` (the historical +/// [`HTTPS_SIZE_BUCKETS`](padding::HTTPS_SIZE_BUCKETS) palette). #[derive(Clone, Copy, Debug)] pub struct UdpOpts { - /// When `true`, pad every outgoing DATA datagram up to the next - /// [`padding::HTTPS_SIZE_BUCKETS`] size class with random trailing bytes (the receiver ignores - /// the pad). Adds bandwidth overhead in exchange for a more uniform on-wire size distribution. + /// When `true`, pad every outgoing DATA datagram up to the next bucket of the configured + /// [`Self::padding_profile`] with random trailing bytes (the receiver ignores the pad). + /// Adds bandwidth overhead in exchange for a more uniform on-wire size distribution. pub obfuscate: bool, + /// Padding profile id (index into [`padding::PADDING_PROFILES`]); the daily mask rotation + /// picks this from [`aura_crypto::MaskSet::padding_profile_id`]. `0` is the original + /// [`HTTPS_SIZE_BUCKETS`](padding::HTTPS_SIZE_BUCKETS) palette, kept as the default so callers + /// that do not set this field retain pre-rotation wire behaviour. + pub padding_profile: u8, /// Handshake retransmit timeout (RTO): every `hs_rto`, all still-unacked HS datagrams are resent. pub hs_rto: Duration, /// Overall handshake deadline; if the handshake has not completed within this, it errors. @@ -120,6 +126,7 @@ impl Default for UdpOpts { fn default() -> Self { Self { obfuscate: false, + padding_profile: 0, hs_rto: Duration::from_millis(250), hs_timeout: Duration::from_secs(10), hs_linger: Duration::from_secs(2), @@ -719,9 +726,10 @@ impl PacketConnection for UdpConnection { dg.extend_from_slice(&rec); if self.opts.obfuscate { - // Pad the *whole datagram* up to the next HTTPS-like size bucket with random bytes. The - // receiver reads exactly `rec_len` of the sealed record and ignores the trailing pad. - let target = padding::next_https_bucket(dg.len()); + // Pad the *whole datagram* up to the next size bucket of the configured padding + // profile (the daily mask picks the profile id). The receiver reads exactly `rec_len` + // of the sealed record and ignores the trailing pad bytes. + let target = padding::next_bucket_for_profile(dg.len(), self.opts.padding_profile); if target > dg.len() { let pad = target - dg.len(); let mut pad_bytes = vec![0u8; pad]; @@ -801,7 +809,11 @@ pub struct UdpServer { /// `try_clone` an independent handle for the per-connection [`PeerSocket`] (no `unsafe`). std_socket: std::net::UdpSocket, proto_cfg: Arc, - opts: UdpOpts, + /// Live options: kept behind an `Arc` so the daily mask rotator can update the + /// padding profile (and any future per-rotation field) and the next [`Self::accept`] picks up + /// the change. Already-accepted [`UdpConnection`]s hold their own snapshot, so an in-flight + /// connection's wire behaviour does not change mid-stream. + opts: Arc>, } impl UdpServer { @@ -823,10 +835,22 @@ impl UdpServer { socket: Arc::new(socket), std_socket, proto_cfg: Arc::new(proto_cfg), - opts, + opts: Arc::new(tokio::sync::RwLock::new(opts)), }) } + /// Replace the server's accept-time options. The change applies to the **next** [`Self::accept`]; + /// already-accepted connections keep their snapshot. Used by the daily mask rotator to update + /// the padding profile new connections will use. + pub async fn set_opts(&self, new_opts: UdpOpts) { + *self.opts.write().await = new_opts; + } + + /// A snapshot of the current accept-time options. + pub async fn opts(&self) -> UdpOpts { + *self.opts.read().await + } + /// The local address (including the OS-assigned port) this server is bound to. /// /// # Errors @@ -872,7 +896,10 @@ impl UdpServer { seed_first_hs(&state, &first).await; let cfg = self.proto_cfg.clone(); - let opts = self.opts; + // Snapshot the current accept-time options once: the resulting connection keeps this exact + // copy for its lifetime, so a concurrent mask rotation does not change in-flight wire + // behaviour (only the *next* accept will see the new mask). + let opts = *self.opts.read().await; let est = run_reliable_handshake(peer_socket, state, opts, move |r, w| async move { let session = server_handshake(r, w, &cfg).await?; Ok(session.into_datagram_parts()) diff --git a/crates/aura-transport/tests/tcp_loopback.rs b/crates/aura-transport/tests/tcp_loopback.rs index 2370808..0e7c440 100644 --- a/crates/aura-transport/tests/tcp_loopback.rs +++ b/crates/aura-transport/tests/tcp_loopback.rs @@ -71,6 +71,7 @@ async fn tcp_loopback_end_to_end_masquerade() { run_case(TcpOpts { masquerade: true, host: "cdn.example.com".to_string(), + ..TcpOpts::default() }) .await; }