diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 3ac71b58f..7f8131bc3 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -30,6 +30,12 @@ pub enum AuthError { #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), + /// A NIP-98 event with the same id has already been observed within the + /// replay-prevention window. The event itself was structurally valid; the + /// rejection is on freshness, not validity. + #[error("NIP-98 replay: event id already seen within window")] + Nip98Replay, + /// The pubkey in the auth event does not match the expected identity. #[error("pubkey mismatch: event pubkey does not match authenticated identity")] PubkeyMismatch, diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index fcb39010c..4e801e20c 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -23,6 +23,8 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; +/// NIP-98 replay protection — shared, community-scoped, atomic seen-set. +pub mod nip98_replay; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -32,6 +34,7 @@ pub use access::{check_read_access, check_write_access, require_scope, ChannelAc pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; +pub use nip98_replay::{nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; @@ -40,6 +43,8 @@ pub use scope::{parse_scopes, Scope}; #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] +pub use nip98_replay::AlwaysFreshReplayGuard; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip98_replay.rs b/crates/buzz-auth/src/nip98_replay.rs new file mode 100644 index 000000000..429d380e1 --- /dev/null +++ b/crates/buzz-auth/src/nip98_replay.rs @@ -0,0 +1,175 @@ +//! NIP-98 replay protection — shared, community-scoped, atomic seen-set. +//! +//! NIP-98 verification ([`crate::nip98::verify_nip98_event`]) is structurally +//! complete: it checks signature, kind, timestamp window, URL, method, and +//! optional body hash. It does **not** check whether the same event id has +//! already been used — that requires shared state. With multiple relay pods +//! ("any pod, any connection" per the rewrite §4 architecture), an in-process +//! cache (moka, DashMap) does not carry the freshness proof across pods, so +//! replay protection is a §5 hard gate. +//! +//! The required shape (§5): +//! +//! - shared state (Redis), atomic set-if-absent, TTL ≥ 120s +//! - community-scoped key — see [`nip98_replay_key`] +//! +//! ## Usage shape +//! +//! Verify first, then mark. Burning a seen-set slot on a forgery would let an +//! attacker who knows a future event id of a victim DoS the legitimate event. +//! +//! ```ignore +//! let pubkey = buzz_auth::verify_nip98_event(json, url, method, body)?; +//! if !replay.try_mark(&ctx, &event_id).await? { +//! return Err(AuthError::Nip98Replay); +//! } +//! // safe to honor the request as `pubkey` +//! ``` +//! +//! The TTL must cover the verifier's clock-skew tolerance (currently ±60s, so +//! the window over which a duplicate event id is even plausible is 2×60 = 120s). +//! [`DEFAULT_REPLAY_TTL_SECS`] is the floor; deployments may raise it. + +use buzz_core::TenantContext; +use nostr::EventId; + +use crate::error::AuthError; + +/// Floor for the replay-prevention window, in seconds. +/// +/// Matches the §5 gate ("TTL ≥ 120s") and the doubled NIP-98 timestamp +/// tolerance (±60s window → 120s span). Implementations MAY use a larger TTL +/// for safety margin; they MUST NOT use a smaller one. +pub const DEFAULT_REPLAY_TTL_SECS: u64 = 120; + +/// Shared seen-set for NIP-98 event ids, scoped per community. +/// +/// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). +/// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`. +pub trait Nip98ReplayGuard: Send + Sync { + /// Atomically claim `event_id` for `ctx`'s community. + /// + /// Returns `Ok(true)` when the id is newly inserted (proceed) and + /// `Ok(false)` when an entry already exists (the caller MUST reject the + /// request as replay). + /// + /// On `Err` (Redis unreachable, etc.) callers MUST fail closed — reject + /// the request rather than admitting it. The shared seen-set is a + /// correctness fence; degrading to "best effort, allow on error" forfeits + /// the freshness proof. + /// + /// Implementations MUST use an atomic set-if-absent operation; a + /// read-then-write sequence loses to concurrent inserts and forfeits the + /// freshness proof. + /// + /// `ttl_secs` MUST be at least [`DEFAULT_REPLAY_TTL_SECS`]. Implementations + /// MAY clamp a smaller value up to the floor rather than reject; they MUST + /// NOT honor it as-given. + fn try_mark( + &self, + ctx: &TenantContext, + event_id: &EventId, + ttl_secs: u64, + ) -> impl std::future::Future> + Send; +} + +/// Redis key for a NIP-98 replay marker: +/// `buzz:{community}:nip98:{event_id_hex}`. +/// +/// The community prefix is the S1 isolation fence at the replay layer. +/// Event ids are content-addressed (SHA-256 of the canonical event tuple) so +/// natural cross-community collision is zero, but the gate is fail-closed +/// isolation: a same-id replay across communities must consult two distinct +/// seen-set rows, not one shared row. +pub fn nip98_replay_key(ctx: &TenantContext, event_id: &EventId) -> String { + format!("buzz:{}:nip98:{}", ctx.community(), event_id.to_hex()) +} + +/// Always-fresh seen-set for unit tests — every `try_mark` returns `Ok(true)`. +/// +/// Use only in test code that does not exercise the replay path itself. +#[cfg(any(test, feature = "test-utils"))] +pub struct AlwaysFreshReplayGuard; + +#[cfg(any(test, feature = "test-utils"))] +impl Nip98ReplayGuard for AlwaysFreshReplayGuard { + async fn try_mark( + &self, + _ctx: &TenantContext, + _event_id: &EventId, + _ttl_secs: u64, + ) -> Result { + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::CommunityId; + use nostr::{EventBuilder, Keys, Kind}; + use sha2::{Digest, Sha256}; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + let bytes = Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } + + fn fixture_event_id() -> EventId { + let keys = Keys::generate(); + EventBuilder::new(Kind::HttpAuth, "") + .sign_with_keys(&keys) + .expect("sign") + .id + } + + #[test] + fn key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + let key = nip98_replay_key(&ctx, &eid); + let expected_prefix = format!("buzz:{}:nip98:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); + assert!(key.ends_with(&eid.to_hex())); + } + + #[test] + fn key_isolates_communities_for_same_event_id() { + // Belt-and-suspenders: even if a same-id event surfaces in two + // communities (which content-addressing makes implausible), the + // seen-set MUST consult two distinct rows. + let eid = fixture_event_id(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = nip98_replay_key(&ctx_a, &eid); + let key_b = nip98_replay_key(&ctx_b, &eid); + assert_ne!( + key_a, key_b, + "same event id in two communities must not share a seen-set key" + ); + } + + #[test] + fn default_ttl_meets_gate_floor() { + // §5 gate: TTL ≥ 120s. Drift this constant down and the gate breaks. + assert!(DEFAULT_REPLAY_TTL_SECS >= 120); + } + + #[tokio::test] + async fn always_fresh_returns_true() { + let guard = AlwaysFreshReplayGuard; + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + assert!(guard + .try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .unwrap()); + } +} diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index a77da4fa7..2c59c7b0c 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -8,6 +8,7 @@ use std::net::IpAddr; +use buzz_core::TenantContext; use nostr::PublicKey; use serde::{Deserialize, Serialize}; @@ -147,14 +148,32 @@ impl Default for RateLimitConfig { /// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`. /// A no-op `AlwaysAllowRateLimiter` is provided for unit tests. /// +/// ## Tenant scoping +/// +/// Pubkey-keyed limits ([`check_and_increment`]) take `&TenantContext` and the Redis +/// key is community-prefixed (`buzz:{community}:ratelimit:{pubkey}:{suffix}`). The +/// same pubkey active in two communities consumes two independent quotas — that is +/// the correct behavior under multi-tenant isolation (S1 cross-community fence). +/// +/// IP-keyed limits ([`check_ip_connection`]) are **operator-global** by design. They +/// gate connection acceptance at the network edge, before host→community resolution +/// has completed (or, on resolve failure, instead of it). Threading `&TenantContext` +/// through the connection-rate fence would invert the order of operations. If +/// per-(community, IP) caps are ever needed as a tenant-fairness signal, that +/// belongs in an additive `LimitType` keyed on `(community, ip)`, not in this trait. +/// /// ⚠️ The fixed-window algorithm used by the Redis implementation allows up to 2× /// burst at window boundaries. Upgrade to a sliding window or token bucket if strict /// per-second limiting is required. pub trait RateLimiter: Send + Sync { - /// Increment the counter for `pubkey` + `limit_type` and return whether the - /// request is within the configured `limit` for the given `window_secs`. + /// Increment the per-(community, pubkey) counter for `limit_type` and return + /// whether the request is within `limit` for the given `window_secs`. + /// + /// `ctx` scopes the counter to the resolved community; the same pubkey in two + /// communities is two independent quotas. fn check_and_increment( &self, + ctx: &TenantContext, pubkey: &PublicKey, limit_type: LimitType, window_secs: u64, @@ -162,7 +181,10 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; /// Increment the per-IP connection counter and return whether the connection - /// is within the configured `limit` for the given `window_secs`. + /// is within `limit` for the given `window_secs`. + /// + /// Operator-global — see trait docs. This fence runs before / outside of host + /// resolution and intentionally does not take a `TenantContext`. fn check_ip_connection( &self, ip: &IpAddr, @@ -171,16 +193,23 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; } -/// Redis key for pubkey-based rate limit: `buzz:ratelimit::` -pub fn rate_limit_key(pubkey: &PublicKey, limit_type: &LimitType) -> String { +/// Redis key for pubkey-based rate limit: +/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. +/// +/// Community-prefixed: the same pubkey in two communities maps to two distinct +/// keys, so quotas don't bleed across the tenancy fence. +pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &LimitType) -> String { format!( - "buzz:ratelimit:{}:{}", + "buzz:{}:ratelimit:{}:{}", + ctx.community(), pubkey.to_hex(), limit_type.key_suffix() ) } -/// Redis key for IP-based rate limit: `buzz:ratelimit:ip::conn` +/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`. +/// +/// Operator-global by design — see [`RateLimiter`] docs. pub fn ip_rate_limit_key(ip: &IpAddr) -> String { format!("buzz:ratelimit:ip:{}:conn", ip) } @@ -193,6 +222,7 @@ pub struct AlwaysAllowRateLimiter; impl RateLimiter for AlwaysAllowRateLimiter { async fn check_and_increment( &self, + _ctx: &TenantContext, _pubkey: &PublicKey, _limit_type: LimitType, window_secs: u64, @@ -214,19 +244,52 @@ impl RateLimiter for AlwaysAllowRateLimiter { #[cfg(test)] mod tests { use super::*; + use buzz_core::CommunityId; use nostr::Keys; + use sha2::Digest; use std::net::Ipv4Addr; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + // Deterministic community id from host so test assertions can name the prefix. + let bytes = sha2::Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } #[test] - fn rate_limit_key_format() { + fn rate_limit_key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); - let key = rate_limit_key(&keys.public_key(), &LimitType::Messages); - assert!(key.starts_with("buzz:ratelimit:")); + let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages); + let expected_prefix = format!("buzz:{}:ratelimit:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); assert!(key.ends_with(":msg")); } + #[test] + fn rate_limit_key_isolates_communities_for_same_pubkey() { + // The S1 cross-community isolation fence at the rate-limit key layer: + // same pubkey, two communities -> two distinct Redis keys -> independent quotas. + let keys = Keys::generate(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = rate_limit_key(&ctx_a, &keys.public_key(), &LimitType::Messages); + let key_b = rate_limit_key(&ctx_b, &keys.public_key(), &LimitType::Messages); + assert_ne!( + key_a, key_b, + "same pubkey in two communities must not share a rate-limit key" + ); + } + #[test] fn ip_rate_limit_key_format() { + // IP fence stays operator-global — no community in the key. let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn"); } @@ -234,9 +297,10 @@ mod tests { #[tokio::test] async fn always_allow_limiter() { let limiter = AlwaysAllowRateLimiter; + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); let result = limiter - .check_and_increment(&keys.public_key(), LimitType::Messages, 60, 60) + .check_and_increment(&ctx, &keys.public_key(), LimitType::Messages, 60, 60) .await .unwrap(); assert!(result.allowed); diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index c7b714786..3d9e2d378 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -25,6 +25,8 @@ pub mod cache_invalidation; /// Error types for pub/sub operations. pub mod error; +/// Redis-backed NIP-98 replay seen-set. +pub mod nip98_replay; /// Online/offline presence tracking in Redis. pub mod presence; /// Redis PUBLISH for channel event fan-out. diff --git a/crates/buzz-pubsub/src/nip98_replay.rs b/crates/buzz-pubsub/src/nip98_replay.rs new file mode 100644 index 000000000..b10817c25 --- /dev/null +++ b/crates/buzz-pubsub/src/nip98_replay.rs @@ -0,0 +1,151 @@ +//! Redis-backed NIP-98 replay seen-set. +//! +//! Implements the [`Nip98ReplayGuard`] trait from `buzz-auth`. Uses Redis +//! `SET NX EX` for an atomic set-if-absent with TTL — the §5 pre-build gate +//! for multi-tenant HA replay protection. + +use buzz_auth::{ + error::AuthError, + nip98_replay::{nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}, +}; +use buzz_core::TenantContext; +use nostr::EventId; + +/// Redis-backed NIP-98 replay seen-set. +/// +/// Each `try_mark(ctx, event_id, ttl)` issues a single +/// `SET buzz:{community}:nip98:{event_id_hex} 1 NX EX ` against Redis. +/// `NX` makes the operation atomic set-if-absent — the freshness proof comes +/// from Redis returning `OK` only on the first claim. Subsequent claims within +/// the TTL window return `nil`, which we surface as `Ok(false)` so the caller +/// rejects the request as replay. +pub struct RedisNip98ReplayGuard { + pool: deadpool_redis::Pool, +} + +impl RedisNip98ReplayGuard { + /// Create a new replay guard backed by the given Redis connection pool. + pub fn new(pool: deadpool_redis::Pool) -> Self { + Self { pool } + } +} + +impl Nip98ReplayGuard for RedisNip98ReplayGuard { + async fn try_mark( + &self, + ctx: &TenantContext, + event_id: &EventId, + ttl_secs: u64, + ) -> Result { + // §5 gate floor — never accept a sub-floor TTL silently. + let ttl = ttl_secs.max(DEFAULT_REPLAY_TTL_SECS); + + let mut conn = self + .pool + .get() + .await + .map_err(|e| AuthError::Internal(format!("Redis pool: {e}")))?; + + let key = nip98_replay_key(ctx, event_id); + + // SET key 1 NX EX . redis-rs typed return: Some("OK") on first + // claim, None on existing key. Any other value would be a Redis-side + // bug; treat it as internal error. + let result: Option = redis::cmd("SET") + .arg(&key) + .arg("1") + .arg("NX") + .arg("EX") + .arg(ttl) + .query_async(&mut *conn) + .await + .map_err(|e| AuthError::Internal(format!("Redis SET NX EX: {e}")))?; + + match result.as_deref() { + Some("OK") => Ok(true), + None => Ok(false), + Some(other) => Err(AuthError::Internal(format!( + "unexpected SET NX EX reply: {other}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::{CommunityId, TenantContext}; + use deadpool_redis::{Config, Runtime}; + use nostr::{EventBuilder, Keys, Kind}; + use uuid::Uuid; + + fn redis_pool() -> deadpool_redis::Pool { + let url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + Config::from_url(url) + .create_pool(Some(Runtime::Tokio1)) + .expect("create pool") + } + + fn fresh_ctx() -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example") + } + + fn fresh_event_id() -> EventId { + EventBuilder::new(Kind::HttpAuth, "") + .sign_with_keys(&Keys::generate()) + .expect("sign") + .id + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn first_claim_succeeds_replay_fails() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + let ctx = fresh_ctx(); + let eid = fresh_event_id(); + + assert!(guard + .try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("first mark")); + assert!(!guard + .try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("replay mark")); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn isolation_between_communities() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + let ctx_a = fresh_ctx(); + let ctx_b = fresh_ctx(); + let eid = fresh_event_id(); + + assert!(guard + .try_mark(&ctx_a, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("mark in A")); + // Same event id under ctx_b is still a first claim — communities are + // independent seen-sets. + assert!(guard + .try_mark(&ctx_b, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .expect("mark in B")); + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn sub_floor_ttl_is_lifted_to_default() { + let guard = RedisNip98ReplayGuard::new(redis_pool()); + let ctx = fresh_ctx(); + let eid = fresh_event_id(); + + // Caller asks for 30s; impl lifts to ≥ DEFAULT_REPLAY_TTL_SECS. + // Smoke-test the path: pass sub-floor TTL, claim once, then expect + // replay rejection — the TTL lift kept the marker alive past 30s and + // the contract holds. + assert!(guard.try_mark(&ctx, &eid, 30).await.expect("mark")); + assert!(!guard.try_mark(&ctx, &eid, 30).await.expect("replay")); + } +} diff --git a/crates/buzz-pubsub/src/rate_limiter.rs b/crates/buzz-pubsub/src/rate_limiter.rs index e466893aa..8d5494f1d 100644 --- a/crates/buzz-pubsub/src/rate_limiter.rs +++ b/crates/buzz-pubsub/src/rate_limiter.rs @@ -13,6 +13,7 @@ use buzz_auth::{ error::AuthError, rate_limit::{LimitType, RateLimitResult, RateLimiter}, }; +use buzz_core::TenantContext; use nostr::PublicKey; use redis::Script; @@ -79,9 +80,11 @@ async fn run_rate_limit( /// Redis-backed rate limiter using fixed-window counters. /// -/// Each key is `buzz:ratelimit::` (pubkey) or -/// `buzz:ratelimit:ip::conn` (IP). The counter and its TTL are managed -/// atomically via a Lua script to prevent keys from persisting without expiry. +/// Pubkey keys are community-scoped via `&TenantContext`: +/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain +/// operator-global: `buzz:ratelimit:ip:{ip}:conn`. The counter and its TTL are +/// managed atomically via a Lua script to prevent keys from persisting without +/// expiry. pub struct RedisRateLimiter { pool: deadpool_redis::Pool, } @@ -96,12 +99,13 @@ impl RedisRateLimiter { impl RateLimiter for RedisRateLimiter { async fn check_and_increment( &self, + ctx: &TenantContext, pubkey: &PublicKey, limit_type: LimitType, window_secs: u64, limit: u64, ) -> Result { - let key = buzz_auth::rate_limit::rate_limit_key(pubkey, &limit_type); + let key = buzz_auth::rate_limit::rate_limit_key(ctx, pubkey, &limit_type); run_rate_limit(&self.pool, &key, window_secs, limit).await }