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
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:
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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 challenge–response 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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user