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:
@@ -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 <PATH>` (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,
|
||||
|
||||
@@ -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<Vec<TransportMode>> {
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RwLock<MaskSet>>` into the
|
||||
/// code that builds per-connection transport options.
|
||||
pub type MaskHandle = Arc<RwLock<MaskSet>>;
|
||||
|
||||
/// 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<Self> {
|
||||
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<Self>) -> 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);
|
||||
}
|
||||
}
|
||||
@@ -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 <PATH>` (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;
|
||||
|
||||
@@ -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<dyn PacketConnection> = Arc::new(server_conn);
|
||||
let client_conn: Arc<dyn PacketConnection> = Arc::new(client_conn);
|
||||
|
||||
for pkt in [
|
||||
b"hello-mask".to_vec(),
|
||||
vec![0xA5u8; 1300],
|
||||
(0..200u8).collect::<Vec<u8>>(),
|
||||
] {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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::<Sha256>::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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
fn val(b: u8) -> Option<u8> {
|
||||
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<u8> = (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
|
||||
}
|
||||
}
|
||||
@@ -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<Accepted>,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
/// 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<Arc<UdpServer>>,
|
||||
/// 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<Arc<TcpServer>>,
|
||||
}
|
||||
|
||||
impl MultiServer {
|
||||
@@ -194,14 +204,26 @@ impl MultiServer {
|
||||
let (txc, rx) = mpsc::channel::<Accepted>(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<Accepted>) {
|
||||
async fn udp_accept_loop(server: Arc<UdpServer>, tx: mpsc::Sender<Accepted>) {
|
||||
loop {
|
||||
match server.accept().await {
|
||||
Ok(conn) => {
|
||||
@@ -255,7 +298,7 @@ async fn udp_accept_loop(server: UdpServer, tx: mpsc::Sender<Accepted>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn tcp_accept_loop(server: TcpServer, tx: mpsc::Sender<Accepted>) {
|
||||
async fn tcp_accept_loop(server: Arc<TcpServer>, tx: mpsc::Sender<Accepted>) {
|
||||
loop {
|
||||
match server.accept().await {
|
||||
Ok(conn) => {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ServerConfig>,
|
||||
opts: TcpOpts,
|
||||
/// Live options: kept behind an `Arc<RwLock>` 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<tokio::sync::RwLock<TcpOpts>>,
|
||||
}
|
||||
|
||||
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<TcpConnection> {
|
||||
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();
|
||||
|
||||
@@ -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<ServerConfig>,
|
||||
opts: UdpOpts,
|
||||
/// Live options: kept behind an `Arc<RwLock>` 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<tokio::sync::RwLock<UdpOpts>>,
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user