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
+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();