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:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user