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 @@
-
Joining the chat...
+
Preparing your invitation...
@@ -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);
}
}