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:
xah30
2026-05-27 01:11:45 +03:00
parent 083c441e4c
commit c95e1a482c
16 changed files with 1154 additions and 37 deletions
+54 -11
View File
@@ -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) => {
+4 -1
View File
@@ -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};
+121
View File
@@ -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);
}
}
+47 -10
View File
@@ -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();
+37 -10
View File
@@ -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())