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
+3 -2
View File
@@ -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;
+186 -5
View File
@@ -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");