mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Merge auth-tenant-scope: community-scoped RateLimiter + NIP-98 replay guard
Integrates Sami's auth lane (15dd8dfc3) into relay-wiring: community-scoped rate_limit_key, RedisNip98ReplayGuard, and the IpConnections fence primitives. Brings RateLimiter + Nip98ReplayGuard into the workspace so the relay can wire the IP-fence-before-host ordering and the NIP-98 verify-then-mark call site. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/rewrite/auth-tenant-scope: feat(auth): NIP-98 replay seen-set — shared, community-scoped, atomic feat(auth): community-scope RateLimiter pubkey quotas
This commit is contained in:
co-authored by
Tyler Longwell
commit
87d5a8e357
@@ -30,6 +30,12 @@ pub enum AuthError {
|
|||||||
#[error("NIP-98 HTTP Auth verification failed: {0}")]
|
#[error("NIP-98 HTTP Auth verification failed: {0}")]
|
||||||
Nip98Invalid(String),
|
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.
|
/// The pubkey in the auth event does not match the expected identity.
|
||||||
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
|
#[error("pubkey mismatch: event pubkey does not match authenticated identity")]
|
||||||
PubkeyMismatch,
|
PubkeyMismatch,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ pub mod error;
|
|||||||
pub mod nip42;
|
pub mod nip42;
|
||||||
/// NIP-98 HTTP Auth verification (kind:27235).
|
/// NIP-98 HTTP Auth verification (kind:27235).
|
||||||
pub mod nip98;
|
pub mod nip98;
|
||||||
|
/// NIP-98 replay protection — shared, community-scoped, atomic seen-set.
|
||||||
|
pub mod nip98_replay;
|
||||||
/// Per-connection rate limiting.
|
/// Per-connection rate limiting.
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
/// OAuth scope parsing and enforcement.
|
/// 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 error::AuthError;
|
||||||
pub use nip42::{generate_challenge, verify_nip42_event};
|
pub use nip42::{generate_challenge, verify_nip42_event};
|
||||||
pub use nip98::verify_nip98_event;
|
pub use nip98::verify_nip98_event;
|
||||||
|
pub use nip98_replay::{nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS};
|
||||||
pub use rate_limit::{
|
pub use rate_limit::{
|
||||||
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
|
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"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub use access::MockAccessChecker;
|
pub use access::MockAccessChecker;
|
||||||
#[cfg(any(test, feature = "test-utils"))]
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
|
pub use nip98_replay::AlwaysFreshReplayGuard;
|
||||||
|
#[cfg(any(test, feature = "test-utils"))]
|
||||||
pub use rate_limit::AlwaysAllowRateLimiter;
|
pub use rate_limit::AlwaysAllowRateLimiter;
|
||||||
|
|
||||||
/// How the connection was authenticated.
|
/// How the connection was authenticated.
|
||||||
|
|||||||
@@ -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<Output = Result<bool, AuthError>> + 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<bool, AuthError> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use buzz_core::TenantContext;
|
||||||
use nostr::PublicKey;
|
use nostr::PublicKey;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -147,14 +148,32 @@ impl Default for RateLimitConfig {
|
|||||||
/// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`.
|
/// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`.
|
||||||
/// A no-op `AlwaysAllowRateLimiter` is provided for unit tests.
|
/// 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×
|
/// ⚠️ 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
|
/// burst at window boundaries. Upgrade to a sliding window or token bucket if strict
|
||||||
/// per-second limiting is required.
|
/// per-second limiting is required.
|
||||||
pub trait RateLimiter: Send + Sync {
|
pub trait RateLimiter: Send + Sync {
|
||||||
/// Increment the counter for `pubkey` + `limit_type` and return whether the
|
/// Increment the per-(community, pubkey) counter for `limit_type` and return
|
||||||
/// request is within the configured `limit` for the given `window_secs`.
|
/// 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(
|
fn check_and_increment(
|
||||||
&self,
|
&self,
|
||||||
|
ctx: &TenantContext,
|
||||||
pubkey: &PublicKey,
|
pubkey: &PublicKey,
|
||||||
limit_type: LimitType,
|
limit_type: LimitType,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
@@ -162,7 +181,10 @@ pub trait RateLimiter: Send + Sync {
|
|||||||
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
||||||
|
|
||||||
/// Increment the per-IP connection counter and return whether the connection
|
/// 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(
|
fn check_ip_connection(
|
||||||
&self,
|
&self,
|
||||||
ip: &IpAddr,
|
ip: &IpAddr,
|
||||||
@@ -171,16 +193,23 @@ pub trait RateLimiter: Send + Sync {
|
|||||||
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redis key for pubkey-based rate limit: `buzz:ratelimit:<hex>:<suffix>`
|
/// Redis key for pubkey-based rate limit:
|
||||||
pub fn rate_limit_key(pubkey: &PublicKey, limit_type: &LimitType) -> String {
|
/// `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!(
|
format!(
|
||||||
"buzz:ratelimit:{}:{}",
|
"buzz:{}:ratelimit:{}:{}",
|
||||||
|
ctx.community(),
|
||||||
pubkey.to_hex(),
|
pubkey.to_hex(),
|
||||||
limit_type.key_suffix()
|
limit_type.key_suffix()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:<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 {
|
pub fn ip_rate_limit_key(ip: &IpAddr) -> String {
|
||||||
format!("buzz:ratelimit:ip:{}:conn", ip)
|
format!("buzz:ratelimit:ip:{}:conn", ip)
|
||||||
}
|
}
|
||||||
@@ -193,6 +222,7 @@ pub struct AlwaysAllowRateLimiter;
|
|||||||
impl RateLimiter for AlwaysAllowRateLimiter {
|
impl RateLimiter for AlwaysAllowRateLimiter {
|
||||||
async fn check_and_increment(
|
async fn check_and_increment(
|
||||||
&self,
|
&self,
|
||||||
|
_ctx: &TenantContext,
|
||||||
_pubkey: &PublicKey,
|
_pubkey: &PublicKey,
|
||||||
_limit_type: LimitType,
|
_limit_type: LimitType,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
@@ -214,19 +244,52 @@ impl RateLimiter for AlwaysAllowRateLimiter {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use buzz_core::CommunityId;
|
||||||
use nostr::Keys;
|
use nostr::Keys;
|
||||||
|
use sha2::Digest;
|
||||||
use std::net::Ipv4Addr;
|
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]
|
#[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 keys = Keys::generate();
|
||||||
let key = rate_limit_key(&keys.public_key(), &LimitType::Messages);
|
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
|
||||||
assert!(key.starts_with("buzz:ratelimit:"));
|
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"));
|
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]
|
#[test]
|
||||||
fn ip_rate_limit_key_format() {
|
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));
|
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");
|
assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn");
|
||||||
}
|
}
|
||||||
@@ -234,9 +297,10 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn always_allow_limiter() {
|
async fn always_allow_limiter() {
|
||||||
let limiter = AlwaysAllowRateLimiter;
|
let limiter = AlwaysAllowRateLimiter;
|
||||||
|
let ctx = fixture_ctx("relay-a.example");
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
let result = limiter
|
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
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(result.allowed);
|
assert!(result.allowed);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
pub mod cache_invalidation;
|
pub mod cache_invalidation;
|
||||||
/// Error types for pub/sub operations.
|
/// Error types for pub/sub operations.
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
/// Redis-backed NIP-98 replay seen-set.
|
||||||
|
pub mod nip98_replay;
|
||||||
/// Online/offline presence tracking in Redis.
|
/// Online/offline presence tracking in Redis.
|
||||||
pub mod presence;
|
pub mod presence;
|
||||||
/// Redis PUBLISH for channel event fan-out.
|
/// Redis PUBLISH for channel event fan-out.
|
||||||
|
|||||||
@@ -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 <ttl>` 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<bool, AuthError> {
|
||||||
|
// §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 <ttl>. 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<String> = 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use buzz_auth::{
|
|||||||
error::AuthError,
|
error::AuthError,
|
||||||
rate_limit::{LimitType, RateLimitResult, RateLimiter},
|
rate_limit::{LimitType, RateLimitResult, RateLimiter},
|
||||||
};
|
};
|
||||||
|
use buzz_core::TenantContext;
|
||||||
use nostr::PublicKey;
|
use nostr::PublicKey;
|
||||||
use redis::Script;
|
use redis::Script;
|
||||||
|
|
||||||
@@ -79,9 +80,11 @@ async fn run_rate_limit(
|
|||||||
|
|
||||||
/// Redis-backed rate limiter using fixed-window counters.
|
/// Redis-backed rate limiter using fixed-window counters.
|
||||||
///
|
///
|
||||||
/// Each key is `buzz:ratelimit:<pubkey_hex>:<suffix>` (pubkey) or
|
/// Pubkey keys are community-scoped via `&TenantContext`:
|
||||||
/// `buzz:ratelimit:ip:<ip>:conn` (IP). The counter and its TTL are managed
|
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain
|
||||||
/// atomically via a Lua script to prevent keys from persisting without expiry.
|
/// 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 {
|
pub struct RedisRateLimiter {
|
||||||
pool: deadpool_redis::Pool,
|
pool: deadpool_redis::Pool,
|
||||||
}
|
}
|
||||||
@@ -96,12 +99,13 @@ impl RedisRateLimiter {
|
|||||||
impl RateLimiter for RedisRateLimiter {
|
impl RateLimiter for RedisRateLimiter {
|
||||||
async fn check_and_increment(
|
async fn check_and_increment(
|
||||||
&self,
|
&self,
|
||||||
|
ctx: &TenantContext,
|
||||||
pubkey: &PublicKey,
|
pubkey: &PublicKey,
|
||||||
limit_type: LimitType,
|
limit_type: LimitType,
|
||||||
window_secs: u64,
|
window_secs: u64,
|
||||||
limit: u64,
|
limit: u64,
|
||||||
) -> Result<RateLimitResult, AuthError> {
|
) -> Result<RateLimitResult, AuthError> {
|
||||||
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
|
run_rate_limit(&self.pool, &key, window_secs, limit).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user