e0e53665f1
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>
539 lines
24 KiB
Rust
539 lines
24 KiB
Rust
//! Daily-rotated **protocol masks** (project §7 / v2): the SNI, HTTP headers, and padding profile
|
|
//! that the wire-level obfuscation presents to a passive observer.
|
|
//!
|
|
//! Both peers derive the same `MaskSet` from a shared seed (the CA certificate's SHA-256
|
|
//! fingerprint) combined with the current calendar date in Moscow time (UTC+3). This means:
|
|
//!
|
|
//! * **No coordination on the wire.** A new connection just picks the current `MaskSet`; the peer
|
|
//! independently derived the identical set from the CA fingerprint it already trusts.
|
|
//! * **Daily rotation.** At 05:00 MSK (= 02:00 UTC) both sides recompute the set for the new MSK
|
|
//! day, so the on-wire fingerprint (SNI, HTTP `User-Agent` / `Server` headers, the padding bucket
|
|
//! palette) changes once per day.
|
|
//! * **Connection stickiness.** A `MaskSet` is taken once per `connect`/`accept` and stored in the
|
|
//! connection's options; already-established connections keep their original mask, only new ones
|
|
//! pick up the rotated set.
|
|
//!
|
|
//! The derivation is HKDF-SHA256 with a fixed salt and `info`, and indices into fixed palettes
|
|
//! (SNIs, User-Agents, Server headers, padding profiles). The palettes intentionally include
|
|
//! plausible real-world values so any single sample resembles a CDN-like fingerprint.
|
|
//!
|
|
//! ## Wire compatibility
|
|
//! Padding profile id `0` reproduces the original
|
|
//! [`crate::HTTPS_SIZE_BUCKETS`](../../aura_transport/padding/constant.HTTPS_SIZE_BUCKETS.html)
|
|
//! palette `{64,128,256,512,1024,1280,1460}` exactly — so when a fresh deployment happens to
|
|
//! derive profile 0 the wire behaviour is byte-identical to the pre-rotation default, and the
|
|
//! existing transport tests stay valid.
|
|
|
|
use hkdf::Hkdf;
|
|
use sha2::{Digest, Sha256};
|
|
|
|
use crate::CryptoError;
|
|
|
|
/// Palette of SNI / HTTP `Host` values rotated through daily. Plausible CDN-like names so a single
|
|
/// observed connection resembles ordinary HTTPS traffic.
|
|
pub const SNI_PALETTE: &[&str] = &[
|
|
"cdn.cloudflare.com",
|
|
"ajax.googleapis.com",
|
|
"static.fbcdn.net",
|
|
"s3.amazonaws.com",
|
|
"code.jquery.com",
|
|
"fonts.googleapis.com",
|
|
"i.ytimg.com",
|
|
"edge.microsoft.com",
|
|
"cdnjs.cloudflare.com",
|
|
"static.cloudflareinsights.com",
|
|
"assets.gitlab-static.net",
|
|
"raw.githubusercontent.com",
|
|
"media.licdn.com",
|
|
"static.licdn.com",
|
|
"cdn.jsdelivr.net",
|
|
"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",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.0.0",
|
|
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
];
|
|
|
|
/// Palette of HTTP `Server` header values used by the TCP transport's server masquerade preamble.
|
|
pub const SERVER_HEADER_PALETTE: &[&str] =
|
|
&["nginx", "nginx/1.25.3", "Apache", "cloudflare", "AmazonS3"];
|
|
|
|
/// Number of padding profiles available. Mirrors
|
|
/// [`crate::PADDING_PROFILES`](../../aura_transport/padding/constant.PADDING_PROFILES.html) in
|
|
/// `aura-transport`; this is a constant here so the crypto crate can index into a palette without
|
|
/// depending on the transport crate.
|
|
///
|
|
/// The transport-side palette is the source of truth for the actual bucket lists; this constant
|
|
/// only sizes the modulo on the derived id.
|
|
pub const PADDING_PROFILE_COUNT: u8 = 4;
|
|
|
|
/// HKDF salt for daily mask derivation (versioned so we can rotate the derivation itself later).
|
|
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
|
|
/// the server and the client always agree on the current set without any wire coordination.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct MaskSet {
|
|
/// Outer TLS SNI (QUIC) and also the TCP masquerade `Host:` header source.
|
|
pub sni: String,
|
|
/// HTTP `Host:` header value used in the TCP client preamble. Typically equals [`Self::sni`];
|
|
/// kept as a separate field so deployments can decouple them later without changing the type.
|
|
pub http_host: String,
|
|
/// `User-Agent:` value used in the TCP client preamble.
|
|
pub user_agent: String,
|
|
/// `Server:` header value used in the TCP server preamble.
|
|
pub server_header: String,
|
|
/// Index into the padding-profile palette in `aura-transport::padding`; modulo the actual
|
|
/// palette length on the transport side (`% PADDING_PROFILES.len()`).
|
|
pub padding_profile_id: u8,
|
|
}
|
|
|
|
/// SHA-256 fingerprint of the **DER** bytes of the first `CERTIFICATE` block in `ca_cert_pem`.
|
|
///
|
|
/// PEM is parsed without bringing in a full X.509 dependency: we look for the `-----BEGIN
|
|
/// CERTIFICATE-----` / `-----END CERTIFICATE-----` markers and base64-decode the content between
|
|
/// them.
|
|
///
|
|
/// # Errors
|
|
/// * [`CryptoError::InvalidLength`] with `what = "ca-pem"` if no CERTIFICATE block is found or the
|
|
/// base64 payload is malformed.
|
|
pub fn ca_fingerprint(ca_cert_pem: &str) -> Result<[u8; 32], CryptoError> {
|
|
let der = pem_first_cert_der(ca_cert_pem).ok_or(CryptoError::InvalidLength {
|
|
what: "ca-pem",
|
|
expected: 1,
|
|
got: 0,
|
|
})?;
|
|
let mut h = Sha256::new();
|
|
h.update(&der);
|
|
let digest = h.finalize();
|
|
let mut out = [0u8; 32];
|
|
out.copy_from_slice(&digest);
|
|
Ok(out)
|
|
}
|
|
|
|
/// 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. Uses the default SNI palette
|
|
/// ([`SniPalette::Default`] — back-compat with every pre-v3.2 deployment).
|
|
///
|
|
/// 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);
|
|
ikm.push(b'|');
|
|
ikm.extend_from_slice(format_ymd(year, month, day).as_bytes());
|
|
|
|
// 4 indices * 2 bytes = 8 bytes minimum; we draw 64 bytes so future fields have headroom.
|
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), &ikm);
|
|
let mut okm = [0u8; 64];
|
|
// `expand` on this hk type with 64-byte output cannot fail (well under 255 * HashLen).
|
|
hk.expand(HKDF_INFO, &mut okm)
|
|
.expect("HKDF expand of 64 bytes cannot fail for SHA-256");
|
|
|
|
// 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();
|
|
MaskSet {
|
|
http_host: sni.clone(),
|
|
sni,
|
|
user_agent: USER_AGENT_PALETTE[ua_idx].to_string(),
|
|
server_header: SERVER_HEADER_PALETTE[srv_idx].to_string(),
|
|
padding_profile_id: pad_idx,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// Zero-pad a `(year, month, day)` into `"YYYY-MM-DD"`. Years outside `[0, 9999]` are still printed
|
|
/// in their natural width (only the four-zero pad is the requirement here, and any historical CA
|
|
/// will land in the 21st century).
|
|
fn format_ymd(year: i32, month: u32, day: u32) -> String {
|
|
format!("{year:04}-{month:02}-{day:02}")
|
|
}
|
|
|
|
/// Locate the first `-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----` block in `pem` and
|
|
/// return its base64-decoded DER bytes. Returns `None` if no block is found or the base64 payload
|
|
/// is malformed.
|
|
fn pem_first_cert_der(pem: &str) -> Option<Vec<u8>> {
|
|
const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
|
|
const END: &str = "-----END CERTIFICATE-----";
|
|
let start = pem.find(BEGIN)? + BEGIN.len();
|
|
let end = pem[start..].find(END)? + start;
|
|
let body = &pem[start..end];
|
|
// Strip whitespace (newlines, CRLF, spaces) before base64-decoding.
|
|
let mut compact = String::with_capacity(body.len());
|
|
for c in body.chars() {
|
|
if !c.is_whitespace() {
|
|
compact.push(c);
|
|
}
|
|
}
|
|
base64_decode(&compact)
|
|
}
|
|
|
|
/// Minimal standard-base64 decoder (no padding-only chars, ignores final `=` padding). Returns
|
|
/// `None` if the input has an invalid character or length.
|
|
///
|
|
/// We avoid a dependency on a base64 crate (the workspace ships none in `aura-crypto`); the input
|
|
/// is a CA certificate, so a few KB at most, and this runs once at startup.
|
|
fn base64_decode(s: &str) -> Option<Vec<u8>> {
|
|
fn val(b: u8) -> Option<u8> {
|
|
match b {
|
|
b'A'..=b'Z' => Some(b - b'A'),
|
|
b'a'..=b'z' => Some(b - b'a' + 26),
|
|
b'0'..=b'9' => Some(b - b'0' + 52),
|
|
b'+' => Some(62),
|
|
b'/' => Some(63),
|
|
_ => None,
|
|
}
|
|
}
|
|
let bytes = s.as_bytes();
|
|
// Strip trailing '=' padding.
|
|
let mut end = bytes.len();
|
|
while end > 0 && bytes[end - 1] == b'=' {
|
|
end -= 1;
|
|
}
|
|
let body = &bytes[..end];
|
|
let mut out = Vec::with_capacity(body.len() * 3 / 4 + 2);
|
|
|
|
let mut buf: u32 = 0;
|
|
let mut bits: u32 = 0;
|
|
for &b in body {
|
|
let v = val(b)? as u32;
|
|
buf = (buf << 6) | v;
|
|
bits += 6;
|
|
if bits >= 8 {
|
|
bits -= 8;
|
|
out.push(((buf >> bits) & 0xFF) as u8);
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn derive_mask_deterministic_same_inputs() {
|
|
let ca_fp = [7u8; 32];
|
|
let a = derive_mask_for_msk_date(&ca_fp, 2026, 1, 15);
|
|
let b = derive_mask_for_msk_date(&ca_fp, 2026, 1, 15);
|
|
assert_eq!(a, b, "same (ca_fp, date) must yield identical MaskSet");
|
|
}
|
|
|
|
#[test]
|
|
fn derive_mask_changes_with_date() {
|
|
let ca_fp = [3u8; 32];
|
|
let day1 = derive_mask_for_msk_date(&ca_fp, 2026, 5, 27);
|
|
let day2 = derive_mask_for_msk_date(&ca_fp, 2026, 5, 28);
|
|
// The HKDF output is different, so the *indices* are different, but the indexing is
|
|
// modulo small palettes — so any individual field could coincide. Assert at least one
|
|
// field differs (the palettes all have >= 4 entries, so this is overwhelmingly likely).
|
|
assert!(
|
|
day1.sni != day2.sni
|
|
|| day1.user_agent != day2.user_agent
|
|
|| day1.server_header != day2.server_header
|
|
|| day1.padding_profile_id != day2.padding_profile_id,
|
|
"consecutive days produced identical MaskSet across all fields: {day1:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn derive_mask_changes_with_ca_fp() {
|
|
let fp1 = [1u8; 32];
|
|
let fp2 = [2u8; 32];
|
|
let m1 = derive_mask_for_msk_date(&fp1, 2026, 5, 27);
|
|
let m2 = derive_mask_for_msk_date(&fp2, 2026, 5, 27);
|
|
assert!(
|
|
m1.sni != m2.sni
|
|
|| m1.user_agent != m2.user_agent
|
|
|| m1.server_header != m2.server_header
|
|
|| m1.padding_profile_id != m2.padding_profile_id,
|
|
"different CA fingerprints produced identical MaskSet: {m1:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mask_fields_are_within_palettes() {
|
|
let m = derive_mask_for_msk_date(&[42u8; 32], 2026, 5, 27);
|
|
assert!(SNI_PALETTE.iter().any(|s| *s == m.sni));
|
|
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 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");
|
|
assert_eq!(format_ymd(2026, 12, 31), "2026-12-31");
|
|
assert_eq!(format_ymd(1, 1, 1), "0001-01-01");
|
|
}
|
|
|
|
#[test]
|
|
fn base64_decode_round_trips_simple() {
|
|
// "hello world" -> "aGVsbG8gd29ybGQ="
|
|
let out = base64_decode("aGVsbG8gd29ybGQ=").expect("decode");
|
|
assert_eq!(&out, b"hello world");
|
|
// Trailing pad is optional in our decoder.
|
|
let out2 = base64_decode("aGVsbG8gd29ybGQ").expect("decode no pad");
|
|
assert_eq!(&out2, b"hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn base64_rejects_invalid_char() {
|
|
assert!(base64_decode("hello!world").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn ca_fingerprint_rejects_missing_block() {
|
|
let err = ca_fingerprint("no certificate here").unwrap_err();
|
|
match err {
|
|
CryptoError::InvalidLength { what, .. } => assert_eq!(what, "ca-pem"),
|
|
e => panic!("unexpected error: {e:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ca_fingerprint_matches_direct_sha256() {
|
|
// Build a fake "PEM" by base64-encoding a known DER payload; assert the resulting
|
|
// fingerprint equals SHA-256 of that payload.
|
|
let der: Vec<u8> = (0..=255u8).collect();
|
|
let b64 = simple_base64_encode(&der);
|
|
let pem = format!("-----BEGIN CERTIFICATE-----\n{b64}\n-----END CERTIFICATE-----\n");
|
|
let fp = ca_fingerprint(&pem).expect("fingerprint");
|
|
let mut h = Sha256::new();
|
|
h.update(&der);
|
|
let want = h.finalize();
|
|
assert_eq!(&fp, want.as_slice());
|
|
}
|
|
|
|
/// Tiny base64 encoder used by the round-trip test (we ship our own decoder above to avoid a
|
|
/// new dependency, and the encoder is just as small).
|
|
fn simple_base64_encode(data: &[u8]) -> String {
|
|
const ALPHA: &[u8; 64] =
|
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity((data.len() * 4).div_ceil(3));
|
|
let chunks = data.chunks(3);
|
|
for chunk in chunks {
|
|
let b0 = chunk[0];
|
|
let b1 = chunk.get(1).copied().unwrap_or(0);
|
|
let b2 = chunk.get(2).copied().unwrap_or(0);
|
|
out.push(ALPHA[(b0 >> 2) as usize] as char);
|
|
out.push(ALPHA[(((b0 & 0x03) << 4) | (b1 >> 4)) as usize] as char);
|
|
if chunk.len() >= 2 {
|
|
out.push(ALPHA[(((b1 & 0x0F) << 2) | (b2 >> 6)) as usize] as char);
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
if chunk.len() >= 3 {
|
|
out.push(ALPHA[(b2 & 0x3F) as usize] as char);
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|