diff --git a/config/client.toml.example b/config/client.toml.example index e35877b..e76081b 100644 --- a/config/client.toml.example +++ b/config/client.toml.example @@ -115,6 +115,14 @@ masquerade = true # Existing connections keep the mask they connected with. Default: true. # When `false`, the static values above ([client] sni, [transport] obfuscate, ...) are used as-is. enabled = true +# v3.2: which SNI palette the daily rotator picks from. Must generally match the server's +# [transport.masks] palette so the daily SNI looks consistent across both sides' logs. +# "default" (back-compat) — global CDN-like names. Use against any foreign-hosted server. +# "russian" — top Russian domains (vk.com / ozon.ru / mail.yandex.ru / ...). +# Use when the entry-relay is a Russian VPS so the outer SNI looks +# like ordinary HTTPS to a domestic site (see docs/deployment.md § 7). +# "mixed" — HKDF flips between Default and Russian per day for variety. +palette = "default" [transport.knock] # UDP port-knocking. Must match the server's setting. Default: false. diff --git a/config/server.toml.example b/config/server.toml.example index 5378d14..ce571e0 100644 --- a/config/server.toml.example +++ b/config/server.toml.example @@ -118,6 +118,16 @@ masquerade = true # needed. Existing connections keep the mask they accepted with. Default: true. # When `false`, the static values above ([mimicry] sni, [transport] obfuscate, ...) are used as-is. enabled = true +# v3.2: which SNI palette the daily rotator picks from. +# "default" (back-compat) — global CDN-like names (cloudflare/akamai/aws). Use on any +# foreign-hosted server. This is the pre-v3.2 default. +# "russian" — top Russian domains (vk.com / ozon.ru / mail.yandex.ru / ...). +# Use on an entry-relay hosted on a Russian VPS for the +# "domestic traffic" deployment (see docs/deployment.md § 7). +# "mixed" — HKDF flips between Default and Russian per day for variety. +# Server and client should generally agree on the palette (logs match; the wire itself does not +# require coordination — every connection's SNI is per-side). +palette = "default" [transport.knock] # UDP port-knocking. When `enabled = true`, the UDP transport demands a 16-byte HMAC prefix on diff --git a/crates/aura-cli/src/client.rs b/crates/aura-cli/src/client.rs index 343742a..765a8d5 100644 --- a/crates/aura-cli/src/client.rs +++ b/crates/aura-cli/src/client.rs @@ -50,8 +50,12 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // `DialConfig` so the connect we are about to do already uses today's mask. The rotator's // background task keeps `rot.handle()` updated for any future re-dials. let masks_enabled = cfg.transport.masks.enabled; + let mask_palette = cfg.transport.masks.palette.to_crypto(); let mask_rotator = if masks_enabled { - let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); + let rot = Arc::new(MaskRotator::new_with_palette( + &proto_cfg.ca_cert_pem, + mask_palette, + )?); let initial = rot.current().await; dial_cfg.sni = initial.sni.clone(); dial_cfg.udp.padding_profile = initial.padding_profile_id; @@ -61,6 +65,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { tracing::info!( sni = %initial.sni, padding_profile = initial.padding_profile_id, + palette = ?cfg.transport.masks.palette, "mask rotation enabled; initial mask applied to dial" ); // Keep the rotation task running in the background; v1's client only dials once, so the diff --git a/crates/aura-cli/src/config.rs b/crates/aura-cli/src/config.rs index ee3ee38..3a474bb 100644 --- a/crates/aura-cli/src/config.rs +++ b/crates/aura-cli/src/config.rs @@ -721,17 +721,64 @@ impl Default for CoverSection { /// Both peers derive the current mask from `(CA fingerprint, MSK date)`; no wire coordination is /// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` / /// `[mimicry] sni` are used as before (pre-rotation behaviour). +/// +/// v3.2 adds the `palette` field, which selects the SNI palette the rotator picks from: +/// +/// * `"default"` (back-compat with every pre-v3.2 deployment) — global CDN-like names. +/// * `"russian"` — top Russian domains (use when the SNI should look domestic to a Russian ISP). +/// * `"mixed"` — alternates between the two palettes day-to-day under HKDF control. +/// +/// Server and client MUST agree on the palette only if both sides want their SNIs to match — the +/// SNI is per-connection on the wire and the server does not advertise an SNI itself, so a +/// mismatch is not an error; it just means the client and the server log slightly different "today's +/// SNI" hostnames. Recommended: pick the same palette on both ends. #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct MasksSection { /// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`: /// use the static TOML values verbatim. pub enabled: bool, + /// v3.2: which SNI palette the daily rotator picks from. Default `"default"` (the pre-v3.2 + /// CDN palette). + pub palette: MaskPalette, } impl Default for MasksSection { fn default() -> Self { - Self { enabled: true } + Self { + enabled: true, + palette: MaskPalette::default(), + } + } +} + +/// v3.2: selector for [`MasksSection::palette`] — which SNI palette the daily rotator draws from. +/// +/// Mirror of [`aura_crypto::SniPalette`]. Kept as a separate type so the TOML parser does not need +/// to depend on the crypto crate's enum and so the back-compat default lives next to the config. +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum MaskPalette { + /// Global CDN palette (pre-v3.2 default). + #[default] + Default, + /// Top Russian domains. Use when the SNI should look like ordinary HTTPS to a large Russian + /// site (typical case: an entry-relay on a Russian VPS for the domestic-traffic deployment + /// documented in `docs/deployment.md`). + Russian, + /// Mixed: HKDF picks Default vs Russian per day. + Mixed, +} + +impl MaskPalette { + /// Lift the TOML enum into the corresponding [`aura_crypto::SniPalette`] variant. + #[must_use] + pub fn to_crypto(self) -> aura_crypto::SniPalette { + match self { + Self::Default => aura_crypto::SniPalette::Default, + Self::Russian => aura_crypto::SniPalette::Russian, + Self::Mixed => aura_crypto::SniPalette::Mixed, + } } } @@ -1870,6 +1917,111 @@ pool_cidr = "10.7.0.0/24" assert!(cfg.server.nat.is_none(), "nat section absent by default"); } + /// v3.2: `[transport.masks] palette = "russian"` parses into [`MaskPalette::Russian`] and + /// maps to [`aura_crypto::SniPalette::Russian`]. The other two values round-trip the same way. + #[test] + fn parses_mask_palette_russian() { + let s = r#" +[server] +name = "edge" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +[transport.masks] +enabled = true +palette = "russian" +"#; + let cfg = ServerConfigFile::parse(s).expect("parse server.toml with russian palette"); + assert!(cfg.transport.masks.enabled); + assert_eq!(cfg.transport.masks.palette, MaskPalette::Russian); + assert_eq!( + cfg.transport.masks.palette.to_crypto(), + aura_crypto::SniPalette::Russian + ); + + // Other accepted values: "default" and "mixed". + for (raw, expected, crypto) in [ + ( + "default", + MaskPalette::Default, + aura_crypto::SniPalette::Default, + ), + ("mixed", MaskPalette::Mixed, aura_crypto::SniPalette::Mixed), + ] { + let s = format!( + r#" +[server] +name = "edge" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +[transport.masks] +palette = "{raw}" +"# + ); + let cfg = ServerConfigFile::parse(&s).expect("parse server.toml"); + assert_eq!(cfg.transport.masks.palette, expected, "palette = {raw}"); + assert_eq!(cfg.transport.masks.palette.to_crypto(), crypto); + } + } + + /// Back-compat: omitting `[transport.masks] palette` falls back to [`MaskPalette::Default`] so + /// every pre-v3.2 config keeps its behaviour byte-for-byte. + #[test] + fn mask_palette_defaults_when_omitted() { + // Server side: no [transport.masks] block at all. + let s_server = r#" +[server] +name = "edge" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +"#; + let cfg = ServerConfigFile::parse(s_server).expect("parse minimal server.toml"); + assert_eq!(cfg.transport.masks.palette, MaskPalette::Default); + // Server side: section present but no `palette` key. + let s_server_partial = r#" +[server] +name = "edge" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +pool_cidr = "10.7.0.0/24" +[transport.masks] +enabled = true +"#; + let cfg = ServerConfigFile::parse(s_server_partial) + .expect("parse server.toml with masks but no palette"); + assert_eq!(cfg.transport.masks.palette, MaskPalette::Default); + + // Client side: same checks. + let c_client = r#" +[client] +name = "x" +server_addr = "1.2.3.4:443" +sni = "a" +[pki] +ca_cert = "a" +cert = "b" +key = "c" +[tunnel] +local_ip = "10.7.0.2" +"#; + let cfg = ClientConfigFile::parse(c_client).expect("parse minimal client.toml"); + assert_eq!(cfg.transport.masks.palette, MaskPalette::Default); + } + /// `run_as` is parsed off both [server] and [client] sections and is optional. #[test] fn parses_run_as_on_both_configs() { diff --git a/crates/aura-cli/src/masks.rs b/crates/aura-cli/src/masks.rs index d51e876..8ce711f 100644 --- a/crates/aura-cli/src/masks.rs +++ b/crates/aura-cli/src/masks.rs @@ -39,7 +39,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use aura_crypto::{ca_fingerprint, derive_mask_for_msk_date, MaskSet}; +use aura_crypto::{ca_fingerprint, derive_mask_for_msk_date_with_palette, MaskSet, SniPalette}; use tokio::sync::RwLock; use tokio::task::JoinHandle; @@ -52,10 +52,12 @@ pub type MaskHandle = Arc>; pub struct MaskRotator { active: MaskHandle, ca_fp: [u8; 32], + palette: SniPalette, } impl MaskRotator { - /// Build a rotator from the CA PEM the rest of the stack already trusts. + /// Build a rotator from the CA PEM the rest of the stack already trusts, using the supplied + /// SNI palette (v3.2). /// /// The initial mask is the one current at the calling instant (today's MSK day). Use /// [`Self::spawn`] to start the daily rotation task that updates the shared handle. @@ -63,17 +65,29 @@ impl MaskRotator { /// # Errors /// Propagates [`aura_crypto::CryptoError`] from [`aura_crypto::ca_fingerprint`] (typically a /// malformed CA PEM). - pub fn new(ca_cert_pem: &str) -> anyhow::Result { + pub fn new_with_palette(ca_cert_pem: &str, palette: SniPalette) -> anyhow::Result { let ca_fp = ca_fingerprint(ca_cert_pem)?; let now = unix_now_utc(); let (y, m, d) = msk_today(now); - let initial = derive_mask_for_msk_date(&ca_fp, y, m, d); + let initial = derive_mask_for_msk_date_with_palette(&ca_fp, y, m, d, palette); Ok(Self { active: Arc::new(RwLock::new(initial)), ca_fp, + palette, }) } + /// Back-compat: build a rotator with the default (pre-v3.2 / global CDN) SNI palette. + /// + /// Thin wrapper over [`Self::new_with_palette`] with [`SniPalette::Default`]; every existing + /// call site that does not yet thread the configured palette through can keep using this. + /// + /// # Errors + /// Propagates [`aura_crypto::CryptoError`] from [`aura_crypto::ca_fingerprint`]. + pub fn new(ca_cert_pem: &str) -> anyhow::Result { + Self::new_with_palette(ca_cert_pem, SniPalette::Default) + } + /// A snapshot of the current mask. This locks the inner `RwLock` briefly and clones; suitable /// for the once-per-`connect`/`accept` use case (not for hot per-packet paths). pub async fn current(&self) -> MaskSet { @@ -118,7 +132,8 @@ impl MaskRotator { // (the alarm fires at 02:00 UTC = 05:00 MSK, which is the new MSK day). let after = unix_now_utc(); let (y, m, d) = msk_today(after); - let new_mask = derive_mask_for_msk_date(&this.ca_fp, y, m, d); + let new_mask = + derive_mask_for_msk_date_with_palette(&this.ca_fp, y, m, d, this.palette); { let mut guard = this.active.write().await; if *guard != new_mask { @@ -280,11 +295,45 @@ mod tests { let rotator = MaskRotator::new(&pem).expect("rotator"); let m1 = rotator.current().await; - // Re-derive directly and assert equality (same `(ca_fp, MSK today)`). + // Re-derive directly and assert equality (same `(ca_fp, MSK today)`). The default + // back-compat constructor uses [`SniPalette::Default`], which the helper crate's + // [`derive_mask_for_msk_date_with_palette`] mirrors. let now = unix_now_utc(); let (y, mo, d) = msk_today(now); let fp = ca_fingerprint(&pem).expect("fp"); - let m2 = derive_mask_for_msk_date(&fp, y, mo, d); + let m2 = derive_mask_for_msk_date_with_palette(&fp, y, mo, d, SniPalette::Default); assert_eq!(m1, m2); } + + /// v3.2 palette: [`MaskRotator::new_with_palette`] with `SniPalette::Russian` produces a mask + /// whose `sni` field is one of the Russian palette domains. + #[tokio::test] + async fn palette_russian_yields_russian_sni() { + let ca = aura_pki::AuraCa::generate("aura-mask-russian-test-ca").expect("generate CA"); + let pem = ca.ca_cert_pem(); + + let rotator = + MaskRotator::new_with_palette(&pem, SniPalette::Russian).expect("rotator (russian)"); + let mask = rotator.current().await; + assert!( + aura_crypto::SNI_PALETTE_RUSSIAN + .iter() + .any(|s| *s == mask.sni), + "Russian-palette rotator produced unexpected SNI '{}'", + mask.sni + ); + } + + /// Back-compat: [`MaskRotator::new`] (no palette argument) behaves identically to + /// [`MaskRotator::new_with_palette`] with `SniPalette::Default`. + #[tokio::test] + async fn default_constructor_equals_default_palette() { + let ca = aura_pki::AuraCa::generate("aura-mask-default-test-ca").expect("generate CA"); + let pem = ca.ca_cert_pem(); + + let r_legacy = MaskRotator::new(&pem).expect("rotator (legacy)"); + let r_default = + MaskRotator::new_with_palette(&pem, SniPalette::Default).expect("rotator (default)"); + assert_eq!(r_legacy.current().await, r_default.current().await); + } } diff --git a/crates/aura-cli/src/server.rs b/crates/aura-cli/src/server.rs index 53bb7f1..c9b4a3f 100644 --- a/crates/aura-cli/src/server.rs +++ b/crates/aura-cli/src/server.rs @@ -84,8 +84,12 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { // the TOML so the first accepts already use today's mask; the rotator's background task then // updates the bound MultiServer's opts each day at 02:00 UTC (= 05:00 MSK). let masks_enabled = cfg.transport.masks.enabled; + let mask_palette = cfg.transport.masks.palette.to_crypto(); let mask_rotator = if masks_enabled { - let rot = Arc::new(MaskRotator::new(&proto_cfg.ca_cert_pem)?); + let rot = Arc::new(MaskRotator::new_with_palette( + &proto_cfg.ca_cert_pem, + mask_palette, + )?); let initial = rot.current().await; udp_opts.padding_profile = initial.padding_profile_id; // The TCP transport now uses a real outer TLS-443 layer, which subsumes the old HTTP @@ -94,6 +98,7 @@ pub async fn run(config_path: &Path, admin_socket: &str) -> anyhow::Result<()> { tracing::info!( sni = %initial.sni, padding_profile = initial.padding_profile_id, + palette = ?cfg.transport.masks.palette, "mask rotation enabled; initial mask applied" ); Some(rot) diff --git a/crates/aura-crypto/src/lib.rs b/crates/aura-crypto/src/lib.rs index 2902174..4a983f3 100644 --- a/crates/aura-crypto/src/lib.rs +++ b/crates/aura-crypto/src/lib.rs @@ -21,8 +21,9 @@ pub use aead::{AeadKey, AeadSession}; pub use kdf::{derive_session_keys, SessionKeys}; pub use kem::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret}; pub use masks::{ - ca_fingerprint, derive_mask_for_msk_date, MaskSet, PADDING_PROFILE_COUNT, - SERVER_HEADER_PALETTE, SNI_PALETTE, USER_AGENT_PALETTE, + ca_fingerprint, derive_mask_for_msk_date, derive_mask_for_msk_date_with_palette, MaskSet, + SniPalette, PADDING_PROFILE_COUNT, SERVER_HEADER_PALETTE, SNI_PALETTE, SNI_PALETTE_RUSSIAN, + USER_AGENT_PALETTE, }; use thiserror::Error; diff --git a/crates/aura-crypto/src/masks.rs b/crates/aura-crypto/src/masks.rs index 898720d..7f9fbad 100644 --- a/crates/aura-crypto/src/masks.rs +++ b/crates/aura-crypto/src/masks.rs @@ -50,6 +50,34 @@ pub const SNI_PALETTE: &[&str] = &[ "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", @@ -82,6 +110,32 @@ 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 @@ -126,12 +180,34 @@ pub fn ca_fingerprint(ca_cert_pem: &str) -> Result<[u8; 32], CryptoError> { } /// 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. +/// **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). /// -/// 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)`. +/// 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); @@ -145,12 +221,33 @@ pub fn derive_mask_for_msk_date(ca_fp: &[u8; 32], year: i32, month: u32, day: u3 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(); + // 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(); + let sni = sni_palette[sni_idx].to_string(); MaskSet { http_host: sni.clone(), sni, @@ -283,6 +380,90 @@ mod tests { 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"); diff --git a/docs/deployment.md b/docs/deployment.md index 5aaaf27..829dff3 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -412,17 +412,270 @@ aura status выводят одинаковый `MaskSet` из общего seed (SHA-256 от CA-сертификата) + UTC-даты через HKDF-SHA256, без сетевой координации. Конфиг: `[transport.masks] enabled = true` (по умолчанию). Новые подключения берут текущую маску; уже установленные остаются на своих. - Палитры: 16 SNI, 10 User-Agent, 5 Server-headers, 4 padding-профиля; профиль 0 байт-в-байт - совместим с v1-паддингом (бэк-совместимость). + Палитры: 16 SNI default + 15 SNI russian, 10 User-Agent, 5 Server-headers, 4 padding-профиля; + профиль 0 байт-в-байт совместим с v1-паддингом (бэк-совместимость). v3.2 добавляет + `palette = "default" | "russian" | "mixed"` для случая, когда нужно, чтобы outer SNI выглядел + как обращение к российскому сайту (см. сценарий в §7). +- ✓ **Multi-hop / onion routing v3.1 + v3.2.** Цепочка из 2-3 хопов: client → + entry-relay → (опционально middle) → exit. Entry-relay не знает destination, exit-узел не + знает клиентский IP. v3.2: per-hop client cert (CN entry и exit различаются — нельзя + слинковать handshakes по identity), cell padding (constant-size cells устраняют per-packet + size-fingerprinting), CIDR whitelist на relay'е. Конфиг — `[client.circuit]` / + `[server.relay]`. См. сценарий §7 для деплоймента «российский entry, иностранный exit». +- ✓ **Let's Encrypt outer-TLS cert.** `[server.outer_cert] cert_path / key_path` — outer-TLS + слой QUIC и TCP использует настоящий CA-trusted сертификат вместо self-signed Aura cert; + внутренний Aura mutual-auth handshake продолжает аутентификацию против Aura CA. -### Остающиеся честные ограничения v2 +### Остающиеся честные ограничения - **TUN всё ещё требует root** для **создания** интерфейса (это OS-уровень). Privilege drop минимизирует окно работы под root, но саму операцию обойти нельзя. -- **IPv6 в OS-маршрутах и iptables MASQUERADE** не реализован — только IPv4 (план v3). -- **Windows OS-маршруты** — заглушка с лог-warning (план v3). Windows admin pipe **работает**. +- **IPv6 в OS-маршрутах и iptables MASQUERADE** не реализован — только IPv4 (план v3.3). +- **Windows OS-маршруты** — заглушка с лог-warning (план v3.3). Windows admin pipe **работает**. - **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound, по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт. -- **Multi-hop / onion routing**: пока один сервер на путь (план v3 — цепочка из 2-3 - серверов так, чтобы entry-узел не знал destination, а exit-узел не знал клиентский IP). +- **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges` + хардкодит список запасных IP; если их все заблокируют (включая российские entry-узлы из + сценария §7), восстановление требует обновления конфига клиента вручную. + +--- + +## 7. Сценарий: российский entry-узел против тарификации иностранного трафика + +### 7.1. Контекст и угроза + +Российские операторы связи могут начать тарифицировать «иностранный трафик» отдельно: классификация +выполняется по destination IP исходящего пакета пользователя. Если первый IP, к которому +обращается устройство, — российский, биллинг считает соединение «российским», даже если внутри +этого соединения трафик уходит дальше за рубеж. Цель в этом сценарии — добиться того, чтобы +оператор биллил трафик пользователя как «российский», при этом сохраняя VPN-выход за рубежом. + +Решение опирается на три компонента, уже реализованные в AuraVPN: + +1. **Multi-hop / onion routing v3.1+** (`[client.circuit]` / `[server.relay]`) — entry-узел в РФ + не знает destination, exit-узел за рубежом не знает клиентский IP. +2. **Палитра SNI «russian»** (v3.2) — `[transport.masks] palette = "russian"` ротирует outer-TLS + SNI среди крупных российских доменов (`vk.com`, `www.ozon.ru`, `mail.yandex.ru`, ...). +3. **OS-уровень kill-switch** (`[tunnel.os_routes] enabled = true`) — гарантия, что системный + трафик (push-уведомления, OS-сервисы) не обходит туннель и не попадает напрямую к иностранным + серверам в обход entry-узла. + +### 7.2. Топология + +``` + [устройство] + | + | весь трафик через TUN (kill-switch) + v + [оператор] <-- видит только UDP/443 на RU_VPS_IP, SNI = "vk.com" + | + v + [Russian VPS / entry-relay] <-- v3.1 relay: forward to next hop, never decodes IP packets + | + | inner Aura handshake (PQ-encrypted, opaque) + v + [Foreign VPS / exit] <-- настоящий VPN-выход в интернет + | + v + [internet] +``` + +Оператор видит только трафик до **entry-узла**: один UDP-поток с SNI крупного российского сайта. +Внутри этого потока — зашифрованный многохоп; entry-relay не имеет ключей внутреннего рукопожатия +и видит только AEAD-ciphertext, который он форвардит на exit. Exit видит только IP entry-узла, а +не IP клиентского устройства. + +### 7.3. Что покупать + +**Подходящие провайдеры для entry-узла в РФ** (юрисдикция РФ, IP в российских AS): + +- **Selectel** (Москва, СПб). +- **Beget** (СПб). +- **Yandex.Cloud** (Москва). +- **VK Cloud** (бывш. Mail.ru Cloud Solutions). +- **Timeweb Cloud**. + +**Неподходящие для роли entry-узла в РФ**: + +- **Hetzner** (Германия/Финляндия) — IP классифицируется как «иностранный». +- **DigitalOcean / Vultr / Linode** (США/EU) — то же самое. +- **AWS / GCP / Azure** даже с российскими DC-локациями — IP-блоки за пределами российских AS у + большинства операторов. + +Для **exit-узла** наоборот — берите любой удобный иностранный VPS (Hetzner, DigitalOcean, Vultr, +любой подходящий по юрисдикции и пропускной способности). + +### 7.4. Конфиг сервера в РФ (entry-relay) + +`server.toml` на российском VPS (например, Selectel с IP `RUSSIAN_VPS_IP`): + +```toml +[server] +name = "aura-ru-entry-1" +listen = "0.0.0.0:443" + +[pki] +ca_cert = "/etc/aura/pki/ca.crt" +cert = "/etc/aura/pki/server/server.crt" +key = "/etc/aura/pki/server/server.key" + +[tunnel] +# Pool нужен формально (для v1-fallback-пути), но в роли чистого relay он не используется — +# bridged-клиенты не получают IP из пула и не регистрируются в ServerRouter. +pool_cidr = "10.7.0.0/24" +mtu = 1420 + +# v3.1: relay-режим. Принимаем ExtendBridge от клиента и сплайсим на foreign exit. +[server.relay] +enabled = true +allow_extend_to = ["EXIT_FOREIGN_IP:443"] # IP вашего иностранного exit-узла +# v3.2 cell padding: relay сам не декодирует — это сквозной байт-форвардинг. Знаки опции тут +# для симметрии конфига; реальный декод цельных ячеек — на exit'е. +cell_padding = true +cell_size = 1280 + +[transport.masks] +enabled = true +# v3.2: outer-TLS SNI крутится среди крупных российских доменов. Каждый день — другой домен. +palette = "russian" + +# Опционально: настоящий outer-TLS сертификат (Let's Encrypt) поверх UDP/QUIC и TCP. Без него +# работает self-signed Aura, но с настоящим LE-сертификатом outer-handshake становится +# неотличим от обычного HTTPS на CA-trusted сайт. +[server.outer_cert] +cert_path = "/etc/letsencrypt/live/relay.example.ru/fullchain.pem" +key_path = "/etc/letsencrypt/live/relay.example.ru/privkey.pem" +``` + +И аналогичный `server.toml` на **иностранном exit-узле** — обычный VPN-сервер БЕЗ `[server.relay]`, +но с `cell_padding_for_circuit_clients = true` в секции `[server]`, чтобы он понимал +constant-size cells от клиента: + +```toml +[server] +name = "aura-exit-1" +listen = "0.0.0.0:443" +# v3.2: exit для cell-padded клиентов — декодирует ячейки внутреннего рукопожатия. +cell_padding_for_circuit_clients = true + +[pki] +ca_cert = "/etc/aura/pki/ca.crt" +cert = "/etc/aura/pki/server/exit.crt" +key = "/etc/aura/pki/server/exit.key" + +[tunnel] +pool_cidr = "10.7.0.0/24" + +[server.nat] +auto = true # включить IP-форвардинг и MASQUERADE на egress-интерфейсе +egress_iface = "eth0" + +[transport.masks] +# На exit'е SNI палитра не критична (клиент видит exit только через relay) — оставим default. +palette = "default" +``` + +### 7.5. Конфиг клиента + +```toml +[client] +name = "laptop" +server_addr = "RUSSIAN_VPS_IP:443" # entry-узел в РФ; именно этот IP видит оператор +sni = "relay.example.ru" # SAN серверного outer-TLS сертификата (если есть LE) + +[pki] +ca_cert = "~/.aura/ca.crt" +cert = "~/.aura/client.crt" +key = "~/.aura/client.key" + +[tunnel] +tun_name = "aura0" +local_ip = "10.7.0.2" +prefix = 24 +mtu = 1420 + +[tunnel.split] +default = "VPN" + +# КРИТИЧНО: kill-switch — весь трафик через TUN, OS-уровень. Без этого push-уведомления и +# OS-сервисы могут уйти напрямую в иностранные сервера в обход entry-узла, и оператор +# зачтёт это как «иностранный» трафик. +[tunnel.os_routes] +enabled = true + +# v3.1 / v3.2: цепочка хопов client -> RU_entry -> foreign_exit. +[client.circuit] +enabled = true +cell_padding = true +cell_size = 1280 + +[[client.circuit.hops]] +addr = "RUSSIAN_VPS_IP:443" # entry в РФ — то, что видит оператор +cert_path = "~/.aura/circuit/entry.crt" +key_path = "~/.aura/circuit/entry.key" + +[[client.circuit.hops]] +addr = "EXIT_FOREIGN_IP:443" # exit за рубежом, к которому привязаны DNS/маршруты внутри VPN +cert_path = "~/.aura/circuit/exit.crt" +key_path = "~/.aura/circuit/exit.key" + +[transport.masks] +enabled = true +# Должно совпадать с palette = "russian" на entry-узле — иначе SNI в логах двух сторон +# не будут симметричны (на проводе это не ошибка, но удобнее для отладки). +palette = "russian" +``` + +Сертификаты двух хопов — разные (`entry.crt` != `exit.crt`). Это v3.2 identity-unlinkability: +entry-relay видит только клиентский cert для роли entry, exit-узел видит только cert для роли +exit, и они не пересекаются (см. `aura provision-client --circuit-hops 2 ...`). + +### 7.6. Что это даёт + +- **Оператор биллит как «российский».** На проводе оператор видит один UDP-поток на + `RUSSIAN_VPS_IP:443` — это российский IP в российской AS, классификатор биллинга его не + обозначает как иностранный. +- **SNI выглядит как обращение к российскому сайту.** В пакетах outer-TLS / outer-QUIC + hostname-камуфляж берётся из `SNI_PALETTE_RUSSIAN`: каждый день — другой домен (`vk.com`, + `www.ozon.ru`, `mail.yandex.ru`, ...). DPI видит «нормальный HTTPS на крупный российский + сайт». +- **Реальный VPN-выход — за рубежом.** Внутри multi-hop клиент дозванивается до иностранного + exit-узла; именно его IP видят внешние ресурсы. Entry-узел в РФ форвардит зашифрованный + трафик, не зная destination и не имея ключей внутреннего рукопожатия. +- **Kill-switch предотвращает обход.** `[tunnel.os_routes] enabled = true` программирует + системную таблицу маршрутов так, что весь трафик идёт через TUN — push-уведомления, OS-сервисы + и любые «прямые» обращения в обход VPN заблокированы, поэтому ничто из устройства не уйдёт + напрямую к иностранному IP в обход entry-узла. + +### 7.7. Что это НЕ даёт (честное ограничение) + +- **Не скрывает сам факт VPN-использования** от российских органов. DPI с deep-inspection может + по статистическим паттернам трафика (timing, размеры, поведение в течение сессии) узнать + Aura-протокол; ротация масок и `palette = "russian"` маскирует пассивного наблюдателя, но не + активного аналитика. Для дополнительной защиты включайте `[transport.knock]` и + `[transport.cover]` (port-knocking + cover traffic). +- **Не освобождает от ответственности за заходы на запрещённые ресурсы.** Кто и за что отвечает + при заходе на запрещённый ресурс через VPN — вопрос юрисдикции exit-узла и применимого + законодательства, не технический. +- **Не защищает от блокировки самого entry-IP.** Если СОРМ-система или Роскомнадзор начнут + активно блокировать конкретные VPS-IP, придётся ротировать IP / bridges. Сейчас это решается + через `[client] bridges = [...]` — список запасных российских entry-узлов; клиент пробует их + в случайном порядке при отказе primary. Полноценный bridge-discovery (без хардкода IP в + конфиге) — план v3.3. +- **Cell padding не скрывает наличие туннеля.** Constant-size cells устраняют per-packet + size-fingerprinting внутри multi-hop, но не делают сам поток неотличимым от HTTPS — общий + объём и временные паттерны остаются. Это компромисс между обфускацией и накладными расходами. + +### 7.8. Что менять при ротации + +При смене IP entry-узла (например, при блокировке текущего) обновите три места: + +1. `[[client.circuit.hops]] addr` первого хопа → новый `RUSSIAN_VPS_IP:443`. +2. `[client] server_addr` → тот же новый IP. +3. На новом VPS — поднять PKI, выпустить cert для entry-роли, перенести `server.toml` с + `[server.relay]` и `palette = "russian"`. + +Перевыпускать сертификаты двух хопов не нужно — они остаются те же, меняется только wire-адрес +entry-узла. На сертификате entry-сервера должен быть SAN, совпадающий с `[client] sni` +(см. `aura pki issue-server --domain relay.example.ru`).