Files
buzz/crates/buzz-auth/src/lib.rs
Cea Stapleton Cordasco 341a08a428 feat(relay): install provider-neutral authorization runtime
Cumulative PR layer #4847. Installs the reviewed provider-neutral relay authorization runtime, crash-recoverable current-status delivery, protected-ingress production composition, canonical mutation ordering, and exact runtime-contract regressions. A configured required identity gate remains fail-closed when its retired compatibility verifier is unavailable.

Neighbor disclosure: canonical database/operator authority remains owned by the lower layers, while the core status contract is owned by #4790 and native desktop consumption remains in #4987.

Signed-off-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
2026-08-12 11:20:13 -05:00

290 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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;
/// Blossom transport proof verification for canonical authorization.
pub mod blossom;
/// Authentication error types.
pub mod error;
/// Sealed transport provenance shared by protected authorization paths.
pub mod evidence;
/// Provider-free authorization composition and sealed verifier evidence.
pub mod foundation;
/// 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;
/// Origin-sealed deployment-operator identity lifecycle authority.
pub mod operator_lifecycle;
/// Per-connection rate limiting.
pub mod rate_limit;
/// OAuth scope parsing and enforcement.
pub mod scope;
/// Request-bound HMAC provenance verification for trusted proxies.
pub mod trusted_proxy;
pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
pub use blossom::{verify_blossom_authorization_proof, BlossomAuthorizationVerb};
pub use error::AuthError;
pub use evidence::{
AssertionTransportProfile, AuthenticatedClientPeer, SealedTransportEvidence,
TrustedProxyNonceClaim,
};
pub use foundation::{
ActiveLocalBinding, AuthContext as FinalizedAuthContext, AuthoritativeAuthorizationRecheck,
AuthorizationAuditConfig, AuthorizationAuditConfigError, AuthorizationError,
AuthorizationEventCapacityPolicy, AuthorizationEventCapacityPolicyError,
AuthorizationFinalizationRechecker, AuthorizationFinalizationWitness, AuthorizationFinalizer,
AuthorizationInput, AuthorizationLeaseDependencySnapshot, AuthorizationReason,
BindingResolutionRequest, BoundedAuthorizationLease, CanonicalFederatedAssertionVerifier,
CanonicalVerifierError, CanonicalVerifierKeySet, CanonicalVerifierPolicy,
CanonicalVerifierPolicyId, CurrentBindingStatusEvidenceRequest, DirectEnrollmentMode,
DirectEnrollmentProposal, FederatedPrincipalStorageKey, LocalAuthorizationPolicy,
LocalBindingResolution, LocalBindingResolver, LocalBindingResolverCapability,
LocalEnrollmentAuthority, LocalStatusEvidenceResolver, NipFiMode, PreparedAuthorization,
PreparedAuthorizationRecheck, ProofTransport, RouteCapability, RouteProtection,
VerifiedDelegation, VerifiedFederatedAssertion, VerifiedNostrProof, VerifierKeyGeneration,
VerifierPolicyStamp,
};
pub use nip42::{
generate_challenge, verify_nip42_authorization_proof, verify_nip42_binding_status_proof,
verify_nip42_event, Nip42AuthorizationProofError, Nip42BindingStatusCoordinates,
VerifiedBindingStatusProof,
};
pub use nip98::{
verify_nip42_moderation_command_proof, verify_nip98_authorization_proof, verify_nip98_event,
verify_nip98_invite_claim_proof, verify_nip98_moderation_command_proof,
Nip42ModerationCommandCoordinates, Nip98InviteClaimCoordinates,
Nip98ModerationCommandCoordinates, VerifiedModerationCommandProof,
VerifiedNip98InviteClaimProof,
};
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};
pub use trusted_proxy::{
HttpHeaderField, TrustedProxyError, TrustedProxyNonceReplayReader,
TrustedProxyProvenanceVerifier, TrustedProxyReplayReadError, TrustedProxyRequest,
ASSERTION_HEADER_NAME, CLIENT_PEER_HEADER_NAME, PROVENANCE_HEADER_NAME,
};
#[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)));
}
}