diff --git a/hugo-site/content/quickstart/_index.md b/hugo-site/content/quickstart/_index.md index 1facb0b1..a7d83446 100644 --- a/hugo-site/content/quickstart/_index.md +++ b/hugo-site/content/quickstart/_index.md @@ -27,7 +27,7 @@ browser. ## Step 2: Join the room -Click below to join the **Freenet Official** room. Invites are limited to 20 per day. +Click below to join the **Freenet Official** room. A small daily limit helps protect the room from spam. {{< river-invite-button room="Freenet Official" >}} diff --git a/hugo-site/themes/freenet/layouts/shortcodes/river-invite-button.html b/hugo-site/themes/freenet/layouts/shortcodes/river-invite-button.html index 2b5c36e1..ef2cbc34 100644 --- a/hugo-site/themes/freenet/layouts/shortcodes/river-invite-button.html +++ b/hugo-site/themes/freenet/layouts/shortcodes/river-invite-button.html @@ -153,7 +153,7 @@ @@ -182,6 +182,148 @@ const inviteLink = document.getElementById('invite-link'); const errorMessage = document.getElementById('error-message'); const retryMessage = document.getElementById('retry-message'); + const loadingMessage = document.getElementById('invite-loading-message'); + + const powDomain = new TextEncoder().encode('freenet-river-invite-pow-v1'); + + function hexToBytes(hex) { + if (typeof hex !== 'string' || hex.length % 2 !== 0) { + throw new Error('The invite server returned an invalid challenge'); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; + } + + function hasLeadingZeroBits(hash, difficulty) { + const bytes = new Uint8Array(hash); + const fullBytes = Math.floor(difficulty / 8); + for (let i = 0; i < fullBytes; i++) { + if (bytes[i] !== 0) return false; + } + const remaining = difficulty % 8; + return remaining === 0 || (bytes[fullBytes] >> (8 - remaining)) === 0; + } + + async function solveChallenge(challenge) { + if (!window.crypto || !window.crypto.subtle) { + throw new Error('This browser cannot perform invite verification'); + } + const id = hexToBytes(challenge.challenge); + if (id.length !== 16 || !Number.isInteger(challenge.difficulty)) { + throw new Error('The invite server returned an invalid challenge'); + } + + const prefix = new Uint8Array(powDomain.length + id.length + 8); + prefix.set(powDomain, 0); + prefix.set(id, powDomain.length); + const nonceOffset = powDomain.length + id.length; + const batchSize = 128; + let nonce = 0; + const started = performance.now(); + + while (nonce < Number.MAX_SAFE_INTEGER - batchSize) { + const attempts = []; + for (let i = 0; i < batchSize; i++) { + const candidate = nonce + i; + const input = prefix.slice(); + new DataView(input.buffer).setBigUint64(nonceOffset, BigInt(candidate), false); + attempts.push(crypto.subtle.digest('SHA-256', input)); + } + const hashes = await Promise.all(attempts); + for (let i = 0; i < hashes.length; i++) { + if (hasLeadingZeroBits(hashes[i], challenge.difficulty)) { + return nonce + i; + } + } + nonce += batchSize; + if (nonce % 4096 === 0) { + const elapsed = Math.max(1, Math.round((performance.now() - started) / 1000)); + loadingMessage.textContent = `Preparing a spam-resistant invitation… ${elapsed}s`; + await new Promise(resolve => requestAnimationFrame(resolve)); + } + } + throw new Error('Invite verification took too long'); + } + + async function readApiResponse(response) { + let data = {}; + try { + data = await response.json(); + } catch (_) { + // Preserve the HTTP status below when an upstream error is not JSON. + } + if (!response.ok) { + const error = new Error(data.error || `Invite server error (${response.status})`); + error.status = response.status; + error.retryAfter = data.retry_after_seconds; + throw error; + } + return data; + } + + async function requestInvite() { + // A challenge is short-lived and single-use. Retry once if it expires or + // races with a duplicate submission, without bothering the user. + for (let attempt = 0; attempt < 2; attempt++) { + const challengeResponse = await fetch(`${apiUrl}/invite-challenge`); + // Deployment compatibility: publish this page first, then update + // gkapi. The old API has no challenge route, so it remains usable + // during that short rollout window. The new API never accepts this + // legacy request, so it cannot bypass proof of work afterward. + if (challengeResponse.status === 404) { + const legacyResponse = await fetch(`${apiUrl}/create-invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + return readApiResponse(legacyResponse); + } + const challenge = await readApiResponse(challengeResponse); + loadingMessage.textContent = 'Preparing a spam-resistant invitation…'; + const nonce = await solveChallenge(challenge); + loadingMessage.textContent = 'Joining the chat…'; + + const response = await fetch(`${apiUrl}/create-invite`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge: challenge.challenge, + issued_at: challenge.issued_at, + difficulty: challenge.difficulty, + signature: challenge.signature, + nonce + }) + }); + if ([400, 409, 410].includes(response.status) && attempt === 0) { + continue; + } + return readApiResponse(response); + } + throw new Error('Invite verification could not be completed'); + } + + function showInviteError(error) { + inviteLoading.style.display = 'none'; + inviteError.style.display = 'block'; + errorMessage.textContent = error.message || 'Unable to create an invitation. Please try again.'; + if (error.retryAfter != null) { + const secs = Math.max(0, Math.round(error.retryAfter)); + let when; + if (secs < 60) { + when = `${Math.max(1, secs)} second(s)`; + } else if (secs < 3600) { + when = `${Math.ceil(secs / 60)} minute(s)`; + } else { + when = `${Math.ceil(secs / 3600)} hour(s)`; + } + retryMessage.textContent = `You can try again in approximately ${when}.`; + } else { + retryMessage.textContent = ''; + } + } document.getElementById('copy-invite-btn').addEventListener('click', function() { const code = document.getElementById('invite-code-text').textContent; @@ -196,47 +338,10 @@ inviteLoading.style.display = 'block'; inviteError.style.display = 'none'; inviteResult.style.display = 'none'; + loadingMessage.textContent = 'Preparing your invitation…'; try { - const response = await fetch(`${apiUrl}/create-invite`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}) - }); - - if (response.status === 429) { - const data = await response.json(); - inviteLoading.style.display = 'none'; - inviteError.style.display = 'block'; - errorMessage.textContent = data.error; - // Retry hints can now be sub-hour: the shared Tor ceiling uses a - // 60-minute window, so values arrive anywhere in 0..3600s. - // Rounding everything up to hours told a user waiting 90 seconds - // to come back in "approximately 1 hour(s)". - // Note `!= null` rather than truthiness, so a legitimate 0 is - // rendered instead of leaving a stale message from a prior try. - if (data.retry_after_seconds != null) { - const secs = Math.max(0, Math.round(data.retry_after_seconds)); - let when; - if (secs < 60) { - when = `${Math.max(1, secs)} second(s)`; - } else if (secs < 3600) { - when = `${Math.ceil(secs / 60)} minute(s)`; - } else { - when = `${Math.ceil(secs / 3600)} hour(s)`; - } - retryMessage.textContent = `You can try again in approximately ${when}.`; - } else { - retryMessage.textContent = ''; - } - return; - } - - if (!response.ok) { - throw new Error(`Server error: ${response.status}`); - } - - const data = await response.json(); + const data = await requestInvite(); const inviteCode = data.invite_code; const riverUrl = `${riverBaseUrl}?invitation=${encodeURIComponent(inviteCode)}`; @@ -253,10 +358,7 @@ inviteResult.style.display = 'block'; } catch (error) { - inviteLoading.style.display = 'none'; - inviteError.style.display = 'block'; - errorMessage.textContent = `Error: ${error.message}. Please try again later.`; - retryMessage.textContent = ''; + showInviteError(error); } }); }); diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 25f1fee8..fb6b14fc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -692,6 +692,12 @@ dependencies = [ "syn 2.0.71", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "der" version = "0.6.1" @@ -1074,12 +1080,14 @@ dependencies = [ "ciborium", "clap", "curve25519-dalek", + "data-encoding", "dotenv", "ed25519-dalek", "env_logger", "fantoccini", "ghostkey_lib", "hex", + "hmac", "log", "rand 0.8.5", "rand_core 0.6.4", diff --git a/rust/api/Cargo.toml b/rust/api/Cargo.toml index 03b06b43..64c9634b 100644 --- a/rust/api/Cargo.toml +++ b/rust/api/Cargo.toml @@ -23,6 +23,7 @@ env_logger = "0.11.3" base64 = "0.22.1" rand_core = "0.6.4" sha2 = "0.10.6" +hmac = "0.12" clap = { version = "4.3", features = ["derive", "env"] } rand = "0.8" fantoccini = "0.21.0" @@ -32,6 +33,7 @@ serde = { version = "1.0", features = ["derive"] } blind-rsa-signatures = "0.15.1" ciborium = "0.2" bs58 = "0.5" +data-encoding = "2.3.3" # Tor exit-list fetch. # # NOTE: `rustls-tls` enables `rustls/ring`, while axum-server's `tls-rustls` diff --git a/rust/api/src/invite.rs b/rust/api/src/invite.rs index 1bf76040..4c8f1aec 100644 --- a/rust/api/src/invite.rs +++ b/rust/api/src/invite.rs @@ -3,6 +3,7 @@ //! This module replicates the necessary types from river-core to generate //! room invitations without depending on the full river-core crate. +use data_encoding::BASE32; use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -26,6 +27,17 @@ pub struct FastHash(pub i64); #[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct MemberId(pub FastHash); +impl MemberId { + /// The same 8-character id shown by River and `riverctl member list`. + pub fn short(&self) -> String { + BASE32 + .encode(&self.0 .0.to_le_bytes()) + .chars() + .take(8) + .collect() + } +} + impl From<&VerifyingKey> for MemberId { fn from(vk: &VerifyingKey) -> Self { MemberId(fast_hash(&vk.to_bytes())) @@ -70,6 +82,11 @@ pub enum InviteError { Serialization(String), } +pub struct CreatedInvitation { + pub code: String, + pub member_id: String, +} + /// Sign a serializable struct using CBOR encoding /// Matches river_core::util::sign_struct fn sign_struct(message: &T, signing_key: &SigningKey) -> Signature { @@ -89,7 +106,7 @@ fn sign_struct(message: &T, signing_key: &SigningKey) -> Signature pub fn create_invitation( room_owner_vk: &VerifyingKey, inviter_signing_key: &SigningKey, -) -> Result { +) -> Result { // Generate a new signing key for the invitee let invitee_signing_key = SigningKey::generate(&mut rand::thread_rng()); let invitee_vk = invitee_signing_key.verifying_key(); @@ -117,7 +134,10 @@ pub fn create_invitation( ciborium::ser::into_writer(&invitation, &mut data) .map_err(|e| InviteError::Serialization(e.to_string()))?; - Ok(bs58::encode(data).into_string()) + Ok(CreatedInvitation { + code: bs58::encode(data).into_string(), + member_id: MemberId::from(invitee_vk).short(), + }) } #[cfg(test)] @@ -148,11 +168,12 @@ mod tests { let owner_vk = owner_signing_key.verifying_key(); // Create invitation - let invite_code = create_invitation(&owner_vk, &owner_signing_key).unwrap(); + let invite = create_invitation(&owner_vk, &owner_signing_key).unwrap(); // Verify it's valid base58 - assert!(!invite_code.is_empty()); - let decoded = bs58::decode(&invite_code).into_vec().unwrap(); + assert!(!invite.code.is_empty()); + assert_eq!(invite.member_id.len(), 8); + let decoded = bs58::decode(&invite.code).into_vec().unwrap(); assert!(!decoded.is_empty()); // Verify we can deserialize it @@ -181,12 +202,12 @@ mod tests { create_invitation(&owner_vk, &signing_key).expect("Failed to create invitation"); println!("\n=== Generated Invite for freenet-chat ==="); - println!("{}", invite); - println!("Length: {} chars\n", invite.len()); + println!("{}", invite.code); + println!("Length: {} chars\n", invite.code.len()); // Verify format - assert!(!invite.is_empty()); - let decoded = bs58::decode(&invite).into_vec().unwrap(); + assert!(!invite.code.is_empty()); + let decoded = bs58::decode(&invite.code).into_vec().unwrap(); let invitation: Invitation = ciborium::de::from_reader(&decoded[..]).unwrap(); assert_eq!(invitation.room, owner_vk); } diff --git a/rust/api/src/invite_pow.rs b/rust/api/src/invite_pow.rs new file mode 100644 index 00000000..92643976 --- /dev/null +++ b/rust/api/src/invite_pow.rs @@ -0,0 +1,272 @@ +//! Stateless proof-of-work challenges for River invitation issuance. +//! +//! A challenge is authenticated with a process-local HMAC key, so clients +//! cannot lower its difficulty or extend its lifetime. Successfully used +//! challenge ids are retained until expiry to make each proof single-use. + +use std::collections::HashMap; +use std::sync::Mutex; + +use chrono::Utc; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +type HmacSha256 = Hmac; + +const CHALLENGE_BYTES: usize = 16; +const SIGNATURE_BYTES: usize = 32; +const CHALLENGE_TTL_SECONDS: i64 = 5 * 60; +const DOMAIN: &[u8] = b"freenet-river-invite-pow-v1"; + +/// Difficulty increases as successful invitation volume approaches the global +/// emergency ceiling. Each additional bit doubles expected work. +pub const MEDIUM_TRAFFIC_THRESHOLD: usize = 90; +pub const HIGH_TRAFFIC_THRESHOLD: usize = 150; +pub const DEFAULT_POW_DIFFICULTY: u8 = 16; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PowChallenge { + pub challenge: String, + pub issued_at: i64, + pub difficulty: u8, + pub signature: String, +} + +#[derive(Debug, Serialize)] +pub struct PowChallengeResponse { + #[serde(flatten)] + pub challenge: PowChallenge, + pub algorithm: &'static str, + pub expires_in_seconds: i64, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum PowError { + #[error("malformed challenge")] + Malformed, + #[error("challenge signature is invalid")] + InvalidSignature, + #[error("challenge has expired")] + Expired, + #[error("proof of work is invalid")] + InvalidProof, + #[error("challenge has already been used")] + Reused, + #[error("proof-of-work state is unavailable")] + Lock, +} + +pub struct PowManager { + secret: [u8; 32], + base_difficulty: u8, + /// challenge id -> wall-clock expiry. Entries exist only after a valid + /// proof is consumed, so challenge-request floods do not grow this map. + used: Mutex>, +} + +impl PowManager { + pub fn new(base_difficulty: u8) -> Self { + let mut secret = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret); + Self { + secret, + base_difficulty, + used: Mutex::new(HashMap::new()), + } + } + + #[cfg(test)] + fn with_secret(base_difficulty: u8, secret: [u8; 32]) -> Self { + Self { + secret, + base_difficulty, + used: Mutex::new(HashMap::new()), + } + } + + pub fn difficulty(&self, recent_invites: usize) -> u8 { + let extra = if recent_invites >= HIGH_TRAFFIC_THRESHOLD { + 4 + } else if recent_invites >= MEDIUM_TRAFFIC_THRESHOLD { + 2 + } else { + 0 + }; + self.base_difficulty.saturating_add(extra).min(30) + } + + pub fn issue(&self, recent_invites: usize) -> PowChallengeResponse { + let mut id = [0u8; CHALLENGE_BYTES]; + rand::thread_rng().fill_bytes(&mut id); + let issued_at = Utc::now().timestamp(); + let difficulty = self.difficulty(recent_invites); + let signature = self.sign(&id, issued_at, difficulty); + PowChallengeResponse { + challenge: PowChallenge { + challenge: hex::encode(id), + issued_at, + difficulty, + signature: hex::encode(signature), + }, + algorithm: "sha256-leading-zero-bits-v1", + expires_in_seconds: CHALLENGE_TTL_SECONDS, + } + } + + /// Validate and atomically consume a proof. The returned challenge id can + /// be passed to [`Self::release`] if a downstream admission check fails. + pub fn verify_and_consume( + &self, + challenge: &PowChallenge, + nonce: u64, + ) -> Result<[u8; CHALLENGE_BYTES], PowError> { + let id: [u8; CHALLENGE_BYTES] = hex::decode(&challenge.challenge) + .ok() + .and_then(|v| v.try_into().ok()) + .ok_or(PowError::Malformed)?; + let signature: [u8; SIGNATURE_BYTES] = hex::decode(&challenge.signature) + .ok() + .and_then(|v| v.try_into().ok()) + .ok_or(PowError::Malformed)?; + + let now = Utc::now().timestamp(); + let age = now.saturating_sub(challenge.issued_at); + if !(0..=CHALLENGE_TTL_SECONDS).contains(&age) { + return Err(PowError::Expired); + } + + let mut mac = HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts 32-byte keys"); + mac.update(DOMAIN); + mac.update(&id); + mac.update(&challenge.issued_at.to_be_bytes()); + mac.update(&[challenge.difficulty]); + mac.verify_slice(&signature) + .map_err(|_| PowError::InvalidSignature)?; + + if !valid_proof(&id, nonce, challenge.difficulty) { + return Err(PowError::InvalidProof); + } + + let mut used = self.used.lock().map_err(|_| PowError::Lock)?; + used.retain(|_, expiry| *expiry > now); + if used.contains_key(&id) { + return Err(PowError::Reused); + } + used.insert(id, challenge.issued_at + CHALLENGE_TTL_SECONDS); + Ok(id) + } + + /// Make a consumed challenge reusable after a downstream refusal. This + /// prevents a race at the global ceiling or a transient storage error from + /// forcing a legitimate browser to repeat the expensive work. + pub fn release(&self, id: &[u8; CHALLENGE_BYTES]) { + if let Ok(mut used) = self.used.lock() { + used.remove(id); + } + } + + fn sign( + &self, + id: &[u8; CHALLENGE_BYTES], + issued_at: i64, + difficulty: u8, + ) -> [u8; SIGNATURE_BYTES] { + let mut mac = HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts 32-byte keys"); + mac.update(DOMAIN); + mac.update(id); + mac.update(&issued_at.to_be_bytes()); + mac.update(&[difficulty]); + mac.finalize().into_bytes().into() + } +} + +pub(crate) fn valid_proof(id: &[u8; CHALLENGE_BYTES], nonce: u64, difficulty: u8) -> bool { + let mut hasher = Sha256::new(); + hasher.update(DOMAIN); + hasher.update(id); + hasher.update(nonce.to_be_bytes()); + has_leading_zero_bits(&hasher.finalize(), difficulty) +} + +fn has_leading_zero_bits(hash: &[u8], difficulty: u8) -> bool { + let full_bytes = usize::from(difficulty / 8); + let remaining_bits = difficulty % 8; + if full_bytes > hash.len() || (full_bytes == hash.len() && remaining_bits > 0) { + return false; + } + if hash[..full_bytes].iter().any(|byte| *byte != 0) { + return false; + } + remaining_bits == 0 || hash[full_bytes] >> (8 - remaining_bits) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solve(challenge: &PowChallenge) -> u64 { + (0..u64::MAX) + .find(|nonce| { + let id: [u8; CHALLENGE_BYTES] = hex::decode(&challenge.challenge) + .unwrap() + .try_into() + .unwrap(); + valid_proof(&id, *nonce, challenge.difficulty) + }) + .unwrap() + } + + #[test] + fn difficulty_is_adaptive() { + let manager = PowManager::with_secret(8, [7; 32]); + assert_eq!(manager.difficulty(0), 8); + assert_eq!(manager.difficulty(MEDIUM_TRAFFIC_THRESHOLD), 10); + assert_eq!(manager.difficulty(HIGH_TRAFFIC_THRESHOLD), 12); + } + + #[test] + fn valid_proof_is_single_use() { + let manager = PowManager::with_secret(8, [7; 32]); + let response = manager.issue(0); + let nonce = solve(&response.challenge); + let id = manager + .verify_and_consume(&response.challenge, nonce) + .unwrap(); + assert_eq!( + manager.verify_and_consume(&response.challenge, nonce), + Err(PowError::Reused) + ); + manager.release(&id); + assert!(manager + .verify_and_consume(&response.challenge, nonce) + .is_ok()); + } + + #[test] + fn signed_fields_cannot_be_changed() { + let manager = PowManager::with_secret(8, [7; 32]); + let mut challenge = manager.issue(0).challenge; + challenge.difficulty = 1; + assert_eq!( + manager.verify_and_consume(&challenge, 0), + Err(PowError::InvalidSignature) + ); + } + + #[test] + fn incorrect_nonce_is_rejected() { + let manager = PowManager::with_secret(8, [7; 32]); + let challenge = manager.issue(0).challenge; + let nonce = solve(&challenge); + let wrong = nonce.wrapping_add(1); + if wrong != nonce { + assert_eq!( + manager.verify_and_consume(&challenge, wrong), + Err(PowError::InvalidProof) + ); + } + } +} diff --git a/rust/api/src/main.rs b/rust/api/src/main.rs index 90153495..4a298b4d 100644 --- a/rust/api/src/main.rs +++ b/rust/api/src/main.rs @@ -8,15 +8,16 @@ use dotenv::dotenv; use ed25519_dalek::{SigningKey, VerifyingKey}; use log::{error, info, warn, LevelFilter}; use tokio::sync::Mutex; -use tower_http::cors::CorsLayer; use tower_http::trace::TraceLayer; +use crate::invite_pow::DEFAULT_POW_DIFFICULTY; use crate::routes::InviteState; mod delegates; mod errors; mod handle_sign_cert; mod invite; +mod invite_pow; mod rate_limit; mod routes; mod tor; @@ -85,18 +86,11 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option { .map(|s| s.as_str()) .unwrap_or("/var/lib/gkapi/tor_exit_list.txt"), )); - // Runtime off-switch / tuning knob for the shared Tor ceiling. `0` disables - // it entirely, so an operator can turn this off (or resize it) without a - // rebuild if it turns out to refuse legitimate users. - let tor_invites_per_hour = matches - .get_one::("tor-invites-per-hour") - .and_then(|s| match s.parse::() { - Ok(v) => Some(v), - Err(e) => { - error!("Invalid --tor-invites-per-hour {s:?}: {e}; using default"); - None - } - }); + let global_invites_per_hour = matches.get_one::("global-invites-per-hour").copied(); + let pow_base_difficulty = matches + .get_one::("invite-pow-difficulty") + .copied() + .unwrap_or(DEFAULT_POW_DIFFICULTY); // Load signing key from file (32 bytes raw) let signing_key_bytes = match fs::read(signing_key_path) { @@ -155,7 +149,8 @@ fn load_invite_config(matches: &clap::ArgMatches) -> Option { Some(InviteState::new( rate_limit_file, tor_exit_cache, - tor_invites_per_hour, + global_invites_per_hour, + pow_base_difficulty, room_owner_vk, inviter_signing_key, room_name, @@ -305,14 +300,21 @@ async fn main() { .help("Path to the cached Tor exit-node list (refreshed hourly)"), ) .arg( - Arg::new("tor-invites-per-hour") - .long("tor-invites-per-hour") + Arg::new("global-invites-per-hour") + .long("global-invites-per-hour") .value_name("N") - .env("TOR_INVITES_PER_HOUR") - .help( - "Shared hourly invite ceiling across ALL Tor exits (0 disables). \ - Defaults to DEFAULT_TOR_INVITES_PER_HOUR.", - ), + .env("GLOBAL_INVITES_PER_HOUR") + .value_parser(value_parser!(usize)) + .help("Emergency hourly ceiling across all issued invitations (0 disables)."), + ) + .arg( + Arg::new("invite-pow-difficulty") + .long("invite-pow-difficulty") + .value_name("BITS") + .env("INVITE_POW_DIFFICULTY") + .value_parser(value_parser!(u8).range(1..=30)) + .default_value("16") + .help("Base leading-zero-bit difficulty for invite proof of work"), ) .get_matches(); @@ -357,19 +359,15 @@ async fn main() { "River room invite endpoint enabled for room: {}", state.room_name ); - // Keep the Tor exit list current so the shared Tor ceiling can be - // applied. If this never succeeds the list stays empty and invite - // limiting silently degrades to per-IP only (fail open). + // Keep the Tor exit list current. Invite issuance fails closed while + // the list is unavailable so Tor blocking cannot silently degrade. tor::spawn_refresher(Arc::clone(&state.tor_exits)); app = app.merge(routes::get_invite_routes(state)); } else { warn!("River room invite endpoint not configured. Set ROOM_SIGNING_KEY_FILE and ROOM_OWNER_VK to enable."); } - let app = app - .layer(TraceLayer::new_for_http()) - .layer(CorsLayer::permissive()) - .fallback(not_found); + let app = app.layer(TraceLayer::new_for_http()).fallback(not_found); let challenge_dir_clone = challenge_dir.clone(); let challenge_app = diff --git a/rust/api/src/rate_limit.rs b/rust/api/src/rate_limit.rs index c211a6bf..17f69e44 100644 --- a/rust/api/src/rate_limit.rs +++ b/rust/api/src/rate_limit.rs @@ -23,39 +23,16 @@ use thiserror::Error; /// `test_rate_limiter_enforces_four_per_window`. /// /// NOTE: this limit is per-IP and therefore cannot bound an actor who rotates -/// IPs. For the Tor exit set specifically, see [`AggregateBucket`] and -/// [`TOR_INVITES_PER_HOUR`]. +/// IPs. The global emergency ceiling and proof of work provide that bound. pub const MAX_INVITES_PER_WINDOW: usize = 4; -/// Default shared hourly invite ceiling across the ENTIRE Tor exit set. -/// -/// # Sizing, and why this number is uncomfortable -/// -/// From `invite_rate_limits.json` (1728 invites, 2026-07-24T06:23Z.. -/// 2026-07-25T05:54Z), classified against the official bulk exit list: -/// -/// | Tor invites/hour | | -/// |---|---| -/// | organic hours (burst window excluded BY TIME, n=7) | mean 11.9, **max 33** | -/// | burst hour 02:00Z | **208** | -/// -/// An earlier revision used 25, derived from a smaller sample filtered by -/// MAGNITUDE (hours above a threshold were excluded as "burst", then the -/// remaining max was called the organic peak). That is circular, and it -/// understated the peak by 3x. 25 would have refused real users. -/// -/// 60 sits above the observed organic max (33) with ~1.8x headroom and still -/// cuts the observed burst hour by ~71%. It is a judgement call on ambiguous -/// data, not a derived constant: hours 04Z (28 invites / 25 exits) and 05Z -/// (33 / 33) are ~1.0 invites per exit, which looks like many distinct real -/// users -- but a one-request-per-exit attacker is indistinguishable from that -/// by volume alone. Treat this as a starting value to tune from telemetry, -/// which is why it is overridable at runtime (`--tor-invites-per-hour` / -/// `TOR_INVITES_PER_HOUR`, `0` disables the ceiling entirely). -pub const DEFAULT_TOR_INVITES_PER_HOUR: usize = 60; - -/// Window for the Tor ceiling, in minutes. -pub const TOR_WINDOW_MINUTES: i64 = 60; +/// Emergency ceiling across all invitation issuance. Normal traffic has been +/// observed at 30-60 users/hour; 200 leaves substantial headroom while bounding +/// the 200+/hour automated waves that overloaded the room and Freenet network. +/// Runtime-configurable via `GLOBAL_INVITES_PER_HOUR`; 0 disables it. +pub const DEFAULT_GLOBAL_INVITES_PER_HOUR: usize = 200; + +pub const GLOBAL_WINDOW_MINUTES: i64 = 60; /// SHA256 hashes of IPs exempt from rate limiting (for testing) const EXEMPT_IP_HASHES: &[&str] = @@ -184,6 +161,37 @@ impl RateLimiter { Ok(None) } + /// IPs and ages of recorded invitations still inside `window_minutes`. + /// + /// Used once at startup to seed the in-memory global ceiling from the + /// persistent per-IP store. A deploy therefore cannot reset the emergency + /// allowance and grant an attacker a fresh burst. + pub fn recent_events( + &self, + window_minutes: i64, + ) -> Result, RateLimitError> { + let _guard = self.lock.lock().map_err(|_| RateLimitError::Lock)?; + let data = self.load()?; + let now = Utc::now(); + let window = Duration::minutes(window_minutes); + Ok(data + .invites + .iter() + .filter_map(|(ip, timestamps)| ip.parse::().ok().map(|ip| (ip, timestamps))) + .flat_map(|(ip, timestamps)| { + timestamps.iter().filter_map(move |ts| { + let t: DateTime = DateTime::parse_from_rfc3339(ts).ok()?.into(); + let age = now.signed_duration_since(t); + if age >= Duration::zero() && age < window { + age.to_std().ok().map(|age| (ip, age)) + } else { + None + } + }) + }) + .collect()) + } + fn load(&self) -> Result { if self.data_path.exists() { let content = fs::read_to_string(&self.data_path)?; @@ -204,31 +212,13 @@ impl RateLimiter { } } -/// A single sliding-window counter shared by many identities. -/// -/// # Why a shared bucket -/// -/// Per-IP limiting assumes an IP approximates a person. For Tor that assumption -/// is false in the attacker's favour: exits are a public, rotatable pool, so N -/// exits multiply any per-IP limit by N. Metering the whole pool through ONE -/// bucket removes the multiplier. -/// -/// # The cost, stated plainly +/// A single sliding-window counter shared by all invitation requests. /// -/// Sharing a budget across an anonymity set means the loudest member can -/// consume the whole allowance. An attacker who sustains the ceiling denies -/// invites to EVERY Tor user for as long as they keep it up, and refused -/// requests cost them nothing, so an automated poller wins each freed slot -/// against a human. This is inherent to any aggregate cap over an unlinkable -/// pool, not an implementation defect -- fixing it needs a per-request cost -/// signal (proof-of-work / CAPTCHA), tracked in freenet/web#81. Do not describe -/// this bucket as leaving ordinary Tor users unaffected: that is only true -/// while nobody is attacking. -/// -/// Deliberately in-memory (not persisted like [`RateLimiter`]): a restart -/// forgives at most one window's worth, gkapi restarts only on deploy, and this -/// keeps the hot path free of the read-modify-write file IO the per-IP limiter -/// does. +/// This is an emergency safety valve rather than the primary anti-abuse +/// mechanism. Proof of work adds per-request cost; the bucket places an absolute +/// upper bound on room/network churn if that cost is bypassed. At startup it is +/// seeded from [`RateLimiter`]'s persistent timestamps so a deploy does not +/// reset the allowance. /// /// Uses a MONOTONIC clock ([`Instant`]) rather than wall time: an NTP step /// backwards would otherwise stall pruning and pin the bucket at full, denying @@ -241,6 +231,7 @@ pub struct AggregateBucket { } impl AggregateBucket { + #[cfg(test)] pub fn new(limit: usize, window_minutes: i64) -> Self { Self { limit, @@ -249,6 +240,26 @@ impl AggregateBucket { } } + pub fn new_seeded( + limit: usize, + window_minutes: i64, + ages: impl IntoIterator, + ) -> Self { + let window = StdDuration::from_secs((window_minutes.max(0) as u64) * 60); + let now = Instant::now(); + let mut hits: Vec<_> = ages + .into_iter() + .filter(|age| *age < window) + .map(|age| now.checked_sub(age).unwrap_or(now)) + .collect(); + hits.sort_unstable(); + Self { + limit, + window, + hits: Mutex::new(hits.into()), + } + } + /// A limit of 0 disables the ceiling entirely (runtime off-switch). pub fn is_disabled(&self) -> bool { self.limit == 0 @@ -293,8 +304,9 @@ impl AggregateBucket { false } } - // Fail open: a poisoned lock must not block legitimate users. - Err(_) => true, + // This is the final safety valve; fail closed if its state becomes + // unreliable rather than silently allowing unlimited issuance. + Err(_) => false, } } @@ -329,7 +341,7 @@ impl AggregateBucket { self.prune(&mut hits, now); hits.len() < self.limit } - Err(_) => true, + Err(_) => false, } } @@ -444,33 +456,35 @@ mod tests { assert!(bucket.retry_after_seconds().is_none()); } - /// Sizing pin. Bounds are tied to the MEASURED organic peak, so raising the - /// ceiling far above real traffic (or dropping it below it) fails here. - /// - /// Accepted collateral, deliberately NOT claimed away by this test: while - /// an attacker holds the ceiling, ordinary Tor users are refused too. See - /// `AggregateBucket` docs and freenet/web#81. + /// Sizing pin: the user-reported organic rate is 30-60/hour. The emergency + /// ceiling must leave several times that much headroom while still placing + /// a finite bound on a bypass of the primary controls. #[test] #[allow(clippy::assertions_on_constants)] // deliberate: this pins the constants - fn tor_ceiling_is_sized_between_organic_peak_and_burst() { - // Measured 2026-07-24/25 against the official bulk exit list. - const OBSERVED_ORGANIC_PEAK: usize = 33; - const OBSERVED_BURST_HOUR: usize = 208; - + fn global_ceiling_leaves_headroom_for_organic_traffic() { + const REPORTED_ORGANIC_HIGH: usize = 60; assert!( - DEFAULT_TOR_INVITES_PER_HOUR > OBSERVED_ORGANIC_PEAK, - "ceiling {DEFAULT_TOR_INVITES_PER_HOUR} must exceed the measured organic \ - peak {OBSERVED_ORGANIC_PEAK}/h or it refuses real users" + DEFAULT_GLOBAL_INVITES_PER_HOUR >= 3 * REPORTED_ORGANIC_HIGH, + "ceiling {DEFAULT_GLOBAL_INVITES_PER_HOUR} needs at least 3x headroom over \ + the reported organic high of {REPORTED_ORGANIC_HIGH}/h" ); assert!( - DEFAULT_TOR_INVITES_PER_HOUR <= 2 * OBSERVED_ORGANIC_PEAK, - "ceiling {DEFAULT_TOR_INVITES_PER_HOUR} must stay within 2x the organic \ - peak {OBSERVED_ORGANIC_PEAK}/h or it barely constrains a burst" + DEFAULT_GLOBAL_INVITES_PER_HOUR <= 250, + "ceiling {DEFAULT_GLOBAL_INVITES_PER_HOUR} is too high to be a useful safety valve" ); - assert!( - DEFAULT_TOR_INVITES_PER_HOUR < OBSERVED_BURST_HOUR / 2, - "ceiling must be well under the observed burst {OBSERVED_BURST_HOUR}/h" + } + + #[test] + fn seeded_bucket_preserves_recent_usage() { + let bucket = AggregateBucket::new_seeded( + 3, + 60, + [StdDuration::from_secs(60), StdDuration::from_secs(61 * 60)], ); + assert_eq!(bucket.current(), 1, "expired seed must be discarded"); + assert!(bucket.try_acquire()); + assert!(bucket.try_acquire()); + assert!(!bucket.try_acquire()); } #[test] diff --git a/rust/api/src/routes.rs b/rust/api/src/routes.rs index daa6b894..da27d505 100644 --- a/rust/api/src/routes.rs +++ b/rust/api/src/routes.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use axum::{ extract::{ConnectInfo, Path, State}, - http::StatusCode, + http::{header::CONTENT_TYPE, HeaderValue, Method, StatusCode}, response::{IntoResponse, Json}, routing::{get, post}, Router, @@ -22,19 +22,23 @@ use crate::handle_sign_cert::{ sign_certificate, CertificateError, SignCertificateRequest, SignCertificateResponse, }; use crate::invite; +use crate::invite_pow::{PowChallenge, PowChallengeResponse, PowError, PowManager}; use crate::rate_limit::{ - AggregateBucket, RateLimiter, DEFAULT_TOR_INVITES_PER_HOUR, MAX_INVITES_PER_WINDOW, - TOR_WINDOW_MINUTES, + AggregateBucket, RateLimiter, DEFAULT_GLOBAL_INVITES_PER_HOUR, GLOBAL_WINDOW_MINUTES, + MAX_INVITES_PER_WINDOW, }; use crate::tor::TorExitList; +use tower_http::cors::CorsLayer; /// Shared application state for invite generation #[derive(Clone)] pub struct InviteState { pub rate_limiter: Arc, - /// Shared ceiling across the whole Tor exit set (see `AggregateBucket`). - pub tor_bucket: Arc, - /// Membership test for "is this IP a Tor exit". Empty => nothing is Tor. + /// Emergency ceiling across all successful invitation issuance. + pub global_bucket: Arc, + pub pow: Arc, + /// Membership test for "is this IP a Tor exit". An empty list makes the + /// invite endpoint fail closed until the first refresh succeeds. pub tor_exits: Arc, pub room_owner_vk: VerifyingKey, pub inviter_signing_key: SigningKey, @@ -45,18 +49,40 @@ impl InviteState { pub fn new( rate_limit_file: PathBuf, tor_exit_cache: Option, - tor_invites_per_hour: Option, + global_invites_per_hour: Option, + pow_base_difficulty: u8, room_owner_vk: VerifyingKey, inviter_signing_key: SigningKey, room_name: String, ) -> Self { + let rate_limiter = Arc::new(RateLimiter::new(rate_limit_file, 24)); + let tor_exits = Arc::new(TorExitList::new(tor_exit_cache)); + let recent_ages = match rate_limiter.recent_events(GLOBAL_WINDOW_MINUTES) { + // Seed only traffic the new policy would have admitted. Otherwise + // a pre-deploy Tor wave consumes legitimate global headroom even + // though every equivalent request is blocked after the restart. + Ok(events) => events + .into_iter() + .filter_map(|(ip, age)| (!tor_exits.is_exit(&ip)).then_some(age)) + .collect(), + Err(e) => { + warn!("Could not seed global invite ceiling from persistent state: {e}"); + Vec::new() + } + }; + info!( + "Seeding global invite ceiling with {} invitation(s) from the last hour", + recent_ages.len() + ); Self { - rate_limiter: Arc::new(RateLimiter::new(rate_limit_file, 24)), - tor_bucket: Arc::new(AggregateBucket::new( - tor_invites_per_hour.unwrap_or(DEFAULT_TOR_INVITES_PER_HOUR), - TOR_WINDOW_MINUTES, + rate_limiter, + global_bucket: Arc::new(AggregateBucket::new_seeded( + global_invites_per_hour.unwrap_or(DEFAULT_GLOBAL_INVITES_PER_HOUR), + GLOBAL_WINDOW_MINUTES, + recent_ages, )), - tor_exits: Arc::new(TorExitList::new(tor_exit_cache)), + pow: Arc::new(PowManager::new(pow_base_difficulty)), + tor_exits, room_owner_vk, inviter_signing_key, room_name, @@ -361,6 +387,13 @@ pub struct InviteErrorResponse { pub retry_after_seconds: Option, } +#[derive(Deserialize)] +struct CreateInviteRequest { + #[serde(flatten)] + challenge: PowChallenge, + nonce: u64, +} + /// Extract the client IP used to key the invite rate limiter. /// /// We deliberately key on the TCP connection's peer address (`addr.ip()`) and @@ -378,133 +411,192 @@ fn get_client_ip(addr: SocketAddr) -> IpAddr { addr.ip() } +fn invite_error( + status: StatusCode, + message: impl Into, + retry_after_seconds: Option, +) -> (StatusCode, Json) { + ( + status, + Json(InviteErrorResponse { + error: message.into(), + retry_after_seconds, + }), + ) +} + +/// Enforce the network-level admission policy before issuing a challenge or +/// accepting proof of work. Tor is intentionally blocked for this public room: +/// rotating exits defeated IP rate limiting during the July 2026 spam waves. +fn check_invite_network( + state: &InviteState, + client_ip: IpAddr, +) -> Result<(), (StatusCode, Json)> { + if state.tor_exits.is_empty() { + error!( + "Invite request from {} refused: Tor exit list is unavailable", + client_ip + ); + return Err(invite_error( + StatusCode::SERVICE_UNAVAILABLE, + "Invitations are temporarily unavailable. Please try again shortly.", + Some(30), + )); + } + if state.tor_exits.is_exit(&client_ip) { + warn!("Invite request blocked from Tor exit: {}", client_ip); + return Err(invite_error( + StatusCode::FORBIDDEN, + "Invitations are not available from this network.", + None, + )); + } + Ok(()) +} + +async fn get_invite_challenge( + State(state): State, + ConnectInfo(addr): ConnectInfo, +) -> Result, (StatusCode, Json)> { + let client_ip = get_client_ip(addr); + check_invite_network(&state, client_ip)?; + + if !state.global_bucket.has_capacity() { + let retry_after = state.global_bucket.retry_after_seconds(); + warn!( + "Invite challenge refused: global ceiling reached ({}/{}), IP: {}", + state.global_bucket.current(), + state.global_bucket.limit(), + client_ip + ); + return Err(invite_error( + StatusCode::TOO_MANY_REQUESTS, + "Too many invite requests right now. Please try again shortly.", + retry_after, + )); + } + + match state.rate_limiter.get_retry_after(client_ip) { + Ok(Some(retry_after)) => { + return Err(invite_error( + StatusCode::TOO_MANY_REQUESTS, + format!( + "Rate limited. You can request up to {MAX_INVITES_PER_WINDOW} invites per 24 hours." + ), + Some(retry_after), + )); + } + Ok(None) => {} + Err(e) => { + error!("Rate limiter error while issuing challenge: {e:?}"); + return Err(invite_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error. Please try again later.", + None, + )); + } + } + + Ok(Json(state.pow.issue(state.global_bucket.current()))) +} + async fn create_room_invite( State(state): State, ConnectInfo(addr): ConnectInfo, + Json(request): Json, ) -> Result, (StatusCode, Json)> { let client_ip = get_client_ip(addr); - let via_tor = state.tor_exits.is_exit(&client_ip); - info!( - "Received create-invite request from IP: {} (tor_exit={})", - client_ip, via_tor - ); + check_invite_network(&state, client_ip)?; + info!("Received create-invite request from IP: {}", client_ip); + + let proof_id = match state + .pow + .verify_and_consume(&request.challenge, request.nonce) + { + Ok(id) => id, + Err(e) => { + let status = match e { + PowError::Expired => StatusCode::GONE, + PowError::Reused => StatusCode::CONFLICT, + PowError::Lock => StatusCode::INTERNAL_SERVER_ERROR, + _ => StatusCode::BAD_REQUEST, + }; + warn!("Invalid invite proof from {}: {}", client_ip, e); + return Err(invite_error( + status, + "The invite verification could not be completed. Please try again.", + None, + )); + } + }; - // Tor exits share ONE hourly ceiling, because per-IP limiting cannot bound - // an actor who rotates exit nodes (2026-07-25: 152 invites via 113 exits, - // none near the per-IP limit). - // - // Ordering matters. This capacity check runs BEFORE `check_and_record` so a - // request refused here does not also burn the requester's per-IP allowance - // -- otherwise a burst would silently consume the quota of the ordinary Tor - // users it is sharing the bucket with. The matching `record()` happens only - // AFTER the per-IP check passes, so a request rejected for per-IP reasons - // never consumes shared Tor capacity either. - if via_tor && !state.tor_bucket.has_capacity() { - let retry_after = state.tor_bucket.retry_after_seconds(); - // warn!, not info!: sustained exhaustion means legitimate Tor users are - // being refused and an operator should see it. + // The global bucket is the final safety valve. Acquire it atomically before + // recording the per-IP allowance, and refund it on all downstream failures. + if !state.global_bucket.try_acquire() { + state.pow.release(&proof_id); + let retry_after = state.global_bucket.retry_after_seconds(); warn!( - "Tor exit {} refused: shared Tor ceiling reached ({}/{}), retry_after: {:?}", - client_ip, - state.tor_bucket.current(), - state.tor_bucket.limit(), - retry_after + "Invite refused at acquire: global ceiling reached ({}/{}), IP: {}", + state.global_bucket.current(), + state.global_bucket.limit(), + client_ip ); - return Err(( + return Err(invite_error( StatusCode::TOO_MANY_REQUESTS, - Json(InviteErrorResponse { - // Deliberately does NOT tell a privacy-tool user to stop using - // it, and does NOT state the exact ceiling (which would tell an - // attacker precisely what budget to drain). - error: "Too many invite requests right now. Please try again shortly.".to_string(), - retry_after_seconds: retry_after, - }), + "Too many invite requests right now. Please try again shortly.", + retry_after, )); } - // Check rate limit match state.rate_limiter.check_and_record(client_ip) { - Ok(true) => { - // Request allowed, generate invite - } + Ok(true) => {} Ok(false) => { - // Rate limited + state.global_bucket.release(); + state.pow.release(&proof_id); let retry_after = state.rate_limiter.get_retry_after(client_ip).ok().flatten(); info!( "Rate limited IP: {}, retry_after: {:?}", client_ip, retry_after ); - return Err(( + return Err(invite_error( StatusCode::TOO_MANY_REQUESTS, - Json(InviteErrorResponse { - error: format!( - "Rate limited. You can request up to {MAX_INVITES_PER_WINDOW} invites per 24 hours." - ), - retry_after_seconds: retry_after, - }), + format!( + "Rate limited. You can request up to {MAX_INVITES_PER_WINDOW} invites per 24 hours." + ), + retry_after, )); } Err(e) => { + state.global_bucket.release(); + state.pow.release(&proof_id); error!("Rate limiter error: {:?}", e); - return Err(( + return Err(invite_error( StatusCode::INTERNAL_SERVER_ERROR, - Json(InviteErrorResponse { - error: "Internal server error. Please try again later.".to_string(), - retry_after_seconds: None, - }), + "Internal server error. Please try again later.", + None, )); } } - // The per-IP check passed, so charge the shared Tor ceiling. `try_acquire` - // (not the earlier `has_capacity`) is the admission AUTHORITY: it prunes, - // checks and records under one lock, so the ceiling holds however many - // requests race, and stays correct if an `.await` is ever introduced - // between the pre-check and here. - // - // Placed after the per-IP check so a per-IP rejection never consumes shared - // capacity that ordinary Tor users depend on. - if via_tor && !state.tor_bucket.try_acquire() { - // Lost the race against a concurrent request since the pre-check. - let retry_after = state.tor_bucket.retry_after_seconds(); - warn!( - "Tor exit {} refused at acquire: shared ceiling reached ({}/{})", - client_ip, - state.tor_bucket.current(), - state.tor_bucket.limit() - ); - return Err(( - StatusCode::TOO_MANY_REQUESTS, - Json(InviteErrorResponse { - error: "Too many invite requests right now. Please try again shortly.".to_string(), - retry_after_seconds: retry_after, - }), - )); - } - - // Generate invite match invite::create_invitation(&state.room_owner_vk, &state.inviter_signing_key) { - Ok(invite_code) => { - info!("Generated invite for IP: {}", client_ip); + Ok(created) => { + info!( + "Generated invite for IP: {} member_id={}", + client_ip, created.member_id + ); Ok(Json(CreateInviteResponse { - invite_code, + invite_code: created.code, room_name: state.room_name.clone(), })) } Err(e) => { error!("Failed to generate invite: {:?}", e); - // No invite was issued, so give the Tor slot back. Otherwise a - // burst of generation failures would lock out every Tor user for a - // full window on top of the outage itself. - if via_tor { - state.tor_bucket.release(); - } - Err(( + state.global_bucket.release(); + state.pow.release(&proof_id); + Err(invite_error( StatusCode::INTERNAL_SERVER_ERROR, - Json(InviteErrorResponse { - error: "Failed to generate invite. Please try again later.".to_string(), - retry_after_seconds: None, - }), + "Failed to generate invite. Please try again later.", + None, )) } } @@ -521,215 +613,188 @@ pub fn get_routes() -> Router { "/check-payment-status/:payment_intent_id", get(check_payment_status_route), ) + .layer(CorsLayer::permissive()) } /// Get routes that require invite state (for River room invites) pub fn get_invite_routes(state: InviteState) -> Router { + let cors = CorsLayer::new() + .allow_origin([ + HeaderValue::from_static("https://freenet.org"), + HeaderValue::from_static("https://www.freenet.org"), + HeaderValue::from_static("http://localhost:1313"), + HeaderValue::from_static("http://127.0.0.1:1313"), + ]) + .allow_methods([Method::GET, Method::POST]) + .allow_headers([CONTENT_TYPE]); Router::new() + .route("/invite-challenge", get(get_invite_challenge)) .route("/create-invite", post(create_room_invite)) .with_state(state) + .layer(cors) } #[cfg(test)] mod invite_handler_tests { - //! End-to-end tests for the invite handler's rate-limit wiring. - //! - //! Every unit below this is correct in isolation; the VALUE of the change - //! lives in how they compose in `create_room_invite`. Three one-line - //! regressions -- dropping `try_acquire`, inverting `via_tor`, or moving the - //! acquire above the per-IP check -- each leave the unit tests fully green - //! and the limiter fully defeated. These tests are what fail instead. - use super::*; - use crate::rate_limit::AggregateBucket; - use crate::tor::TorExitList; - use std::net::SocketAddr; + use crate::invite_pow::{valid_proof, PowManager}; use tempfile::TempDir; - /// Build state with an injected exit list and a small ceiling. - fn state_with(dir: &TempDir, exits: &[String], ceiling: usize) -> InviteState { + fn state_with(dir: &TempDir, exits: &[&str], ceiling: usize) -> InviteState { let cache = dir.path().join("exits.txt"); std::fs::write(&cache, exits.join("\n")).unwrap(); let mut seed = [0u8; 32]; seed[0] = 7; let signing_key = SigningKey::from_bytes(&seed); - let owner = signing_key.verifying_key(); InviteState { rate_limiter: Arc::new(RateLimiter::new(dir.path().join("rl.json"), 24)), - tor_bucket: Arc::new(AggregateBucket::new(ceiling, 60)), + global_bucket: Arc::new(AggregateBucket::new(ceiling, 60)), + pow: Arc::new(PowManager::new(4)), tor_exits: Arc::new(TorExitList::new(Some(cache))), - room_owner_vk: owner, + room_owner_vk: signing_key.verifying_key(), inviter_signing_key: signing_key, room_name: "Test Room".to_string(), } } fn addr(ip: &str) -> SocketAddr { - SocketAddr::new(ip.parse::().unwrap(), 12345) + SocketAddr::new(ip.parse().unwrap(), 12345) } - async fn request(state: &InviteState, ip: &str) -> StatusCode { - match create_room_invite(State(state.clone()), ConnectInfo(addr(ip))).await { + fn solve(challenge: PowChallenge) -> CreateInviteRequest { + let id: [u8; 16] = hex::decode(&challenge.challenge) + .unwrap() + .try_into() + .unwrap(); + let nonce = (0..u64::MAX) + .find(|nonce| valid_proof(&id, *nonce, challenge.difficulty)) + .unwrap(); + CreateInviteRequest { challenge, nonce } + } + + async fn challenge(state: &InviteState, ip: &str) -> Result { + get_invite_challenge(State(state.clone()), ConnectInfo(addr(ip))) + .await + .map(|response| response.0.challenge) + .map_err(|(status, _)| status) + } + + async fn request_with( + state: &InviteState, + ip: &str, + request: CreateInviteRequest, + ) -> StatusCode { + match create_room_invite(State(state.clone()), ConnectInfo(addr(ip)), Json(request)).await { Ok(_) => StatusCode::OK, Err((code, _)) => code, } } - /// THE regression test for the incident this change exists to fix: an actor - /// rotating many distinct Tor exits must be capped in TOTAL, not per-IP. - /// - /// Fails if `try_acquire` is dropped, or if `via_tor` is inverted. + async fn request(state: &InviteState, ip: &str) -> StatusCode { + let proof = solve(challenge(state, ip).await.unwrap()); + request_with(state, ip, proof).await + } + #[tokio::test] - async fn tor_exits_share_one_ceiling_across_rotating_ips() { - const CEILING: usize = 5; + async fn tor_is_blocked_before_work_is_issued() { let dir = tempfile::tempdir().unwrap(); - // 40 distinct exits, each used once -- exactly the burst's shape. - let exits: Vec = (1..=40).map(|i| format!("185.220.101.{i}")).collect(); - let state = state_with(&dir, &exits, CEILING); - - let mut ok = 0; - let mut refused = 0; - for i in 1..=40 { - match request(&state, &format!("185.220.101.{i}")).await { - StatusCode::OK => ok += 1, - StatusCode::TOO_MANY_REQUESTS => refused += 1, - other => panic!("unexpected status {other}"), - } - } + let state = state_with(&dir, &["185.220.101.1"], 100); assert_eq!( - ok, CEILING, - "rotation across 40 exits must yield exactly {CEILING} invites, got {ok}" + challenge(&state, "185.220.101.1").await.unwrap_err(), + StatusCode::FORBIDDEN ); - assert_eq!(refused, 40 - CEILING); + assert_eq!(state.global_bucket.current(), 0); } - /// A non-Tor IP must be unaffected by a saturated Tor ceiling. - /// - /// Fails if `via_tor` is inverted or ignored. #[tokio::test] - async fn non_tor_ip_unaffected_by_full_tor_bucket() { + async fn missing_tor_list_fails_closed() { let dir = tempfile::tempdir().unwrap(); - let exits: Vec = (1..=10).map(|i| format!("185.220.101.{i}")).collect(); - let state = state_with(&dir, &exits, 2); - - // Saturate via Tor. - assert_eq!(request(&state, "185.220.101.1").await, StatusCode::OK); - assert_eq!(request(&state, "185.220.101.2").await, StatusCode::OK); - assert_eq!( - request(&state, "185.220.101.3").await, - StatusCode::TOO_MANY_REQUESTS - ); - - let before = state.tor_bucket.current(); + let state = state_with(&dir, &[], 100); assert_eq!( - request(&state, "203.0.113.9").await, - StatusCode::OK, - "a non-Tor IP must still be served" - ); - assert_eq!( - state.tor_bucket.current(), - before, - "a non-Tor request must not consume shared Tor capacity" + challenge(&state, "203.0.113.1").await.unwrap_err(), + StatusCode::SERVICE_UNAVAILABLE ); } - /// Pins the second half of the ordering rationale: a request refused by the - /// PER-IP limiter must not consume shared Tor capacity. - /// - /// Fails if `try_acquire` is moved above `check_and_record`. #[tokio::test] - async fn per_ip_rejection_does_not_consume_tor_capacity() { + async fn valid_proof_is_required_and_single_use() { let dir = tempfile::tempdir().unwrap(); - let state = state_with(&dir, &["185.220.101.1".to_string()], 100); - - // Exhaust this single exit's per-IP allowance. - for _ in 0..MAX_INVITES_PER_WINDOW { - assert_eq!(request(&state, "185.220.101.1").await, StatusCode::OK); - } - let consumed = state.tor_bucket.current(); - assert_eq!(consumed, MAX_INVITES_PER_WINDOW); - - // Further requests are per-IP rejections; they must NOT take Tor slots. - for _ in 0..5 { - assert_eq!( - request(&state, "185.220.101.1").await, - StatusCode::TOO_MANY_REQUESTS - ); - } + let state = state_with(&dir, &["185.220.101.1"], 100); + let proof = solve(challenge(&state, "203.0.113.1").await.unwrap()); + let replay = CreateInviteRequest { + challenge: proof.challenge.clone(), + nonce: proof.nonce, + }; assert_eq!( - state.tor_bucket.current(), - consumed, - "per-IP rejections must not consume shared Tor capacity" + request_with(&state, "203.0.113.1", proof).await, + StatusCode::OK ); + assert_eq!( + request_with(&state, "203.0.113.1", replay).await, + StatusCode::CONFLICT + ); + assert_eq!(state.global_bucket.current(), 1); } - /// Pins the first half of the ordering rationale: a request refused by the - /// TOR ceiling must not burn the requester's per-IP allowance, so an - /// ordinary Tor user still has their full quota once capacity frees. #[tokio::test] - async fn tor_ceiling_refusal_does_not_burn_per_ip_allowance() { + async fn global_ceiling_holds_across_rotating_non_tor_ips() { let dir = tempfile::tempdir().unwrap(); - let exits: Vec = (1..=10).map(|i| format!("185.220.101.{i}")).collect(); - let state = state_with(&dir, &exits, 1); - - assert_eq!(request(&state, "185.220.101.1").await, StatusCode::OK); - // Victim is refused purely by the ceiling. - for _ in 0..3 { + let state = state_with(&dir, &["185.220.101.1"], 3); + for i in 1..=3 { assert_eq!( - request(&state, "185.220.101.5").await, - StatusCode::TOO_MANY_REQUESTS + request(&state, &format!("203.0.113.{i}")).await, + StatusCode::OK ); } - // Free the ceiling; the victim must still have all 4 per-IP invites. - state.tor_bucket.release(); - let mut served = 0; - for _ in 0..MAX_INVITES_PER_WINDOW { - if request(&state, "185.220.101.5").await == StatusCode::OK { - served += 1; - state.tor_bucket.release(); // keep ceiling free for this probe - } - } assert_eq!( - served, MAX_INVITES_PER_WINDOW, - "ceiling refusals must not have consumed the victim's per-IP quota" + challenge(&state, "203.0.113.4").await.unwrap_err(), + StatusCode::TOO_MANY_REQUESTS ); + assert_eq!(state.global_bucket.current(), 3); } - /// The fail-open claim that actually matters: with no exit list, the - /// endpoint behaves exactly as it did before this feature existed. #[tokio::test] - async fn empty_exit_list_is_a_handler_no_op() { + async fn per_ip_rejection_refunds_global_capacity_and_proof() { let dir = tempfile::tempdir().unwrap(); - let state = state_with(&dir, &[], 1); // ceiling of 1, but nothing is Tor - + let state = state_with(&dir, &["185.220.101.1"], 100); for _ in 0..MAX_INVITES_PER_WINDOW { - assert_eq!(request(&state, "185.220.101.1").await, StatusCode::OK); + assert_eq!(request(&state, "203.0.113.1").await, StatusCode::OK); } + let proof = solve(state.pow.issue(state.global_bucket.current()).challenge); assert_eq!( - request(&state, "185.220.101.1").await, - StatusCode::TOO_MANY_REQUESTS, - "per-IP limit still applies" - ); - assert_eq!( - state.tor_bucket.current(), - 0, - "an empty exit list must never consume Tor capacity" + request_with(&state, "203.0.113.1", proof).await, + StatusCode::TOO_MANY_REQUESTS ); + assert_eq!(state.global_bucket.current(), MAX_INVITES_PER_WINDOW); } - /// The runtime off-switch must fully disable the ceiling. - #[tokio::test] - async fn zero_ceiling_disables_tor_metering() { + #[test] + fn startup_seed_excludes_historical_tor_issuance() { let dir = tempfile::tempdir().unwrap(); - let exits: Vec = (1..=20).map(|i| format!("185.220.101.{i}")).collect(); - let state = state_with(&dir, &exits, 0); - - for i in 1..=20 { - assert_eq!( - request(&state, &format!("185.220.101.{i}")).await, - StatusCode::OK, - "ceiling 0 must never refuse" - ); - } + let cache = dir.path().join("exits.txt"); + std::fs::write(&cache, "185.220.101.1\n").unwrap(); + let now = chrono::Utc::now().to_rfc3339(); + std::fs::write( + dir.path().join("rl.json"), + serde_json::json!({ + "invites": { + "185.220.101.1": [now.clone()], + "203.0.113.1": [now] + } + }) + .to_string(), + ) + .unwrap(); + let signing_key = SigningKey::from_bytes(&[7; 32]); + let state = InviteState::new( + dir.path().join("rl.json"), + Some(cache), + Some(200), + 4, + signing_key.verifying_key(), + signing_key, + "Test Room".to_string(), + ); + assert_eq!(state.global_bucket.current(), 1); } }