From 10ca35e3521dbd15eaddd00e6b2fc69876a0d13c Mon Sep 17 00:00:00 2001 From: Julius Joska Date: Fri, 23 Oct 2026 11:44:47 +0200 Subject: [PATCH] feat(relay): optional proof-of-work for registration GET /v1/register/challenge issues a stateless challenge (version, difficulty, expiry and a random nonce, HMAC-signed with a per-process key). The client finds a counter so that SHA-256(domain || challenge || identity_key || counter) has the requested number of leading zero bits and sends it in the X-Sepot-Pow header of POST /v1/register. Verification is one HMAC and one SHA-256; used challenges are kept in memory until they expire. SEPOT_REGISTER_POW=off|optional|required (default optional): in optional mode registrations with a valid solution get a separate, more generous per-IP bucket (SEPOT_RL_REGISTER_POW_*), registrations without one keep the existing limit; both share the global cap. required answers 428 without a valid solution. Difficulty: SEPOT_REGISTER_POW_BITS (18), raised by one bit per doubling of the registration rate over SEPOT_REGISTER_POW_ADAPT_PER_MIN up to SEPOT_REGISTER_POW_MAX_BITS (22). server-info advertises the mode. The paused-registration 503 now carries Retry-After. --- rust/Cargo.lock | 1 + rust/sepot-relay/Cargo.toml | 2 + rust/sepot-relay/src/limits.rs | 72 ++++- rust/sepot-relay/src/main.rs | 16 +- rust/sepot-relay/src/pow.rs | 482 +++++++++++++++++++++++++++++++++ rust/sepot-relay/src/rest.rs | 113 +++++++- rust/sepot-relay/src/state.rs | 5 + 7 files changed, 674 insertions(+), 17 deletions(-) create mode 100644 rust/sepot-relay/src/pow.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 836d6e5..b969001 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2412,6 +2412,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "sha2", "sqlx", "tokio", "tokio-util", diff --git a/rust/sepot-relay/Cargo.toml b/rust/sepot-relay/Cargo.toml index a6afcd4..5617996 100644 --- a/rust/sepot-relay/Cargo.toml +++ b/rust/sepot-relay/Cargo.toml @@ -29,6 +29,8 @@ serde_json = "1" base64 = "0.22" # TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret) hmac = "0.12" +# Proof-of-work registrace (pow.rs); už v grafu závislostí přes libsignal +sha2 = "0.10" sha1 = "0.10" hex = "0.4" chrono = "0.4" diff --git a/rust/sepot-relay/src/limits.rs b/rust/sepot-relay/src/limits.rs index bb4af48..1011ae2 100644 --- a/rust/sepot-relay/src/limits.rs +++ b/rust/sepot-relay/src/limits.rs @@ -93,6 +93,13 @@ pub struct Limits { /// Dřív 10 + 1/5 min: za CGNAT mobilního operátora čekal jedenáctý /// člověk na účet minuty. pub register: RatePair, + /// `SEPOT_RL_REGISTER_POW_*` na IP pro registrace s platným proof-of-work + /// (pow.rs): 200 naráz, pak 1 za 2 s (1 800/h). Výpočet zdražuje každou + /// registraci, takže velký CGNAT může dostat víc bez rizika levné záplavy + /// z jedné adresy. Globální strop platí společně s registracemi bez PoW. + pub register_pow: RatePair, + /// `SEPOT_RL_POW_CHALLENGE_*`: vydávání výzev (jen HMAC, nic do DB). + pub pow_challenge: RatePair, /// `SEPOT_RL_REGISTER_GLOBAL_*`: všechny registrace dohromady, 600 naráz, /// pak 40/s. Chrání DB (změřeno 117–147 registrací/s na 2 jádrech), když /// registruje mnoho adres naráz; BURST 0 = vypnuto. @@ -159,6 +166,16 @@ impl Limits { Rate::new(50, 10_000), Rate::new(100, 3_000), ), + register_pow: pair( + "SEPOT_RL_REGISTER_POW", + Rate::new(200, 2_000), + Rate::new(300, 1_000), + ), + pow_challenge: pair( + "SEPOT_RL_POW_CHALLENGE", + Rate::new(120, 100), + Rate::new(600, 20), + ), register_global: (!global_off).then_some(global), registration_paused: matches!( std::env::var("SEPOT_REGISTRATION_PAUSED").as_deref(), @@ -201,16 +218,45 @@ impl Limits { pub struct RateLimit { clearnet: DefaultKeyedRateLimiter, tor: DefaultDirectRateLimiter, - global: Option, + global: Option>, trusted: Arc, } +/// Registrace bez a s proof-of-work: vlastní buckety per IP, společný +/// globální strop (chrání DB bez ohledu na to, kdo PoW přinesl). +pub struct RegisterLimits { + pub base: Arc, + pub pow: Arc, +} + +impl RegisterLimits { + pub fn new(limits: &Limits, trusted: Arc) -> Arc { + let base = RateLimit::new(limits.register, limits.register_global, trusted); + let pow = base.sibling(limits.register_pow); + Arc::new(Self { base, pow }) + } +} + impl RateLimit { pub fn new(rates: RatePair, global: Option, trusted: Arc) -> Arc { + let global = global.map(|g| Arc::new(DefaultDirectRateLimiter::direct(g.quota()))); + Self::build(rates, global, trusted) + } + + /// Limiter s vlastními buckety per IP a stejným globálním stropem. + pub fn sibling(&self, rates: RatePair) -> Arc { + Self::build(rates, self.global.clone(), self.trusted.clone()) + } + + fn build( + rates: RatePair, + global: Option>, + trusted: Arc, + ) -> Arc { let limiter = Arc::new(Self { clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()), tor: DefaultDirectRateLimiter::direct(rates.tor.quota()), - global: global.map(|g| DefaultDirectRateLimiter::direct(g.quota())), + global, trusted, }); // Zapomenout nečinné buckety, ať mapa neroste s každou kdy viděnou IP. @@ -256,7 +302,7 @@ pub async fn enforce( } } -fn too_many(wait: Duration) -> Response { +pub fn too_many(wait: Duration) -> Response { let secs = wait.as_secs() + u64::from(wait.subsec_nanos() > 0); let mut response = (StatusCode::TOO_MANY_REQUESTS, "příliš mnoho požadavků").into_response(); if let Ok(value) = HeaderValue::from_str(&secs.max(1).to_string()) { @@ -326,6 +372,26 @@ mod tests { assert!(rl.check(proxy, &xff("198.51.100.3")).is_err()); } + #[tokio::test] + async fn sourozenec_sdili_globalni_strop_ne_ip_bucket() { + let base_rates = RatePair { + clearnet: Rate::new(1, 3_600_000), + tor: Rate::new(1, 3_600_000), + }; + let pow_rates = RatePair { + clearnet: Rate::new(5, 3_600_000), + tor: Rate::new(5, 3_600_000), + }; + let base = RateLimit::new(base_rates, Some(Rate::new(3, 3_600_000)), trusted("10.0.0.1", "")); + let pow = base.sibling(pow_rates); + let proxy: IpAddr = "10.0.0.1".parse().unwrap(); + assert!(base.check(proxy, &xff("198.51.100.1")).is_ok()); + assert!(base.check(proxy, &xff("198.51.100.1")).is_err(), "IP bucket bez PoW vyčerpán"); + assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok(), "s PoW vlastní bucket"); + assert!(pow.check(proxy, &xff("198.51.100.1")).is_ok()); + assert!(pow.check(proxy, &xff("198.51.100.1")).is_err(), "globální strop 3 společný"); + } + #[test] fn rate_z_env_padne_na_vychozi_po_polich() { std::env::set_var("SEPOT_TEST_RL_BURST", "7"); diff --git a/rust/sepot-relay/src/main.rs b/rust/sepot-relay/src/main.rs index 57f2230..0400cfc 100644 --- a/rust/sepot-relay/src/main.rs +++ b/rust/sepot-relay/src/main.rs @@ -12,6 +12,7 @@ mod disk; mod fcm; mod limits; mod net; +mod pow; mod push; mod quota; mod rest; @@ -31,7 +32,7 @@ use axum::serve::ListenerExt; use axum::Router; use sqlx::postgres::PgPoolOptions; -use crate::limits::{Limits, RateLimit}; +use crate::limits::{Limits, RateLimit, RegisterLimits}; use crate::net::TrustedProxies; use tower_http::timeout::TimeoutLayer; use tracing_subscriber::EnvFilter; @@ -75,6 +76,7 @@ async fn main() -> anyhow::Result<()> { attachments::spawn_cleanup(state.clone()); state.disk.spawn(&state.attachments.dir); tracing::info!(?limits, "limity"); + tracing::info!(pow = ?state.pow.cfg, "proof-of-work registrace"); // ── Rate limiting ── // @@ -83,7 +85,10 @@ async fn main() -> anyhow::Result<()> { // (SEPOT_TOR_PEERS) a mají vlastní společný bucket — hlavičkou si IP // nastavit nemohou (SECURITY audit #1). Jen v paměti, nikdy na disk. // Hodnoty a jejich zdůvodnění: limits.rs. - let rl_register = RateLimit::new(limits.register, limits.register_global, trusted.clone()); + // Registrace: bucket per IP volí handler podle proof-of-work (rest.rs), + // proto limiter jako Extension, ne middleware. + let rl_register = RegisterLimits::new(&limits, trusted.clone()); + let rl_challenge = RateLimit::new(limits.pow_challenge, None, trusted.clone()); let rl_general = RateLimit::new(limits.general, None, trusted.clone()); let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone()); let rl_ws = RateLimit::new(limits.ws_upgrade, None, trusted.clone()); @@ -102,7 +107,12 @@ async fn main() -> anyhow::Result<()> { let register_routes = Router::new() .route("/v1/register", post(rest::register)) - .layer(from_fn_with_state(rl_register, crate::limits::enforce)); + .layer(axum::Extension(rl_register)) + .merge( + Router::new() + .route("/v1/register/challenge", get(rest::register_challenge)) + .layer(from_fn_with_state(rl_challenge, crate::limits::enforce)), + ); let general_routes = Router::new() .route("/v1/server-info", get(rest::server_info)) diff --git a/rust/sepot-relay/src/pow.rs b/rust/sepot-relay/src/pow.rs new file mode 100644 index 0000000..c90d9fe --- /dev/null +++ b/rust/sepot-relay/src/pow.rs @@ -0,0 +1,482 @@ +//! Proof-of-work při registraci (`GET /v1/register/challenge`). +//! +//! Proč: registrace je jediný anonymní zápis do DB. Limit per IP nejde +//! utáhnout (za CGNAT mobilního operátora sdílí jednu IPv4 stovky lidí) +//! a proti záplavě z mnoha adres stojí jen globální strop, který zablokuje +//! i skutečné lidi. PoW zdraží každou registraci výpočtem na straně klienta; +//! kdo ho přinese, dostane volnější limit per IP. +//! +//! Úloha (hashcash): `SHA-256("sepot-register-pow-v1" ‖ výzva ‖ identity_key +//! ‖ counter_be64)` má aspoň `bits` úvodních nulových bitů. Ověření = jedna +//! HMAC a jedna SHA-256 — levné i pod náporem, proto ne Argon2 (paměťově +//! náročné ověření by relay pod záplavou sám přetížilo). +//! +//! Výzva je bezstavová: verze, obtížnost, expirace a náhodná nonce podepsané +//! HMAC klíčem, který vzniká náhodně při startu procesu. Nic se neukládá do DB +//! ani do `.env`; restart relaye zneplatní rozdané výzvy (klient si vyžádá +//! novou) a tím i znovupoužití řešení přes restart. Jednorázovost v rámci +//! běhu hlídá paměťová sada spotřebovaných nonce, mazaná po expiraci. +//! +//! Stejné testovací vektory má klient (app/lib/core/network/register_pow.dart). + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use axum::http::HeaderMap; +use base64::prelude::*; +use dashmap::DashMap; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use sha2::{Digest, Sha256}; + +const DOMAIN: &[u8] = b"sepot-register-pow-v1"; +const VERSION: u8 = 1; +const NONCE_LEN: usize = 16; +const TAG_LEN: usize = 16; +/// verze(1) ‖ bits(1) ‖ expirace unix s (8, BE) ‖ nonce(16) ‖ HMAC(16) +pub const TOKEN_LEN: usize = 2 + 8 + NONCE_LEN + TAG_LEN; +const SIGNED_LEN: usize = TOKEN_LEN - TAG_LEN; +/// Strop obtížnosti: 2^32 pokusů je na telefonu přes hodinu — víc nemá smysl +/// nastavovat ani adaptivně. +const MAX_BITS: u8 = 32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// Relay výzvy nevydává (`/v1/register/challenge` = 404), registruje jako dřív. + Off, + /// Výzvy vydává, registrace bez PoW projde s běžným limitem per IP, + /// s platným PoW s volnějším. Výchozí — appky v obchodech PoW neumí. + Optional, + /// Registrace bez platného PoW = 428. Nouzová páka; staré appky se + /// nezaregistrují. + Required, +} + +impl Mode { + pub fn as_str(self) -> &'static str { + match self { + Mode::Off => "off", + Mode::Optional => "optional", + Mode::Required => "required", + } + } + + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "off" | "0" => Some(Mode::Off), + "optional" => Some(Mode::Optional), + "required" | "1" => Some(Mode::Required), + _ => None, + } + } +} + +#[derive(Clone, Debug)] +pub struct Config { + /// `SEPOT_REGISTER_POW` (off | optional | required), výchozí optional. + pub mode: Mode, + /// `SEPOT_REGISTER_POW_BITS`: základní obtížnost (výchozí 18 ≈ 0,3 s na + /// x86 jádře, ~1 s na středním Androidu — měření v README relaye). + pub bits: u8, + /// `SEPOT_REGISTER_POW_MAX_BITS`: strop adaptivní obtížnosti (výchozí 22). + pub max_bits: u8, + /// `SEPOT_REGISTER_POW_ADAPT_PER_MIN`: od kolika registrací za minutu + /// (všech, s PoW i bez) přidávat obtížnost — +1 bit za každé zdvojnásobení + /// nad prahem. 0 = obtížnost pevná (výchozí 600 = 10/s, ~10× nad odhadem + /// běžné špičky; při útoku se zdraží registrace jen těm, kdo PoW posílají, + /// ostatní drží limity per IP a globální strop). + pub adapt_per_min: u32, + /// `SEPOT_REGISTER_POW_TTL_S`: platnost výzvy (výchozí 120 s; pomalý telefon + /// při 20 bitech potřebuje v 99 % případů pod 60 s). + pub ttl: Duration, +} + +impl Config { + pub fn from_env() -> Self { + let mode = match std::env::var("SEPOT_REGISTER_POW") { + Ok(raw) => Mode::parse(&raw).unwrap_or_else(|| { + tracing::warn!("SEPOT_REGISTER_POW neplatná hodnota, platí optional"); + Mode::Optional + }), + Err(_) => Mode::Optional, + }; + let bits = env_u64("SEPOT_REGISTER_POW_BITS", 18).clamp(1, MAX_BITS as u64) as u8; + let max_bits = + env_u64("SEPOT_REGISTER_POW_MAX_BITS", 22).clamp(bits as u64, MAX_BITS as u64) as u8; + Self { + mode, + bits, + max_bits, + adapt_per_min: env_u64("SEPOT_REGISTER_POW_ADAPT_PER_MIN", 600) as u32, + ttl: Duration::from_secs(env_u64("SEPOT_REGISTER_POW_TTL_S", 120).clamp(10, 3600)), + } + } +} + +fn env_u64(name: &str, default: u64) -> u64 { + match std::env::var(name) { + Ok(raw) => raw.trim().parse().unwrap_or_else(|_| { + tracing::warn!(name, "neplatná hodnota, platí výchozí"); + default + }), + Err(_) => default, + } +} + +/// Výsledek kontroly hlavičky `X-Sepot-Pow`. +#[derive(Debug, PartialEq, Eq)] +pub enum Verdict { + Valid, + Missing, + Invalid(&'static str), +} + +/// Výzva pro klienta (`GET /v1/register/challenge`). +pub struct Challenge { + pub token: [u8; TOKEN_LEN], + pub bits: u8, + pub expires_at: u64, +} + +pub struct RegisterPow { + pub cfg: Config, + key: [u8; 32], + /// nonce → expirace (unix s). Jen spotřebované výzvy; velikost drží + /// globální strop registrací × TTL. + used: DashMap<[u8; NONCE_LEN], u64>, + /// (začátek aktuální minuty, registrace v ní, registrace v minulé minutě) + load: Mutex<(Instant, u32, u32)>, +} + +impl RegisterPow { + pub fn new(cfg: Config) -> Self { + let mut key = [0u8; 32]; + rand::rng().fill_bytes(&mut key); + Self { + cfg, + key, + used: DashMap::new(), + load: Mutex::new((Instant::now(), 0, 0)), + } + } + + pub fn mode(&self) -> Mode { + self.cfg.mode + } + + fn mac(&self, signed: &[u8]) -> Hmac { + let mut mac = Hmac::::new_from_slice(&self.key).expect("HMAC bere libovolný klíč"); + mac.update(signed); + mac + } + + pub fn issue(&self, now_unix: u64) -> Challenge { + let bits = self.current_bits(); + let expires_at = now_unix + self.cfg.ttl.as_secs(); + let mut token = [0u8; TOKEN_LEN]; + token[0] = VERSION; + token[1] = bits; + token[2..10].copy_from_slice(&expires_at.to_be_bytes()); + rand::rng().fill_bytes(&mut token[10..10 + NONCE_LEN]); + let tag = self.mac(&token[..SIGNED_LEN]).finalize().into_bytes(); + token[SIGNED_LEN..].copy_from_slice(&tag[..TAG_LEN]); + Challenge { + token, + bits, + expires_at, + } + } + + /// Ověří `X-Sepot-Pow: .` vůči identity klíči + /// registrace. Platné řešení se tím spotřebuje (druhé použití = Invalid). + pub fn verify(&self, headers: &HeaderMap, identity_key: &[u8], now_unix: u64) -> Verdict { + let Some(raw) = headers.get("x-sepot-pow").and_then(|v| v.to_str().ok()) else { + return Verdict::Missing; + }; + let Some((token_b64, counter_str)) = raw.trim().split_once('.') else { + return Verdict::Invalid("formát"); + }; + let Ok(token) = BASE64_URL_SAFE_NO_PAD.decode(token_b64) else { + return Verdict::Invalid("formát"); + }; + let Ok(counter) = counter_str.parse::() else { + return Verdict::Invalid("formát"); + }; + if token.len() != TOKEN_LEN || token[0] != VERSION { + return Verdict::Invalid("formát"); + } + if self + .mac(&token[..SIGNED_LEN]) + .verify_truncated_left(&token[SIGNED_LEN..]) + .is_err() + { + return Verdict::Invalid("podpis"); + } + let expires_at = u64::from_be_bytes(token[2..10].try_into().expect("8 B")); + if now_unix > expires_at { + return Verdict::Invalid("prošlá"); + } + if leading_zero_bits(&digest(&token, identity_key, counter)) < token[1] as u32 { + return Verdict::Invalid("řešení"); + } + let nonce: [u8; NONCE_LEN] = token[10..10 + NONCE_LEN].try_into().expect("16 B"); + // entry = atomické vložení: dva souběžné požadavky se stejným řešením + // neprojdou oba. + match self.used.entry(nonce) { + dashmap::Entry::Occupied(_) => Verdict::Invalid("použitá"), + dashmap::Entry::Vacant(slot) => { + slot.insert(expires_at); + Verdict::Valid + } + } + } + + /// Započítá registraci do zátěže pro adaptivní obtížnost. + pub fn note_registration(&self) { + let mut load = self.load.lock().expect("zámek zátěže"); + roll_window(&mut load, Instant::now()); + load.1 = load.1.saturating_add(1); + } + + /// Obtížnost pro novou výzvu: základ + 1 bit za každé zdvojnásobení + /// zátěže nad prahem (větší z aktuální a minulé minuty, ať obtížnost + /// nespadne hned na začátku nové minuty). + pub fn current_bits(&self) -> u8 { + if self.cfg.adapt_per_min == 0 { + return self.cfg.bits; + } + let per_min = { + let mut load = self.load.lock().expect("zámek zátěže"); + roll_window(&mut load, Instant::now()); + load.1.max(load.2) + }; + self.cfg + .bits + .saturating_add(extra_bits(per_min, self.cfg.adapt_per_min)) + .min(self.cfg.max_bits) + } + + /// Zapomene spotřebované výzvy po expiraci (periodická úloha). + pub fn prune(&self, now_unix: u64) { + self.used.retain(|_, expires_at| *expires_at >= now_unix); + } +} + +fn roll_window(load: &mut (Instant, u32, u32), now: Instant) { + const MINUTE: Duration = Duration::from_secs(60); + let elapsed = now.duration_since(load.0); + if elapsed >= MINUTE { + // Víc než dvě minuty bez registrace = minulá minuta byla prázdná. + load.2 = if elapsed >= 2 * MINUTE { 0 } else { load.1 }; + load.1 = 0; + load.0 = now; + } +} + +fn extra_bits(per_min: u32, threshold: u32) -> u8 { + if per_min < threshold { + return 0; + } + // per_min ≥ práh → +1, ≥ 2× práh → +2, ≥ 4× → +3 … + let ratio = per_min / threshold; + (32 - ratio.leading_zeros()) as u8 +} + +pub fn digest(token: &[u8], identity_key: &[u8], counter: u64) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(DOMAIN); + h.update(token); + h.update(identity_key); + h.update(counter.to_be_bytes()); + h.finalize().into() +} + +pub fn leading_zero_bits(digest: &[u8; 32]) -> u32 { + let mut bits = 0; + for byte in digest { + if *byte == 0 { + bits += 8; + } else { + return bits + byte.leading_zeros(); + } + } + bits +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn cfg(mode: Mode, bits: u8) -> Config { + Config { + mode, + bits, + max_bits: 22, + adapt_per_min: 0, + ttl: Duration::from_secs(120), + } + } + + fn solve(token: &[u8], identity_key: &[u8], bits: u8) -> u64 { + (0u64..) + .find(|c| leading_zero_bits(&digest(token, identity_key, *c)) >= bits as u32) + .unwrap() + } + + fn header(token: &[u8], counter: u64) -> HeaderMap { + let mut h = HeaderMap::new(); + let value = format!("{}.{counter}", BASE64_URL_SAFE_NO_PAD.encode(token)); + h.insert("x-sepot-pow", HeaderValue::from_str(&value).unwrap()); + h + } + + const NOW: u64 = 1_800_000_000; + const IK: [u8; 33] = [5u8; 33]; + + #[test] + fn platne_reseni_projde_jednou() { + let pow = RegisterPow::new(cfg(Mode::Optional, 8)); + let ch = pow.issue(NOW); + assert_eq!(ch.expires_at, NOW + 120); + let counter = solve(&ch.token, &IK, ch.bits); + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW), + Verdict::Valid + ); + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW), + Verdict::Invalid("použitá") + ); + } + + #[test] + fn chybejici_a_poskozena_hlavicka() { + let pow = RegisterPow::new(cfg(Mode::Optional, 8)); + assert_eq!(pow.verify(&HeaderMap::new(), &IK, NOW), Verdict::Missing); + let mut h = HeaderMap::new(); + h.insert("x-sepot-pow", HeaderValue::from_static("bez-tecky")); + assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát")); + h.insert("x-sepot-pow", HeaderValue::from_static("AAAA.12")); + assert_eq!(pow.verify(&h, &IK, NOW), Verdict::Invalid("formát")); + } + + #[test] + fn spatne_reseni_a_jiny_identity_klic() { + let pow = RegisterPow::new(cfg(Mode::Optional, 12)); + let ch = pow.issue(NOW); + let counter = solve(&ch.token, &IK, ch.bits); + let bad = (0u64..) + .find(|c| leading_zero_bits(&digest(&ch.token, &IK, *c)) < 12) + .unwrap(); + assert_eq!( + pow.verify(&header(&ch.token, bad), &IK, NOW), + Verdict::Invalid("řešení") + ); + // řešení je vázané na identitu — pro jiný účet neplatí (bez spotřeby) + let other = [6u8; 33]; + if leading_zero_bits(&digest(&ch.token, &other, counter)) < 12 { + assert_eq!( + pow.verify(&header(&ch.token, counter), &other, NOW), + Verdict::Invalid("řešení") + ); + } + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW), + Verdict::Valid + ); + } + + #[test] + fn prosla_vyzva() { + let pow = RegisterPow::new(cfg(Mode::Optional, 8)); + let ch = pow.issue(NOW); + let counter = solve(&ch.token, &IK, ch.bits); + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW + 121), + Verdict::Invalid("prošlá") + ); + } + + #[test] + fn podvrzena_obtiznost_nebo_cizi_klic() { + let pow = RegisterPow::new(cfg(Mode::Optional, 16)); + let mut ch = pow.issue(NOW); + // klient si sníží obtížnost v tokenu → HMAC nesedí + ch.token[1] = 1; + assert_eq!( + pow.verify(&header(&ch.token, 0), &IK, NOW), + Verdict::Invalid("podpis") + ); + // výzva jiného procesu relaye (jiný klíč, např. před restartem) + let jiny = RegisterPow::new(cfg(Mode::Optional, 8)); + let ch = jiny.issue(NOW); + let counter = solve(&ch.token, &IK, ch.bits); + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW), + Verdict::Invalid("podpis") + ); + } + + #[test] + fn prune_maze_jen_prosle() { + let pow = RegisterPow::new(cfg(Mode::Optional, 4)); + let ch = pow.issue(NOW); + let counter = solve(&ch.token, &IK, ch.bits); + assert_eq!( + pow.verify(&header(&ch.token, counter), &IK, NOW), + Verdict::Valid + ); + pow.prune(NOW); + assert_eq!(pow.used.len(), 1); + pow.prune(NOW + 121); + assert_eq!(pow.used.len(), 0); + } + + #[test] + fn adaptivni_obtiznost() { + assert_eq!(extra_bits(0, 600), 0); + assert_eq!(extra_bits(599, 600), 0); + assert_eq!(extra_bits(600, 600), 1); + assert_eq!(extra_bits(1200, 600), 2); + assert_eq!(extra_bits(2399, 600), 2); + assert_eq!(extra_bits(2400, 600), 3); + let pow = RegisterPow::new(Config { + adapt_per_min: 2, + ..cfg(Mode::Optional, 18) + }); + assert_eq!(pow.current_bits(), 18); + for _ in 0..4 { + pow.note_registration(); + } + assert_eq!(pow.current_bits(), 20); + for _ in 0..1000 { + pow.note_registration(); + } + assert_eq!(pow.current_bits(), 22, "strop max_bits"); + } + + #[test] + fn rezim_z_env_hodnoty() { + assert_eq!(Mode::parse("OFF"), Some(Mode::Off)); + assert_eq!(Mode::parse(" optional "), Some(Mode::Optional)); + assert_eq!(Mode::parse("required"), Some(Mode::Required)); + assert_eq!(Mode::parse("ano"), None); + } + + /// Sdílené vektory s klientem (app/test/network/register_pow_test.dart): + /// pevný token (42 B = 0..41), identity klíč 33 × 0x05. + #[test] + fn vektory_kompatibilni_s_klientem() { + let token: Vec = (0u8..42).collect(); + assert_eq!( + hex::encode(digest(&token, &IK, 0)), + "ab9d9008c6ad674271da019b142f25cba7045107da67ad0938fd90a3d096d90a" + ); + assert_eq!( + hex::encode(digest(&token, &IK, 0x0123_4567_89ab_cdef)), + "3fc46d7b70b5c23fef86d979503e03a723929f31e52d2c49e84349e07cfe7d46" + ); + assert_eq!(solve(&token, &IK, 16), 145_733); + } +} diff --git a/rust/sepot-relay/src/rest.rs b/rust/sepot-relay/src/rest.rs index 1c5e87a..dd6e164 100644 --- a/rust/sepot-relay/src/rest.rs +++ b/rust/sepot-relay/src/rest.rs @@ -1,16 +1,20 @@ //! REST endpointy — registrace, prekey bundle, sender cert. //! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů. +use std::net::SocketAddr; use std::sync::Arc; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, StatusCode}; -use axum::Json; +use axum::extract::{ConnectInfo, Path, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::{Extension, Json}; use base64::prelude::*; use libsignal_protocol::PublicKey; use rand::RngCore; use serde::{Deserialize, Serialize}; +use crate::limits::RegisterLimits; +use crate::pow::{Mode as PowMode, Verdict}; use crate::state::AppState; type ApiResult = Result; @@ -57,15 +61,50 @@ pub async fn ready(State(state): State>) -> ApiResult>) -> Json { + let mut capabilities = vec!["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"]; + if state.pow.mode() != PowMode::Off { + capabilities.push("register-pow"); + } Json(serde_json::json!({ "frame_version": 1, "trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public), - "capabilities": ["pqxdh", "sealed-sender", "prekey-upload", "identity-reclaim"], + "capabilities": capabilities, + "register_pow": state.pow.mode().as_str(), "source": state.source_url, "revision": state.revision, })) } +fn unix_now() -> u64 { + chrono::Utc::now().timestamp().max(0) as u64 +} + +// ── GET /v1/register/challenge ───────────────────────────────────────── + +/// Výzva proof-of-work pro registraci (pow.rs). `no-store`: každá výzva je +/// jednorázová, sdílená cache by ji rozdala víc klientům. +pub async fn register_challenge(State(state): State>) -> Response { + if state.pow.mode() == PowMode::Off { + return (StatusCode::NOT_FOUND, "proof-of-work je vypnutý").into_response(); + } + let now = unix_now(); + let challenge = state.pow.issue(now); + let mut response = Json(serde_json::json!({ + "algorithm": "sha256-v1", + "challenge": BASE64_URL_SAFE_NO_PAD.encode(challenge.token), + "bits": challenge.bits, + "expires_at": challenge.expires_at, + // relativně: hodiny telefonu bývají mimo, platnost si klient měří sám + "expires_in": challenge.expires_at - now, + "mode": state.pow.mode().as_str(), + })) + .into_response(); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + /// Ověří hlavičku `X-Sepot-Identity-Proof: .` — podpis /// identity klíčem nad `"\n"`, stejné okno jako /// X-Sepot-Auth. Důkaz držení privátního klíče bez znalosti device_uuid: @@ -127,17 +166,71 @@ pub struct RegisterOut { /// Strop OTP klíčů v jedné registraci (klient posílá 100). const MAX_ONE_TIME_PREKEYS: usize = 100; +/// Limit registrací se vybírá až v handleru: bucket per IP závisí na tom, +/// jestli klient přinesl platný proof-of-work, a ten je vázaný na identity +/// klíč z těla. Ověření PoW je jedna HMAC a jedna SHA-256, takže pořadí +/// „parsovat → ověřit → limit → podpisy a DB“ nic drahého nepouští dřív. pub async fn register( State(state): State>, + Extension(limits): Extension>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, Json(body): Json, -) -> ApiResult<(StatusCode, Json)> { +) -> Response { if state.limits.registration_paused { - return Err(( + return ( StatusCode::SERVICE_UNAVAILABLE, - "registrace jsou dočasně pozastavené, zkus to za pár minut".into(), - )); + [(header::RETRY_AFTER, "300")], + "registrace jsou dočasně pozastavené, zkus to za pár minut", + ) + .into_response(); } + let identity_key = match b64("identity_key", &body.identity_key, Some(33)) { + Ok(key) => key, + Err(e) => return e.into_response(), + }; + let with_pow = match (state.pow.mode(), state.pow.verify(&headers, &identity_key, unix_now())) { + (PowMode::Off, _) => false, + (_, Verdict::Valid) => true, + (PowMode::Optional, Verdict::Missing) => false, + (PowMode::Optional, Verdict::Invalid(why)) => { + // Prošlá nebo poškozená výzva v režimu optional = registrace jako + // bez PoW (běžný limit), ne chyba: starší výzva po restartu relaye + // nesmí člověka zablokovat. + tracing::debug!(why, "neplatný proof-of-work, běžný limit"); + false + } + (PowMode::Required, verdict) => { + if let Verdict::Invalid(why) = verdict { + tracing::debug!(why, "neplatný proof-of-work"); + } + return ( + StatusCode::PRECONDITION_REQUIRED, + "registrace vyžaduje proof-of-work (GET /v1/register/challenge)", + ) + .into_response(); + } + }; + let limiter = if with_pow { &limits.pow } else { &limits.base }; + if let Err(wait) = limiter.check(peer.ip(), &headers) { + return crate::limits::too_many(wait); + } + match register_device(&state, &headers, identity_key, body).await { + Ok(created) => { + state.pow.note_registration(); + tracing::info!(pow = with_pow, "nová registrace zařízení"); + created.into_response() + } + Err(e) => e.into_response(), + } +} + +async fn register_device( + state: &AppState, + headers: &HeaderMap, + identity_key: Vec, + body: RegisterIn, +) -> ApiResult<(StatusCode, Json)> { if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS { return Err(bad("příliš mnoho one-time prekeys")); } @@ -146,7 +239,6 @@ pub async fn register( if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) { return Err(bad("device_id nebo registration_id mimo rozsah")); } - let identity_key = b64("identity_key", &body.identity_key, Some(33))?; let delivery_address = b64("delivery_address", &body.delivery_address, Some(32))?; let spk_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?; let spk_sig = b64( @@ -197,7 +289,7 @@ pub async fn register( // prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré // ratchet relace stejně nemá), account_id zůstává — kontakty ho znají. let account_id = if inserted.rows_affected() == 0 { - if !identity_proof_valid(&headers, &identity, "POST /v1/register") { + if !identity_proof_valid(headers, &identity, "POST /v1/register") { return Err((StatusCode::CONFLICT, "identita už existuje".into())); } let (existing,): (Vec,) = @@ -277,7 +369,6 @@ pub async fn register( } tx.commit().await.map_err(internal)?; - tracing::info!("nová registrace zařízení"); Ok(( StatusCode::CREATED, diff --git a/rust/sepot-relay/src/state.rs b/rust/sepot-relay/src/state.rs index c23c03f..95ccb34 100644 --- a/rust/sepot-relay/src/state.rs +++ b/rust/sepot-relay/src/state.rs @@ -199,6 +199,8 @@ pub struct AppState { pub wake_marks: DashMap, WakeMarks>, /// delivery_address → (začátek okna, vydaných OTP) — drain ochrana. pub otp_issued: DashMap, (Instant, u32)>, + /// Proof-of-work registrace: klíč výzev, spotřebované výzvy, zátěž (paměť). + pub pow: crate::pow::RegisterPow, /// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info). pub source_url: String, pub revision: String, @@ -242,6 +244,7 @@ impl AppState { limits, wake_marks: DashMap::new(), otp_issued: DashMap::new(), + pow: crate::pow::RegisterPow::new(crate::pow::Config::from_env()), source_url: std::env::var("SEPOT_SOURCE_URL") .unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()), revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()), @@ -264,6 +267,8 @@ impl AppState { self.send_budget.prune(); self.attachment_budget.prune(); self.attachment_stored.prune(); + self.pow + .prune(chrono::Utc::now().timestamp().max(0) as u64); } /// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.