mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
🤖 Follows #7990 (merged). Binds every enforce-mode NIP-FI assertion to the community served at the request's `Host`. Before this, an assertion was accepted if its `aud` matched any value in a deployment-wide set. That meant an issuer trusted by one community could mint an assertion another community accepted. **Rollout consequence: an enforce relay will not start without `BUZZ_NIP_FI_COMMUNITIES`.** `off` and `deny_protected` do not read it, so repair mode still boots. ## What changes - **Host resolution runs first.** In enforce mode, right after the `off` and `deny_protected` checks, the `Host` must map to a configured community. An unmapped or missing `Host` returns 503 `authorization unavailable`. This happens at all three call sites: the router guard, the upgrade (WS root h1/h2 and audio), and HTTP admission. Off mode behaves exactly as before. - **The allowlist is checked before JWKS.** `VerifyAssertion::verify_assertion(token, &CommunityBinding)` rejects an issuer the community does not authorize before any key-source lookup. Clients can't tell this rejection apart from an unknown `iss`. `aud` must exactly equal the community's canonical URI. - **New config: `BUZZ_NIP_FI_COMMUNITIES`.** It is a JSON array of `{canonical_uri, authorized_issuers}`. Startup fails if: - a `canonical_uri` is anything other than a canonical `https://<host>[:port]`: the URL parser must reserialize the configured authority byte-for-byte, so whitespace, control characters, backslashes, uppercase, a redundant `:443`, and non-canonical IPv6 are rejected rather than repaired. The authority must also already be Host-normalized, so a trailing dot or `:80` is rejected and the Host map key always equals the `aud` authority exactly (never empty); - two communities share a `Host`; - an allowlisted issuer is missing from `BUZZ_NIP_FI_ISSUERS`; - an allowlist is empty; - a configured issuer belongs to no community. Errors name entries by index only. - **`IssuerPolicy.audiences` is removed.** Command JWTs use the new per-issuer `command_audiences`, so #7977's admin-disconnect command behavior is unchanged. A config that still sets `audiences`, even to `null`, fails with a migration message instead of being silently ignored. - **`AssertionPolicyId` now covers the community allowlist.** It hashes the `aud` of each community that authorizes the issuer, so changing the allowlist changes the ID. This is code only; no spec file changes here. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
268 lines
9.9 KiB
Rust
268 lines
9.9 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;
|
||
/// NIP-98 replay protection — shared, community-scoped, atomic seen-set.
|
||
pub mod nip98_replay;
|
||
/// NIP-FI federated-identity assertion verifier and contracts.
|
||
pub mod nip_fi;
|
||
/// 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};
|
||
|
||
pub use nip_fi::{
|
||
command_replay_key, validate_nip_fi_config, AssertionKeySet, AssertionPolicyId,
|
||
CanonicalCapabilities, ClientSubjectPosture, CommandError, CommandIssuerPolicy,
|
||
CommandPolicyError, CommandReplayGuard, CommandResult, CommandVerifier, CommunityBinding,
|
||
CommunityBindingError, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull,
|
||
FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass,
|
||
HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy,
|
||
IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract,
|
||
NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies,
|
||
SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion,
|
||
VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP,
|
||
MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM,
|
||
};
|
||
|
||
#[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 nip_fi::jwks::ScriptedJwksFetcher;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::InMemoryCommandReplayGuard;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::StaticIssuerKeySource;
|
||
#[cfg(any(test, feature = "test-utils"))]
|
||
pub use nip_fi::ToggleJwksFetcher;
|
||
#[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)));
|
||
}
|
||
}
|