feat(crypto,cli,docs): russian SNI palette + RF-billing deployment scenario

Adds a way to make the outer-TLS SNI rotate among popular Russian-language
domains so that Russian carriers — who may start metering "foreign traffic"
separately — see the user's first hop as a domestic CDN/site request, not
as an exotic foreign destination.

- aura-crypto::masks:
  - SNI_PALETTE_RUSSIAN (15 real domains: 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).
  - enum SniPalette { Default, Russian, Mixed } (Default = v2 behavior).
  - derive_mask_for_msk_date_with_palette(...): pick from chosen palette,
    Mixed flips ~50/50 by HKDF okm[8]&1. Old derive_mask_for_msk_date kept
    as a thin wrapper -> byte-for-byte unchanged Default.
- aura-cli::masks::MaskRotator gains new_with_palette(...); the spawn loop
  uses the stored palette. Old new() preserves Default.
- aura-cli config: [transport.masks] palette = "default"|"russian"|"mixed"
  (serde rename_all = "lowercase", default Default).
- server.rs/client.rs read cfg.transport.masks.palette and pass it to the
  rotator at startup; logged at INFO so the operator sees the choice.
- docs/deployment.md: new §7 "Сервер в РФ против тарификации иностранного
  трафика" — context, ASCII topology, recommended RF providers, full
  server.toml + client.toml examples wiring [server.relay] + russian
  palette + LE outer cert + multi-hop, plus an honest list of what this
  does and does not give.
- config/{server,client}.toml.example updated with palette = "default".

Workspace: 284 tests passed (+8 new = 4 crypto + 2 cli masks + 2 config),
clippy -D warnings clean, fmt clean. 276 baseline tests untouched.
Backward-compatible: configs without palette default to Default, identical
to v2 wire behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 20:29:18 +03:00
parent 9b98004424
commit e0e53665f1
9 changed files with 688 additions and 24 deletions
+6 -1
View File
@@ -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
+153 -1
View File
@@ -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() {
+56 -7
View File
@@ -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<RwLock<MaskSet>>;
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<Self> {
pub fn new_with_palette(ca_cert_pem: &str, palette: SniPalette) -> anyhow::Result<Self> {
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> {
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);
}
}
+6 -1
View File
@@ -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)