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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user