mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com>
236 lines
8.2 KiB
Rust
236 lines
8.2 KiB
Rust
#![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;
|
||
/// 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 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 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)));
|
||
}
|
||
}
|