//! 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 SNI / HTTP `Host` values for the **Russian** palette ([`SniPalette::Russian`]). /// /// Real, well-known Russian domains (top portals, marketplaces, banking, video, jobs, news, state /// services). The goal is for a passive on-path observer (e.g. a Russian ISP doing "domestic vs /// foreign" billing classification by destination IP / SNI) to see SNI strings that look like /// ordinary HTTPS to a large Russian site. Combined with an entry-relay hosted on a Russian VPS, /// this is the v3.2 building block for the "domestic traffic" deployment scenario documented in /// `docs/deployment.md`. /// /// All entries are real, currently-live domains as of 2026. pub const SNI_PALETTE_RUSSIAN: &[&str] = &[ "mail.yandex.ru", "vk.com", "www.ozon.ru", "dzen.ru", "ya.ru", "www.gosuslugi.ru", "www.wildberries.ru", "rutube.ru", "news.rambler.ru", "hh.ru", "www.tinkoff.ru", "lenta.ru", "www.kinopoisk.ru", "afisha.yandex.ru", "music.yandex.ru", ]; /// 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"; /// Which SNI palette to pick the daily mask's `sni` / `http_host` from. /// /// The v2 default ([`SniPalette::Default`]) picks from [`SNI_PALETTE`] (global CDN-like names) and /// is what every existing deployment uses unless explicitly opted out. v3.2 adds /// [`SniPalette::Russian`] (picks from [`SNI_PALETTE_RUSSIAN`]) so a client behind a Russian /// "domestic vs foreign" traffic classifier can pin the outer-TLS SNI to a domestic-looking /// hostname while still tunneling through a multi-hop circuit; [`SniPalette::Mixed`] uses one of /// the HKDF output bytes to flip between the two palettes day-by-day for variety. /// /// Only the `sni` and `http_host` fields of the produced [`MaskSet`] are affected; the User-Agent / /// Server-header / padding-profile palettes are not palette-dependent in v3.2. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum SniPalette { /// Global CDN-like palette ([`SNI_PALETTE`]). The pre-v3.2 default; back-compat behaviour. #[default] Default, /// Russian top-domain palette ([`SNI_PALETTE_RUSSIAN`]). Use when the SNI should look like /// ordinary HTTPS to a large Russian site (typical case: an entry-relay hosted on a Russian /// VPS that an ISP would classify as "domestic" traffic). Russian, /// Mix of both palettes: an HKDF output byte selects Default vs Russian per (CA, MSK-date), so /// across a population of days roughly half the SNI strings come from each palette. Useful for /// adding variety without committing entirely to one classifier signal. Mixed, } /// 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. Uses the default SNI palette /// ([`SniPalette::Default`] — back-compat with every pre-v3.2 deployment). /// /// Thin wrapper over [`derive_mask_for_msk_date_with_palette`]. #[must_use] pub fn derive_mask_for_msk_date(ca_fp: &[u8; 32], year: i32, month: u32, day: u32) -> MaskSet { derive_mask_for_msk_date_with_palette(ca_fp, year, month, day, SniPalette::Default) } /// Derive the daily [`MaskSet`] for `(ca_fingerprint, year-month-day)` from a specific SNI /// palette. 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 2-byte big-endian indices (each taken `mod len(palette)`); for /// [`SniPalette::Mixed`] an extra OKM byte chooses between the Default and Russian palettes. /// /// Only the `sni` / `http_host` fields are affected by `palette`; User-Agent, Server-header, and /// padding-profile index always come from the same OKM bytes in the same palettes (so a v3.2 /// deployment that flips `palette` between days does NOT alter those fields and therefore stays /// byte-compatible with every existing transport-side test). #[must_use] pub fn derive_mask_for_msk_date_with_palette( ca_fp: &[u8; 32], year: i32, month: u32, day: u32, palette: SniPalette, ) -> 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::::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"); // Pick the SNI palette to draw from. For Mixed, byte 8 of the OKM (untouched by the existing // four 2-byte indices below) selects Default vs Russian — its low bit gives ~50/50 across // (CA, date) pairs without disturbing the v2 indexing of the other fields. let effective_palette = match palette { SniPalette::Default => SniPalette::Default, SniPalette::Russian => SniPalette::Russian, SniPalette::Mixed => { if okm[8] & 1 == 0 { SniPalette::Default } else { SniPalette::Russian } } }; let sni_palette: &[&str] = match effective_palette { SniPalette::Default => SNI_PALETTE, SniPalette::Russian => SNI_PALETTE_RUSSIAN, // `Mixed` cannot survive the resolution above; the match is exhaustive on the variant set. SniPalette::Mixed => SNI_PALETTE, }; 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> { 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> { fn val(b: u8) -> Option { 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"); } /// v3.2 palette: every day derived with [`SniPalette::Russian`] yields an SNI in /// [`SNI_PALETTE_RUSSIAN`] (and the `http_host` mirror tracks the SNI as before). #[test] fn russian_palette_picks_from_russian_list() { let ca_fp = [13u8; 32]; // Sweep through a month so we exercise multiple HKDF outputs / palette indices. for day in 1..=28u32 { let m = derive_mask_for_msk_date_with_palette(&ca_fp, 2026, 5, day, SniPalette::Russian); assert!( SNI_PALETTE_RUSSIAN.iter().any(|s| *s == m.sni), "Russian palette produced unexpected SNI '{}' on day 2026-05-{day:02}", m.sni ); // The other fields still come from the global palettes — palette is sni-only. 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 for Russian palette too" ); } } /// Back-compat: [`SniPalette::Default`] (and the v2 [`derive_mask_for_msk_date`] wrapper) /// produce byte-identical `MaskSet`s — every existing test that used the wrapper stays valid. #[test] fn default_palette_unchanged() { let ca_fp = [55u8; 32]; // Sample a handful of dates including the today-of-the-task one and edges of months. let dates = [(2026, 1, 1), (2026, 5, 27), (2026, 12, 31), (2024, 2, 29)]; for (y, m, d) in dates { let legacy = derive_mask_for_msk_date(&ca_fp, y, m, d); let with_default = derive_mask_for_msk_date_with_palette(&ca_fp, y, m, d, SniPalette::Default); assert_eq!( legacy, with_default, "Default palette must equal legacy derive_mask_for_msk_date for {y}-{m:02}-{d:02}" ); } } /// [`SniPalette::Mixed`] over a month-long sweep yields SNIs from both palettes (or at least /// changes between consecutive days), proving the palette-selector bit actually toggles. We /// assert "at least one Default-palette SNI AND at least one Russian-palette SNI appear". #[test] fn mixed_palette_picks_from_either() { let ca_fp = [77u8; 32]; let mut saw_default = false; let mut saw_russian = false; // 30 consecutive days — more than enough HKDF outputs to flip the selector bit both ways // unless we have a wildly biased input (we don't: ca_fp is constant, only the date varies). for day in 1..=30u32 { let m = derive_mask_for_msk_date_with_palette(&ca_fp, 2026, 5, day, SniPalette::Mixed); let in_default = SNI_PALETTE.iter().any(|s| *s == m.sni); let in_russian = SNI_PALETTE_RUSSIAN.iter().any(|s| *s == m.sni); assert!( in_default || in_russian, "Mixed-palette SNI '{}' is in neither palette on day 2026-05-{day:02}", m.sni ); saw_default |= in_default; saw_russian |= in_russian; } assert!( saw_default && saw_russian, "Mixed palette never produced both palette types in 30 days \ (saw_default={saw_default}, saw_russian={saw_russian}); the selector bit is stuck" ); } /// Sanity: the Russian palette has at least the documented size of 10 entries (the modulo /// indexing would panic on `% 0` if the array were empty, so this also guards against an /// accidental wipe). #[test] fn russian_palette_has_entries() { assert!( SNI_PALETTE_RUSSIAN.len() >= 10, "Russian palette is too small: {} entries", SNI_PALETTE_RUSSIAN.len() ); } #[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 = (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 } }