Files
buzz/crates/buzz-auth/src/error.rs
npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc a2a9ef4f21 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>
2026-06-26 16:40:53 -04:00

60 lines
2.4 KiB
Rust

//! Error types for buzz-auth.
/// All errors that can occur during authentication and authorization.
///
/// Variants are designed to be safe to return to callers without leaking
/// internal implementation details. Do **not** include raw token values,
/// database contents, or stack traces in error messages.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
/// The NIP-42 event signature is invalid or the event is structurally malformed.
#[error("invalid signature or malformed auth event")]
InvalidSignature,
/// The `challenge` tag in the AUTH event does not match the relay's issued challenge.
#[error("challenge mismatch")]
ChallengeMismatch,
/// The `relay` tag in the AUTH event does not match this relay's URL.
#[error("relay url mismatch")]
RelayUrlMismatch,
/// The AUTH event's `created_at` timestamp is more than ±60 seconds from now.
#[error("auth event timestamp outside ±60s window")]
EventExpired,
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
///
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
/// and is safe to include in server logs. Do **not** forward raw event content to clients.
#[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,
/// The authenticated context does not have the required scope for this operation.
#[error("insufficient scope: required {required}, have {have:?}")]
InsufficientScope {
/// The scope that was required.
required: String,
/// The scopes the caller actually holds.
have: Vec<String>,
},
/// The authenticated user is not a member of the requested channel.
#[error("channel access denied")]
ChannelAccessDenied,
/// An unexpected internal error occurred (e.g. a `spawn_blocking` panic).
#[error("internal auth error: {0}")]
Internal(String),
}