mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Depends on #6994 (merged). Stack: PR 2 #6994 (merged) → this PR (#7109) → PR 4 #7148 → PR 5 ## What Production assertion runtime for NIP-FI Phase A: JWKS caching layer, SSRF-hardened HTTP fetcher, startup validation gate, NIP-11 discovery serialization, federated assertion verifier with sealed key-source authority, and supporting invariant tests. ## Changes ### `crates/buzz-auth/src/nip_fi/jwks/` `ProductionJwksSource<F>` implements the sealed `IssuerKeySource` trait: - `HttpJwksFetcher`: reqwest-backed fetch with SSRF protection — URI validation (HTTPS, no credentials, no fragment, no host matched by the shared enumerated deny policy), per-fetch DNS resolution rejecting any resolved address matched by the shared enumerated deny policy, address pinning to prevent DNS rebinding TOCTOU, redirect denial, incremental body streaming capped at 512 KiB before any parse - IPv6 host extraction via typed `Url::host()` accessor (strips brackets before SSRF check and provides the correct bare input form for reqwest `resolve()` pinning; the bracketed form from `host_str()` fails `IpAddr::parse` and does not match the URL authority key) - Complete-operation deadline via `tokio::time::timeout` covering DNS resolution through body streaming - Bounded periodic refresh with configurable interval and hard snapshot deadline - Cancellation-safe RAII refresh permit: dropped on future cancellation so the next caller can re-fetch - Content-digest-gated generation counter: identical re-fetches preserve generation; key rotations advance it - Injectable clock (`now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>`): production uses `Arc::new(Utc::now)`; all four deadline creation and expiry checks use `(self.now_fn)()`, enabling controlled-time testing without wall-clock sleep `JwksSourceContract`: a closed value type that is the single source of truth for the three deployment fields whose change alters which keys the runtime trusts and how long it trusts them: - `jwks_uri` — selects the authenticated key source; validated at construction (HTTPS, no credentials/fragment, no bare private-IP host); stored as the `Url`-normalized form so that equivalent spellings (uppercase host, explicit default port `:443`, dot-segment paths like `/.well-known/./jwks.json`) converge to the same `AssertionPolicyId` - `refresh_interval_seconds` — defines bounded refresh behavior; positive, ≤ 1 year, strictly < `key_snapshot_hard_deadline_seconds` - `key_snapshot_hard_deadline_seconds` — defines the source's accepted time rule; every `VerifiedAssertion.revalidation_dependencies` deadline derives from this `JwksSourceContract` is a required `IssuerPolicy` input and is included in `derive_assertion_policy_id` after a domain separator. `IssuerJwksConfig` embeds the contract instead of independently restating these fields — startup validation rejects any contract mismatch (`NipFiStartupError::JwksContractMismatch`). ### `crates/buzz-auth/src/nip_fi/verifier.rs` - `FederatedAssertionVerifier<S>`: provider-neutral verifier over a closed multi-issuer registry and sealed `IssuerKeySource` - `Arc<S>: IssuerKeySource` forwarding impl (blanket seal for `Arc<S>` in the sealed module) — one `Arc<ProductionJwksSource>` can be shared across multiple verifiers; all observe JWKS refreshes through the shared cache without rebuilding the verifier - `AssertionKeySet`: crate-private constructor seals issuer binding — no external crate can relabel issuer B's JWKS as issuer A - Sealed `IssuerKeySource` trait closes the authority-construction seam at both ends ### `crates/buzz-core/src/network.rs` Renamed `is_private_ip` to `is_not_global_unicast` (compat alias retained) and restored the complete IANA deny/exception table from this branch's own history (`272dacadb`). The predicate is an enumerated deny/explicit exception policy: addresses covered by a named deny rule are rejected; addresses not covered by any explicit deny rule (e.g. `fe00::1`) pass through. Deny rules are derived from the IANA Special-Purpose Address Space registries (last updated 2025-10-09), with globally-reachable exceptions carved out explicitly (e.g. PCP/TURN anycast inside 2001::/23). Blocked IPv4 classes: loopback (127/8), private RFC 1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16), unspecified (0/8), broadcast, CGNAT/RFC 6598 (100.64/10), benchmarking/RFC 2544 (198.18/15), IETF Protocol Assignments (192.0.0.0/24, globally reachable exceptions: 192.0.0.9 PCP anycast RFC 7723 and 192.0.0.10 TURN anycast RFC 8155), documentation/RFC 5737 (192.0.2/24, 198.51.100/24, 203.0.113/24), deprecated 6to4 relay anycast (192.88.99.0/24, RFC 7526, global=None → conservative deny), multicast/RFC 5771 (224/4), reserved class-E (240/4). Blocked IPv6 classes: loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10), deprecated site-local (fec0::/10, RFC 3879), multicast (ff00::/8), IETF Protocol Assignments envelope (2001::/23, globally reachable exceptions: 2001:1::1–::3 PCP/TURN/DNS-SD anycast, 2001:3::/32 AMT RFC 7450, 2001:4:112::/48 AS112-v6 RFC 7535, 2001:20::/28 ORCHIDv2 RFC 7343, 2001:30::/28 DETs RFC 9374), documentation (2001:db8::/32 RFC 3849, 3fff::/20 RFC 9637), 6to4 (2002::/16, RFC 3056), Discard-Only (100::/64, RFC 6666), Dummy IPv6 Prefix (100:0:0:1::/64, RFC 9780), SRv6 SIDs (5f00::/16, RFC 9252), NAT64 local-use (64:ff9b:1::/48, RFC 8215). IPv4 embedded in mapped, compatible, NAT64 well-known (64:ff9b::/96), and SIIT IPv4-translated (::ffff:0:0:0/96) forms is checked recursively. All three callers (JWKS boundary, webhook SSRF, link-preview SSRF) inherit the complete predicate through the inline `is_private_ip` compatibility alias. ### Invariant coverage **Canonical URI convergence.** `jwks_contract_uri_canonicalization_convergence_and_divergence` asserts that uppercase host, explicit `:443`, and dot-segment path (`/.well-known/./jwks.json`) each produce the same `AssertionPolicyId` as the canonical form; a genuinely different host or path diverges. Mutation: storing raw input bytes instead of `parsed.to_string()` turns the three convergence assertions red. **Resolved-target and pin-input seam.** `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` carries a public `2606:4700::1` URI through all three stages of `fetch_jwks_inner`: `extract_url_host_and_port` yields the bare host (no brackets), `resolve_and_check_ssrf` takes the IP-literal fast path and returns the accepted `IpAddr`, and the extracted host string equals the URL authority form (verifying the correct bare input to reqwest's `resolve()` pin call). `fec0::1` traverses the same extraction and SSRF stages and is rejected as `InvalidUri`. Network-free: both addresses are IP literals with no DNS lookup. Mutation: restoring `host_str()` brackets the address, `IpAddr::parse` fails, the SSRF fast path is unreachable, and all three assertions flip red. **Controlled original-deadline rotation.** `shared_arc_source_verifier_rejects_expired_a1_accepts_a2` uses an `AtomicI64`-backed injectable clock to advance past A1's original absolute deadline without wall-clock sleep. A1's deadline is computed at T0 and never mutated. The clock advances to T0 + HARD_DEADLINE_SECS + 1; `get_snapshot` fires a re-fetch and installs A2. One unchanged `FederatedAssertionVerifier` then rejects A1-signed tokens (deadline enforced by the `key_set` read path) and accepts A2-signed tokens, proves A2's generation is strictly greater, and confirms A2's deadline is later than A1's original. Mutation oracle: replace the shared `Arc` with an independently constructed source built from the same configs and sharing the same controlled clock, warmed with a separate A1 fetch before advancement. Post-advancement, `key_set()` on the verifier's independent source filters the expired A1 snapshot (`filter(|c| now < c.hard_deadline)`) and returns no keys — the verifier never re-fetches and never observes A2. A1-reject stays green (the independent cache is also expired, so no A1 keys are served), but A2-accept flips red, because the verifier never observes A2. A2 acceptance is the reliable shared-source oracle. **Complete SSRF classifier boundary.** JWKS-boundary tests cover every newly restored class through `validate_jwks_uri` (URI-validation path): `192.0.0.1` (IETF Protocol Assignments interior), `192.0.0.9`/`.10` (PCP/TURN anycast global exceptions), `192.88.99.1` (deprecated 6to4 anycast), `2001:2::1` (2001::/23 interior), `2001:1::1` (2001::/23 global exception), `100::1` (Discard-Only), `3fff::1` (documentation), and `5f00::1` (SRv6 SIDs). URI validation and resolved-target enforcement share the same `is_not_global_unicast` predicate, so these URI-path tests exercise the complete classifier table. All pass mutation: removing any deny branch makes the rejection assertion red; removing any exception branch makes the acceptance assertion red. The resolved-target enforcement path is covered separately by `resolved_target_and_pin_key_seam_public_ipv6_and_fec0_rejection` for `::1` (loopback), public `2606:4700::1`, and `fec0::1`. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Cea Stapleton Cordasco <261786559+cea@users.noreply.github.com>
257 lines
9.3 KiB
Rust
257 lines
9.3 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::{
|
||
validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities,
|
||
ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier,
|
||
FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher,
|
||
IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry,
|
||
JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError,
|
||
ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass,
|
||
TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER,
|
||
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 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)));
|
||
}
|
||
}
|