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
+8
View File
@@ -115,6 +115,14 @@ masquerade = true
# Existing connections keep the mask they connected with. Default: 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. # When `false`, the static values above ([client] sni, [transport] obfuscate, ...) are used as-is.
enabled = true 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] [transport.knock]
# UDP port-knocking. Must match the server's setting. Default: false. # UDP port-knocking. Must match the server's setting. Default: false.
+10
View File
@@ -118,6 +118,16 @@ masquerade = true
# needed. Existing connections keep the mask they accepted with. Default: 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. # When `false`, the static values above ([mimicry] sni, [transport] obfuscate, ...) are used as-is.
enabled = true 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] [transport.knock]
# UDP port-knocking. When `enabled = true`, the UDP transport demands a 16-byte HMAC prefix on # UDP port-knocking. When `enabled = true`, the UDP transport demands a 16-byte HMAC prefix on
+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 // `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. // background task keeps `rot.handle()` updated for any future re-dials.
let masks_enabled = cfg.transport.masks.enabled; let masks_enabled = cfg.transport.masks.enabled;
let mask_palette = cfg.transport.masks.palette.to_crypto();
let mask_rotator = if masks_enabled { 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; let initial = rot.current().await;
dial_cfg.sni = initial.sni.clone(); dial_cfg.sni = initial.sni.clone();
dial_cfg.udp.padding_profile = initial.padding_profile_id; 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!( tracing::info!(
sni = %initial.sni, sni = %initial.sni,
padding_profile = initial.padding_profile_id, padding_profile = initial.padding_profile_id,
palette = ?cfg.transport.masks.palette,
"mask rotation enabled; initial mask applied to dial" "mask rotation enabled; initial mask applied to dial"
); );
// Keep the rotation task running in the background; v1's client only dials once, so the // 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 /// 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` / /// needed. When disabled, the static values from `[client] sni` / `[transport] obfuscate` /
/// `[mimicry] sni` are used as before (pre-rotation behaviour). /// `[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)] #[derive(Debug, Clone, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct MasksSection { pub struct MasksSection {
/// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`: /// `true` (default): rotate the obfuscation surface daily at 05:00 MSK = 02:00 UTC. `false`:
/// use the static TOML values verbatim. /// use the static TOML values verbatim.
pub enabled: bool, 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 { impl Default for MasksSection {
fn default() -> Self { 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"); 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. /// `run_as` is parsed off both [server] and [client] sections and is optional.
#[test] #[test]
fn parses_run_as_on_both_configs() { fn parses_run_as_on_both_configs() {
+56 -7
View File
@@ -39,7 +39,7 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; 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::sync::RwLock;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
@@ -52,10 +52,12 @@ pub type MaskHandle = Arc<RwLock<MaskSet>>;
pub struct MaskRotator { pub struct MaskRotator {
active: MaskHandle, active: MaskHandle,
ca_fp: [u8; 32], ca_fp: [u8; 32],
palette: SniPalette,
} }
impl MaskRotator { 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 /// 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. /// [`Self::spawn`] to start the daily rotation task that updates the shared handle.
@@ -63,17 +65,29 @@ impl MaskRotator {
/// # Errors /// # Errors
/// Propagates [`aura_crypto::CryptoError`] from [`aura_crypto::ca_fingerprint`] (typically a /// Propagates [`aura_crypto::CryptoError`] from [`aura_crypto::ca_fingerprint`] (typically a
/// malformed CA PEM). /// 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 ca_fp = ca_fingerprint(ca_cert_pem)?;
let now = unix_now_utc(); let now = unix_now_utc();
let (y, m, d) = msk_today(now); 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 { Ok(Self {
active: Arc::new(RwLock::new(initial)), active: Arc::new(RwLock::new(initial)),
ca_fp, 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 /// 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). /// for the once-per-`connect`/`accept` use case (not for hot per-packet paths).
pub async fn current(&self) -> MaskSet { 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). // (the alarm fires at 02:00 UTC = 05:00 MSK, which is the new MSK day).
let after = unix_now_utc(); let after = unix_now_utc();
let (y, m, d) = msk_today(after); 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; let mut guard = this.active.write().await;
if *guard != new_mask { if *guard != new_mask {
@@ -280,11 +295,45 @@ mod tests {
let rotator = MaskRotator::new(&pem).expect("rotator"); let rotator = MaskRotator::new(&pem).expect("rotator");
let m1 = rotator.current().await; 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 now = unix_now_utc();
let (y, mo, d) = msk_today(now); let (y, mo, d) = msk_today(now);
let fp = ca_fingerprint(&pem).expect("fp"); 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); 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 // 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). // updates the bound MultiServer's opts each day at 02:00 UTC (= 05:00 MSK).
let masks_enabled = cfg.transport.masks.enabled; let masks_enabled = cfg.transport.masks.enabled;
let mask_palette = cfg.transport.masks.palette.to_crypto();
let mask_rotator = if masks_enabled { 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; let initial = rot.current().await;
udp_opts.padding_profile = initial.padding_profile_id; udp_opts.padding_profile = initial.padding_profile_id;
// The TCP transport now uses a real outer TLS-443 layer, which subsumes the old HTTP // 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!( tracing::info!(
sni = %initial.sni, sni = %initial.sni,
padding_profile = initial.padding_profile_id, padding_profile = initial.padding_profile_id,
palette = ?cfg.transport.masks.palette,
"mask rotation enabled; initial mask applied" "mask rotation enabled; initial mask applied"
); );
Some(rot) Some(rot)
+3 -2
View File
@@ -21,8 +21,9 @@ pub use aead::{AeadKey, AeadSession};
pub use kdf::{derive_session_keys, SessionKeys}; pub use kdf::{derive_session_keys, SessionKeys};
pub use kem::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret}; pub use kem::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret};
pub use masks::{ pub use masks::{
ca_fingerprint, derive_mask_for_msk_date, MaskSet, PADDING_PROFILE_COUNT, ca_fingerprint, derive_mask_for_msk_date, derive_mask_for_msk_date_with_palette, MaskSet,
SERVER_HEADER_PALETTE, SNI_PALETTE, USER_AGENT_PALETTE, SniPalette, PADDING_PROFILE_COUNT, SERVER_HEADER_PALETTE, SNI_PALETTE, SNI_PALETTE_RUSSIAN,
USER_AGENT_PALETTE,
}; };
use thiserror::Error; use thiserror::Error;
+186 -5
View File
@@ -50,6 +50,34 @@ pub const SNI_PALETTE: &[&str] = &[
"ssl.gstatic.com", "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. /// Palette of `User-Agent` strings used by the TCP transport's client masquerade preamble.
pub const USER_AGENT_PALETTE: &[&str] = &[ pub const USER_AGENT_PALETTE: &[&str] = &[
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (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). /// HKDF info string for daily mask derivation (versioned alongside the salt).
const HKDF_INFO: &[u8] = b"aura-mask-v1"; 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. /// 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 /// 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 /// 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 /// Thin wrapper over [`derive_mask_for_msk_date_with_palette`].
/// is sliced into four 2-byte big-endian indices, each taken `mod len(palette)`.
#[must_use] #[must_use]
pub fn derive_mask_for_msk_date(ca_fp: &[u8; 32], year: i32, month: u32, day: u32) -> MaskSet { 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. // Build IKM = ca_fp || "|" || "YYYY-MM-DD" (zero-padded). No allocations beyond this small Vec.
let mut ikm = Vec::with_capacity(32 + 1 + 10); let mut ikm = Vec::with_capacity(32 + 1 + 10);
ikm.extend_from_slice(ca_fp); 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) hk.expand(HKDF_INFO, &mut okm)
.expect("HKDF expand of 64 bytes cannot fail for SHA-256"); .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 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 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 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 { MaskSet {
http_host: sni.clone(), http_host: sni.clone(),
sni, sni,
@@ -283,6 +380,90 @@ mod tests {
assert_eq!(m.http_host, m.sni, "http_host mirrors sni by default"); 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] #[test]
fn format_ymd_zero_pads() { fn format_ymd_zero_pads() {
assert_eq!(format_ymd(2026, 1, 5), "2026-01-05"); assert_eq!(format_ymd(2026, 1, 5), "2026-01-05");
+260 -7
View File
@@ -412,17 +412,270 @@ aura status
выводят одинаковый `MaskSet` из общего seed (SHA-256 от CA-сертификата) + UTC-даты через выводят одинаковый `MaskSet` из общего seed (SHA-256 от CA-сертификата) + UTC-даты через
HKDF-SHA256, без сетевой координации. Конфиг: `[transport.masks] enabled = true` (по HKDF-SHA256, без сетевой координации. Конфиг: `[transport.masks] enabled = true` (по
умолчанию). Новые подключения берут текущую маску; уже установленные остаются на своих. умолчанию). Новые подключения берут текущую маску; уже установленные остаются на своих.
Палитры: 16 SNI, 10 User-Agent, 5 Server-headers, 4 padding-профиля; профиль 0 байт-в-байт Палитры: 16 SNI default + 15 SNI russian, 10 User-Agent, 5 Server-headers, 4 padding-профиля;
совместим с v1-паддингом (бэк-совместимость). профиль 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 - **TUN всё ещё требует root** для **создания** интерфейса (это OS-уровень). Privilege drop
минимизирует окно работы под root, но саму операцию обойти нельзя. минимизирует окно работы под root, но саму операцию обойти нельзя.
- **IPv6 в OS-маршрутах и iptables MASQUERADE** не реализован — только IPv4 (план v3). - **IPv6 в OS-маршрутах и iptables MASQUERADE** не реализован — только IPv4 (план v3.3).
- **Windows OS-маршруты** — заглушка с лог-warning (план v3). Windows admin pipe **работает**. - **Windows OS-маршруты** — заглушка с лог-warning (план v3.3). Windows admin pipe **работает**.
- **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound, - **Нативного Go-клиента для телефона нет** — через sing-box (Option B нативный Go-outbound,
по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только по `protocol.md` + KAT из Rust, см. [`sing-box.md`](sing-box.md)). Сейчас доступен только
десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт. десктоп-клиент / process-bridge. Это явно исключённый из v2 пункт.
- **Multi-hop / onion routing**: пока один сервер на путь (план v3 — цепочка из 2-3 - **Bridge-discovery без хардкода IP в конфиге** — план v3.3. Сейчас `[client] bridges`
серверов так, чтобы entry-узел не знал destination, а exit-узел не знал клиентский IP). хардкодит список запасных 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`).