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:
Julius Joska
2026-10-23 11:44:47 +02:00
parent 45f169c20d
commit 10ca35e352
7 changed files with 674 additions and 17 deletions
+1
View File
@@ -2412,6 +2412,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"sha2",
"sqlx",
"tokio",
"tokio-util",
+2
View File
@@ -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"
+69 -3
View File
@@ -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");
+13 -3
View File
@@ -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))
+482
View File
@@ -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
View File
@@ -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,
+5
View File
@@ -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ě.