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.
This commit is contained in:
Generated
+1
@@ -2412,6 +2412,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<IpAddr>,
|
||||
tor: DefaultDirectRateLimiter,
|
||||
global: Option<DefaultDirectRateLimiter>,
|
||||
global: Option<Arc<DefaultDirectRateLimiter>>,
|
||||
trusted: Arc<TrustedProxies>,
|
||||
}
|
||||
|
||||
/// 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<RateLimit>,
|
||||
pub pow: Arc<RateLimit>,
|
||||
}
|
||||
|
||||
impl RegisterLimits {
|
||||
pub fn new(limits: &Limits, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
||||
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<Rate>, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
||||
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> {
|
||||
Self::build(rates, self.global.clone(), self.trusted.clone())
|
||||
}
|
||||
|
||||
fn build(
|
||||
rates: RatePair,
|
||||
global: Option<Arc<DefaultDirectRateLimiter>>,
|
||||
trusted: Arc<TrustedProxies>,
|
||||
) -> Arc<Self> {
|
||||
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");
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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<Self> {
|
||||
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<Sha256> {
|
||||
let mut mac = Hmac::<Sha256>::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ýzva base64url>.<counter>` 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::<u64>() 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<u8> = (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);
|
||||
}
|
||||
}
|
||||
+102
-11
@@ -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<T> = Result<T, (StatusCode, String)>;
|
||||
@@ -57,15 +61,50 @@ pub async fn ready(State(state): State<Arc<AppState>>) -> ApiResult<Json<serde_j
|
||||
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
|
||||
/// s relayem mluví, má vědět, který zdroj právě běží.
|
||||
pub async fn server_info(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
|
||||
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<Arc<AppState>>) -> 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: <ts_ms>.<sig_b64>` — podpis
|
||||
/// identity klíčem nad `"<method_path>\n<ts_ms>"`, 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<Arc<AppState>>,
|
||||
Extension(limits): Extension<Arc<RegisterLimits>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<RegisterIn>,
|
||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
||||
) -> 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<u8>,
|
||||
body: RegisterIn,
|
||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
||||
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<u8>,) =
|
||||
@@ -277,7 +369,6 @@ pub async fn register(
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(internal)?;
|
||||
tracing::info!("nová registrace zařízení");
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
|
||||
@@ -199,6 +199,8 @@ pub struct AppState {
|
||||
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||||
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||||
pub otp_issued: DashMap<Vec<u8>, (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ě.
|
||||
|
||||
Reference in New Issue
Block a user