feat(auth): NIP-98 replay seen-set — shared, community-scoped, atomic

Adds the §5 pre-build gate for multi-tenant replay protection.

buzz-auth gains a Nip98ReplayGuard trait plus the
nip98_replay_key(ctx, event_id) helper. The trait's try_mark contract
requires atomic set-if-absent semantics; an in-process cache (moka,
DashMap) does not carry the freshness proof across pods under the
"any pod, any connection" architecture (§4B), so the production
implementation MUST be shared state. The Redis-backed impl lives in
buzz-pubsub as RedisNip98ReplayGuard and uses a single SET key 1 NX
EX <ttl> per claim.

Key shape: buzz:{community}:nip98:{event_id_hex}. Event ids are
content-addressed 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. Tests pin both the prefix and the cross-community isolation
guarantee.

TTL floor is DEFAULT_REPLAY_TTL_SECS = 120, matching the §5 gate
requirement and the doubled NIP-98 ±60s timestamp tolerance.
Implementations MAY clamp sub-floor TTLs up to the floor; they MUST
NOT honor smaller values. The Redis impl clamps.

Caller contract documented in the trait: verify first, then mark.
Burning a seen-set slot on a forgery would let an attacker who learns
a future event id DoS the legitimate event. On Err (Redis
unreachable) callers MUST fail closed.

Not wired into a call site in this commit — there is no NIP-98 HTTP
handler in Lane 0 yet. Eva's relay-wiring lane will consume the trait
when the HTTP path lands; the contract is documented for that
integration.

Validation:
- cargo test -p buzz-auth --lib  40 passed (4 new in nip98_replay).
- cargo test -p buzz-pubsub --lib  3 passed, 9 Redis-required
  ignored (3 new in nip98_replay).
- cargo test -p buzz-pubsub --lib nip98_replay -- --ignored against
  local Redis  3 passed: first-claim/replay, cross-community
  isolation, sub-floor TTL lifted to floor.
- Workspace check not run locally (sqlx 0.9.0 / rustc 1.94 vs local
  1.89); CI catches it.

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
2026-06-26 16:40:53 -04:00
co-authored by Tyler Longwell
parent 6a92f0b7ff
commit a2a9ef4f21
5 changed files with 339 additions and 0 deletions
+6
View File
@@ -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,
+5
View File
@@ -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.
+175
View File
@@ -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());
}
}
+2
View File
@@ -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.
+151
View File
@@ -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"));
}
}