Files
buzz/crates/buzz-auth/src/lib.rs
Will Pfleger 2ebfde9147 feat(nip-fi): bind assertions to the request Host's community (#8028)
🤖 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>
2026-10-01 23:56:25 +00:00

268 lines
9.9 KiB
Rust
Raw Permalink 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;
/// 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)));
}
}