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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1",
|
"sha1",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ serde_json = "1"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
|
# TURN údaje pro hovory: coturn ověřuje HMAC-SHA1 (REST schéma use-auth-secret)
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
|
# Proof-of-work registrace (pow.rs); už v grafu závislostí přes libsignal
|
||||||
|
sha2 = "0.10"
|
||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
chrono = "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ý
|
/// Dřív 10 + 1/5 min: za CGNAT mobilního operátora čekal jedenáctý
|
||||||
/// člověk na účet minuty.
|
/// člověk na účet minuty.
|
||||||
pub register: RatePair,
|
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,
|
/// `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ž
|
/// 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.
|
/// registruje mnoho adres naráz; BURST 0 = vypnuto.
|
||||||
@@ -159,6 +166,16 @@ impl Limits {
|
|||||||
Rate::new(50, 10_000),
|
Rate::new(50, 10_000),
|
||||||
Rate::new(100, 3_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),
|
register_global: (!global_off).then_some(global),
|
||||||
registration_paused: matches!(
|
registration_paused: matches!(
|
||||||
std::env::var("SEPOT_REGISTRATION_PAUSED").as_deref(),
|
std::env::var("SEPOT_REGISTRATION_PAUSED").as_deref(),
|
||||||
@@ -201,16 +218,45 @@ impl Limits {
|
|||||||
pub struct RateLimit {
|
pub struct RateLimit {
|
||||||
clearnet: DefaultKeyedRateLimiter<IpAddr>,
|
clearnet: DefaultKeyedRateLimiter<IpAddr>,
|
||||||
tor: DefaultDirectRateLimiter,
|
tor: DefaultDirectRateLimiter,
|
||||||
global: Option<DefaultDirectRateLimiter>,
|
global: Option<Arc<DefaultDirectRateLimiter>>,
|
||||||
trusted: Arc<TrustedProxies>,
|
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 {
|
impl RateLimit {
|
||||||
pub fn new(rates: RatePair, global: Option<Rate>, trusted: Arc<TrustedProxies>) -> Arc<Self> {
|
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 {
|
let limiter = Arc::new(Self {
|
||||||
clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()),
|
clearnet: DefaultKeyedRateLimiter::keyed(rates.clearnet.quota()),
|
||||||
tor: DefaultDirectRateLimiter::direct(rates.tor.quota()),
|
tor: DefaultDirectRateLimiter::direct(rates.tor.quota()),
|
||||||
global: global.map(|g| DefaultDirectRateLimiter::direct(g.quota())),
|
global,
|
||||||
trusted,
|
trusted,
|
||||||
});
|
});
|
||||||
// Zapomenout nečinné buckety, ať mapa neroste s každou kdy viděnou IP.
|
// 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 secs = wait.as_secs() + u64::from(wait.subsec_nanos() > 0);
|
||||||
let mut response = (StatusCode::TOO_MANY_REQUESTS, "příliš mnoho požadavků").into_response();
|
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()) {
|
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());
|
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]
|
#[test]
|
||||||
fn rate_z_env_padne_na_vychozi_po_polich() {
|
fn rate_z_env_padne_na_vychozi_po_polich() {
|
||||||
std::env::set_var("SEPOT_TEST_RL_BURST", "7");
|
std::env::set_var("SEPOT_TEST_RL_BURST", "7");
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ mod disk;
|
|||||||
mod fcm;
|
mod fcm;
|
||||||
mod limits;
|
mod limits;
|
||||||
mod net;
|
mod net;
|
||||||
|
mod pow;
|
||||||
mod push;
|
mod push;
|
||||||
mod quota;
|
mod quota;
|
||||||
mod rest;
|
mod rest;
|
||||||
@@ -31,7 +32,7 @@ use axum::serve::ListenerExt;
|
|||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
|
||||||
use crate::limits::{Limits, RateLimit};
|
use crate::limits::{Limits, RateLimit, RegisterLimits};
|
||||||
use crate::net::TrustedProxies;
|
use crate::net::TrustedProxies;
|
||||||
use tower_http::timeout::TimeoutLayer;
|
use tower_http::timeout::TimeoutLayer;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
@@ -75,6 +76,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
attachments::spawn_cleanup(state.clone());
|
attachments::spawn_cleanup(state.clone());
|
||||||
state.disk.spawn(&state.attachments.dir);
|
state.disk.spawn(&state.attachments.dir);
|
||||||
tracing::info!(?limits, "limity");
|
tracing::info!(?limits, "limity");
|
||||||
|
tracing::info!(pow = ?state.pow.cfg, "proof-of-work registrace");
|
||||||
|
|
||||||
// ── Rate limiting ──
|
// ── Rate limiting ──
|
||||||
//
|
//
|
||||||
@@ -83,7 +85,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// (SEPOT_TOR_PEERS) a mají vlastní společný bucket — hlavičkou si IP
|
// (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.
|
// nastavit nemohou (SECURITY audit #1). Jen v paměti, nikdy na disk.
|
||||||
// Hodnoty a jejich zdůvodnění: limits.rs.
|
// 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_general = RateLimit::new(limits.general, None, trusted.clone());
|
||||||
let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone());
|
let rl_attachments = RateLimit::new(limits.attachments, None, trusted.clone());
|
||||||
let rl_ws = RateLimit::new(limits.ws_upgrade, 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()
|
let register_routes = Router::new()
|
||||||
.route("/v1/register", post(rest::register))
|
.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()
|
let general_routes = Router::new()
|
||||||
.route("/v1/server-info", get(rest::server_info))
|
.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.
|
//! REST endpointy — registrace, prekey bundle, sender cert.
|
||||||
//! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů.
|
//! Chybové odpovědi nikdy neleakují interní detail; logy bez identifikátorů.
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{ConnectInfo, Path, State};
|
||||||
use axum::http::{HeaderMap, StatusCode};
|
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::Json;
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::{Extension, Json};
|
||||||
use base64::prelude::*;
|
use base64::prelude::*;
|
||||||
use libsignal_protocol::PublicKey;
|
use libsignal_protocol::PublicKey;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::limits::RegisterLimits;
|
||||||
|
use crate::pow::{Mode as PowMode, Verdict};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
type ApiResult<T> = Result<T, (StatusCode, String)>;
|
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
|
/// Veřejné informace o relayi. `source` + `revision` plní AGPL §13 — kdo
|
||||||
/// s relayem mluví, má vědět, který zdroj právě běží.
|
/// 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> {
|
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!({
|
Json(serde_json::json!({
|
||||||
"frame_version": 1,
|
"frame_version": 1,
|
||||||
"trust_root": BASE64_STANDARD.encode(&state.certs.trust_root_public),
|
"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,
|
"source": state.source_url,
|
||||||
"revision": state.revision,
|
"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
|
/// Ověří hlavičku `X-Sepot-Identity-Proof: <ts_ms>.<sig_b64>` — podpis
|
||||||
/// identity klíčem nad `"<method_path>\n<ts_ms>"`, stejné okno jako
|
/// 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:
|
/// 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).
|
/// Strop OTP klíčů v jedné registraci (klient posílá 100).
|
||||||
const MAX_ONE_TIME_PREKEYS: usize = 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(
|
pub async fn register(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
Extension(limits): Extension<Arc<RegisterLimits>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(body): Json<RegisterIn>,
|
Json(body): Json<RegisterIn>,
|
||||||
) -> ApiResult<(StatusCode, Json<RegisterOut>)> {
|
) -> Response {
|
||||||
if state.limits.registration_paused {
|
if state.limits.registration_paused {
|
||||||
return Err((
|
return (
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
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 {
|
if body.one_time_prekeys.len() > MAX_ONE_TIME_PREKEYS {
|
||||||
return Err(bad("příliš mnoho 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) {
|
if !(1..=127).contains(&body.device_id) || !(1..=16_383).contains(&body.registration_id) {
|
||||||
return Err(bad("device_id nebo registration_id mimo rozsah"));
|
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 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_pub = b64("signed_prekey.public_key", &body.signed_prekey.public_key, Some(33))?;
|
||||||
let spk_sig = b64(
|
let spk_sig = b64(
|
||||||
@@ -197,7 +289,7 @@ pub async fn register(
|
|||||||
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
|
// prekeys, push tokeny i nedoručená fronta pryč (nové zařízení staré
|
||||||
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
|
// ratchet relace stejně nemá), account_id zůstává — kontakty ho znají.
|
||||||
let account_id = if inserted.rows_affected() == 0 {
|
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()));
|
return Err((StatusCode::CONFLICT, "identita už existuje".into()));
|
||||||
}
|
}
|
||||||
let (existing,): (Vec<u8>,) =
|
let (existing,): (Vec<u8>,) =
|
||||||
@@ -277,7 +369,6 @@ pub async fn register(
|
|||||||
}
|
}
|
||||||
|
|
||||||
tx.commit().await.map_err(internal)?;
|
tx.commit().await.map_err(internal)?;
|
||||||
tracing::info!("nová registrace zařízení");
|
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ pub struct AppState {
|
|||||||
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
pub wake_marks: DashMap<Vec<u8>, WakeMarks>,
|
||||||
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
/// delivery_address → (začátek okna, vydaných OTP) — drain ochrana.
|
||||||
pub otp_issued: DashMap<Vec<u8>, (Instant, u32)>,
|
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).
|
/// Odkaz na zdroj a revize běžící binárky (AGPL §13, /v1/server-info).
|
||||||
pub source_url: String,
|
pub source_url: String,
|
||||||
pub revision: String,
|
pub revision: String,
|
||||||
@@ -242,6 +244,7 @@ impl AppState {
|
|||||||
limits,
|
limits,
|
||||||
wake_marks: DashMap::new(),
|
wake_marks: DashMap::new(),
|
||||||
otp_issued: DashMap::new(),
|
otp_issued: DashMap::new(),
|
||||||
|
pow: crate::pow::RegisterPow::new(crate::pow::Config::from_env()),
|
||||||
source_url: std::env::var("SEPOT_SOURCE_URL")
|
source_url: std::env::var("SEPOT_SOURCE_URL")
|
||||||
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
.unwrap_or_else(|_| "https://github.com/juliusjoska/sepot".into()),
|
||||||
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
revision: std::env::var("SEPOT_REVISION").unwrap_or_else(|_| "dev".into()),
|
||||||
@@ -264,6 +267,8 @@ impl AppState {
|
|||||||
self.send_budget.prune();
|
self.send_budget.prune();
|
||||||
self.attachment_budget.prune();
|
self.attachment_budget.prune();
|
||||||
self.attachment_stored.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ě.
|
/// Smí se vydat další OTP pro tuhle adresu? Počítá v hodinovém okně.
|
||||||
|
|||||||
Reference in New Issue
Block a user