feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "buzz-auth"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Authentication and authorization for Buzz"
[features]
test-utils = []
dev = []
[dependencies]
buzz-core = { workspace = true }
nostr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
rand = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
+251
View File
@@ -0,0 +1,251 @@
//! Channel access enforcement.
//!
//! Defines [`ChannelAccessChecker`] so `buzz-auth` can enforce access
//! without depending on `buzz-db` directly.
use std::collections::HashSet;
use std::future::Future;
use buzz_core::TenantContext;
use nostr::PublicKey;
use uuid::Uuid;
use crate::error::AuthError;
use crate::scope::Scope;
/// Async trait for checking channel membership.
///
/// Implemented by the database layer (`buzz-db`) in production. The `buzz-auth`
/// crate defines the trait so it can enforce access rules without a direct dependency
/// on `buzz-db`.
///
/// ## Tenant scoping
///
/// Every method takes `&TenantContext`. Channel UUIDs are not globally unique under
/// multi-tenant — the frozen schema's `channels` PK is `(community_id, id)`, so the
/// same UUID can legitimately exist in two communities. A bare `WHERE id = $1`
/// implementation would be a cross-community existence oracle and could return
/// `true` for a B-community membership when the request bound community is A.
/// Implementations MUST scope every query by `ctx.community()` (S1 cross-community
/// fence at the access layer).
pub trait ChannelAccessChecker: Send + Sync {
/// Return the set of channel UUIDs in `ctx`'s community accessible to `pubkey`.
///
/// Channels in other communities, even with the same UUID, MUST NOT appear.
fn accessible_channel_ids(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> impl Future<Output = Result<HashSet<Uuid>, AuthError>> + Send;
/// Returns `true` if `pubkey` is a member of `(ctx.community, channel_id)`.
///
/// Default implementation calls [`Self::accessible_channel_ids`] and checks
/// membership. Implementations may override this with a more efficient
/// scoped point-lookup query.
fn can_access(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
) -> impl Future<Output = Result<bool, AuthError>> + Send {
async move {
let ids = self.accessible_channel_ids(ctx, pubkey).await?;
Ok(ids.contains(&channel_id))
}
}
}
/// Check that `scopes` contains the required scope.
pub fn require_scope(scopes: &[Scope], required: Scope) -> Result<(), AuthError> {
if scopes.contains(&required) {
Ok(())
} else {
Err(AuthError::InsufficientScope {
required: required.as_str().to_string(),
have: scopes.iter().map(|s| s.as_str().to_string()).collect(),
})
}
}
/// Verify read access: scope + membership in `ctx`'s community.
pub async fn check_read_access(
checker: &impl ChannelAccessChecker,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
scopes: &[Scope],
) -> Result<(), AuthError> {
require_scope(scopes, Scope::MessagesRead)?;
if checker.can_access(ctx, pubkey, channel_id).await? {
Ok(())
} else {
Err(AuthError::ChannelAccessDenied)
}
}
/// Verify write access: scope + membership in `ctx`'s community.
pub async fn check_write_access(
checker: &impl ChannelAccessChecker,
ctx: &TenantContext,
pubkey: &PublicKey,
channel_id: Uuid,
scopes: &[Scope],
) -> Result<(), AuthError> {
require_scope(scopes, Scope::MessagesWrite)?;
if checker.can_access(ctx, pubkey, channel_id).await? {
Ok(())
} else {
Err(AuthError::ChannelAccessDenied)
}
}
/// In-memory [`ChannelAccessChecker`] for unit tests.
///
/// Membership is keyed on the full `(community_id, pubkey, channel_id)` tuple
/// so the mock can't accidentally model a non-tenant-scoped checker.
#[cfg(any(test, feature = "test-utils"))]
pub struct MockAccessChecker {
allowed: HashSet<(uuid::Uuid, String, Uuid)>,
}
#[cfg(any(test, feature = "test-utils"))]
impl MockAccessChecker {
/// Create an empty checker (all access denied by default).
pub fn new() -> Self {
Self {
allowed: HashSet::new(),
}
}
/// Grant `pubkey` access to `channel_id` inside `ctx`'s community.
pub fn allow(&mut self, ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid) {
self.allowed
.insert((*ctx.community().as_uuid(), pubkey.to_hex(), channel_id));
}
}
#[cfg(any(test, feature = "test-utils"))]
impl Default for MockAccessChecker {
fn default() -> Self {
Self::new()
}
}
#[cfg(any(test, feature = "test-utils"))]
impl ChannelAccessChecker for MockAccessChecker {
async fn accessible_channel_ids(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
) -> Result<HashSet<Uuid>, AuthError> {
let community = *ctx.community().as_uuid();
let hex = pubkey.to_hex();
Ok(self
.allowed
.iter()
.filter(|(c, pk, _)| *c == community && pk == &hex)
.map(|(_, _, id)| *id)
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::CommunityId;
use nostr::Keys;
fn fixture_ctx() -> TenantContext {
TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example")
}
#[tokio::test]
async fn mock_checker_allow_and_deny() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let allowed_ch = Uuid::new_v4();
let denied_ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, allowed_ch);
assert!(checker.can_access(&ctx, &pk, allowed_ch).await.unwrap());
assert!(!checker.can_access(&ctx, &pk, denied_ch).await.unwrap());
}
#[tokio::test]
async fn read_access_denied_by_scope() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, ch);
assert!(matches!(
check_read_access(&checker, &ctx, &pk, ch, &[]).await,
Err(AuthError::InsufficientScope { .. })
));
}
#[tokio::test]
async fn read_access_denied_by_membership() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let checker = MockAccessChecker::new();
assert!(matches!(
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]).await,
Err(AuthError::ChannelAccessDenied)
));
}
#[tokio::test]
async fn read_access_granted() {
let ctx = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx, &pk, ch);
assert!(
check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead])
.await
.is_ok()
);
}
#[tokio::test]
async fn access_does_not_cross_communities() {
// S1 fence at the access layer: same pubkey, same channel UUID, two
// communities. A grant in A MUST NOT show up under B's TenantContext.
// This bites the existence-oracle direction a bare `WHERE id=$1`
// checker would have left open.
let ctx_a = fixture_ctx();
let ctx_b = fixture_ctx();
let keys = Keys::generate();
let pk = keys.public_key();
let ch = Uuid::new_v4();
let mut checker = MockAccessChecker::new();
checker.allow(&ctx_a, &pk, ch);
assert!(checker.can_access(&ctx_a, &pk, ch).await.unwrap());
assert!(
!checker.can_access(&ctx_b, &pk, ch).await.unwrap(),
"access in community A must NOT leak into community B for same (pubkey, channel_id)"
);
assert!(checker
.accessible_channel_ids(&ctx_b, &pk)
.await
.unwrap()
.is_empty());
}
}
+59
View File
@@ -0,0 +1,59 @@
//! 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),
}
+243
View File
@@ -0,0 +1,243 @@
#![deny(unsafe_code)]
#![warn(missing_docs)]
//! `buzz-auth` — Authentication and authorization for the Buzz relay.
//!
//! ## Auth paths
//!
//! | Path | Transport | Description |
//! |------|-----------|-------------|
//! | NIP-42 | WebSocket | Challenge/response; client signs kind:22242 event |
//! | NIP-98 | HTTP | Signed kind:27235 event in `Authorization: Nostr` header |
//!
//! ## Security invariants
//!
//! - **AUTH events (kind:22242) are NEVER stored or logged.**
//! - All paths produce an [`AuthContext`] bound to the connection.
//! - No JWT validation, no token management, no IdP runtime dependency.
/// Channel access checking trait and helpers.
pub mod access;
/// Authentication error types.
pub mod error;
/// NIP-42 challengeresponse authentication.
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.
pub mod scope;
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
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, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS,
MAX_REPLAY_TTL_SECS,
};
pub use rate_limit::{
ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter,
};
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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthMethod {
/// NIP-42 challenge/response — Schnorr signature over kind:22242.
Nip42,
/// NIP-98 HTTP Auth — Schnorr signature over kind:27235.
Nip98,
}
/// The result of a successful authentication, bound to a connection.
#[derive(Debug, Clone)]
pub struct AuthContext {
/// The authenticated Nostr public key.
pub pubkey: nostr::PublicKey,
/// Permission scopes granted to this connection.
pub scopes: Vec<Scope>,
/// Channel restriction (reserved for future per-channel access control).
///
/// `None` means unrestricted.
pub channel_ids: Option<Vec<uuid::Uuid>>,
/// How the connection was authenticated.
pub auth_method: AuthMethod,
/// NIP-OA verified owner pubkey (if authenticated via owner attestation).
///
/// `None` for direct relay members or non-NIP-OA auth paths.
/// Set by the relay membership gate when NIP-OA fallback succeeds.
pub agent_owner_pubkey: Option<nostr::PublicKey>,
}
impl AuthContext {
/// Returns `true` if this context includes the given [`Scope`].
pub fn has_scope(&self, scope: &Scope) -> bool {
self.scopes.contains(scope)
}
}
/// Top-level authentication configuration, typically loaded from the relay's TOML config file.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct AuthConfig {
/// Per-user and per-IP rate limit thresholds.
#[serde(default)]
pub rate_limits: RateLimitConfig,
}
/// Simplified auth service — NIP-42 and NIP-98 only.
/// No JWT validation, no token management, no IdP runtime dependency.
#[derive(Debug, Clone)]
pub struct AuthService {
config: AuthConfig,
}
impl AuthService {
/// Create a new `AuthService` with the given configuration.
pub fn new(config: AuthConfig) -> Self {
Self { config }
}
/// Return a reference to the auth configuration.
pub fn config(&self) -> &AuthConfig {
&self.config
}
/// Verify a NIP-42 AUTH event and return an [`AuthContext`].
///
/// Pure cryptographic verification — no network calls, no JWT, no tokens.
pub async fn verify_auth_event(
&self,
auth_event: nostr::Event,
expected_challenge: &str,
relay_url: &str,
) -> Result<AuthContext, AuthError> {
// Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify)
let event_clone = auth_event.clone();
let challenge_owned = expected_challenge.to_string();
let relay_owned = relay_url.to_string();
tokio::task::spawn_blocking(move || {
verify_nip42_event(&event_clone, &challenge_owned, &relay_owned)
})
.await
.map_err(|_| AuthError::Internal("spawn_blocking panicked".into()))??;
// In pure Nostr mode, all authenticated connections get full scopes.
// Per-channel access is enforced by the relay's membership checks (NIP-29).
Ok(AuthContext {
pubkey: auth_event.pubkey,
scopes: Scope::all_known(),
channel_ids: None,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA
})
}
}
/// Derive a deterministic Nostr pubkey from a username string.
///
/// Uses `SHA-256("buzz-test-key:{username}")` as the secret key material.
/// This matches the derivation used by the desktop's `set_test_identity` function,
/// allowing the relay to resolve usernames to Nostr pubkeys in dev mode.
///
/// # ⚠️ SECURITY — Dev/test only
///
/// This function is gated behind `#[cfg(any(test, feature = "dev"))]`
/// and **must never be compiled into a production release build**.
///
/// - The derived keys are deterministic and predictable from the username alone.
/// - Any attacker who knows a username can compute the corresponding private key.
#[cfg(any(test, feature = "dev"))]
pub fn derive_pubkey_from_username(username: &str) -> Result<nostr::PublicKey, AuthError> {
use sha2::{Digest, Sha256};
let seed = format!("buzz-test-key:{username}");
let hash: [u8; 32] = Sha256::digest(seed.as_bytes()).into();
let secret_key = nostr::SecretKey::from_slice(&hash)
.map_err(|e| AuthError::Internal(format!("key derivation failed: {e}")))?;
Ok(nostr::Keys::new(secret_key).public_key())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, RelayUrl};
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> nostr::Event {
let url = RelayUrl::parse(relay_url).expect("valid url");
EventBuilder::auth(challenge, url)
.sign_with_keys(keys)
.expect("signing failed")
}
fn test_service() -> AuthService {
AuthService::new(AuthConfig::default())
}
#[test]
fn auth_context_scope_check() {
let keys = Keys::generate();
let ctx = AuthContext {
pubkey: keys.public_key(),
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
channel_ids: None,
auth_method: AuthMethod::Nip42,
agent_owner_pubkey: None,
};
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(!ctx.has_scope(&Scope::MessagesWrite));
}
#[tokio::test]
async fn nip42_auth_succeeds() {
let keys = Keys::generate();
let challenge = generate_challenge();
let relay = "wss://relay.example.com";
let event = make_auth_event(&keys, &challenge, relay);
let ctx = test_service()
.verify_auth_event(event, &challenge, relay)
.await
.expect("NIP-42 auth should succeed");
assert_eq!(ctx.pubkey, keys.public_key());
assert_eq!(ctx.auth_method, AuthMethod::Nip42);
assert!(ctx.has_scope(&Scope::MessagesRead));
assert!(ctx.has_scope(&Scope::MessagesWrite));
}
#[tokio::test]
async fn wrong_challenge_rejected() {
let keys = Keys::generate();
let challenge = generate_challenge();
let relay = "wss://relay.example.com";
let event = make_auth_event(&keys, &challenge, relay);
let result = test_service()
.verify_auth_event(event, "wrong-challenge", relay)
.await;
assert!(matches!(result, Err(AuthError::ChallengeMismatch)));
}
#[tokio::test]
async fn wrong_kind_rejected() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "not auth")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
let result = test_service()
.verify_auth_event(event, &generate_challenge(), "wss://relay.example.com")
.await;
assert!(matches!(result, Err(AuthError::InvalidSignature)));
}
}
+183
View File
@@ -0,0 +1,183 @@
//! NIP-42 challenge/response authentication.
//!
//! 1. Relay sends `["AUTH", "<challenge>"]` via [`generate_challenge`].
//! 2. Client signs a kind:22242 event with challenge + relay tags.
//! 3. Relay validates via [`verify_nip42_event`].
//!
//! AUTH events are **never** stored or logged (may contain bearer tokens).
use nostr::{Event, Kind, TagKind, Timestamp};
use url::Url;
use crate::error::AuthError;
/// Normalize a relay URL for comparison.
///
/// Uses the `url` crate for proper parsing rather than string manipulation.
/// Normalizes localhost variants to 127.0.0.1 and strips trailing slashes
/// (the `url` crate handles the latter automatically via path normalization).
fn normalize_relay_url(raw: &str) -> String {
let mut parsed = match Url::parse(raw) {
Ok(u) => u,
Err(_) => return raw.to_string(),
};
// Treat localhost variants as equivalent by normalizing to 127.0.0.1.
if let Some(host) = parsed.host_str() {
if host == "localhost" || host == "::1" {
let _ = parsed.set_host(Some("127.0.0.1"));
}
}
let path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(&path);
parsed.to_string()
}
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
/// Generate a random NIP-42 challenge (32 CSPRNG bytes, hex-encoded).
pub fn generate_challenge() -> String {
let bytes: [u8; 32] = rand::random();
hex::encode(bytes)
}
/// Verify a NIP-42 AUTH event.
///
/// Checks kind, signature, challenge, relay URL, and timestamp (±60s).
/// CPU-bound (Schnorr verify) — call via `spawn_blocking` in async contexts.
pub fn verify_nip42_event(
event: &Event,
expected_challenge: &str,
relay_url: &str,
) -> Result<(), AuthError> {
if event.kind != Kind::Authentication {
return Err(AuthError::InvalidSignature);
}
buzz_core::verify_event(event).map_err(|_| AuthError::InvalidSignature)?;
let challenge = event
.tags
.find(TagKind::Challenge)
.and_then(|t| t.content())
.ok_or(AuthError::ChallengeMismatch)?;
if challenge != expected_challenge {
return Err(AuthError::ChallengeMismatch);
}
let relay = event
.tags
.find(TagKind::Relay)
.and_then(|t| t.content())
.ok_or(AuthError::RelayUrlMismatch)?;
if normalize_relay_url(relay) != normalize_relay_url(relay_url) {
return Err(AuthError::RelayUrlMismatch);
}
let now = Timestamp::now().as_secs();
let event_ts = event.created_at.as_secs();
let delta = now.abs_diff(event_ts);
if delta > TIMESTAMP_TOLERANCE_SECS {
return Err(AuthError::EventExpired);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, RelayUrl, Timestamp};
const TEST_RELAY: &str = "wss://relay.example.com";
fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> Event {
let url = RelayUrl::parse(relay_url).expect("valid relay url");
EventBuilder::auth(challenge, url)
.sign_with_keys(keys)
.expect("signing failed")
}
#[test]
fn challenge_is_64_hex_chars_and_unique() {
let c1 = generate_challenge();
let c2 = generate_challenge();
assert_eq!(c1.len(), 64);
assert!(c1.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(c1, c2);
}
#[test]
fn valid_event_passes() {
let keys = Keys::generate();
let challenge = generate_challenge();
let event = make_auth_event(&keys, &challenge, TEST_RELAY);
assert!(verify_nip42_event(&event, &challenge, TEST_RELAY).is_ok());
}
#[test]
fn wrong_challenge_rejected() {
let keys = Keys::generate();
let challenge = generate_challenge();
let event = make_auth_event(&keys, &challenge, TEST_RELAY);
assert!(matches!(
verify_nip42_event(&event, "wrong", TEST_RELAY),
Err(AuthError::ChallengeMismatch)
));
}
#[test]
fn wrong_kind_rejected() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "not auth")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
assert!(matches!(
verify_nip42_event(&event, "x", TEST_RELAY),
Err(AuthError::InvalidSignature)
));
}
#[test]
fn expired_event_rejected() {
let keys = Keys::generate();
let challenge = generate_challenge();
let url = RelayUrl::parse(TEST_RELAY).unwrap();
let old_ts = Timestamp::from(Timestamp::now().as_secs().saturating_sub(120));
let event = EventBuilder::auth(&challenge, url)
.custom_created_at(old_ts)
.sign_with_keys(&keys)
.expect("sign");
assert!(matches!(
verify_nip42_event(&event, &challenge, TEST_RELAY),
Err(AuthError::EventExpired)
));
}
#[test]
fn wrong_relay_rejected() {
let keys = Keys::generate();
let challenge = generate_challenge();
let event = make_auth_event(&keys, &challenge, "wss://other.example.com");
assert!(matches!(
verify_nip42_event(&event, &challenge, TEST_RELAY),
Err(AuthError::RelayUrlMismatch)
));
}
#[test]
fn localhost_and_127_are_equivalent() {
let a = normalize_relay_url("ws://localhost:3030");
let b = normalize_relay_url("ws://127.0.0.1:3030");
assert_eq!(a, b);
}
#[test]
fn trailing_slash_normalized() {
let a = normalize_relay_url("wss://relay.example.com/");
let b = normalize_relay_url("wss://relay.example.com");
assert_eq!(a, b);
}
}
+317
View File
@@ -0,0 +1,317 @@
//! NIP-98 HTTP Auth verification (kind:27235).
//!
//! NIP-98 is the standard Nostr HTTP Auth pattern used by Nostr.build, Blossom, and
//! other Nostr HTTP services. It is **stateless** — no WebSocket session required.
//!
//! The client signs a short-lived kind:27235 event containing the target URL, HTTP method,
//! and an optional SHA-256 hash of the request body, then sends it as:
//!
//! ```text
//! Authorization: Nostr <base64(JSON-serialized-event)>
//! ```
//!
//! ## Verification steps
//!
//! 1. Parse JSON into a `nostr::Event`
//! 2. Verify `kind == 27235` (`Kind::HttpAuth`)
//! 3. Verify Schnorr signature via `buzz_core::verify_event`
//! 4. Verify `created_at` within ±60 seconds of server time
//! 5. Verify `["u", <url>]` tag matches `expected_url` (normalised: case-insensitive
//! scheme/host, trailing slash stripped)
//! 6. Verify `["method", <method>]` tag matches `expected_method` (case-insensitive)
//! 7. If `["payload", <hash>]` tag is present **and** `body` is `Some`: verify
//! `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks.
//! 8. Return `event.pubkey` on success.
use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp};
use sha2::{Digest, Sha256};
use url::Url;
use crate::error::AuthError;
const TIMESTAMP_TOLERANCE_SECS: u64 = 60;
/// Verify a NIP-98 HTTP Auth event (kind:27235).
///
/// # Parameters
///
/// - `event_json` — the raw JSON string of the Nostr event (decoded from base64 by the caller).
/// - `expected_url` — the canonical URL of the request being authenticated.
/// For reverse-proxy deployments, reconstruct from `X-Forwarded-Proto` / `X-Forwarded-Host`
/// before passing here.
/// - `expected_method` — the HTTP method (e.g. `"POST"`). Compared case-insensitively.
/// - `body` — raw request body bytes. If `Some` and a `payload` tag is present in the event,
/// the SHA-256 hash of `body` must match the tag value. If `None`, the `payload` tag is
/// ignored (clients SHOULD include it for POST requests, but it is not required).
///
/// # Returns
///
/// The authenticated `nostr::PublicKey` on success.
///
/// # Errors
///
/// Returns [`AuthError::Nip98Invalid`] with a descriptive message for any verification failure.
/// The message is safe for server logs but should not be forwarded verbatim to clients.
pub fn verify_nip98_event(
event_json: &str,
expected_url: &str,
expected_method: &str,
body: Option<&[u8]>,
) -> Result<nostr::PublicKey, AuthError> {
// 1. Parse JSON.
let event: Event = serde_json::from_str(event_json)
.map_err(|e| AuthError::Nip98Invalid(format!("event JSON parse error: {e}")))?;
// 2. Verify kind == 27235.
if event.kind != Kind::HttpAuth {
return Err(AuthError::Nip98Invalid(format!(
"expected kind 27235, got {}",
event.kind.as_u16()
)));
}
// 3. Verify Schnorr signature (also verifies event ID hash).
buzz_core::verify_event(&event)
.map_err(|_| AuthError::Nip98Invalid("invalid Schnorr signature".to_string()))?;
// 4. Verify created_at within ±60 seconds of now.
let now = Timestamp::now().as_secs();
let event_ts = event.created_at.as_secs();
let delta = now.abs_diff(event_ts);
if delta > TIMESTAMP_TOLERANCE_SECS {
return Err(AuthError::Nip98Invalid(format!(
"event timestamp outside ±{TIMESTAMP_TOLERANCE_SECS}s window (delta: {delta}s)"
)));
}
// 5. Verify `u` tag matches expected_url (normalised).
// NIP-98 uses the single-letter "u" tag, not the multi-letter "url" tag.
let u_tag = event
.tags
.find(TagKind::SingleLetter(SingleLetterTag::lowercase(
Alphabet::U,
)))
.and_then(|t| t.content())
.ok_or_else(|| AuthError::Nip98Invalid("missing `u` tag".to_string()))?;
if normalize_url(u_tag) != normalize_url(expected_url) {
return Err(AuthError::Nip98Invalid(format!(
"URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
)));
}
// 6. Verify `method` tag matches expected_method (case-insensitive).
let method_tag = event
.tags
.find(TagKind::Method)
.and_then(|t| t.content())
.ok_or_else(|| AuthError::Nip98Invalid("missing `method` tag".to_string()))?;
if !method_tag.eq_ignore_ascii_case(expected_method) {
return Err(AuthError::Nip98Invalid(format!(
"method mismatch: event has `{method_tag}`, expected `{expected_method}`"
)));
}
// 7. If `payload` tag present AND body is Some: verify SHA-256(body) == payload hex.
let payload_tag = event.tags.find(TagKind::Payload).and_then(|t| t.content());
if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) {
let computed: [u8; 32] = Sha256::digest(body_bytes).into();
let computed_hex = hex::encode(computed);
if computed_hex != payload_hex {
return Err(AuthError::Nip98Invalid(
"payload tag SHA-256 mismatch: request body does not match signed hash".to_string(),
));
}
}
// 8. Return the authenticated pubkey.
Ok(event.pubkey)
}
/// Normalize a URL for comparison.
///
/// - Lowercases scheme and host (already done by the `url` crate).
/// - Strips trailing slash from path.
///
/// **No loopback aliasing.** `localhost`, `::1`, and `127.0.0.1` are three
/// distinct hosts here. Under multi-tenant the `u`-tag host is the row-zero
/// community binding (`docs/multi-tenant-conformance.md`, NIP-98 row): if
/// `verify_nip98_event` collapses them, an event signed for `localhost`
/// would pass against a `127.0.0.1`-resolved community (or vice versa) —
/// a host-binding side door. Tests reconstruct `expected_url` from their
/// own bound host, the same shape production does.
fn normalize_url(raw: &str) -> String {
let mut parsed = match Url::parse(raw) {
Ok(u) => u,
Err(_) => return raw.to_lowercase(),
};
let path = parsed.path().trim_end_matches('/').to_string();
parsed.set_path(&path);
parsed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Timestamp};
const TEST_URL: &str = "https://relay.example.com/api/tokens";
const TEST_METHOD: &str = "POST";
fn make_nip98_event(
keys: &Keys,
url: &str,
method: &str,
payload_hex: Option<&str>,
created_at: Option<Timestamp>,
) -> String {
use nostr::Tag;
let mut tags = vec![
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", method]).unwrap(),
];
if let Some(hex) = payload_hex {
tags.push(Tag::parse(["payload", hex]).unwrap());
}
let mut builder = EventBuilder::new(Kind::HttpAuth, "").tags(tags);
if let Some(ts) = created_at {
builder = builder.custom_created_at(ts);
}
let event = builder.sign_with_keys(keys).expect("sign");
serde_json::to_string(&event).expect("serialize")
}
#[test]
fn valid_event_returns_pubkey() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(result.is_ok(), "verify failed: {:?}", result.err());
assert_eq!(result.unwrap(), keys.public_key());
}
#[test]
fn wrong_kind_rejected() {
let keys = Keys::generate();
let event = EventBuilder::new(Kind::TextNote, "")
.tags([])
.sign_with_keys(&keys)
.expect("sign");
let json = serde_json::to_string(&event).unwrap();
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn expired_timestamp_rejected() {
let keys = Keys::generate();
let old_ts = Timestamp::from(Timestamp::now().as_secs().saturating_sub(120));
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, Some(old_ts));
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn url_mismatch_rejected() {
let keys = Keys::generate();
let json = make_nip98_event(
&keys,
"https://other.example.com/api/tokens",
TEST_METHOD,
None,
None,
);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn method_mismatch_rejected() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, "GET", None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn method_case_insensitive() {
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, "post", None, None);
let result = verify_nip98_event(&json, TEST_URL, "POST", None);
assert!(result.is_ok());
}
#[test]
fn payload_tag_correct_hash_passes() {
let keys = Keys::generate();
let body = b"hello world";
let hash: [u8; 32] = Sha256::digest(body).into();
let hash_hex = hex::encode(hash);
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&hash_hex), None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
assert!(result.is_ok());
}
#[test]
fn payload_tag_wrong_hash_rejected() {
let keys = Keys::generate();
let body = b"hello world";
let wrong_hex = "deadbeef".repeat(8); // 64 hex chars but wrong hash
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, Some(&wrong_hex), None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body));
assert!(matches!(result, Err(AuthError::Nip98Invalid(_))));
}
#[test]
fn payload_tag_absent_with_body_passes() {
// payload tag is optional per spec; clients SHOULD include it but it's not required
let keys = Keys::generate();
let json = make_nip98_event(&keys, TEST_URL, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(b"some body"));
assert!(result.is_ok());
}
#[test]
fn trailing_slash_normalized() {
let keys = Keys::generate();
let url_with_slash = "https://relay.example.com/api/tokens/";
let json = make_nip98_event(&keys, url_with_slash, TEST_METHOD, None, None);
// expected_url without trailing slash — should still match
let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None);
assert!(result.is_ok());
}
#[test]
fn loopback_aliases_are_distinct_hosts() {
// Under multi-tenant, the `u`-tag host is the row-zero community
// binding. An event signed for `localhost` MUST NOT pass against an
// expected URL on `127.0.0.1` (or `::1`) — collapsing the three would
// be a host-check side door. Production reconstructs `expected_url`
// from the community-bound host; tests do the same.
let keys = Keys::generate();
let localhost_url = "http://localhost:3000/api/tokens";
let loopback_url = "http://127.0.0.1:3000/api/tokens";
let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None);
let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None);
assert!(
matches!(result, Err(AuthError::Nip98Invalid(_))),
"localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}"
);
// Symmetric: signed-for-127.0.0.1 against expected localhost — same answer.
let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None);
assert!(
matches!(result2, Err(AuthError::Nip98Invalid(_))),
"127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}"
);
// And identity still holds — same host on both sides verifies.
let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok());
}
}
+249
View File
@@ -0,0 +1,249 @@
//! 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, buzz_auth::DEFAULT_REPLAY_TTL_SECS).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 std::{future::Future, pin::Pin};
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;
/// Ceiling for the replay-prevention window, in seconds.
///
/// Any TTL beyond an hour is implausible for NIP-98 replay protection: the
/// verifier only accepts events within ±60s, so a same-id replay is only
/// physically possible inside that window plus clock skew. A 1-hour cap is
/// 30× the natural maximum and still keeps Redis values well inside
/// `i64::MAX` seconds (which Redis `EX` requires). Anything larger reaching
/// this code is a config/caller bug; implementations MUST clamp down to it
/// rather than admit values that risk Redis `EX` parse failures or
/// pathologically long-lived seen-set entries.
pub const MAX_REPLAY_TTL_SECS: u64 = 3600;
/// 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` in an explicit deployment or community scope.
fn try_mark_in_scope<'a>(
&'a self,
scope: &'a str,
event_id: &'a EventId,
ttl_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>>;
/// 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.
///
/// `ttl_secs` MUST be clamped down to [`MAX_REPLAY_TTL_SECS`] if larger.
/// The replay window's natural maximum is the verifier's ±60s tolerance;
/// values past an hour are implausible and risk Redis `EX` parse failures
/// (Redis interprets `EX` as a signed 64-bit integer).
fn try_mark<'a>(
&'a self,
ctx: &'a TenantContext,
event_id: &'a EventId,
ttl_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
let scope = ctx.community().to_string();
Box::pin(async move { self.try_mark_in_scope(&scope, event_id, ttl_secs).await })
}
}
/// 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 {
nip98_replay_key_for_scope(&ctx.community().to_string(), event_id)
}
/// Redis key for a NIP-98 replay marker in an explicit trusted scope.
pub fn nip98_replay_key_for_scope(scope: &str, event_id: &EventId) -> String {
format!("buzz:{scope}:nip98:{}", 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 {
fn try_mark_in_scope<'a>(
&'a self,
_scope: &'a str,
_event_id: &'a EventId,
_ttl_secs: u64,
) -> Pin<Box<dyn Future<Output = Result<bool, AuthError>> + Send + 'a>> {
Box::pin(async { 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 key_components_are_lowercase() {
// Stability/idempotence: if event id hex or community Display ever
// started emitting uppercase, a same logical claim would produce two
// distinct Redis rows → the seen-set would no longer be a seen-set.
let ctx = fixture_ctx("relay-a.example");
let eid = fixture_event_id();
let key = nip98_replay_key(&ctx, &eid);
for c in key.chars() {
assert!(
!c.is_ascii_uppercase(),
"nip98 replay key {key} must be all-lowercase ASCII"
);
}
}
#[test]
fn default_ttl_meets_gate_floor() {
// §5 gate: TTL ≥ 120s. Drift this constant down and the gate breaks.
// Const-drift tripwire: the assertion is intentionally over a constant.
#[allow(clippy::assertions_on_constants)]
{
assert!(DEFAULT_REPLAY_TTL_SECS >= 120);
}
}
#[test]
fn ttl_floor_below_ceiling() {
// Sanity: any caller's clamped TTL must end up in [DEFAULT, MAX].
// If these ever cross, the impl can't satisfy both bounds and the
// contract is broken.
// Const-drift tripwire: the assertion is intentionally over a constant.
#[allow(clippy::assertions_on_constants)]
{
assert!(DEFAULT_REPLAY_TTL_SECS < MAX_REPLAY_TTL_SECS);
}
}
#[test]
fn max_ttl_fits_in_redis_signed_ex() {
// Redis `EX` is parsed as i64. `MAX_REPLAY_TTL_SECS` must fit so the
// clamp itself can't push us into a Redis-side parse failure.
assert!(MAX_REPLAY_TTL_SECS <= i64::MAX as u64);
}
#[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());
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Rate limiting types and interface.
//!
//! Defines the [`RateLimiter`] trait. The Redis-backed implementation lives in
//! `buzz-relay` / `buzz-pubsub`. Fixed-window counter algorithm.
//!
//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding
//! window or token bucket for strict limiting.
use std::net::IpAddr;
use buzz_core::TenantContext;
use nostr::PublicKey;
use serde::{Deserialize, Serialize};
use crate::error::AuthError;
/// The outcome of a rate-limit check, including counter state for response headers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RateLimitResult {
/// Whether the request is permitted (`true`) or should be rejected (`false`).
pub allowed: bool,
/// Current counter value after this increment.
pub current: u64,
/// The configured limit for this window.
pub limit: u64,
/// Seconds until the current window resets.
pub reset_in_secs: u64,
}
impl RateLimitResult {
/// Construct an **allowed** result.
pub fn allowed(current: u64, limit: u64, reset_in_secs: u64) -> Self {
Self {
allowed: true,
current,
limit,
reset_in_secs,
}
}
/// Construct a **denied** result.
pub fn denied(current: u64, limit: u64, reset_in_secs: u64) -> Self {
Self {
allowed: false,
current,
limit,
reset_in_secs,
}
}
}
/// The category of operation being rate-limited.
///
/// Each variant maps to a distinct Redis key suffix so limits are tracked
/// independently per operation type.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LimitType {
/// Nostr message events (kind:1 etc.) sent via WebSocket.
Messages,
/// HTTP REST API calls.
ApiCalls,
/// All WebSocket events (broader than `Messages`).
WsEvents,
/// Concurrent WebSocket connections from a single IP address.
IpConnections,
}
impl LimitType {
/// Short suffix used in Redis key construction (e.g. `"msg"`, `"api"`).
pub fn key_suffix(&self) -> &'static str {
match self {
Self::Messages => "msg",
Self::ApiCalls => "api",
Self::WsEvents => "ws",
Self::IpConnections => "conn",
}
}
}
/// Per-tier rate limit thresholds.
///
/// All values are counts per the relevant time window (per-minute or per-second).
/// Loaded from the relay config file; sensible defaults are provided for all fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitConfig {
/// Maximum messages per minute for human users. Default: 60.
#[serde(default = "default_human_msg")]
pub human_messages_per_min: u64,
/// Maximum HTTP API calls per minute for human users. Default: 300.
#[serde(default = "default_human_api")]
pub human_api_calls_per_min: u64,
/// Maximum WebSocket events per second for human users. Default: 10.
#[serde(default = "default_human_ws")]
pub human_ws_events_per_sec: u64,
/// Maximum messages per minute for standard-tier agent tokens. Default: 120.
#[serde(default = "default_agent_std_msg")]
pub agent_standard_messages_per_min: u64,
/// Maximum HTTP API calls per minute for standard-tier agent tokens. Default: 600.
#[serde(default = "default_agent_std_api")]
pub agent_standard_api_calls_per_min: u64,
/// Maximum messages per minute for elevated-tier agent tokens. Default: 300.
#[serde(default = "default_agent_elev_msg")]
pub agent_elevated_messages_per_min: u64,
/// Maximum messages per minute for platform-tier agent tokens. Default: 600.
#[serde(default = "default_agent_plat_msg")]
pub agent_platform_messages_per_min: u64,
}
fn default_human_msg() -> u64 {
60
}
fn default_human_api() -> u64 {
300
}
fn default_human_ws() -> u64 {
10
}
fn default_agent_std_msg() -> u64 {
120
}
fn default_agent_std_api() -> u64 {
600
}
fn default_agent_elev_msg() -> u64 {
300
}
fn default_agent_plat_msg() -> u64 {
600
}
impl Default for RateLimitConfig {
fn default() -> Self {
Self {
human_messages_per_min: default_human_msg(),
human_api_calls_per_min: default_human_api(),
human_ws_events_per_sec: default_human_ws(),
agent_standard_messages_per_min: default_agent_std_msg(),
agent_standard_api_calls_per_min: default_agent_std_api(),
agent_elevated_messages_per_min: default_agent_elev_msg(),
agent_platform_messages_per_min: default_agent_plat_msg(),
}
}
}
/// Async rate-limiting interface.
///
/// 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 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,
limit: u64,
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
/// Increment the per-IP connection counter and return whether the connection
/// 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,
window_secs: u64,
limit: u64,
) -> impl std::future::Future<Output = Result<RateLimitResult, AuthError>> + Send;
}
/// 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:{}:{}",
ctx.community(),
pubkey.to_hex(),
limit_type.key_suffix()
)
}
/// 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)
}
/// Always-allow rate limiter for unit tests.
#[cfg(any(test, feature = "test-utils"))]
pub struct AlwaysAllowRateLimiter;
#[cfg(any(test, feature = "test-utils"))]
impl RateLimiter for AlwaysAllowRateLimiter {
async fn check_and_increment(
&self,
_ctx: &TenantContext,
_pubkey: &PublicKey,
_limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
Ok(RateLimitResult::allowed(1, limit, window_secs))
}
async fn check_ip_connection(
&self,
_ip: &IpAddr,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
Ok(RateLimitResult::allowed(1, limit, window_secs))
}
}
#[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_includes_community_prefix() {
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
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 rate_limit_key_components_are_lowercase() {
// Stability/idempotence invariant: if pubkey hex or community Display
// ever started emitting uppercase, the same (community, pubkey) would
// produce two distinct Redis keys → effective 2× quota. Pin the
// lowercase property here so the regression surfaces in unit tests,
// not in production traffic.
let ctx = fixture_ctx("relay-a.example");
let keys = Keys::generate();
let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages);
for c in key.chars() {
assert!(
!c.is_ascii_uppercase(),
"rate-limit key {key} must be all-lowercase ASCII"
);
}
}
#[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");
}
#[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(&ctx, &keys.public_key(), LimitType::Messages, 60, 60)
.await
.unwrap();
assert!(result.allowed);
}
}
+249
View File
@@ -0,0 +1,249 @@
//! Authorization scopes.
//!
//! Scopes control what operations an authenticated connection may perform.
//! In pure Nostr mode, all NIP-42 authenticated connections receive the full
//! scope set; per-channel access is enforced by NIP-29 membership checks.
use std::fmt;
use std::str::FromStr;
/// An authorization scope granted to an authenticated connection or API token.
///
/// Scopes are stored as `TEXT[]` in the database so new variants can be added
/// without schema migrations. Unknown scope strings are preserved via [`Scope::Unknown`]
/// to allow forward-compatibility with future scope additions.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Scope {
/// Read messages from channels the user is a member of.
MessagesRead,
/// Send messages to channels the user is a member of.
MessagesWrite,
/// List and read channel metadata.
ChannelsRead,
/// Create and update channels.
ChannelsWrite,
/// Administrative channel operations (e.g. delete, force-remove members).
AdminChannels,
/// Read user profile information.
UsersRead,
/// Update user profile information.
UsersWrite,
/// Administrative user operations (e.g. suspend, impersonate).
AdminUsers,
/// Read background job status.
JobsRead,
/// Submit and cancel background jobs.
JobsWrite,
/// Read subscription/plan information.
SubscriptionsRead,
/// Modify subscription/plan information.
SubscriptionsWrite,
/// Download files and attachments.
FilesRead,
/// Upload files and attachments.
FilesWrite,
/// Clone git repositories.
///
/// Reserved for future use. Not currently enforced by git HTTP routes —
/// those use NIP-98 auth directly. Will be enforced when collaborator
/// access (read-only, maintainer) is added in v2.
ReposRead,
/// Push to git repositories and create repos (kind:30617).
///
/// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT
/// enforced by git HTTP push routes (which use NIP-98 + owner check).
/// Full enforcement deferred to v2 collaborator model.
ReposWrite,
/// A scope string not recognised by this version of the relay.
///
/// Preserved as-is to allow forward-compatibility with future scope additions.
Unknown(String),
}
impl Scope {
/// Return a `Vec` containing every known scope variant.
///
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header
/// auth grants unrestricted access — there is no token to derive scopes from.
pub fn all_known() -> Vec<Scope> {
vec![
Self::MessagesRead,
Self::MessagesWrite,
Self::ChannelsRead,
Self::ChannelsWrite,
Self::AdminChannels,
Self::UsersRead,
Self::UsersWrite,
Self::AdminUsers,
Self::JobsRead,
Self::JobsWrite,
Self::SubscriptionsRead,
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
/// Return a `Vec` containing every known scope variant except admin scopes.
///
/// Used in dev mode (`require_auth_token=false`) where `X-Pubkey` header auth grants
/// access without a real token. Admin operations (`AdminChannels`, `AdminUsers`) require
/// a real token even in dev mode, so they are excluded here.
pub fn all_non_admin() -> Vec<Scope> {
vec![
Self::MessagesRead,
Self::MessagesWrite,
Self::ChannelsRead,
Self::ChannelsWrite,
Self::UsersRead,
Self::UsersWrite,
Self::JobsRead,
Self::JobsWrite,
Self::SubscriptionsRead,
Self::SubscriptionsWrite,
Self::FilesRead,
Self::FilesWrite,
Self::ReposRead,
Self::ReposWrite,
]
}
/// Return the canonical wire-format string for this scope (e.g. `"messages:read"`).
pub fn as_str(&self) -> &str {
match self {
Self::MessagesRead => "messages:read",
Self::MessagesWrite => "messages:write",
Self::ChannelsRead => "channels:read",
Self::ChannelsWrite => "channels:write",
Self::AdminChannels => "admin:channels",
Self::UsersRead => "users:read",
Self::UsersWrite => "users:write",
Self::AdminUsers => "admin:users",
Self::JobsRead => "jobs:read",
Self::JobsWrite => "jobs:write",
Self::SubscriptionsRead => "subscriptions:read",
Self::SubscriptionsWrite => "subscriptions:write",
Self::FilesRead => "files:read",
Self::FilesWrite => "files:write",
Self::ReposRead => "repos:read",
Self::ReposWrite => "repos:write",
Self::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for Scope {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"messages:read" => Self::MessagesRead,
"messages:write" => Self::MessagesWrite,
"channels:read" => Self::ChannelsRead,
"channels:write" => Self::ChannelsWrite,
"admin:channels" => Self::AdminChannels,
"users:read" => Self::UsersRead,
"users:write" => Self::UsersWrite,
"admin:users" => Self::AdminUsers,
"jobs:read" => Self::JobsRead,
"jobs:write" => Self::JobsWrite,
"subscriptions:read" => Self::SubscriptionsRead,
"subscriptions:write" => Self::SubscriptionsWrite,
"files:read" => Self::FilesRead,
"files:write" => Self::FilesWrite,
"repos:read" => Self::ReposRead,
"repos:write" => Self::ReposWrite,
other => Self::Unknown(other.to_string()),
})
}
}
/// Parse a slice of scope strings into `Vec<Scope>`.
pub fn parse_scopes(raw: &[impl AsRef<str>]) -> Vec<Scope> {
raw.iter()
.map(|s| {
s.as_ref()
.parse::<Scope>()
.expect("infallible: Scope::from_str cannot fail")
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
for scope in [Scope::MessagesRead, Scope::AdminChannels, Scope::FilesRead] {
let parsed: Scope = scope.as_str().parse().unwrap();
assert_eq!(parsed.as_str(), scope.as_str());
}
}
#[test]
fn unknown_scope_preserved() {
let scope: Scope = "future:capability".parse().unwrap();
assert_eq!(scope.as_str(), "future:capability");
assert!(matches!(scope, Scope::Unknown(_)));
}
#[test]
fn parse_scopes_slice() {
let scopes = parse_scopes(&["messages:read", "channels:write"]);
assert_eq!(scopes, vec![Scope::MessagesRead, Scope::ChannelsWrite]);
}
#[test]
fn all_non_admin_excludes_admin_scopes() {
let scopes = Scope::all_non_admin();
assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect();
assert_eq!(
unique.len(),
14,
"all_non_admin() must not contain duplicates"
);
// Verify no Unknown variants
for scope in &scopes {
assert!(
!matches!(scope, Scope::Unknown(_)),
"all_non_admin() must not contain Unknown variants"
);
}
// Verify admin scopes are excluded
assert!(
!scopes.contains(&Scope::AdminChannels),
"all_non_admin() must not contain AdminChannels"
);
assert!(
!scopes.contains(&Scope::AdminUsers),
"all_non_admin() must not contain AdminUsers"
);
}
#[test]
fn all_known_returns_all_known_variants() {
let all = Scope::all_known();
assert_eq!(all.len(), 16, "expected 16 known scope variants");
// Verify no duplicates
let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect();
assert_eq!(unique.len(), 16, "all_known() must not contain duplicates");
// Verify no Unknown variants
for scope in &all {
assert!(
!matches!(scope, Scope::Unknown(_)),
"all_known() must not contain Unknown variants"
);
}
}
}