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