diff --git a/Cargo.lock b/Cargo.lock index 9ad2a913e6b..88ef62ae31c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1291,7 +1291,9 @@ dependencies = [ "futures-util", "hex", "hmac 0.13.0", + "http-body-util", "infer", + "jsonwebtoken", "mesh-llm-host-runtime", "mesh-llm-sdk", "metrics", @@ -1311,6 +1313,7 @@ dependencies = [ "rustls", "serde", "serde_json", + "serde_urlencoded", "serde_yaml", "sha2 0.11.0", "sqlx", diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index c21872351f1..29f699fd930 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -52,7 +52,7 @@ pub use nip_fi::{ IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, - TransportContractId, VerifiedAssertion, VerifierError, CLIENT_ATTACHED_HEADER, + TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; @@ -61,6 +61,8 @@ 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::StaticIssuerKeySource; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip_fi/assertion.rs b/crates/buzz-auth/src/nip_fi/assertion.rs index 8b8a566cf60..66a6e2a3201 100644 --- a/crates/buzz-auth/src/nip_fi/assertion.rs +++ b/crates/buzz-auth/src/nip_fi/assertion.rs @@ -199,6 +199,31 @@ impl VerifiedAssertion { pub const fn revalidation_dependencies(&self) -> &RevalidationDependencies { &self.revalidation_dependencies } + + /// Test-only constructor: mint a minimal `VerifiedAssertion` for a given + /// `asserted_key`. All other fields are set to safe, arbitrary defaults. + /// + /// Used in unit tests that need to supply a `VerifiedAssertion` with a + /// specific `asserted_key` without performing a real JWKS verification. + #[cfg(any(test, feature = "test-utils"))] + pub fn new_for_test(asserted_key: nostr::PublicKey) -> Self { + use chrono::Duration; + Self::seal( + "https://test.issuer.example".to_owned(), + "test-subject".to_owned(), + Some(asserted_key), + CanonicalCapabilities::from_pairs(vec![]), + vec![Utc::now() + Duration::seconds(3600)], + AssertionPolicyId::zero(), + TransportContractId::zero(), + RevalidationDependencies::new( + "test-kid".to_owned(), + 1, + Utc::now() + Duration::seconds(3600), + "test.header.sig".to_owned(), + ), + ) + } } impl fmt::Debug for VerifiedAssertion { diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 8dabb00b12b..99910598426 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -104,6 +104,12 @@ impl AssertionPolicyId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// All-zeros sentinel for use in tests only. + #[cfg(any(test, feature = "test-utils"))] + pub fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for AssertionPolicyId { @@ -144,6 +150,12 @@ impl TransportContractId { pub const fn as_bytes(&self) -> &[u8; 32] { &self.0 } + + /// All-zeros sentinel for use in tests only. + #[cfg(any(test, feature = "test-utils"))] + pub fn zero() -> Self { + Self([0u8; 32]) + } } impl fmt::Debug for TransportContractId { diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index ce977090645..cc5a9cc8f4a 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -34,4 +34,9 @@ pub use jwks::{ ProductionJwksSource, }; pub use startup::{validate_nip_fi_config, NipFiMode, NipFiStartupError}; -pub use verifier::{AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError}; +pub use verifier::{ + AssertionKeySet, FederatedAssertionVerifier, IssuerKeySource, VerifierError, VerifyAssertion, +}; + +#[cfg(any(test, feature = "test-utils"))] +pub use verifier::StaticIssuerKeySource; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index cf20b57a86e..bc6afcf0f19 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -127,6 +127,20 @@ impl AssertionKeySet { }) } + /// Test-utils / test-only constructor: same validation as the crate-private + /// `new`, exposed under the `test-utils` Cargo feature and `cfg(test)` so + /// integration tests in dependent crates (e.g., `buzz-relay`) can build + /// snapshots for `StaticIssuerKeySource` without requiring a live JWKS fetch. + #[cfg(any(test, feature = "test-utils"))] + pub fn new_for_test( + issuer: String, + generation: u64, + jwks: JwkSet, + hard_deadline: DateTime, + ) -> Option { + Self::new(issuer, generation, jwks, hard_deadline) + } + /// The exact `iss` this snapshot authenticates. pub fn issuer(&self) -> &str { &self.issuer @@ -209,20 +223,20 @@ impl IssuerKeySource for std::sync::Arc { /// reconstruct the authority. An honest source returns only the snapshot bound /// to the exact issuer requested, the invariant the real runtime source /// guarantees. -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] #[derive(Clone, Default)] -pub(crate) struct StaticIssuerKeySource { +pub struct StaticIssuerKeySource { snapshots: std::collections::HashMap, /// When set, returned for every requested issuer regardless of its binding, /// to exercise the verifier's defensive issuer re-check. misbound: Option, } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl StaticIssuerKeySource { /// Build an honest source from a set of snapshots, keyed by each snapshot's /// issuer. - pub(crate) fn new(snapshots: impl IntoIterator) -> Self { + pub fn new(snapshots: impl IntoIterator) -> Self { Self { snapshots: snapshots .into_iter() @@ -235,7 +249,7 @@ impl StaticIssuerKeySource { /// A hostile/buggy source that returns the given snapshot — bound to a /// different issuer than requested — for every lookup, to exercise the /// verifier's defensive issuer re-check. - pub(crate) fn misbinding(snapshot: AssertionKeySet) -> Self { + pub fn misbinding(snapshot: AssertionKeySet) -> Self { Self { snapshots: std::collections::HashMap::new(), misbound: Some(snapshot), @@ -243,10 +257,10 @@ impl StaticIssuerKeySource { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl sealed::Sealed for StaticIssuerKeySource {} -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl IssuerKeySource for StaticIssuerKeySource { fn key_set(&self, issuer: &str) -> Option { self.misbound @@ -255,6 +269,24 @@ impl IssuerKeySource for StaticIssuerKeySource { } } +/// Object-safe wrapper for assertion verification, allowing type-erased storage +/// in `AppState` and test injection of `StaticIssuerKeySource`-backed verifiers. +/// +/// `FederatedAssertionVerifier` implements this for any `S: IssuerKeySource`. +/// The sealed `IssuerKeySource` trait still constrains who can build a real +/// verifier — this trait only erases the `S` type parameter at the storage boundary. +pub trait VerifyAssertion: Send + Sync { + /// Verify one compact JWS assertion. Semantics identical to + /// [`FederatedAssertionVerifier::verify`]. + fn verify_assertion(&self, token: &str) -> Result; +} + +impl VerifyAssertion for FederatedAssertionVerifier { + fn verify_assertion(&self, token: &str) -> Result { + self.verify(token) + } +} + /// The provider-neutral assertion verifier over a closed multi-issuer registry /// and a trusted [`IssuerKeySource`]. #[derive(Debug, Clone)] diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index deb2e7e16a5..33ffbfb9022 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -22,6 +22,7 @@ buzz-db = { workspace = true } buzz-datastore-tracing = { workspace = true } buzz-deletion = { workspace = true } buzz-auth = { workspace = true } +jsonwebtoken = { workspace = true } buzz-pubsub = { workspace = true } buzz-audit = { workspace = true } buzz-search = { workspace = true } @@ -39,6 +40,7 @@ tower-http = { workspace = true } nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_urlencoded = "0.7" tracing = { workspace = true } tracing-subscriber = { workspace = true } tracing-opentelemetry = { workspace = true } @@ -86,6 +88,7 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] +http-body-util = "0.1" mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): @@ -94,7 +97,7 @@ mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag buzz-test-client = { path = "../buzz-test-client" } ed25519-dalek = "=3.0.0-rc.0" buzz-core = { workspace = true, features = ["test-utils"] } -buzz-auth = { workspace = true, features = ["dev"] } +buzz-auth = { workspace = true, features = ["dev", "test-utils"] } reqwest = { workspace = true } tokio-tungstenite = { workspace = true } futures = "0.3" diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610de..53a18c3097b 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -8,18 +8,19 @@ use std::sync::Arc; use axum::{ extract::{Path, Query, RawQuery, State}, http::{HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use base64::Engine; use serde_json::Value; -use buzz_auth::{LimitType, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS}; +use buzz_auth::{LimitType, Nip98ReplayGuard, NipFiMode, DEFAULT_REPLAY_TTL_SECS}; use buzz_core::TenantContext; use crate::handlers::ingest::{IngestAuth, IngestError}; +use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; use crate::state::AppState; -use super::{api_error, internal_error, not_found}; +use super::{api_error, internal_error, not_found, parse_query_or_400}; pub(crate) async fn enforce_http_admission( state: &AppState, @@ -70,7 +71,11 @@ type BridgeAuthResult = Result)>; /// Returns the authenticated public key, an event ID for replay detection, and /// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is /// a zero hash and the timestamp is absent. -pub(crate) fn verify_bridge_auth( +/// +/// Private: external callers use [`make_nip98_closure_for_admission`] (admitted +/// surfaces) or [`verify_nip98_exempt_invite_claim`] / +/// [`verify_nip98_exempt_operator`] (explicitly-named exempt paths). +fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, @@ -80,7 +85,7 @@ pub(crate) fn verify_bridge_auth( verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) } -pub(crate) fn verify_bridge_auth_with_options( +fn verify_bridge_auth_with_options( headers: &HeaderMap, method: &str, url: &str, @@ -146,6 +151,99 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +// ── NIP-FI Authority boundary ───────────────────────────────────────────────── +// +// The two functions below are the ONLY `pub(crate)` entry points to the raw +// NIP-98 verifier. All other callers must use one of: +// +// • `make_nip98_closure_for_admission` — for HTTP surfaces under NIP-FI +// admission. The closure is passed directly to `admit_nip_fi_http_on_state` +// and its result is never projected outside a `NipFiAdmission`. +// +// • `verify_nip98_exempt_invite_claim` / `verify_nip98_exempt_operator` — +// for the two explicitly NIP-FI-exempt paths that pre-date NIP-FI and must +// continue to run independently of the NIP-FI state machine. +// +// [FI-TRACE-AUTHORITY-EXEMPT]: grep this tag to audit all exempt call sites. + +/// Build a NIP-98 extraction closure suitable for passing directly to +/// [`crate::nip_fi_http::admit_nip_fi_http_on_state`]. +/// +/// The closure captures all needed parameters by value and, when called, +/// runs the full NIP-98 verification (including optional payload-tag check and +/// X-Pubkey dev-mode fallback) with the same semantics as the private +/// `verify_bridge_auth_with_options`. +/// +/// Callers outside `bridge.rs` MUST use this instead of calling the private +/// verifier directly. The pubkey in the closure's result is only accessible +/// through the `NipFiAdmission` produced by `admit_nip_fi_http_on_state` — +/// it cannot be projected without executing the full admission sequence. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); see nip_fi_http.rs allow blocks. +#[allow(clippy::result_large_err)] +#[allow(clippy::type_complexity)] // The return type IS the admission closure contract; a type alias cannot name impl Trait +pub(crate) fn make_nip98_closure_for_admission( + headers: HeaderMap, + method: &'static str, + url: String, + body: Option>, + require_auth_token: bool, + require_payload: bool, +) -> impl FnOnce() -> Result)>, axum::http::Response> +{ + move || { + verify_bridge_auth_with_options( + &headers, + method, + &url, + body.as_deref(), + require_auth_token, + require_payload, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + } +} + +/// NIP-FI-exempt NIP-98 verifier for the invite-claim path. +/// +/// Invite claims run before a tenant's NIP-FI config is consulted and are +/// structurally outside the NIP-FI state machine. This function makes the +/// exemption nameable and greppable. [FI-TRACE-AUTHORITY-EXEMPT] +pub(crate) fn verify_nip98_exempt_invite_claim( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> BridgeAuthResult { + verify_bridge_auth_with_options( + headers, method, url, body, + true, // invite-claim always requires NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + ) +} + +/// NIP-FI-exempt NIP-98 verifier for operator-management endpoints. +/// +/// Operator endpoints use a separate auth origin and are structurally outside +/// the per-tenant NIP-FI state machine. [FI-TRACE-AUTHORITY-EXEMPT] +pub(crate) fn verify_nip98_exempt_operator( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> BridgeAuthResult { + verify_bridge_auth_with_options( + headers, + method, + url, + body, + true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback + body.is_some(), + ) +} + /// Check NIP-98 replay and record the event ID atomically. /// /// The correctness boundary is the shared, community-scoped Redis seen-set on @@ -719,11 +817,13 @@ fn truncate_reason(s: &str, max_bytes: usize) -> &str { } /// Submit a signed Nostr event via HTTP bridge (NIP-98 auth). +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn submit_event( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped write, identical to the WS door in `router.rs`. // Unmapped host or lookup failure fails closed with a generic 404 — never a @@ -739,20 +839,36 @@ pub async fn submit_event( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /events carries an authorization-relevant body (the event determines + // resource, effect, and state change), so a payload tag is required in + // NIP-FI enforce mode. [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission: NIP-98 extraction runs inside the closure, followed by + // assertion verify → pair → deny-map in fixed order. The proven pubkey is + // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Everything after auth — admission, replay, membership, parse, ingest — @@ -820,7 +936,7 @@ pub async fn submit_event( } } - outcome.into_response() + Ok(outcome.into_response().into_response()) } /// Log-context outcome for a single [`submit_event`] call. @@ -1007,11 +1123,13 @@ async fn submit_event_authed( /// Query events via HTTP bridge (NIP-98 auth). Returns JSON array of events. /// /// Enforces channel access: results are filtered to channels the user can access. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn query_events( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped read, identical to the WS door in `router.rs`. // An unmapped host or lookup failure fails closed with a generic 404 — never @@ -1028,20 +1146,33 @@ pub async fn query_events( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /query carries an authorization-relevant body (filter selects the + // resources returned), so a payload tag is required in enforce mode. + // [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Admission, replay, membership, and filter execution all run inside the @@ -1080,7 +1211,7 @@ pub async fn query_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`query_events`], run once NIP-98 auth succeeds. @@ -1551,11 +1682,13 @@ async fn repair_requested_channel_access( /// /// Enforces channel access: only counts events in channels the user can access. /// For filters without a `#h` tag, falls back to per-event counting with access checks. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn count_events( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Result { + use axum::response::IntoResponse as _; // Row zero: bind this HTTP request to its community from the request host // before any tenant-scoped read, identical to the WS door in `router.rs` // and `query_events`/`submit_event` above. Fail-closed; never a default @@ -1571,20 +1704,33 @@ pub async fn count_events( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = verify_bridge_auth( - &headers, - "POST", - &url, - Some(&body), - state.config.require_auth_token, - )?; + // In NIP-FI enforce/deny-protected mode, a real NIP-98 event is mandatory. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + // POST /count carries an authorization-relevant body (filter selects what + // is counted), so a payload tag is required in enforce mode. + // [NIP-FI.md:619-637] + let nip_fi_enforce = matches!(state.config.nip_fi.mode, NipFiMode::Enforce); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(&state, &headers, || { + verify_bridge_auth_with_options( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token || nip_fi_active, + nip_fi_enforce, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, (auth.event_id_bytes, auth.signed_created_at))) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); let pubkey_hex = pubkey.to_hex(); // Admission, replay, membership, and count execution all run inside the @@ -1621,7 +1767,7 @@ pub async fn count_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`count_events`], run once NIP-98 auth succeeds. @@ -2350,12 +2496,13 @@ async fn synthesize_presence( /// (`restricted`) pass `None` and keep the bare-path expectation. The verbatim /// request query is used (not a re-serialized parse) so the match stays byte-exact /// with what the client signed regardless of param order or encoding. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_moderation_read( state: &Arc, headers: &HeaderMap, path: &str, raw_query: Option<&str>, -) -> Result)> { +) -> Result { let raw_host = headers .get(axum::http::header::HOST) .and_then(|v| v.to_str().ok()) @@ -2367,6 +2514,7 @@ async fn authorize_moderation_read( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let path_with_query = match raw_query { @@ -2374,12 +2522,29 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let VerifiedBridgeAuth { - pubkey, - event_id_bytes, - .. - } = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; - check_nip98_replay(state, &tenant, event_id_bytes).await?; + // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state(state, headers, || { + verify_bridge_auth( + headers, + "GET", + &url, + None, + state.config.require_auth_token || nip_fi_active, + ) + .map(|auth| Nip98Proof::new(auth.pubkey, auth.event_id_bytes)) + .map_err(|e| e.into_response()) + })?; + let pubkey = *admission.proven_pubkey(); + let event_id_bytes = admission.into_extra(); + + check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; let pubkey_bytes = pubkey.to_bytes().to_vec(); crate::handlers::moderation_authz::authorize_moderation_action( @@ -2396,6 +2561,7 @@ async fn authorize_moderation_read( StatusCode::FORBIDDEN, "restricted: moderator access required", ) + .into_response() })?; Ok(tenant) @@ -2423,16 +2589,28 @@ pub async fn moderation_reports( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(q): Query, -) -> Result, (StatusCode, Json)> { - let tenant = authorize_moderation_read( +) -> Response { + let tenant = match authorize_moderation_read( &state, &headers, "/moderation/reports", raw_query.as_deref(), ) - .await?; - let rows = state + .await + { + Ok(t) => t, + Err(r) => return r, + }; + // Parse query after admission so malformed params cannot 400 before the + // NIP-FI gate fires. A parse failure after admission is a caller error + // (400), not an auth failure; defaulting silently would change query + // semantics (e.g. drop a valid `status=` together with a bad `limit=`). + // [FI-TRACE-HTTP-INGRESS] + let q: ModerationReadQuery = match parse_query_or_400(raw_query.as_deref()) { + Ok(q) => q, + Err(e) => return e.into_response(), + }; + match state .db .list_moderation_reports( tenant.community(), @@ -2440,8 +2618,10 @@ pub async fn moderation_reports( clamp_limit(q.limit), ) .await - .map_err(|e| internal_error(&format!("list reports: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(report_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(report_json).collect())).into_response(), + Err(e) => internal_error(&format!("list reports: {e}")).into_response(), + } } /// `GET /moderation/audit` — the moderation audit log (NIP-98 + mod-authz). @@ -2449,32 +2629,54 @@ pub async fn moderation_audit( State(state): State>, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(q): Query, -) -> Result, (StatusCode, Json)> { - let tenant = - authorize_moderation_read(&state, &headers, "/moderation/audit", raw_query.as_deref()) - .await?; - let rows = state +) -> Response { + let tenant = match authorize_moderation_read( + &state, + &headers, + "/moderation/audit", + raw_query.as_deref(), + ) + .await + { + Ok(t) => t, + Err(r) => return r, + }; + // Parse query after admission so malformed params cannot 400 before the + // NIP-FI gate fires. A parse failure after admission is a caller error + // (400), not an auth failure; defaulting silently would change query + // semantics. [FI-TRACE-HTTP-INGRESS] + let q: ModerationReadQuery = match parse_query_or_400(raw_query.as_deref()) { + Ok(q) => q, + Err(e) => return e.into_response(), + }; + match state .db .list_moderation_actions(tenant.community(), clamp_limit(q.limit)) .await - .map_err(|e| internal_error(&format!("list actions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(action_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(action_json).collect())).into_response(), + Err(e) => internal_error(&format!("list actions: {e}")).into_response(), + } } /// `GET /moderation/restricted` — currently banned/timed-out members. pub async fn moderation_restricted( State(state): State>, headers: HeaderMap, -) -> Result, (StatusCode, Json)> { +) -> Response { let tenant = - authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await?; - let rows = state + match authorize_moderation_read(&state, &headers, "/moderation/restricted", None).await { + Ok(t) => t, + Err(r) => return r, + }; + match state .db .list_community_restrictions(tenant.community()) .await - .map_err(|e| internal_error(&format!("list restrictions: {e}")))?; - Ok(Json(Value::Array(rows.iter().map(ban_json).collect()))) + { + Ok(rows) => Json(Value::Array(rows.iter().map(ban_json).collect())).into_response(), + Err(e) => internal_error(&format!("list restrictions: {e}")).into_response(), + } } fn report_json(r: &buzz_db::moderation::ReportRecord) -> Value { @@ -4238,4 +4440,1043 @@ mod postgres_tests { "attribution line must carry the pubkey;\nlog:\n{log}" ); } + + // ── NIP-FI production-seam tests (F4) ──────────────────────────────────── + // + // These tests drive real HTTP requests through the axum router with NIP-FI + // in Enforce mode and a valid NIP-98 event but NO assertion header. Each + // test must go red if the `admit_nip_fi_http_on_state` call is deleted or + // inverted at the corresponding production call site. + // + // Falsifiability: a request with valid NIP-98 + no assertion in Enforce + // mode → NIP-FI gate fires → 401 (MissingEvidence). If the gate is removed, + // the request proceeds past NIP-FI to community lookup → succeeds (community + // is provisioned) → further processing → some other status (200, 400, etc.) + // that is NOT 401. The assert_eq fires. + // + // Why `#[ignore = "requires Postgres"]`: the handlers call bind_community + // before the NIP-FI gate; the community must exist for the NIP-98 URL to + // match. All four protected surfaces need Postgres for the NIP-FI seam test + // to be exercised (vs. bailing at community lookup with 404 before NIP-FI). + // + // ## NIP-FI route classification + // + // Route classification (PROTECTED vs. EXEMPT) is now owned by + // `router.rs::NIP_FI_EXEMPT_PREFIXES` and enforced by the + // `nip_fi_assertion_guard` middleware layer. See the comment block at the + // top of `router.rs` for the complete classification and the rationale. + // + // The seam tests below remain the executable proof that each handler's own + // `admit_nip_fi_http_on_state` gate is wired correctly (full pairing and + // deny-map); the guard is the backstop that fires when a handler omits it. + + /// Build an AppState with NIP-FI in Enforce mode for production-seam tests. + /// + /// Sets `nip_fi.mode = Enforce` while leaving `nip_fi_verifier = None` + /// (startup race: no issuers configured → verifier not built). This is + /// sufficient for the seam test because the NIP-FI gate fires with 401 + /// (MissingEvidence) when the assertion header is absent, BEFORE any + /// verifier lookup. `require_auth_token = true` forces real NIP-98. + /// + /// Returns `None` when local Postgres is not reachable. + async fn nip_fi_enforce_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + // No issuers configured → nip_fi_verifier = None (startup-race path). + // The seam test fires before verifier is needed (missing assertion → 401). + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Build an AppState with NIP-FI in Off mode for production-seam regression tests. + /// + /// `require_auth_token = false` so requests without NIP-98 auth still reach + /// the application logic rather than rejecting at the NIP-98 layer. + async fn nip_fi_off_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Build an AppState with NIP-FI in DenyProtected mode. + async fn nip_fi_deny_protected_test_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::DenyProtected; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _audit_shutdown) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(Arc::new(state)) + } + + /// Sign a NIP-98 event for a given URL and method, returning a valid + /// `Authorization: Nostr ` header map. + /// + /// Includes a `payload` tag for the given body bytes so the event passes + /// the payload-binding check in NIP-FI Enforce mode. For GET or empty + /// bodies pass `b""` — the SHA-256 of an empty body is included regardless, + /// keeping the event unconditionally valid through `verify_bridge_auth_with_options`. + fn make_nip98_headers( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + ) -> axum::http::HeaderMap { + use base64::engine::general_purpose::STANDARD as BASE64; + use sha2::{Digest, Sha256}; + let payload_hex = hex::encode(Sha256::digest(body)); + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + Tag::parse(["payload", &payload_hex]).expect("payload tag"), + ]; + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + let event_json = serde_json::to_string(&event).expect("serialize NIP-98 event"); + let value = format!("Nostr {}", BASE64.encode(event_json.as_bytes())); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header"), + ); + headers + } + + /// Drive a single oneshot request through the full relay router and return + /// the HTTP status. + async fn oneshot_request( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> axum::http::StatusCode { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("host", host); + for (name, value) in &headers { + builder = builder.header(name, value); + } + crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot") + .status() + } + + // ── F4: bridge POST /events — enforce mode, no assertion → 401 ────────── + // + // Falsifying mutation: delete the `admit_nip_fi_http_on_state` call in + // `submit_event` (bridge.rs). The NIP-98 is valid; without the gate the + // request reaches ingest → returns 200 or a different non-401 status. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_events_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/events"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"{}"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/events", + &host, + auth_headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /events with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from submit_event" + ); + } + + // ── F4: bridge POST /query — enforce mode, no assertion → 401 ─────────── + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_query_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/query", + &host, + auth_headers, + b"[]", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /query with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from query_events" + ); + } + + // ── F4: bridge POST /count — enforce mode, no assertion → 401 ─────────── + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_bridge_count_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/count"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/count", + &host, + auth_headers, + b"[]", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /count with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from count_events" + ); + } + + // ── F4: moderation GET — enforce mode, no assertion → 401 ─────────────── + // + // Shared witness for all three moderation routes: they share + // `authorize_moderation_read` which calls `admit_nip_fi_http_on_state`. + // This test covers the shared call site; the other two routes are covered + // transitively. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_reports_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/moderation/reports"); + let auth_headers = make_nip98_headers(&keys, &url, "GET", b""); + + let status = rt.block_on(oneshot_request( + state, + "GET", + "/moderation/reports", + &host, + auth_headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: GET /moderation/reports with valid NIP-98 + no assertion MUST \ + deny 401 [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate \ + was removed from authorize_moderation_read" + ); + } + + // ── F4: GIF search — enforce mode, no assertion → 401 ─────────────────── + // + // Shared witness for both GIF routes (search + share both go through + // `authenticate` which calls `admit_nip_fi_http_on_state`). + // + // Falsifying mutation: delete the NIP-FI check from `gifs::authenticate`. + // Without the gate, the request proceeds to Klipy config check → 404 + // (GIF search not configured in the test state). 404 ≠ 401. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"{}"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + auth_headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST {} with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from gifs::authenticate", + crate::api::gifs::SEARCH_PATH + ); + } + + // ── F4: workflow runs — enforce mode, no assertion → 401 ──────────────── + // + // Shared witness for both workflow routes (`authorize_workflow_read` + // calls `admit_nip_fi_http_on_state`). + // + // Falsifying mutation: delete the NIP-FI check from + // `authorize_workflow_read`. The request proceeds to workflow lookup → + // 404 (no workflow with the test UUID). 404 ≠ 401. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_runs_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let workflow_id = uuid::Uuid::new_v4(); + let keys = Keys::generate(); + let path = format!("/workflows/{workflow_id}/runs"); + let url = format!("https://{host}{path}"); + let auth_headers = make_nip98_headers(&keys, &url, "GET", b""); + + let status = rt.block_on(oneshot_request( + state, + "GET", + &path, + &host, + auth_headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: GET {path} with valid NIP-98 + no assertion MUST deny 401 \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed from authorize_workflow_read" + ); + } + + // ── F4: bridge POST /query — off mode, no assertion → reaches application ─ + // + // Regression guard [FI-INV-15]: in Off mode the NIP-FI gate MUST be + // transparent. The request has no assertion header and no auth at all + // (require_auth_token=false in off state). It MUST NOT produce a NIP-FI + // denial (401/403/503). Any application-level response (even 404 or 500) is + // acceptable — the gate was not the source. + // + // Falsifying mutation: enabling NIP-FI mode in the Off state would cause the + // gate to fire; the response would be 401, not the downstream 401 from + // missing auth. Wait — Off state has require_auth_token=false, so an + // anonymous /query without any assertion would reach the application layer + // and produce a non-NIP-FI response (could be 200 [] on an open relay). The + // key observable: the status MUST NOT be produced by the NIP-FI gate in Off + // mode. We verify by checking the response body is NOT the NIP-FI contract + // text ("authentication required\n"). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_off_bridge_query_no_assertion_is_not_nip_fi_denied() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // X-Pubkey dev-mode auth: passes verify_bridge_auth (require_auth_token=false + // in Off state) and reaches admit_nip_fi_http_on_state, which MUST admit + // unconditionally in Off mode. + // + // We cannot use no-auth-at-all because verify_bridge_auth returns 401 + // ("missing Nostr auth") before the NIP-FI gate is reached, making the + // assert_ne!(_, UNAUTHORIZED) trivially falsifiable for the wrong reason. + // X-Pubkey is the correct dev-mode bypass when require_auth_token=false. + let keys = nostr::Keys::generate(); + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "x-pubkey", + keys.public_key().to_hex().parse().expect("valid header"), + ); + + let status = rt.block_on(oneshot_request( + state, "POST", "/query", &host, headers, b"[]", + )); + + // In Off mode the NIP-FI gate is transparent — any downstream status + // (200, 400, 500) is acceptable. The forbidden outcomes are NIP-FI + // gate denials: 401 (Enforce missing_evidence) and 503 (DenyProtected). + // + // Mutation evidence: changing the test state to Enforce mode causes the + // gate to fire (no Nostr-Federated-Identity header) returning 401, which + // falsifies the first assert_ne. + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI Off mode MUST NOT produce 401 from the gate [FI-INV-15]" + ); + assert_ne!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI Off mode MUST NOT produce 503 from the gate [FI-INV-15]" + ); + } + + // ── T2-seam: admitted malformed query through real handler → 400 ───────── + // + // Thufir's required seam test: one admitted malformed-query request through + // a real affected handler (`moderation_reports`) asserting 400. + // + // ## What this proves + // + // With the old `.ok().unwrap_or_default()` behavior: `?status=open&limit=abc` + // silently discarded ALL query fields (the entire `ModerationReadQuery` + // became `Default`) and the handler returned 200 with all reports. + // With `parse_query_or_400`: the handler returns 400 after admission. + // + // The test would fail against the old code because the handler would return + // 200 (list all reports) rather than 400. + // + // ## Setup + // + // NIP-FI Off mode + `require_auth_token = false` allows X-Pubkey dev-mode + // auth to bypass NIP-98 and NIP-FI gates, admitting the request to the + // application layer. The actor is seeded as community "owner" so the + // moderation authz check passes without requiring real relay member rows. + // + // ## Falsifying mutation + // + // Revert `parse_query_or_400` to `.ok().unwrap_or_default()` in + // `moderation_reports`. The handler returns 200 (all reports for the + // freshly created community — an empty array `[]`) instead of 400. + // The `assert_eq!(status, BAD_REQUEST)` assertion panics. + #[test] + #[ignore = "requires Postgres"] + fn t2_admitted_malformed_query_through_moderation_reports_is_400() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + // Off mode: NIP-FI gate is transparent; require_auth_token=false allows + // X-Pubkey dev-mode auth to admit the request. + let Some(state) = rt.block_on(nip_fi_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + + let host = format!("t2-seam-{}.local", uuid::Uuid::new_v4().simple()); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // Seed the test actor as "owner" so moderation authz passes. + let actor_keys = Keys::generate(); + let actor_hex = actor_keys.public_key().to_hex(); + rt.block_on( + state + .db + .add_relay_member(community.id, &actor_hex, "owner", None), + ) + .expect("seed actor as owner"); + + // Build headers: X-Pubkey dev-mode admission (require_auth_token=false). + // No Nostr-Federated-Identity header — NIP-FI is Off, so the guard is + // transparent and the per-handler check admits unconditionally. + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-pubkey", actor_hex.parse().expect("valid header")); + + // Malformed query: `status=open` is valid but `limit=abc` is not. + // Old behavior: `.ok().unwrap_or_default()` → status=None, limit=None + // (all fields dropped), handler returns 200. + // New behavior: `parse_query_or_400` → 400 BAD_REQUEST. + let status = rt.block_on(oneshot_request( + state, + "GET", + "/moderation/reports?status=open&limit=abc", + &host, + headers, + b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::BAD_REQUEST, + "T2 seam: GET /moderation/reports?status=open&limit=abc after admission MUST \ + return 400; if this returns 200 the handler is still using .ok().unwrap_or_default() \ + which silently discards all query fields on parse error [FI-TRACE-HTTP-INGRESS T2]" + ); + } + + // ── T1-IMP2: POST /internal/git/policy — Enforce mode → NOT 401 ───────── + // + // Verifies that `/internal/git/policy` is exempt from the NIP-FI guard in + // Enforce mode. The pre-receive hook callback carries no + // Nostr-Federated-Identity assertion and must reach the policy handler's + // own authorization layer, not be rejected by the guard. + // + // ## What this proves + // + // In Enforce mode, every non-exempt route without an assertion header gets + // 401 (MissingEvidence) from `nip_fi_assertion_guard`. `/internal/git/policy` + // appears in `NIP_FI_EXEMPT_PREFIXES`, so the guard forwards it instead. + // `require_localhost` then rejects (403) because Tower's `oneshot` does not + // inject `ConnectInfo`. A 403 proves the NIP-FI guard was NOT the rejector; + // a 401 would mean the guard fired and the exempt entry is broken. + // + // ## Falsifying mutation + // + // Remove `"/internal/git/policy"` from `NIP_FI_EXEMPT_PREFIXES` in + // `router.rs`. The guard fires, returns 401, and the `assert_ne!(401)` + // assertion panics. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_git_policy_callback_reaches_own_auth_not_nip_fi_guard() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + + // Minimal syntactically-valid payload — the HMAC will fail (no real + // hook secret), so the policy handler returns 403. We only care that + // the NIP-FI guard does NOT produce a 401 first. + let body = br#"{ + "repo_id": "test-repo", + "repo_owner": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "community_id": "test", + "pusher_pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "ref_updates": [], + "timestamp": 1234567890, + "signature": "0000000000000000000000000000000000000000000000000000000000000000" + }"#; + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_TYPE, + "application/json".parse().expect("valid header"), + ); + // No Nostr-Federated-Identity header — the guard must pass this through. + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/internal/git/policy", + "test.local", + headers, + body, + )); + + assert_ne!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "NIP-FI Enforce mode: POST /internal/git/policy with no assertion must NOT \ + be denied by the NIP-FI guard (401); the pre-receive hook does not carry an \ + assertion and must reach the policy handler's own auth layer \ + [FI-TRACE-HTTP-INGRESS T1-IMP2]" + ); + // The policy handler returns 403 (require_localhost check, since + // Tower's oneshot does not inject ConnectInfo) — not 401 from the guard. + // 403 proves the NIP-FI guard was not the rejector. + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "POST /internal/git/policy must reach its own authorization layer (403), \ + not be blocked at the NIP-FI guard layer (which would return 401)" + ); + } + + // ── F4: bridge POST /query — deny_protected mode → 503 ────────────────── + // + // DenyProtected fires the gate unconditionally before any NIP-98 check, + // returning 503 authorization_unavailable. + // + // Falsifying mutation: switching DenyProtected to Off or Enforce changes the + // status — Off admits (non-401), Enforce needs assertion (401). Either way + // this assert fails. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_deny_protected_bridge_query_is_503() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(nip_fi_deny_protected_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // DenyProtected mode has nip_fi_active=true, which forces require_auth_token + // || nip_fi_active = true in verify_bridge_auth. The NIP-98 event MUST be + // signed for the community's actual URL (https://{host}/query), not the + // config relay_url, because nip98_expected_url uses the tenant host. + // + // After verify_bridge_auth succeeds, admit_nip_fi_http_on_state fires with + // DenyProtected mode and returns 503 unconditionally — the assertion verifier + // is never consulted. + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + let auth_headers = make_nip98_headers(&keys, &url, "POST", b"[]"); + + let status = rt.block_on(oneshot_request( + state, + "POST", + "/query", + &host, + auth_headers, + b"[]", + )); + + // Mutation evidence: switching DenyProtected to Enforce causes the gate + // to return 401 (no Nostr-Federated-Identity header present); switching + // to Off causes the gate to admit and return a downstream status. Either + // change falsifies this assert_eq. + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI DenyProtected mode: POST /query MUST deny 503 authorization_unavailable \ + [FI-TRACE-HTTP-INGRESS]; if this fails the admit_nip_fi_http_on_state gate was \ + removed or mode was changed" + ); + } + + // ── T1-IMP1 (final): guard performs crypto verification, not just transport ── + // + // ## What this proves + // + // `nip_fi_assertion_guard` now performs the full offline assertion + // verification — not just transport-level shape validation. A structurally + // valid but cryptographically invalid assertion (wrong signature) MUST be + // denied by the guard with 403 `evidence_rejected`, before the handler fires. + // + // ## Why the test distinguishes guard vs per-handler + // + // The request carries a bad-sig assertion token but NO NIP-98 + // `Authorization: Nostr ...` header. With `require_auth_token = true`: + // + // • Guard intact: `verifier.verify_assertion(bad_token)` → EvidenceRejected + // → 403 (guard denies before handler fires). + // + // • Guard mutated (step 2 removed): guard forwards. Handler's NIP-98 + // auth layer fires first → missing auth → 401. + // + // 403 ≠ 401, so the mutation turns this test RED. + // + // ## What "mandatory wiring" means + // + // The removed wiring in the falsifying mutation is the + // `verifier.verify_assertion(token)` call in `nip_fi_assertion_guard` + // (`router.rs`). Removing it restores the old transport-only guard, which + // forwards any structurally valid token to the handler. That is the + // "forgotten-gate" failure class: a handler that omits + // `admit_nip_fi_http_on_state` would admit with an invalidly-signed + // assertion if the guard doesn't verify. + // + // ## Verifier construction + // + // To get a distinguishable outcome, this test injects a real + // `StaticIssuerKeySource`-backed verifier into the state (rather than + // `nip_fi_verifier = None`), so that a bad-sig token produces a definite + // 403 (not a startup-race 503 that a handler check would also produce). + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires() { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + // ── 1. Build the test state with a real injected verifier ───────────── + + let Some(mut state) = rt.block_on(async { + // Clone nip_fi_enforce_test_state setup, but return the state + // before Arc-wrapping so we can inject the verifier. + let mut config = crate::config::Config::from_env().ok()?; + config.database_url = crate::test_support::database_url(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://nip-fi-test.local".to_string(); + config.require_auth_token = true; + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .ok()?, + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?; + + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + Some(state) + }) else { + panic!("local Postgres not reachable"); + }; + + // ── 2. Build the verifier with StaticIssuerKeySource + test key ─────── + // + // The verifier is seeded with a known P-256 public key. Tokens that + // claim `iss=https://issuer.test` will be verified against this key. + // A token with an all-zero signature will fail `InvalidSignatureOrClaims` + // → DenialClass::EvidenceRejected → 403. + // + // Key constants match the canonical test key in buzz-auth + // (verifier/tests.rs): TEST_JWK_X / TEST_JWK_Y / TEST_KID / ISSUER. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": TEST_KID, + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA" + }] + })) + .expect("valid test JWKS"); + + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = AssertionKeySet::new_for_test(TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + + let policy = IssuerPolicy::new( + TEST_ISSUER.to_owned(), + vec![TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, // skew_seconds + 3600, // max_assertion_age_seconds + None, + jwks_contract, + ) + .expect("valid issuer policy"); + + let mut registry = IssuerRegistry::new(); + registry.insert(policy); + + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + )); + + state.nip_fi_verifier = Some(verifier); + let state = Arc::new(state); + + let host = format!("nip-fi-seam-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // ── 3. Build a structurally valid but cryptographically invalid token ─ + // + // Header and claims match the verifier's expectations (correct issuer, + // audience, exp, nostr_pubkey). The signature is 64 zero bytes — + // structurally valid base64url for an ES256 DER signature, but + // cryptographically invalid. The verifier will parse through to the + // signature check and fail with EvidenceRejected (403). + const BAD_SIG_TOKEN: &str = concat!( + // Header: {"alg":"ES256","kid":"test-key-1"} + "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2V5LTEifQ", + ".", + // Claims: {"iss":"https://issuer.example","aud":"https://relay.example", + // "iat":1700000000,"exp":9999999999, + // "nostr_pubkey":"1234...cdef","sub":"test-subject"} + "eyJpc3MiOiJodHRwczovL2lzc3Vlci5leGFtcGxlIiwiYXVkIjoiaHR0cHM6Ly9yZWxheS5leGFtcGxlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjk5OTk5OTk5OTksIm5vc3RyX3B1YmtleSI6IjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3ODkwYWJjZGVmMTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYiLCJzdWIiOiJ0ZXN0LXN1YmplY3QifQ", + ".", + // Signature: 64 zero bytes (invalid) + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + ); + + // Verify the token is structurally valid (3 dots, valid base64url segments) + // but is actually rejected by the verifier: + let verifier_check = state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(BAD_SIG_TOKEN); + assert!( + verifier_check.is_err(), + "pre-condition: the bad-sig token MUST be rejected by the verifier; \ + if it passes, the test cannot distinguish guard-deny from handler-deny" + ); + + // ── 4. Send the request through the production router ───────────────── + // + // The request carries: + // • Nostr-Federated-Identity: Bearer (structurally valid, bad sig) + // • NO Authorization: Nostr ... (no NIP-98) + // + // Expected with guard verifying (current code): + // Guard calls verifier.verify_assertion(bad_token) → EvidenceRejected + // → 403 evidence_rejected before handler fires. + // + // Falsifying mutation (remove verifier.verify_assertion from guard): + // Guard forwards (step 2 removed) → handler's NIP-98 auth fires first + // → missing NIP-98 → 401. 403 ≠ 401 → test fails. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {BAD_SIG_TOKEN}") + .parse() + .expect("valid header"), + ); + // Deliberately NO Authorization header (no NIP-98). + + let status = rt.block_on(oneshot_request( + state, "POST", "/events", &host, headers, b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "NIP-FI enforce mode: POST /events with cryptographically invalid assertion \ + (bad sig) MUST deny 403 evidence_rejected from the guard before the handler \ + fires [FI-TRACE-AUTHORITY-UNIFORM, T1-IMP1]. \ + Falsifying mutation: remove verifier.verify_assertion from nip_fi_assertion_guard \ + → guard forwards → missing NIP-98 → 401 ≠ 403 → test fails." + ); + } } diff --git a/crates/buzz-relay/src/api/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index c29df6746bb..871f279fc82 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -15,7 +15,7 @@ use std::time::Duration; use axum::{ extract::State, http::{header, HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use futures_util::StreamExt; use serde::Deserialize; @@ -118,12 +118,13 @@ fn klipy_share_request( .json(&serde_json::json!({ "customer_id": request.customer_id }))) } +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authenticate( state: &Arc, headers: &HeaderMap, path: &str, body: &[u8], -) -> Result<(buzz_core::TenantContext, nostr::PublicKey), (StatusCode, Json)> { +) -> Result<(buzz_core::TenantContext, nostr::PublicKey), Response> { let raw_host = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) @@ -135,23 +136,33 @@ async fn authenticate( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let expected_url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let bridge::VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = bridge::verify_bridge_auth_with_options( + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, headers, - "POST", - &expected_url, - Some(body), - true, - true, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "POST", + expected_url, + Some(body.to_vec()), + true, + true, + ), )?; - bridge::enforce_http_admission(state, &tenant, &pubkey).await?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + bridge::enforce_http_admission(state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; relay_members::enforce_relay_membership( state, tenant.community(), @@ -159,7 +170,8 @@ async fn authenticate( relay_members::extract_auth_tag_header(headers), signed_created_at, ) - .await?; + .await + .map_err(|e| e.into_response())?; Ok((tenant, pubkey)) } @@ -265,20 +277,35 @@ pub async fn search( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { +) -> Response { + search_inner(state, headers, body).await.into_response() +} + +async fn search_inner( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, Response> { + // Admission first: NIP-98 + NIP-FI must fire before any application-level + // check (including provider availability) so the denial contract wins over + // config or request-validation errors. [FI-TRACE-HTTP-INGRESS] + let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; + let Some(config) = state.config.klipy.as_ref() else { - return Err(api_error( - StatusCode::NOT_FOUND, - "GIF search is not configured", - )); + return Err( + api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), + ); }; - let (tenant, pubkey) = authenticate(&state, &headers, SEARCH_PATH, &body).await?; - let request: SearchRequest = serde_json::from_slice(&body) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON"))?; - validate_text("query", &request.query, 200, true)?; - validate_text("customer_id", &request.customer_id, 128, false)?; - validate_text("locale", &request.locale, 32, false)?; - enforce_search_admission(&state, &tenant, &pubkey).await?; + let request: SearchRequest = serde_json::from_slice(&body).map_err(|_| { + api_error(StatusCode::BAD_REQUEST, "invalid GIF search JSON").into_response() + })?; + validate_text("query", &request.query, 200, true).map_err(|e| e.into_response())?; + validate_text("customer_id", &request.customer_id, 128, false) + .map_err(|e| e.into_response())?; + validate_text("locale", &request.locale, 32, false).map_err(|e| e.into_response())?; + enforce_search_admission(&state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; let endpoint = if request.query.trim().is_empty() { "trending" @@ -294,22 +321,30 @@ pub async fn search( if !request.query.trim().is_empty() { query.push(("q", request.query.trim())); } - let url = klipy_url(config.api_key(), &["gifs", endpoint], &query)?; - let response = send_upstream(state.gif_http_client.get(url)).await?; + let url = + klipy_url(config.api_key(), &["gifs", endpoint], &query).map_err(|e| e.into_response())?; + let response = send_upstream(state.gif_http_client.get(url)) + .await + .map_err(|e| e.into_response())?; if !response.status().is_success() { tracing::warn!(status = response.status().as_u16(), "KLIPY search failed"); return Err(api_error( StatusCode::BAD_GATEWAY, "GIF provider rejected the search request", - )); + ) + .into_response()); } // Never forward the provider response wholesale. KLIPY may report an // application-level failure with HTTP 200 and include request details in // its error fields. Allowlist only successful result data so credentials // and provider diagnostics cannot cross the relay boundary. - let upstream = limited_json(response).await?; - Ok(Json(successful_search_payload(&upstream)?)) + let upstream = limited_json(response) + .await + .map_err(|e| e.into_response())?; + Ok(Json( + successful_search_payload(&upstream).map_err(|e| e.into_response())?, + )) } /// Report a selected GIF to KLIPY so the provider can update Recents. @@ -317,31 +352,44 @@ pub async fn share( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result)> { +) -> Response { + share_inner(state, headers, body).await.into_response() +} + +async fn share_inner( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result { + // Admission first: NIP-98 + NIP-FI must fire before provider availability + // check. [FI-TRACE-HTTP-INGRESS] + authenticate(&state, &headers, SHARE_PATH, &body).await?; + let Some(config) = state.config.klipy.as_ref() else { - return Err(api_error( - StatusCode::NOT_FOUND, - "GIF search is not configured", - )); + return Err( + api_error(StatusCode::NOT_FOUND, "GIF search is not configured").into_response(), + ); }; - authenticate(&state, &headers, SHARE_PATH, &body).await?; - let request: ShareRequest = serde_json::from_slice(&body) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON"))?; - validate_text("slug", &request.slug, 200, false)?; - validate_text("customer_id", &request.customer_id, 128, false)?; - - let response = send_upstream(klipy_share_request( - &state.gif_http_client, - config.api_key(), - &request, - )?) - .await?; + let request: ShareRequest = serde_json::from_slice(&body).map_err(|_| { + api_error(StatusCode::BAD_REQUEST, "invalid GIF share JSON").into_response() + })?; + validate_text("slug", &request.slug, 200, false).map_err(|e| e.into_response())?; + validate_text("customer_id", &request.customer_id, 128, false) + .map_err(|e| e.into_response())?; + + let response = send_upstream( + klipy_share_request(&state.gif_http_client, config.api_key(), &request) + .map_err(|e| e.into_response())?, + ) + .await + .map_err(|e| e.into_response())?; if !response.status().is_success() { tracing::warn!(status = response.status().as_u16(), "KLIPY share failed"); return Err(api_error( StatusCode::BAD_GATEWAY, "GIF provider rejected the share request", - )); + ) + .into_response()); } Ok(StatusCode::NO_CONTENT) diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 638e3c7156b..75a51860fd7 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -79,6 +79,7 @@ pub struct GitAuth { impl axum::extract::FromRequestParts> for GitAuth { type Rejection = Response; + #[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn from_request_parts( parts: &mut axum::http::request::Parts, state: &Arc, @@ -232,6 +233,14 @@ impl axum::extract::FromRequestParts> for GitAuth { ) .await?; + // NIP-FI admission: pubkey proven by NIP-98 above; closure supplies it. + // Assertion verify → pair → deny-map run in fixed order. The admission + // value is intentionally discarded — pubkey came from NIP-98 above. + // [FI-TRACE-AUTHORITY-UNIFORM] + let _ = crate::nip_fi_http::admit_nip_fi_http_on_state(state, &parts.headers, || { + Ok(crate::nip_fi_http::Nip98Proof::new(pubkey, ())) + })?; + Ok(GitAuth { pubkey, tenant }) } } diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6714281f40f..282b8df99d2 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -25,6 +25,7 @@ use serde::Deserialize; use serde_json::Value; use crate::handlers::side_effects::{publish_nip43_member_added, publish_nip43_membership_list}; +use crate::nip_fi_http::admit_nip_fi_http_on_state; use buzz_core::invite::{ hash_v2_code, validate_v2_code, DEFAULT_INVITE_TTL_SECS, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS, V2_PREFIX, @@ -251,14 +252,7 @@ async fn authenticate( pubkey, event_id_bytes, .. - } = bridge::verify_bridge_auth_with_options( - headers, - "POST", - &url, - Some(body), - true, // invites always require NIP-98; no X-Pubkey dev fallback - true, // POST bodies must be covered by a payload tag - )?; + } = bridge::verify_nip98_exempt_invite_claim(headers, "POST", &url, Some(body))?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; Ok((tenant, pubkey)) @@ -285,9 +279,75 @@ pub async fn mint_invite( State(state): State>, headers: HeaderMap, body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - let (tenant, pubkey) = authenticate(&state, &headers, "/api/invites", &body).await?; +) -> axum::response::Response { + // NIP-FI gate wraps the entire handler so the denial is emitted as exact + // text/plain bytes. [FI-TRACE-AUTHORITY-UNIFORM] + mint_invite_checked(state, headers, body).await +} +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +async fn mint_invite_checked( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> axum::response::Response { + use axum::response::IntoResponse as _; + + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = match crate::tenant::bind_community(&state.db, raw_host).await { + Ok(t) => t, + Err(_) => { + return api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + .into_response() + } + }; + + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, "/api/invites"); + + // NIP-FI admission: NIP-98 extraction runs inside the closure, followed by + // assertion verify → pair → deny-map in fixed order. The proven pubkey is + // only available through the returned NipFiAdmission. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = match admit_nip_fi_http_on_state( + &state, + &headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "POST", + url, + Some(body.to_vec()), + true, // invites always require NIP-98; no X-Pubkey dev fallback + true, // POST bodies must be covered by a payload tag + ), + ) { + Ok(a) => a, + Err(resp) => return resp, + }; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, _signed_created_at) = admission.into_extra(); + + // Replay detection runs after NIP-98+assertion admission (both proofs verified). + if let Err(e) = bridge::check_nip98_replay(&state, &tenant, event_id_bytes).await { + return e.into_response(); + } + + match mint_invite_inner(&state, body, tenant, pubkey).await { + Ok(json) => json.into_response(), + Err(e) => e.into_response(), + } +} + +async fn mint_invite_inner( + state: &AppState, + body: axum::body::Bytes, + tenant: buzz_core::TenantContext, + pubkey: nostr::PublicKey, +) -> Result, (StatusCode, Json)> { // Authz mirrors kind:9030 (add member): owner or admin only. let sender_hex = pubkey.to_hex(); let member = state @@ -1813,4 +1873,65 @@ mod postgres_tests { let response = get_page(state, "/api/join-policy/privacy").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + // ── NIP-FI production-seam test: POST /api/invites ──────────────────────── + // + // Gate under test: `check_nip_fi_http_on_state` called in `mint_invite_checked` + // (invites.rs:309). Seam test: valid NIP-98 + no assertion → 401 in Enforce + // mode. + // + // Falsifying mutation: delete the `check_nip_fi_http_on_state` call from + // `mint_invite_checked`. Without the gate the request proceeds to authz + // and returns 403 (not an owner/admin) or another non-401 status — the + // assert_eq! fails. + // + // Infrastructure: same `#[ignore = "requires Postgres"]` + tokio::test. + // The invites harness already has Postgres support (`invite_test_state`); + // NIP-FI enforce mode is enabled by patching the config after state + // construction so we can reuse the existing community setup. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_no_assertion_is_401() { + let host = format!("nip-fi-invites-seam-{}.local", Uuid::new_v4().simple()); + let Some(state_base) = invite_test_state(&host).await else { + return; + }; + + // Clone AppState and patch config to enable NIP-FI Enforce mode. + // nip_fi_verifier = None (no issuers configured) is correct: the seam + // test fires at the missing-assertion check before any verifier lookup. + let mut state_inner = (*state_base).clone(); + let mut config = (*state_inner.config).clone(); + config.require_auth_token = true; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + state_inner.config = Arc::new(config); + let state = Arc::new(state_inner); + + let keys = Keys::generate(); + let url = format!("https://{host}/api/invites"); + // Valid NIP-98 event with payload tag; no Nostr-Federated-Identity header. + let auth = nip98_auth_header(&keys, &url, b"{}"); + + let response = build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/invites") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, auth) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /api/invites with valid NIP-98 + no assertion MUST deny \ + 401 [FI-TRACE-HTTP-INGRESS]; if this fails the check_nip_fi_http_on_state gate was \ + removed from mint_invite_checked" + ); + } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 780532ec5d0..8bbed0206c2 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -62,6 +62,7 @@ fn upload_route_mode(path: &str) -> Result { struct MediaReadAuth { tenant: TenantContext, + pubkey: nostr::PublicKey, } const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); @@ -317,11 +318,45 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// Returns a [`BlobDescriptor`] JSON on success. // TODO(v2): Add persistent per-pubkey storage quotas. Admission limits below // bound active parser/storage work, but they do not cap durable bytes stored. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn upload_blob( State(state): State>, auth: AuthenticatedUpload, headers: HeaderMap, body: axum::body::Body, +) -> axum::response::Response { + use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; + + // NIP-FI admission: the Blossom extractor already verified the NIP-98 + // auth event; the closure supplies the proven pubkey. The admission + // function then runs assertion verify → pair → deny-map in fixed order. + // [FI-TRACE-AUTHORITY-UNIFORM] + let proven_pubkey = auth.auth_event.pubkey; + match admit_nip_fi_http_on_state(&state, &headers, || Ok(Nip98Proof::new(proven_pubkey, ()))) { + Ok(_) => {} + Err(resp) => return resp, + } + + upload_blob_inner(state, auth, headers, body).await +} + +async fn upload_blob_inner( + state: Arc, + auth: AuthenticatedUpload, + headers: HeaderMap, + body: axum::body::Body, +) -> axum::response::Response { + use axum::response::IntoResponse as _; + upload_blob_result(state, auth, headers, body) + .await + .into_response() +} + +async fn upload_blob_result( + state: Arc, + auth: AuthenticatedUpload, + headers: HeaderMap, + body: axum::body::Body, ) -> Result, MediaError> { let attribution = upload_attribution(&state, &auth, &headers).await; @@ -546,7 +581,8 @@ async fn authenticate_media_read( .await .map_err(|_| MediaError::RelayMembershipRequired)?; - Ok(MediaReadAuth { tenant }) + let pubkey = auth_event.pubkey; + Ok(MediaReadAuth { tenant, pubkey }) } fn blob_cache_control() -> &'static str { @@ -632,6 +668,7 @@ const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024; /// - Chunk capped at 16 MiB; clients request additional ranges for the rest /// /// All responses include `Accept-Ranges: bytes` so video players know seeking is supported. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn get_blob( State(state): State>, Path(sha256_ext): Path, @@ -639,6 +676,14 @@ pub async fn get_blob( ) -> Result { validate_media_path(&sha256_ext)?; let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; + let proven_pubkey = media_auth.pubkey; + if let Err(resp) = admit_nip_fi_http_on_state(&state, &req_headers, || { + Ok(Nip98Proof::new(proven_pubkey, ())) + }) { + return Ok(resp); + } serve_blob_for_tenant(&state, &media_auth.tenant, &sha256_ext, &req_headers).await } @@ -897,6 +942,7 @@ fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> { /// Content-type is derived from the validated sidecar only — never from raw S3 /// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar /// is missing, we return 404 rather than fall back to untrusted metadata. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers pub async fn head_blob( State(state): State>, headers: HeaderMap, @@ -904,6 +950,14 @@ pub async fn head_blob( ) -> Result { validate_media_path(&sha256_ext)?; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; + let proven_pubkey = media_auth.pubkey; + if let Err(resp) = + admit_nip_fi_http_on_state(&state, &headers, || Ok(Nip98Proof::new(proven_pubkey, ()))) + { + return Ok(resp); + } let tenant = media_auth.tenant; let cache_control = blob_cache_control(); diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 5745b8d4e59..c84192a6e6d 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -32,6 +32,28 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) } +/// Parse a raw query string into `T` after NIP-FI/NIP-98 admission has +/// already succeeded. +/// +/// - Absent or empty query → `Ok(T::default())` (no params is valid). +/// - Non-empty but malformed → `Err(400 bad request)`. +/// +/// **Do not use `.ok().unwrap_or_default()` here.** That pattern silently +/// discards malformed input and changes query semantics — for example, +/// `?status=open&limit=abc` would drop the valid `status=` field together +/// with the bad `limit=`, broadening the query to all statuses. Post- +/// admission a parse failure is the caller's error, not an auth failure. +/// [FI-TRACE-HTTP-INGRESS] +pub(crate) fn parse_query_or_400( + raw: Option<&str>, +) -> Result)> { + match raw { + None | Some("") => Ok(T::default()), + Some(q) => serde_urlencoded::from_str(q) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid query: {e}"))), + } +} + /// Relay membership enforcement — single gate for all authenticated entry points. /// /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, @@ -383,3 +405,72 @@ pub mod relay_members { } } } + +// ── parse_query_or_400 regression tests ────────────────────────────────────── + +#[cfg(test)] +mod parse_query_tests { + use super::parse_query_or_400; + use serde::Deserialize; + + /// Mirror of `ModerationReadQuery` — independent so this module compiles + /// without pulling in handler dependencies. + #[derive(Debug, Deserialize, Default, PartialEq)] + struct QueryFixture { + status: Option, + limit: Option, + } + + /// Absent query → Default. No params is always valid. + #[test] + fn absent_query_gives_default() { + let result: Result = parse_query_or_400(None); + assert_eq!(result.unwrap(), QueryFixture::default()); + } + + /// Empty string → Default. Same as absent. + #[test] + fn empty_query_gives_default() { + let result: Result = parse_query_or_400(Some("")); + assert_eq!(result.unwrap(), QueryFixture::default()); + } + + /// Well-formed query → parsed correctly. + #[test] + fn valid_query_parses_correctly() { + let result: Result = parse_query_or_400(Some("status=open&limit=50")); + let q = result.unwrap(); + assert_eq!(q.status.as_deref(), Some("open")); + assert_eq!(q.limit, Some(50)); + } + + /// Malformed limit → 400 error, NOT default. + /// + /// Regression for the `.ok().unwrap_or_default()` bug: the old code would + /// silently discard ALL fields on any parse error, so `status=open&limit=abc` + /// would return `QueryFixture::default()` (status=None) instead of 400. + /// That made malformed input yield a BROADER query than intended. + #[test] + fn malformed_limit_is_400_not_default() { + let result: Result = parse_query_or_400(Some("status=open&limit=abc")); + let err = result.unwrap_err(); + assert_eq!( + err.0, + axum::http::StatusCode::BAD_REQUEST, + "malformed ?limit= must return 400, not silently default \ + (old bug: .ok().unwrap_or_default() would drop status= too)" + ); + } + + /// Malformed standalone limit → 400 error, NOT default. + #[test] + fn malformed_standalone_limit_is_400_not_default() { + let result: Result = parse_query_or_400(Some("limit=abc")); + let err = result.unwrap_err(); + assert_eq!( + err.0, + axum::http::StatusCode::BAD_REQUEST, + "malformed ?limit=abc must return 400, not silently default to the cap" + ); + } +} diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 2c49ca6a5c3..f238f2037b8 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -79,14 +79,7 @@ async fn authorize_operator_request( pubkey, event_id_bytes, .. - } = bridge::verify_bridge_auth_with_options( - headers, - method, - &url, - body, - true, // operator endpoints always require NIP-98; no X-Pubkey dev fallback - body.is_some(), - )?; + } = bridge::verify_nip98_exempt_operator(headers, method, &url, body)?; check_operator_replay(state, event_id_bytes).await?; let pubkey_hex = pubkey.to_hex(); diff --git a/crates/buzz-relay/src/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index c7fa09bebd0..5021c1ee198 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use axum::{ - extract::{Path, Query, RawQuery, State}, + extract::{Path, RawQuery, State}, http::{HeaderMap, StatusCode}, - response::Json, + response::{IntoResponse, Json, Response}, }; use chrono::{DateTime, Utc}; use serde::Deserialize; @@ -17,8 +17,11 @@ use uuid::Uuid; use buzz_core::TenantContext; +use buzz_auth::NipFiMode; + use crate::{ - api::{api_error, bridge, internal_error}, + api::{api_error, bridge, internal_error, parse_query_or_400}, + nip_fi_http::admit_nip_fi_http_on_state, state::AppState, }; @@ -40,13 +43,14 @@ fn request_path(path: &str, raw_query: Option<&str>) -> String { } } +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers async fn authorize_workflow_read( state: &Arc, headers: &HeaderMap, path: &str, raw_query: Option<&str>, workflow_id: Uuid, -) -> Result)> { +) -> Result { let raw_host = headers .get(axum::http::header::HOST) .and_then(|value| value.to_str().ok()) @@ -58,17 +62,39 @@ async fn authorize_workflow_read( StatusCode::NOT_FOUND, "relay: no community is configured for this host", ) + .into_response() })?; let path_with_query = request_path(path, raw_query); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let bridge::VerifiedBridgeAuth { - pubkey, - event_id_bytes, - signed_created_at, - } = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; - bridge::enforce_http_admission(state, &tenant, &pubkey).await?; - bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + // In NIP-FI enforce/deny-protected mode a real NIP-98 event is mandatory — + // the X-Pubkey dev-mode fallback must never satisfy the pairing requirement. + // [NIP-FI.md:594-607, FI-TRACE-HTTP-INGRESS] + let nip_fi_active = !matches!(state.config.nip_fi.mode, NipFiMode::Off); + + // NIP-FI admission. [FI-TRACE-AUTHORITY-UNIFORM] + let require_auth = state.config.require_auth_token || nip_fi_active; + let admission = admit_nip_fi_http_on_state( + state, + headers, + bridge::make_nip98_closure_for_admission( + headers.clone(), + "GET", + url, + None, + require_auth, + false, + ), + )?; + let pubkey = *admission.proven_pubkey(); + let (event_id_bytes, signed_created_at) = admission.into_extra(); + + bridge::enforce_http_admission(state, &tenant, &pubkey) + .await + .map_err(|e| e.into_response())?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes) + .await + .map_err(|e| e.into_response())?; let pubkey_bytes = pubkey.to_bytes().to_vec(); let auth_tag = super::relay_members::extract_auth_tag_header(headers); @@ -79,7 +105,8 @@ async fn authorize_workflow_read( auth_tag, signed_created_at, ) - .await?; + .await + .map_err(|e| e.into_response())?; let workflow = state .db @@ -87,22 +114,21 @@ async fn authorize_workflow_read( .await .map_err(|error| match error { buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow not found") + api_error(StatusCode::NOT_FOUND, "workflow not found").into_response() } - other => internal_error(&format!("get workflow for run read: {other}")), + other => internal_error(&format!("get workflow for run read: {other}")).into_response(), })?; - let channel_id = workflow - .channel_id - .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped"))?; + let channel_id = workflow.channel_id.ok_or_else(|| { + api_error(StatusCode::FORBIDDEN, "workflow is not channel-scoped").into_response() + })?; let accessible = state .get_accessible_channel_ids_cached(tenant.community(), &pubkey_bytes) .await - .map_err(|error| internal_error(&format!("workflow channel access lookup: {error}")))?; + .map_err(|error| { + internal_error(&format!("workflow channel access lookup: {error}")).into_response() + })?; if !accessible.contains(&channel_id) { - return Err(api_error( - StatusCode::FORBIDDEN, - "workflow is not accessible", - )); + return Err(api_error(StatusCode::FORBIDDEN, "workflow is not accessible").into_response()); } Ok(tenant) @@ -114,25 +140,47 @@ pub async fn workflow_runs( Path(workflow_id): Path, headers: HeaderMap, RawQuery(raw_query): RawQuery, - Query(query): Query, -) -> Result, (StatusCode, Json)> { +) -> Response { + workflow_runs_inner(state, workflow_id, headers, raw_query) + .await + .into_response() +} + +async fn workflow_runs_inner( + state: Arc, + workflow_id: Uuid, + headers: HeaderMap, + raw_query: Option, +) -> Result, Response> { + // Admission first: NIP-98 + NIP-FI must fire before any application-level + // validation — including query-string parsing — so the denial contract wins + // over request-validation errors. [FI-TRACE-HTTP-INGRESS] + // Raw query is preserved here; parsing happens after admission. + let path = format!("/workflows/{workflow_id}/runs"); + let tenant = + authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; + + // Parse query string after admission so malformed params (e.g. ?limit=abc) + // cannot 400 before the NIP-FI gate fires. A parse failure after + // admission is a caller error (400); defaulting silently would change + // query semantics. [FI-TRACE-HTTP-INGRESS] + let query: RunsQuery = + parse_query_or_400(raw_query.as_deref()).map_err(|e| e.into_response())?; + if query.before.is_some() != query.before_id.is_some() { return Err(api_error( StatusCode::BAD_REQUEST, "before and before_id must be supplied together", - )); + ) + .into_response()); } let limit = query.limit.unwrap_or(DEFAULT_RUN_LIMIT); if !(1..=MAX_RUN_LIMIT).contains(&limit) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "limit must be between 1 and 100", - )); + return Err( + api_error(StatusCode::BAD_REQUEST, "limit must be between 1 and 100").into_response(), + ); } - let path = format!("/workflows/{workflow_id}/runs"); - let tenant = - authorize_workflow_read(&state, &headers, &path, raw_query.as_deref(), workflow_id).await?; let mut rows = state .db .list_workflow_runs_page( @@ -143,7 +191,7 @@ pub async fn workflow_runs( limit + 1, ) .await - .map_err(|error| internal_error(&format!("list workflow runs: {error}")))?; + .map_err(|error| internal_error(&format!("list workflow runs: {error}")).into_response())?; let has_more = rows.len() > limit as usize; rows.truncate(limit as usize); @@ -169,7 +217,18 @@ pub async fn run_approvals( State(state): State>, Path((workflow_id, run_id)): Path<(Uuid, Uuid)>, headers: HeaderMap, -) -> Result, (StatusCode, Json)> { +) -> Response { + run_approvals_inner(state, workflow_id, run_id, headers) + .await + .into_response() +} + +async fn run_approvals_inner( + state: Arc, + workflow_id: Uuid, + run_id: Uuid, + headers: HeaderMap, +) -> Result, Response> { let path = format!("/workflows/{workflow_id}/runs/{run_id}/approvals"); let tenant = authorize_workflow_read(&state, &headers, &path, None, workflow_id).await?; @@ -179,19 +238,20 @@ pub async fn run_approvals( .await .map_err(|error| match error { buzz_db::error::DbError::NotFound(_) => { - api_error(StatusCode::NOT_FOUND, "workflow run not found") + api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response() } - other => internal_error(&format!("get workflow run for approval read: {other}")), + other => internal_error(&format!("get workflow run for approval read: {other}")) + .into_response(), })?; if run.workflow_id != workflow_id { - return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found")); + return Err(api_error(StatusCode::NOT_FOUND, "workflow run not found").into_response()); } let approvals = state .db .get_run_approvals(tenant.community(), workflow_id, run_id) .await - .map_err(|error| internal_error(&format!("list run approvals: {error}")))?; + .map_err(|error| internal_error(&format!("list run approvals: {error}")).into_response())?; Ok(Json(serde_json::json!({ "approvals": approvals.iter().map(approval_json).collect::>(), }))) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e035752ec3a..182dbe87b5d 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -367,6 +367,14 @@ pub struct Config { /// Whether the configured web bundle serves Git browser routes in addition /// to the public invite landing page. Defaults to false. pub serve_git_web_gui: bool, + + /// NIP-FI federated-identity enforcement configuration. + /// + /// Present when `BUZZ_NIP_FI_MODE` is `enforce` or `deny_protected`; in + /// those modes the relay validates assertions at HTTP ingress and (via S3) + /// at WebSocket upgrade. `Off` mode (the default) leaves all identity + /// enforcement to NIP-42 alone. + pub nip_fi: crate::nip_fi_config::NipFiRelayConfig, } fn parse_bind_addr(raw: &str) -> Result { @@ -1257,6 +1265,7 @@ impl Config { admin, web_dir, serve_git_web_gui, + nip_fi: crate::nip_fi_config::NipFiRelayConfig::from_env()?, }) } } diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 18ea187fc7d..ddec559617e 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -33,6 +33,11 @@ pub mod mesh_boot; pub mod metrics; /// NIP-11 relay information document. pub mod nip11; +/// NIP-FI relay configuration: mode, issuer registry, JWKS warm/refresh. +pub mod nip_fi_config; +/// NIP-FI HTTP ingress enforcement: assertion extraction, verification, +/// key-pairing check, and deny-map gate for every protected HTTP surface. +pub(crate) mod nip_fi_http; /// NIP-01 client/relay message parsing. pub mod protocol; /// Durable NIP-PL matcher and delivery worker. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 206f0329c0e..2b54d95adab 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -526,6 +526,80 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // NIP-FI JWKS warm + background refresh. + // + // Per [FI-TRACE-DEPENDENCY-FAIL-CLOSED]: a JWKS warm failure at startup + // MUST NOT abort the relay. The relay starts and HTTP-protected routes deny + // with `authorization_unavailable` (503) until a snapshot lands. The + // background loop retries automatically. + // + // The background task is cancelled cleanly on shutdown via a + // CancellationToken so it does not outlive the process. + let nip_fi_jwks_cancel = tokio_util::sync::CancellationToken::new(); + if let Some(ref jwks_source) = state.nip_fi_jwks_source.clone() { + let jwks_configs = state.config.nip_fi.jwks_configs.clone(); + info!( + issuer_count = jwks_configs.len(), + "NIP-FI: warming JWKS snapshots for HTTP enforcement" + ); + for cfg in &jwks_configs { + match jwks_source.get_snapshot(&cfg.issuer).await { + Some(_) => { + info!(issuer = %cfg.issuer, "NIP-FI: JWKS snapshot warmed"); + } + None => { + warn!( + issuer = %cfg.issuer, + "NIP-FI: JWKS warm failed — HTTP ingress will deny 503 until \ + a snapshot lands; background refresh will retry" + ); + } + } + } + // Background refresh loop: independent per-issuer cadence. + let refresh_source = Arc::clone(jwks_source); + let refresh_configs = jwks_configs.clone(); + let refresh_cancel = nip_fi_jwks_cancel.clone(); + tokio::spawn(async move { + let mut intervals: Vec<(String, u64, tokio::time::Instant)> = refresh_configs + .iter() + .map(|c| { + ( + c.issuer.clone(), + c.contract.refresh_interval_seconds(), + tokio::time::Instant::now(), + ) + }) + .collect(); + loop { + // Sleep until the next scheduled refresh across all issuers. + let next = intervals + .iter() + .map(|(_, interval, last)| *last + std::time::Duration::from_secs(*interval)) + .min() + .unwrap_or_else(|| { + tokio::time::Instant::now() + std::time::Duration::from_secs(300) + }); + tokio::select! { + _ = tokio::time::sleep_until(next) => {} + _ = refresh_cancel.cancelled() => break, + } + let now = tokio::time::Instant::now(); + for (issuer, interval, last) in &mut intervals { + if now >= *last + std::time::Duration::from_secs(*interval) { + if refresh_source.get_snapshot(issuer).await.is_none() { + warn!( + %issuer, + "NIP-FI: background JWKS refresh returned no snapshot" + ); + } + *last = now; + } + } + } + }); + } + // Inter-relay mesh (BUZZ_MESH seam). `boot_mesh` returns None when the // kill switch is off — nothing is bound, published, or spawned, so the // relay behaves byte-identically to a build without the mesh. When diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs new file mode 100644 index 00000000000..a66b890bbf8 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -0,0 +1,422 @@ +//! NIP-FI relay-level configuration: issuer set, session lifetime, and JWKS +//! warm/refresh. +//! +//! All env-var parsing lives here so `config.rs` stays focused on the top-level +//! `Config` struct. This module is `pub` — `config.rs` constructs it, and the +//! relay reads it as `config.nip_fi`. +//! +//! # Environment variables +//! +//! | Variable | Required | Description | +//! |---|---|---| +//! | `BUZZ_NIP_FI_MODE` | No | `off` (default), `enforce`, or `deny_protected`. | +//! | `BUZZ_NIP_FI_ISSUERS` | If enforce | JSON array of issuer configs (see [`IssuerEnvConfig`]). | +//! | `BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS` | If enforce | Per-partition limit on session lifetime. | +//! +//! `maximum_assertion_age` is per-issuer only (field `maximum_assertion_age_seconds` in +//! the issuer JSON array), not a relay-level env var. A relay-level duplicate that could +//! disagree with the enforced per-issuer value was removed in this PR. +//! +//! Absent or empty `BUZZ_NIP_FI_MODE` defaults to `off`, keeping the relay +//! backward-compatible until an operator explicitly enables enforcement. + +use std::time::Duration; + +use buzz_auth::{ + validate_nip_fi_config, FreshnessClass, IssuerJwksConfig, IssuerPolicy, IssuerPolicyError, + IssuerRegistry, JwksSourceContract, NipFiMode, NipFiStartupError, TokenClass, +}; +use jsonwebtoken::Algorithm; + +use crate::config::ConfigError; + +/// Maximum accepted `max_connection_lifetime` in seconds (30 days). +const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; + +// ── Per-issuer JSON config shape ───────────────────────────────────────────── + +/// One entry in the `BUZZ_NIP_FI_ISSUERS` JSON array. +/// +/// **Example** (one issuer, `nip-fi+jwt` dedicated assertions): +/// ```json +/// [ +/// { +/// "issuer": "https://login.example.com", +/// "audiences": ["https://relay.example.com"], +/// "token_class": "nip-fi+jwt", +/// "algorithms": ["ES256"], +/// "skew_seconds": 30, +/// "maximum_assertion_age_seconds": 3600, +/// "jwks_uri": "https://login.example.com/.well-known/jwks.json", +/// "jwks_refresh_interval_seconds": 300, +/// "jwks_hard_deadline_seconds": 86400 +/// } +/// ] +/// ``` +/// The `require_attested_key` field is not part of this schema; S2 removed it +/// from buzz-auth. S3 enforces key pairing structurally for every issuer. +#[derive(Debug, serde::Deserialize)] +pub(super) struct IssuerEnvConfig { + /// Exact `iss` value. + pub issuer: String, + /// One or more accepted `aud` values. + pub audiences: Vec, + /// `"at+jwt"` or `"nip-fi+jwt"`. + pub token_class: TokenClassEnvConfig, + /// Algorithm names, e.g. `["ES256", "RS256"]`. + pub algorithms: Vec, + /// Accepted clock skew in seconds (≤ 300). + #[serde(default)] + pub skew_seconds: u64, + /// `iat + maximum_assertion_age` residual bound in seconds. + pub maximum_assertion_age_seconds: u64, + /// HTTPS endpoint serving the JWK Set for this issuer. + pub jwks_uri: String, + /// Seconds between JWKS refreshes. + pub jwks_refresh_interval_seconds: u64, + /// Hard deadline for accepting a JWKS snapshot in seconds. + pub jwks_hard_deadline_seconds: u64, +} + +/// Token-class discriminant in the issuer config JSON. +#[derive(Debug, serde::Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub(super) enum TokenClassEnvConfig { + #[serde(rename = "nip-fi+jwt")] + DedicatedNipFi, + #[serde(rename = "at+jwt")] + AccessTokenAtJwt, +} + +// ── Relay-level NIP-FI config ───────────────────────────────────────────────── + +/// The relay-level NIP-FI configuration produced by `Config::from_env`. +/// +/// Carries the validated `NipFiMode`, the full `IssuerRegistry`, +/// the parallel `IssuerJwksConfig` slice for `ProductionJwksSource`, and the +/// session-lifetime bound. +#[derive(Debug, Clone)] +pub struct NipFiRelayConfig { + /// The enforcement mode selected by `BUZZ_NIP_FI_MODE`. + pub mode: NipFiMode, + /// Validated per-issuer assertion-policy registry. + pub registry: IssuerRegistry, + /// Parallel JWKS configs for `ProductionJwksSource` construction. + pub jwks_configs: Vec, + /// Hard upper bound on a single connection lease, in seconds. + /// Required in enforce mode per spec (NIP-FI.md §Request and session + /// bounds): every deployment MUST configure a positive finite value. + pub max_connection_lifetime_secs: u64, +} + +impl NipFiRelayConfig { + /// Parse NIP-FI relay configuration from the process environment. + /// + /// Returns `Err` when `BUZZ_NIP_FI_MODE=enforce` but required config is + /// missing or invalid (fail-closed: no token is accepted until this passes). + pub fn from_env() -> Result { + let mode = parse_mode()?; + + if let NipFiMode::Off | NipFiMode::DenyProtected = mode { + return Ok(Self { + mode, + registry: IssuerRegistry::new(), + jwks_configs: Vec::new(), + max_connection_lifetime_secs: 0, + }); + } + + // Enforce mode: all fields required. + let issuers_json = std::env::var("BUZZ_NIP_FI_ISSUERS").map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but BUZZ_NIP_FI_ISSUERS is not set; \ + set it to a JSON array of issuer configs" + .to_string(), + ) + })?; + if issuers_json.trim().is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must not be empty in enforce mode".to_string(), + )); + } + + let issuer_entries: Vec = + serde_json::from_str(&issuers_json).map_err(|e| { + ConfigError::InvalidValue(format!("BUZZ_NIP_FI_ISSUERS is not valid JSON: {e}")) + })?; + + if issuer_entries.is_empty() { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_ISSUERS must contain at least one issuer in enforce mode".to_string(), + )); + } + + // `BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS` is intentionally NOT parsed + // here. The authoritative `maximum_assertion_age` comes from each issuer's + // JSON config entry (field `maximum_assertion_age_seconds`). A relay-level + // duplicate that could disagree with the per-issuer value is a config-drift + // trap — removed in this PR. + + let max_connection_lifetime_secs = parse_u64_bounded( + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + 1, + MAX_CONNECTION_LIFETIME_SECS, + )? + .ok_or_else(|| { + ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE=enforce but \ + BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS is not set; \ + every enforce deployment must configure a positive finite value" + .to_string(), + ) + })?; + + let mut registry = IssuerRegistry::new(); + let mut jwks_configs = Vec::with_capacity(issuer_entries.len()); + + for entry in issuer_entries { + let (policy, jwks_config) = build_issuer(&entry).map_err(|e| { + ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer {:?}: {e}", + entry.issuer + )) + })?; + registry.insert(policy); + jwks_configs.push(jwks_config); + } + + // Delegate final validation to buzz-auth startup gate. + validate_nip_fi_config(NipFiMode::Enforce, ®istry, &jwks_configs).map_err( + |e: NipFiStartupError| ConfigError::InvalidValue(format!("NIP-FI config invalid: {e}")), + )?; + + Ok(Self { + mode, + registry, + jwks_configs, + max_connection_lifetime_secs, + }) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn parse_mode() -> Result { + match std::env::var("BUZZ_NIP_FI_MODE") + .ok() + .as_deref() + .map(str::trim) + { + None | Some("") | Some("off") => Ok(NipFiMode::Off), + Some("enforce") => Ok(NipFiMode::Enforce), + Some("deny_protected") => Ok(NipFiMode::DenyProtected), + Some(other) => Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_MODE must be \"enforce\", \"deny_protected\", or \"off\"; got {other:?}" + ))), + } +} + +/// Parse an optional positive `u64` env var bounded to `[min_val, max_val]`. +/// Returns `None` when the variable is absent or empty. +fn parse_u64_bounded(name: &str, min_val: u64, max_val: u64) -> Result, ConfigError> { + match std::env::var(name) { + Err(_) => Ok(None), + Ok(raw) if raw.trim().is_empty() => Ok(None), + Ok(raw) => { + let v: u64 = raw.trim().parse().map_err(|_| { + ConfigError::InvalidValue(format!("{name} must be a positive integer")) + })?; + if v < min_val || v > max_val { + return Err(ConfigError::InvalidValue(format!( + "{name} must be in {min_val}..={max_val}" + ))); + } + Ok(Some(v)) + } + } +} + +/// Parse a `jsonwebtoken::Algorithm` from a case-sensitive string. +fn parse_algorithm(s: &str) -> Result { + match s { + "ES256" => Ok(Algorithm::ES256), + "ES384" => Ok(Algorithm::ES384), + "RS256" => Ok(Algorithm::RS256), + "RS384" => Ok(Algorithm::RS384), + "RS512" => Ok(Algorithm::RS512), + "PS256" => Ok(Algorithm::PS256), + "PS384" => Ok(Algorithm::PS384), + "PS512" => Ok(Algorithm::PS512), + "EdDSA" => Ok(Algorithm::EdDSA), + other => Err(format!("unknown or non-asymmetric algorithm {other:?}")), + } +} + +fn build_issuer(entry: &IssuerEnvConfig) -> Result<(IssuerPolicy, IssuerJwksConfig), String> { + let algorithms: Vec = entry + .algorithms + .iter() + .map(|s| parse_algorithm(s)) + .collect::>()?; + + let token_class = match entry.token_class { + TokenClassEnvConfig::DedicatedNipFi => TokenClass::DedicatedNipFi, + TokenClassEnvConfig::AccessTokenAtJwt => { + // at+jwt requires a SubjectClassContract; for simplicity in the + // initial deployment, dedicated nip-fi+jwt is the expected class. + // at+jwt support is left for a follow-up — fail closed with a + // clear message so operators know the required fields. + return Err("\"at+jwt\" token class requires a subject-class contract; \ + use \"nip-fi+jwt\" for initial deployments or add \ + subject_class fields to the issuer config" + .to_string()); + } + }; + + let jwks_contract = JwksSourceContract::new( + entry.jwks_uri.clone(), + entry.jwks_refresh_interval_seconds, + entry.jwks_hard_deadline_seconds, + ) + .ok_or_else(|| { + "invalid JWKS source contract (check jwks_uri is HTTPS, \ + refresh_interval < hard_deadline, and both are positive)" + .to_string() + })?; + + let policy = IssuerPolicy::new( + entry.issuer.clone(), + entry.audiences.clone(), + token_class, + FreshnessClass::OfflineJwt, + algorithms, + entry.skew_seconds, + entry.maximum_assertion_age_seconds, + None, // offline-jwt: no status age + jwks_contract.clone(), + ) + .map_err(|e: IssuerPolicyError| e.to_string())?; + + let jwks_config = IssuerJwksConfig { + issuer: entry.issuer.clone(), + contract: jwks_contract, + }; + + Ok((policy, jwks_config)) +} + +// ── Duration helpers ────────────────────────────────────────────────────────── + +impl NipFiRelayConfig { + /// Returns the configured `max_connection_lifetime` as a `Duration`. + /// Returns `None` in `Off`/`DenyProtected` mode (sentinel value 0). + pub fn max_connection_lifetime(&self) -> Option { + if self.max_connection_lifetime_secs == 0 { + None + } else { + Some(Duration::from_secs(self.max_connection_lifetime_secs)) + } + } + + /// Returns `true` when the relay is in `Enforce` mode. + pub fn is_enforce(&self) -> bool { + matches!(self.mode, NipFiMode::Enforce) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Env vars are process-global — serialize tests that mutate them to prevent + // cross-test races when the suite runs with multiple threads. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + /// RAII guard: removes a set of env vars when dropped, restoring a clean + /// state even on test panic. + struct EnvGuard(Vec<&'static str>); + impl EnvGuard { + fn new(keys: &[&'static str]) -> Self { + Self(keys.to_vec()) + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + for key in &self.0 { + std::env::remove_var(key); + } + } + } + + const NIP_FI_VARS: &[&str] = &[ + "BUZZ_NIP_FI_MODE", + "BUZZ_NIP_FI_ISSUERS", + "BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS", + "BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", + ]; + + #[test] + fn off_mode_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // NipFiMode::Off is the default: no issuers, no age limit. + std::env::remove_var("BUZZ_NIP_FI_MODE"); + let cfg = NipFiRelayConfig::from_env().expect("Off mode must not fail"); + assert!(matches!(cfg.mode, NipFiMode::Off)); + assert!(cfg.registry.is_empty()); + } + + #[test] + fn deny_protected_requires_no_other_config() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "deny_protected"); + let cfg = NipFiRelayConfig::from_env().expect("DenyProtected mode must not fail"); + assert!(matches!(cfg.mode, NipFiMode::DenyProtected)); + } + + #[test] + fn enforce_without_issuers_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::remove_var("BUZZ_NIP_FI_ISSUERS"); + std::env::remove_var("BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS"); + let err = NipFiRelayConfig::from_env() + .expect_err("enforce without issuers must be a config error"); + let msg = err.to_string(); + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS"), + "error names the missing var: {msg}" + ); + } + + #[test] + fn enforce_without_assertion_age_fails_closed() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", "[{}]"); // will parse but fail on age first + std::env::remove_var("BUZZ_NIP_FI_MAXIMUM_ASSERTION_AGE_SECS"); + let err = + NipFiRelayConfig::from_env().expect_err("enforce without age must be a config error"); + let msg = err.to_string(); + // Error will be either JSON parse or missing age var — both non-empty. + assert!(!msg.is_empty()); + } + + #[test] + fn unknown_mode_is_rejected() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + std::env::set_var("BUZZ_NIP_FI_MODE", "permissive"); + let err = NipFiRelayConfig::from_env().expect_err("unknown mode must error"); + assert!(err.to_string().contains("BUZZ_NIP_FI_MODE")); + } +} diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs new file mode 100644 index 00000000000..68082ab5875 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -0,0 +1,834 @@ +//! NIP-FI HTTP ingress enforcement. +//! +//! Every protected HTTP surface in enforce mode MUST call +//! [`admit_nip_fi_http`] (or its state-convenience wrapper +//! [`admit_nip_fi_http_on_state`]) which is the single authority for the +//! complete NIP-FI admission decision for one HTTP request: +//! +//! 1. Run the caller's NIP-98 extraction closure → `proven_pubkey`. +//! 2. Extract the `Nostr-Federated-Identity: Bearer ` assertion. +//! 3. Verify it offline against the configured issuer JWKS. +//! 4. Confirm the assertion's `nostr_pubkey` equals `proven_pubkey`. [FI-INV-05] +//! 5. Check the deny map for the proven pubkey. [FI-INV-14] +//! +//! HTTP is sessionless: every request re-verifies. There is no lifetime- +//! partition concept — the session-bounds section of NIP-FI.md is WS-only. +//! +//! ## Structural authority +//! +//! [`NipFiAdmission`] has a private constructor. The only way to produce +//! one is via [`admit_nip_fi_http`]. Handler code that requires a +//! `NipFiAdmission` to obtain `proven_pubkey` cannot be reached without +//! executing the full admission sequence. +//! +//! ## Carrier / precedence +//! +//! Per NIP-FI.md §Client-attached transport: +//! - Assertion: `Nostr-Federated-Identity: Bearer ` (this +//! module's responsibility). +//! - Nostr proof: `Authorization: Nostr ` (NIP-98, owned by +//! the NIP-98 closure passed to `admit_nip_fi_http`). +//! - `Authorization` is RESERVED for NIP-98; the assertion MUST NOT appear +//! there. Mixing the two fields is an `EvidenceRejected` (403) denial. +//! +//! ## Deny map +//! +//! The deny map is S4 (Duncan). Until S4 lands this module stubs it as a +//! fail-open no-op: [`HttpDenyMap::is_denied`] always returns false. When S4 +//! adds the real implementation, replace the stub in `admit_nip_fi_http_on_state` +//! with a reference to the real map. The integration is a one-liner. +//! +//! ## Off-mode regression +//! +//! When `NipFiMode::Off`, `admit_nip_fi_http` still calls the NIP-98 closure +//! (preserving whatever auth the surface required before NIP-FI), then returns +//! `Ok(NipFiAdmission { assertion: None, ... })` immediately without the +//! assertion/pairing/deny steps. Pre-NIP-FI behavior is fully preserved for +//! OSS deployments. +//! +//! [FI-TRACE-DENIAL-ORACLE]: exact HTTP response bytes are fixed in NIP-FI.md. +//! [FI-TRACE-TRANSPORT-CLOSED]: assertion transport is exactly one header. +//! [FI-TRACE-AUTHORITY-UNIFORM]: all protected surfaces call this function. + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, +}; +use buzz_auth::{ + DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion, CLIENT_ATTACHED_HEADER, +}; +use chrono::{DateTime, Utc}; +use nostr::PublicKey; +use std::fmt; + +// ── Deny-map seam (S4 stub) ─────────────────────────────────────────────────── + +/// Narrow interface consumed by HTTP enforcement. S4 (Duncan) will provide +/// the real implementation; until then, `AlwaysAdmitStubDenyMap` stubs it +/// fail-open (admits unconditionally). +/// +/// Signature mirrors `NipFiDenyMap::is_denied` from S4 so integration is a +/// one-liner: replace `AlwaysAdmitStubDenyMap` with the shared map. +/// +/// `(issuer, pubkey, now)` are required because the deny set is issuer- +/// scoped per `NIP-FI.md:624-627`. Passing only pubkey would collide +/// across issuers — a deny for `(iss-A, k)` must not block `(iss-B, k)`. +/// +/// Sealed: only implementations in this crate are accepted. +pub(crate) trait HttpDenyMap: sealed::Sealed { + /// Returns `true` when `(issuer, pubkey)` has an active deny entry at + /// `now` (`now < until`). A poisoned or unavailable backing store MUST + /// return `false` (admits) only when an explicit availability guarantee is + /// established; the S4 real map currently admits on poisoned lock. The + /// S4 integration commit is expected to resolve the fail-closed story + /// before S5 merges; the interface contract here is the agreed shape. + fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool; +} + +pub(crate) mod sealed { + pub(crate) trait Sealed {} +} + +/// Stub deny map that always admits. Used until S4 provides the real map. +/// +/// Name is explicit: this is **fail-open**, not fail-closed. The stub phase +/// is intentional — deny-map enforcement defers to S4 landing. The name +/// `AlwaysAdmitStubDenyMap` prevents a future integrator from assuming this +/// stub is safe for production use. +pub(crate) struct AlwaysAdmitStubDenyMap; +impl sealed::Sealed for AlwaysAdmitStubDenyMap {} +impl HttpDenyMap for AlwaysAdmitStubDenyMap { + /// Always admits: the deny map is not yet wired (S4 pending). + fn is_denied(&self, _issuer: &str, _pubkey: &PublicKey, _now: DateTime) -> bool { + false + } +} + +// ── Admission type ──────────────────────────────────────────────────────────── + +/// Opaque NIP-98 proof produced by a NIP-98 extraction closure. +/// +/// The `pubkey` field is private to this module. Code that calls +/// `bridge::make_nip98_closure_for_admission`—or any other closure that yields +/// this type—cannot read the proven key directly; it must pass the closure to +/// [`admit_nip_fi_http`], which opens the proof internally and returns the key +/// only through the private-constructor `NipFiAdmission`. +/// +/// ## Falsifier +/// +/// Invoking the closure directly (`make_nip98_closure_for_admission(...)()`) +/// returns `Ok(Nip98Proof { .. })`. Without the private `pubkey` accessor, +/// the call site cannot project the key — any attempt to destructure or call +/// `.pubkey` fails to compile. +/// +/// `X` is caller-supplied side-data (e.g. replay-detection fields). +pub(crate) struct Nip98Proof { + /// Private: only `admit_nip_fi_http` may read this field. + pubkey: PublicKey, + /// Side-data threaded through from the extraction closure. + pub(crate) extra: X, +} + +impl Nip98Proof { + /// Construct a proof. `pub(crate)` so that both bridge-internal closures + /// and the media/git surfaces (which already hold a proven pubkey from a + /// prior extractor) can build the token without leaking the key. + pub(crate) fn new(pubkey: PublicKey, extra: X) -> Self { + Self { pubkey, extra } + } +} + +/// Proof that the full NIP-FI admission sequence completed for one HTTP request. +/// +/// Construction is private to [`admit_nip_fi_http`]. **No other code path +/// produces this type.** A handler signature that requires `NipFiAdmission` +/// as input can therefore not be reached without executing the full sequence: +/// +/// NIP-98 extraction → assertion extraction → verify → pair → deny-map → admit +/// +/// `X` is caller-supplied side-data returned by the NIP-98 extraction closure +/// (e.g. replay-detection fields). Use `()` when no side-data is needed. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] Every protected HTTP surface produces this +/// type via `admit_nip_fi_http`; there is no other source. +#[must_use] +pub(crate) struct NipFiAdmission { + /// The pubkey proven by NIP-98 and confirmed by assertion pairing. + /// + /// Private: obtain via [`NipFiAdmission::proven_pubkey`]. + /// Only set from within [`admit_nip_fi_http`]. + proven_pubkey: PublicKey, + /// The verified federation assertion (Some in Enforce mode, None in Off). + assertion: Option, + /// Caller-supplied side-data from the NIP-98 extraction closure. + extra: X, +} + +impl fmt::Debug for NipFiAdmission { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("NipFiAdmission") + .field("proven_pubkey", &self.proven_pubkey) + .field("assertion", &self.assertion) + .finish_non_exhaustive() + } +} + +impl NipFiAdmission { + /// The pubkey proven by both NIP-98 and assertion pairing. + /// + /// This is the only way to obtain an authoritative pubkey for downstream + /// authorization checks. It is equal to the NIP-98 `pubkey` (what the + /// request proved) and to the assertion's `nostr_pubkey` (what the + /// federation identity bound). + pub(crate) fn proven_pubkey(&self) -> &PublicKey { + &self.proven_pubkey + } + + /// The verified federation assertion, if NIP-FI was in Enforce mode. + /// + /// `None` in Off mode — the assertion was not required. + #[allow(dead_code)] + pub(crate) fn assertion(&self) -> Option<&VerifiedAssertion> { + self.assertion.as_ref() + } + + /// Caller-supplied side-data from the NIP-98 extraction closure. + #[allow(dead_code)] + pub(crate) fn extra(&self) -> &X { + &self.extra + } + + /// Consume the admission, returning ownership of the side-data. + pub(crate) fn into_extra(self) -> X { + self.extra + } +} + +// ── Main admission function ─────────────────────────────────────────────────── + +/// Run the full NIP-FI admission sequence for one HTTP request. +/// +/// ## Sequence (per NIP-FI.md §Admission procedure) +/// +/// 1. Run `extract_nip98` — the caller's NIP-98 extraction closure. Returns +/// `(proven_pubkey, X)` on success, or a `Response` to emit on failure. +/// Running NIP-98 first allows the closure to short-circuit (e.g. missing +/// `Authorization` header) before the more expensive assertion verification. +/// 2. Off mode: skip assertion steps; return `Ok(NipFiAdmission { proven_pubkey, +/// assertion: None, extra: X })`. Off-mode behavior is identical to +/// pre-NIP-FI (no assertion requirement). [FI-INV-15] +/// 3. DenyProtected mode: unconditional 503 regardless of assertion presence. +/// 4. Enforce mode: extract `Nostr-Federated-Identity: Bearer `. +/// 5. Verify assertion (signature, issuer, expiry, claims). +/// 6. Assert `assertion.asserted_key == proven_pubkey`. [FI-INV-05] +/// 7. Check deny map for `(iss, proven_pubkey)`. [FI-INV-14] +/// 8. Return `Ok(NipFiAdmission { proven_pubkey, assertion: Some(...), extra: X })`. +/// +/// ## Bypass impossibility +/// +/// [`NipFiAdmission`] has a private constructor. The only source of a +/// `NipFiAdmission` value is this function. A handler that skips this call +/// has no `NipFiAdmission` and cannot obtain `proven_pubkey` through the +/// NIP-FI admission channel. +/// +/// ## Off-mode semantics +/// +/// The NIP-98 closure is always called (steps 1–2). In Off mode the closure +/// result still gates entry — if NIP-98 auth is required for non-NIP-FI +/// reasons (e.g. `require_auth_token`), the closure encodes that. NIP-FI +/// layers (assertion/pairing/deny) are skipped entirely. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); boxing it here +// would add allocation without architectural benefit. The large Err variant +// is load-bearing: it IS the HTTP response, returned directly by handlers. +#[allow(clippy::result_large_err)] +pub(crate) fn admit_nip_fi_http( + headers: &HeaderMap, + extract_nip98: F, + verifier: Option<&dyn VerifyAssertion>, + mode: NipFiMode, + deny_map: &D, +) -> Result, Response> +where + D: HttpDenyMap, + F: FnOnce() -> Result, Response>, +{ + // Step 1: run NIP-98 extraction. Always runs regardless of mode. + let Nip98Proof { + pubkey: proven_pubkey, + extra, + } = extract_nip98()?; + + // Step 2 — Off mode: NIP-FI not required. Return admission immediately. + // The NIP-98 closure already enforced whatever auth the surface required. + // [FI-INV-15 exemption] + if matches!(mode, NipFiMode::Off) { + return Ok(NipFiAdmission { + proven_pubkey, + assertion: None, + extra, + }); + } + + // Step 3 — DenyProtected mode: unconditional 503. + if matches!(mode, NipFiMode::DenyProtected) { + return Err(http_denial(DenialClass::AuthorizationUnavailable)); + } + + // Steps 4–8 — Enforce mode. + + // Step 4: extract the assertion token. + let token = extract_bearer_token(headers).map_err(http_denial)?; + + // Step 5: cryptographic verification (signature, issuer, expiry, claims). + let verifier = verifier.ok_or_else(|| { + // Verifier not yet constructed (startup race); fail closed. + http_denial(DenialClass::AuthorizationUnavailable) + })?; + let assertion = verifier.verify_assertion(token).map_err(|e| { + tracing::debug!(code = e.code(), "nip-fi assertion denied at http ingress"); + http_denial(e.denial_class()) + })?; + + // Step 6: key pairing — assertion.asserted_key MUST equal proven NIP-98 key. + // A claimless assertion (no nostr_pubkey) is also a denial. [FI-INV-05] + match assertion.asserted_key() { + Some(k) if k == proven_pubkey => {} + _ => { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "nip_fi_http_key_mismatch" + ) + .increment(1); + tracing::debug!( + proven = %proven_pubkey.to_hex(), + "NIP-FI HTTP key pairing mismatch" + ); + // Key mismatch is a private-state denial: authorization_denied (403). + // [FI-TRACE-DENIAL-ORACLE] + return Err(http_denial(DenialClass::AuthorizationDenied)); + } + } + + // Step 7: deny-map check — (iss, pubkey) must not be in an active deny window. + // [FI-INV-14] [NIP-FI.md:624-627] + let issuer = assertion.identity().issuer(); + if deny_map.is_denied(issuer, &proven_pubkey, Utc::now()) { + metrics::counter!( + "buzz_auth_failures_total", + "reason" => "nip_fi_http_denied_pubkey" + ) + .increment(1); + // Denied-pubkey is a private-state denial. [FI-TRACE-DENIAL-ORACLE] + return Err(http_denial(DenialClass::AuthorizationDenied)); + } + + // Step 8: admit. + Ok(NipFiAdmission { + proven_pubkey, + assertion: Some(assertion), + extra, + }) +} + +// ── Transport extraction ────────────────────────────────────────────────────── + +/// Extract the single `Bearer ` from the `Nostr-Federated-Identity` +/// header. +/// +/// Rejects all forms the spec prohibits: +/// - Absent → `MissingEvidence` +/// - Repeated (multiple header values) → `EvidenceRejected` +/// - Comma-combined (`,` in a single value) → `EvidenceRejected` +/// - Empty after `Bearer ` stripping → `EvidenceRejected` +/// - Non-`Bearer ` prefix → `EvidenceRejected` +/// - Whitespace in the token (after scheme) → `EvidenceRejected` +/// +/// [FI-TRACE-TRANSPORT-CLOSED] +pub(crate) fn extract_bearer_token(headers: &HeaderMap) -> Result<&str, DenialClass> { + let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter(); + let first = match values.next() { + Some(v) => v, + None => return Err(DenialClass::MissingEvidence), + }; + // Repeated header fields deny. [FI-TRACE-TRANSPORT-CLOSED] + if values.next().is_some() { + return Err(DenialClass::EvidenceRejected); + } + let raw = first.to_str().map_err(|_| DenialClass::EvidenceRejected)?; + // Comma-combined values deny. + if raw.contains(',') { + return Err(DenialClass::EvidenceRejected); + } + let token = raw + .strip_prefix("Bearer ") + .ok_or(DenialClass::EvidenceRejected)?; + // Empty or whitespace-containing token denies. + if token.is_empty() || token.contains(ascii_whitespace) { + return Err(DenialClass::EvidenceRejected); + } + Ok(token) +} + +fn ascii_whitespace(c: char) -> bool { + c.is_ascii_whitespace() +} + +// ── HTTP denial response ────────────────────────────────────────────────────── + +/// Build the exact HTTP denial response for the given class. +/// +/// The response contract is fixed by NIP-FI.md rejection table: +/// - Status, Content-Type, WWW-Authenticate (for 401), and body bytes are the +/// closed contract. No other fields are added that depend on the private +/// condition. [FI-TRACE-DENIAL-ORACLE] +pub(crate) fn http_denial(class: DenialClass) -> Response { + let mut builder = Response::builder() + .status(StatusCode::from_u16(class.http_status()).expect("valid status")) + .header("Content-Type", class.content_type()); + if let Some(challenge) = class.www_authenticate() { + builder = builder.header("WWW-Authenticate", challenge); + } + builder + .body(Body::from(class.http_body())) + .expect("valid denial response") +} + +// ── State-convenience wrapper ───────────────────────────────────────────────── + +/// Convenience wrapper: pull mode + verifier from `AppState` and call +/// [`admit_nip_fi_http`]. +/// +/// `extract_nip98` is a closure that performs NIP-98 authentication and +/// returns `(proven_pubkey, X)`. This wrapper supplies `deny_map = +/// &AlwaysAdmitStubDenyMap`; S4 can replace the stub without touching call +/// sites by changing this wrapper. +/// +/// This is the single entry-point every NIP-FI-protected surface calls. +/// There is no other way to produce a [`NipFiAdmission`]. +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +// Response is intentionally large (axum's design); see admit_nip_fi_http. +#[allow(clippy::result_large_err)] +pub(crate) fn admit_nip_fi_http_on_state( + state: &crate::state::AppState, + headers: &HeaderMap, + extract_nip98: F, +) -> Result, Response> +where + F: FnOnce() -> Result, Response>, +{ + let mode = state.config.nip_fi.mode; + let verifier = state.nip_fi_verifier.as_deref(); + admit_nip_fi_http( + headers, + extract_nip98, + verifier, + mode, + &AlwaysAdmitStubDenyMap, + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + // Response is 128 bytes by axum's design; the large Err is intentional + // throughout this module — it IS the HTTP response returned from tests. + #![allow(clippy::result_large_err)] + use super::*; + use axum::http::HeaderValue; + use buzz_auth::{NipFiMode, VerifyAssertion}; + use chrono::Utc; + + // Helper: read the body bytes synchronously (tests only). + fn body_bytes(resp: Response) -> Vec { + use http_body_util::BodyExt as _; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + resp.into_body() + .collect() + .await + .expect("body") + .to_bytes() + .to_vec() + }) + } + + fn any_pubkey() -> PublicKey { + nostr::Keys::generate().public_key() + } + + // ── extract_bearer_token ───────────────────────────────────────────────── + + // Absent header → MissingEvidence (401). + // + // Mutation evidence: returning EvidenceRejected instead makes the + // `assert_eq!(class, DenialClass::MissingEvidence)` assertion panic. + #[test] + fn missing_header_is_missing_evidence() { + let headers = HeaderMap::new(); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::MissingEvidence); + } + + // Repeated header → EvidenceRejected (403). + // + // Mutation evidence: keeping the first value instead of rejecting makes + // `unwrap_err()` panic. + #[test] + fn repeated_header_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer token1"), + ); + headers.append( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer token2"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Comma-combined → EvidenceRejected. + #[test] + fn comma_combined_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer a, Bearer b"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Empty token after Bearer prefix → EvidenceRejected. + #[test] + fn empty_token_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert(CLIENT_ATTACHED_HEADER, HeaderValue::from_static("Bearer ")); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Wrong prefix (non-Bearer) → EvidenceRejected. + #[test] + fn wrong_prefix_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Token xyz"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Whitespace in token → EvidenceRejected. + #[test] + fn whitespace_in_token_is_evidence_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer foo bar"), + ); + let class = extract_bearer_token(&headers).unwrap_err(); + assert_eq!(class, DenialClass::EvidenceRejected); + } + + // Valid Bearer token → extracted. + #[test] + fn valid_bearer_token_extracted() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer a.b.c"), + ); + let token = extract_bearer_token(&headers).unwrap(); + assert_eq!(token, "a.b.c"); + } + + // ── http_denial ────────────────────────────────────────────────────────── + + // MissingEvidence → 401, exact body, WWW-Authenticate: Nostr. + // + // Mutation evidence: changing status to 403 makes the status assert panic. + #[test] + fn missing_evidence_denial_is_401_with_nostr_challenge() { + let resp = http_denial(DenialClass::MissingEvidence); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()), + Some("Nostr"), + "MissingEvidence MUST carry WWW-Authenticate: Nostr" + ); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + + // EvidenceRejected → 403, exact body, no WWW-Authenticate. + // + // Mutation evidence: changing status to 401 or body to "denied" makes + // corresponding assertions panic. + #[test] + fn evidence_rejected_denial_is_403_exact_bytes() { + let resp = http_denial(DenialClass::EvidenceRejected); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "EvidenceRejected must not carry a WWW-Authenticate header" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + } + + // AuthorizationDenied → 403, exact body. + // + // Mutation evidence: body check. + #[test] + fn authorization_denied_is_403_exact_bytes() { + let resp = http_denial(DenialClass::AuthorizationDenied); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert_eq!(body_bytes(resp), b"authorization denied\n"); + } + + // AuthorizationUnavailable → 503, exact body. + // + // Mutation evidence: status and body checks. + #[test] + fn authorization_unavailable_is_503_exact_bytes() { + let resp = http_denial(DenialClass::AuthorizationUnavailable); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + + // Private-state conditions (AuthorizationDenied) are byte-identical. + // Key mismatch and denied pubkey both map to authorization_denied. + // [FI-TRACE-DENIAL-ORACLE] + // + // Mutation evidence: if key_mismatch path emitted a different class, the + // assert_eq on body would diverge. + #[test] + fn authorization_denied_rows_are_byte_identical() { + let a = body_bytes(http_denial(DenialClass::AuthorizationDenied)); + // A second call produces the same bytes. + let b = body_bytes(http_denial(DenialClass::AuthorizationDenied)); + assert_eq!( + a, b, + "all AuthorizationDenied responses must be byte-identical" + ); + } + + // ── admit_nip_fi_http — off mode ───────────────────────────────────────── + + // Off mode → Ok(NipFiAdmission) with assertion=None regardless of headers. + // The NIP-98 closure is still called; its pubkey is forwarded. + // + // Mutation evidence: returning Err from off mode makes `unwrap()` panic. + #[test] + fn off_mode_admits_unconditionally() { + let headers = HeaderMap::new(); // no assertion + let expected_pubkey = any_pubkey(); + let ep = expected_pubkey; + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(ep, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let admission = + outcome.expect("Off mode MUST not require NIP-FI assertion — OSS default regression"); + assert_eq!(*admission.proven_pubkey(), expected_pubkey); + assert!(admission.assertion().is_none()); + } + + // Off mode: NIP-98 closure failure propagates even in off mode. + // + // Mutation evidence: if off-mode short-circuits before the closure, the + // returned Err is swallowed → `unwrap_err()` panics. + #[test] + fn off_mode_propagates_nip98_closure_failure() { + let headers = HeaderMap::new(); + let deny_resp = http_denial(DenialClass::MissingEvidence); + let deny_status = deny_resp.status(); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(deny_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!(resp.status(), deny_status); + } + + // ── admit_nip_fi_http — deny_protected ─────────────────────────────────── + + // DenyProtected → Err(503 authorization_unavailable). + // + // Mutation evidence: returning Ok from deny_protected mode makes + // `unwrap_err()` panic. + #[test] + fn deny_protected_returns_503() { + let headers = HeaderMap::new(); + let pubkey = any_pubkey(); + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::DenyProtected, + &AlwaysAdmitStubDenyMap, + ); + match outcome { + Err(resp) => { + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + _ => panic!("DenyProtected must deny with 503"), + } + } + + // ── admit_nip_fi_http — enforce, missing assertion ─────────────────────── + + // Enforce + missing assertion header → Err(401). + // + // Mutation evidence: the status assertion on the response panics if the + // missing-header path returns 403 instead of 401. + #[test] + fn enforce_missing_assertion_is_401() { + let headers = HeaderMap::new(); + let pubkey = any_pubkey(); + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + // Missing header → MissingEvidence before verifier check. + match outcome { + Err(resp) => { + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + _ => panic!("Missing assertion must deny with 401"), + } + } + + // ── admit_nip_fi_http — enforce, no verifier (startup race) ───────────── + + // Enforce + valid-looking header but no verifier (startup race) → Err(503). + // + // Mutation evidence: returning 403 from the None-verifier path makes the + // status assertion panic. + #[test] + fn enforce_no_verifier_returns_503() { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer eyJhbGciOiJFUzI1NiJ9.e30.sig"), + ); + let pubkey = any_pubkey(); + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey, ())), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + match outcome { + Err(resp) => { + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body_bytes(resp), b"authorization unavailable\n"); + } + _ => panic!("Missing verifier must deny with 503"), + } + } + + // ── admit_nip_fi_http — key pairing falsifier ──────────────────────────── + + // Enforce mode: valid assertion for key-A + NIP-98 proving key-B → Err(403 + // authorization_denied). + // + // This is the **pairing-wiring falsifier** Thufir required (Round 4). + // The test uses a mock verifier that returns a VerifiedAssertion whose + // asserted_key is key-A, while the NIP-98 closure returns key-B. + // + // Mutation evidence (pairing branch): + // Remove the `Some(k) if k == proven_pubkey` branch (replace with + // `Some(_)`) → function admits instead of denying → `unwrap_err()` panics. + // + // [FI-INV-05] [FI-TRACE-ASSERTION-KEY-MISMATCH] + #[test] + fn enforce_key_mismatch_is_denied() { + use buzz_auth::{VerifiedAssertion, VerifyAssertion}; + + let key_a = nostr::Keys::generate(); + let key_b = nostr::Keys::generate(); + let pubkey_a = key_a.public_key(); + let pubkey_b = key_b.public_key(); + + // Mock verifier: always succeeds, always claims pubkey_a as asserted_key. + struct PairingMockVerifier(nostr::PublicKey); + impl VerifyAssertion for PairingMockVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + Ok(VerifiedAssertion::new_for_test(self.0)) + } + } + let verifier = PairingMockVerifier(pubkey_a); + + // NIP-98 closure returns key-B; assertion claims key-A → mismatch. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer any.valid.looking.token"), + ); + + let outcome = admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(pubkey_b, ())), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + match outcome { + Err(resp) => { + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "key mismatch MUST deny with 403 authorization_denied" + ); + assert_eq!(body_bytes(resp), b"authorization denied\n"); + } + Ok(_) => panic!( + "assertion-for-A + NIP-98-for-B MUST be denied; \ + pairing branch removal would cause this panic" + ), + } + } + + // ── admit_nip_fi_http — deny map stub admits ───────────────────────────── + + // The stub deny map always admits (never denies). + // + // Mutation evidence: if `is_denied` returned true, the deny path would + // fire and the test would receive a Denied outcome instead of reaching + // the verifier check (which would deny for a different reason — invalid + // token). The distinction is observable: 401 vs 403. + #[test] + fn stub_deny_map_never_denies() { + let pubkey = any_pubkey(); + assert!( + !AlwaysAdmitStubDenyMap.is_denied("https://idp.example.com", &pubkey, Utc::now()), + "stub deny map MUST admit unconditionally until S4 provides the real map" + ); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..d467ff4a238 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,9 +24,221 @@ use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; +use crate::nip_fi_http::http_denial; use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; use crate::state::AppState; +// ── NIP-FI fail-closed assertion guard ─────────────────────────────────────── +// +// ## Purpose +// +// This middleware is the crypto backstop for NIP-FI route classification. +// It runs *over the entire merged router*: in Enforce or DenyProtected mode, +// any request whose path does not start with a prefix in +// `NIP_FI_EXEMPT_PREFIXES` must carry the +// `Nostr-Federated-Identity: Bearer …` assertion header with a +// cryptographically valid signature — or it is denied before reaching the +// handler. +// +// The structural admission authority is `admit_nip_fi_http_on_state` in +// `nip_fi_http.rs`. Every protected handler calls it via a NIP-98 extraction +// closure; it runs NIP-98 extraction → assertion verify → pairing → deny-map +// in a fixed sequence, and returns a `NipFiAdmission` whose private +// constructor makes bypass impossible at the type level. +// +// This guard is the belt; `admit_nip_fi_http_on_state` is the suspenders. +// A forgotten-gate handler (one that omits `admit_nip_fi_http_on_state`) +// cannot admit with an invalidly signed assertion because the guard verifies +// the JWT signature first. +// +// ## Adding a new route +// +// * **Protected (NIP-98-authenticated):** call `admit_nip_fi_http_on_state` +// with a NIP-98 extraction closure. No action needed here. +// +// * **Public / exempt (no NIP-FI requirement):** add the path or prefix to +// `NIP_FI_EXEMPT_PREFIXES` below. Failure to do so will deny the route in +// Enforce mode, which is intentional: the default is DENY; public status is +// explicit. +// +// ## Relationship to Off mode +// +// When `NipFiMode::Off` the guard is fully transparent — no request is +// touched. [FI-INV-15] +// +// ## What this guard checks (and does NOT check) +// +// The guard performs the full offline assertion verification (transport +// extraction + JWT signature + issuer + expiry + claims). This means: +// +// • Absent header → 401 MissingEvidence +// • Junk / non-Bearer value → 403 EvidenceRejected +// • Repeated / comma-combined fields → 403 EvidenceRejected +// • Structurally malformed / bad sig → 403 EvidenceRejected +// • Unknown issuer / expired / bad claims → 403 EvidenceRejected +// • No verifier yet (startup race) → 503 AuthorizationUnavailable +// • Cryptographically valid assertion → forward to handler +// +// The guard does NOT check key pairing or deny-map: those require the NIP-98 +// `proven_pubkey` from each handler's closure, which is not available in +// middleware. `admit_nip_fi_http_on_state` performs the full sequence. +// +// [FI-TRACE-AUTHORITY-UNIFORM] Both the guard and `admit_nip_fi_http_on_state` +// delegate to `nip_fi_http.rs`; the guard fires first. + +/// Path prefixes that are exempt from NIP-FI assertion enforcement. +/// +/// Every route in this relay is NIP-FI-protected by default. Routes that +/// should NOT require the `Nostr-Federated-Identity` header in Enforce mode +/// MUST appear in this list; omission means the guard denies the route. +/// +/// **Matching rules:** +/// - Entries ending with `/` match any path with that prefix (subtree match). +/// - All other entries match exactly (the request path must equal the entry +/// or start with the entry followed by `/`, `?`, or `#`). +/// +/// When adding a new public or pre-auth route, add its path or prefix here +/// and include the NIP-FI classification comment in `build_router`. +const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ + // WebSocket upgrade + NIP-11 relay info (public; WS-NIP-FI governs WS) + "/", + // NIP-11 relay info — exact path + "/info", + // NIP-05 — exact path + "/.well-known/nostr.json", + // K8s / health probes (no auth) — exact paths + "/health", + "/_liveness", + "/_readiness", + // Pre-membership enrollment door — identity not yet issued + "/api/invites/claim", + // Pre-membership policy gate — no NIP-98 principal yet + "/api/invites/accept-policy", + // Public policy documents — exact path + subtree + "/api/join-policy", + // Webhook trigger — secret-header auth; subtree for /hooks/{id} + "/hooks/", + // Huddle audio WebSocket — WS-NIP-FI governs WebSocket; subtree + "/huddle/", + // Testbed-only mesh probe — no auth; subtree + "/_mesh/", + // Operator admin plane — keypair-in-config auth; subtree + "/operator/", + // Admin SPA backend — operator-credential gated; subtree + "/api/admin/", + // Static assets served by the SPA fallback; subtree + "/assets/", + "/favicon.svg", + // Invite landing page (SPA) — subtree + "/invite/", + // Git web GUI (SPA) — exact + subtree + "/repos", + // Internal HMAC/localhost control-plane endpoint for the pre-receive hook. + // Already protected by `require_localhost` middleware + signed operation + // payload; does not carry a NIP-FI assertion. Listed by exact path — + // sub-paths (if any) are equally harmless since no routes exist there. + "/internal/git/policy", +]; + +/// Middleware: full offline assertion guard for NIP-FI protected paths. +/// +/// Fires before any handler. In Enforce mode, if the request path is not +/// covered by [`NIP_FI_EXEMPT_PREFIXES`] the guard performs the full offline +/// NIP-FI assertion verification (transport extraction + JWT signature + +/// issuer + expiry + claims) via the relay's `FederatedAssertionVerifier`: +/// +/// - Absent header → 401 `authentication required\n` +/// - Junk / non-Bearer value → 403 `evidence rejected\n` +/// - Repeated / comma-combined → 403 `evidence rejected\n` +/// - Bad signature / claims → 403 `evidence rejected\n` +/// - No verifier (startup race) → 503 `authorization unavailable\n` +/// - Cryptographically valid → forward to handler +/// +/// A "forgotten gate" handler — one that omits its own +/// `admit_nip_fi_http_on_state` call — cannot admit with an invalidly signed +/// assertion because the guard rejects it here before the handler fires. +/// Only a cryptographically verified assertion reaches the handler; the +/// handler then performs the key pairing and deny-map checks via +/// `admit_nip_fi_http_on_state`. +/// +/// In Off mode the middleware is fully transparent. +async fn nip_fi_assertion_guard( + State(state): State>, + request: Request, + next: middleware::Next, +) -> axum::response::Response { + use crate::nip_fi_http::extract_bearer_token; + use buzz_auth::NipFiMode; + + // Off mode: fully transparent. [FI-INV-15] + if matches!(state.config.nip_fi.mode, NipFiMode::Off) { + return next.run(request).await; + } + + let path = request.uri().path(); + + // Exempt paths bypass the assertion-token check. + let exempt = NIP_FI_EXEMPT_PREFIXES.iter().any(|pattern| { + if *pattern == "/" { + // Exact root match only. + return path == "/"; + } + if pattern.ends_with('/') { + // Subtree match: path must start with this prefix. + return path.starts_with(pattern); + } + // Exact-or-subtree match: path equals the pattern, or path starts + // with the pattern followed by a path separator or query character. + // This prevents "/info" from matching "/info-extra". + if path == *pattern { + return true; + } + if let Some(rest) = path.strip_prefix(pattern) { + return rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#'); + } + false + }); + + if exempt { + return next.run(request).await; + } + + // Non-exempt path in Enforce or DenyProtected mode. + // + // DenyProtected: unconditional 503 regardless of assertion presence. + // (`admit_nip_fi_http_on_state` also does this; the guard is the backstop.) + if matches!(state.config.nip_fi.mode, NipFiMode::DenyProtected) { + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + + // Enforce mode: full offline assertion verification. + // + // Step 1 — transport: extract the Bearer token. Rejects absent, junk, + // repeated, comma-combined, empty, and whitespace-containing values. + // [FI-TRACE-TRANSPORT-CLOSED] + let token = match extract_bearer_token(request.headers()) { + Ok(t) => t, + Err(class) => return http_denial(class), + }; + + // Step 2 — cryptographic: verify signature, issuer, expiry, and claims. + // A forgotten-gate handler that omits `admit_nip_fi_http_on_state` can + // only be reached with a cryptographically valid assertion. Key pairing + // and deny-map are performed by `admit_nip_fi_http_on_state` in the + // handler, not here. [FI-TRACE-AUTHORITY-UNIFORM] + let verifier = match state.nip_fi_verifier.as_deref() { + Some(v) => v, + None => { + // Verifier not yet constructed (startup race); fail closed. + return http_denial(buzz_auth::DenialClass::AuthorizationUnavailable); + } + }; + match verifier.verify_assertion(token) { + Ok(_) => next.run(request).await, + Err(e) => http_denial(e.denial_class()), + } +} + /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// /// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom), @@ -202,6 +414,10 @@ pub fn build_router(state: Arc) -> Router { } merged + .layer(middleware::from_fn_with_state( + state.clone(), + nip_fi_assertion_guard, + )) .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) @@ -1376,4 +1592,257 @@ mod tests { "oversized messages must be rejected by the WebSocket parser before the handler sees them" ); } + + // ── nip_fi_assertion_guard: fail-closed classification tests ───────────── + // + // ## What these tests prove + // + // `nip_fi_assertion_guard` is the crypto backstop for NIP-FI route + // classification. These unit tests directly verify the exempt-prefix + // matching logic that determines whether a request is guarded or not. + // + // The key property: a non-exempt path with no assertion header must be + // denied in Enforce mode, even if the handler does NOT call + // `admit_nip_fi_http_on_state`. This is the belt — a handler cannot + // silently bypass NIP-FI by omitting its gate (the guard catches it). + // The suspenders are `admit_nip_fi_http_on_state`'s type-level property: + // pairing and deny-map mandatory at the handler's call site. + // + // ## Dummy-route failure-mode demonstration (for code review) + // + // To confirm the failure mode is dead: + // 1. In `build_router` add a handler with no NIP-FI gate: + // `.route("/dummy-unclassified", get(|| async { "hello" }))` + // (Do NOT add "/dummy-unclassified" to NIP_FI_EXEMPT_PREFIXES.) + // 2. Deploy with NIP-FI in Enforce mode. + // 3. Send `GET /dummy-unclassified` with valid NIP-98 but no assertion. + // 4. Response: 401 `authentication required\n` from the guard. + // 5. Revert the dummy route. + // + // This is the mechanism the tests below exercise at the unit level. + + /// Returns true when `path` is exempt per `NIP_FI_EXEMPT_PREFIXES`. + /// Mirrors the matching logic in `nip_fi_assertion_guard`. + fn is_exempt(path: &str) -> bool { + NIP_FI_EXEMPT_PREFIXES.iter().any(|pattern| { + if *pattern == "/" { + return path == "/"; + } + if pattern.ends_with('/') { + return path.starts_with(pattern); + } + if path == *pattern { + return true; + } + if let Some(rest) = path.strip_prefix(pattern) { + return rest.starts_with('/') || rest.starts_with('?') || rest.starts_with('#'); + } + false + }) + } + + // Exempt paths — guard must pass these through in Enforce mode. + #[test] + fn exempt_paths_are_recognized() { + // Root exact match + assert!(is_exempt("/"), "/ must be exempt (WS + NIP-11)"); + assert!(!is_exempt("/events"), "POST /events must NOT be exempt"); + // Exact-match entries must not bleed into adjacent paths + assert!( + !is_exempt("/info-extra"), + "/info-extra must NOT match /info" + ); + assert!(!is_exempt("/healthz"), "/healthz must NOT match /health"); + + // Probes + assert!(is_exempt("/health")); + assert!(is_exempt("/_liveness")); + assert!(is_exempt("/_readiness")); + + // Pre-membership + assert!(is_exempt("/api/invites/claim")); + assert!(is_exempt("/api/invites/accept-policy")); + + // Public docs + assert!(is_exempt("/api/join-policy")); + assert!(is_exempt("/api/join-policy/terms")); + assert!(is_exempt("/api/join-policy/privacy")); + + // Webhook (prefix) + assert!(is_exempt("/hooks/abc123")); + assert!(!is_exempt("/hooksnot"), "/hooksnot must not match /hooks/"); + + // Operator / admin subtrees + assert!(is_exempt("/operator/communities")); + assert!(is_exempt("/api/admin/v1/something")); + + // SPA / assets + assert!(is_exempt("/assets/main.js")); + assert!(is_exempt("/invite/abc")); + assert!(is_exempt("/repos")); + assert!(is_exempt("/repos/owner/name")); + } + + // Protected paths — guard must deny these in Enforce mode. + #[test] + fn protected_paths_are_not_exempt() { + assert!(!is_exempt("/events"), "POST /events must be protected"); + assert!(!is_exempt("/query"), "POST /query must be protected"); + assert!(!is_exempt("/count"), "POST /count must be protected"); + assert!( + !is_exempt("/gifs/search"), + "POST /gifs/search must be protected" + ); + assert!( + !is_exempt("/gifs/share"), + "POST /gifs/share must be protected" + ); + assert!( + !is_exempt("/workflows/abc/runs"), + "GET /workflows must be protected" + ); + assert!( + !is_exempt("/moderation/reports"), + "GET /moderation/reports must be protected" + ); + assert!( + !is_exempt("/moderation/audit"), + "GET /moderation/audit must be protected" + ); + assert!( + !is_exempt("/moderation/restricted"), + "GET /moderation/restricted must be protected" + ); + assert!( + !is_exempt("/api/invites"), + "POST /api/invites (mint) must be protected" + ); + assert!(!is_exempt("/upload"), "PUT /upload must be protected"); + assert!( + !is_exempt("/media/upload"), + "PUT /media/upload must be protected" + ); + assert!( + !is_exempt("/media/deadbeef.bin"), + "GET /media/{{sha}} must be protected" + ); + } + + // Regression: a newly added unclassified path must NOT be exempt by default. + // If a developer adds a route and forgets to add it to NIP_FI_EXEMPT_PREFIXES, + // `is_exempt` returns false → the guard denies in Enforce mode. + // This test proves that the default is DENY, not ADMIT. + #[test] + fn unclassified_path_is_not_exempt_by_default() { + // A path that looks plausibly authenticated but was just added: + assert!( + !is_exempt("/api/new-feature/data"), + "newly added unclassified path must default to NOT exempt; \ + if this fails, NIP_FI_EXEMPT_PREFIXES has an overly broad entry" + ); + assert!( + !is_exempt("/api/invites/new-endpoint"), + "a new invite sub-path must not be exempt just because /api/invites/ exists; \ + only /api/invites/claim and /api/invites/accept-policy are explicitly exempt" + ); + } + + // ── T1-IMP1: adversarial guard — junk/non-Bearer assertion is denied ────── + // + // Before this fix the guard called `headers.contains_key(CLIENT_ATTACHED_HEADER)`, + // so `Nostr-Federated-Identity: junk` would pass because the header is + // present. After the fix the guard performs full offline assertion + // verification (transport extraction + JWT signature + issuer + expiry): + // + // • Junk / non-Bearer value → transport extraction fails → 403 + // • Empty Bearer token → transport extraction fails → 403 + // • Structurally valid token → transport extraction passes → crypto verify → 403 if bad sig + // + // This test proves the transport-extraction cases. The crypto-verification + // case (structurally valid but bad signature) is proven by the production- + // router test `nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires` + // in bridge.rs, which has a falsifying mutation: removing + // `verifier.verify_assertion(token)` from the guard turns the expected + // 403 into 401 (handler's NIP-98 auth fires instead). + #[test] + fn guard_rejects_junk_assertion_not_just_absent_header() { + use crate::nip_fi_http::extract_bearer_token; + use axum::http::HeaderMap; + use buzz_auth::CLIENT_ATTACHED_HEADER; + + // Case 1: bare junk value (not Bearer-prefixed). + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "junk".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_err(), + "guard calls extract_bearer_token: bare 'junk' must be rejected (EvidenceRejected)" + ); + + // Case 2: valid-looking Bearer prefix but empty token. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "Bearer ".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_err(), + "guard calls extract_bearer_token: 'Bearer ' with empty token must be rejected" + ); + + // Case 3: structurally valid compact JWS (three Base64url-separated dots) + // passes transport extraction. The guard then calls + // `verifier.verify_assertion()` which would reject it as + // InvalidSignatureOrClaims → EvidenceRejected (403) in the full guard. + // This test only exercises transport extraction; the full-guard crypto + // falsifier is in bridge.rs::nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + "Bearer a.b.c".parse().expect("valid header value"), + ); + assert!( + extract_bearer_token(&headers).is_ok(), + "structurally-valid compact JWS passes transport extraction; \ + guard then proceeds to crypto verification" + ); + } + + // ── T1-IMP2 exemption classification ───────────────────────────────────── + // + // `/internal/git/policy` must be exempt so the pre-receive hook callback + // reaches its own `require_localhost` + HMAC authorization layer in active + // NIP-FI mode. Only the exact path and sub-paths are exempt — the broader + // `/internal/` subtree is NOT exempted (no catch-all entry exists). + // + // Matching semantics: a non-`/`-ending pattern matches exact OR sub-paths + // (path equals pattern, or path starts with `pattern/`). This is safe + // because no routes exist under `/internal/git/policy/*` — any sub-path + // passes through the guard to Axum, which returns 404. + // + // Falsifying mutation: remove the "/internal/git/policy" entry from + // NIP_FI_EXEMPT_PREFIXES. `is_exempt("/internal/git/policy")` returns + // false, and the guard would return 401 in Enforce mode (every git push + // would be rejected by the hook callback failing). + #[test] + fn internal_git_policy_is_exempt_but_internal_subtree_is_not() { + assert!( + is_exempt("/internal/git/policy"), + "/internal/git/policy must be exempt: pre-receive hook calls it without \ + a NIP-FI assertion; blocking it breaks git push in Enforce/DenyProtected mode" + ); + // No catch-all /internal/ entry exists — only the specific path is + // listed, so unrelated /internal/* paths are not exempt. + assert!( + !is_exempt("/internal/"), + "the /internal/ subtree must NOT be broadly exempt; \ + only the specific hook-callback path is exempted" + ); + assert!( + !is_exempt("/internal/other"), + "/internal/other must NOT be exempt (no /internal/ subtree entry)" + ); + } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 95372d5bc3b..0fd7c38ca3a 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -778,6 +778,27 @@ pub struct AppState { /// byte-identically to a relay without the mesh. Access via /// [`AppState::mesh`]. pub mesh: Arc>, + + /// NIP-FI federated-identity assertion verifier, shared across all HTTP + /// ingress checks. + /// + /// `None` when `config.nip_fi.mode` is `Off`. When present, the verifier + /// is the single offline authority for assertion validation on every + /// protected HTTP surface. The backing `ProductionJwksSource` is also + /// shared and performs bounded periodic JWKS refresh internally. + /// + /// The field uses `dyn VerifyAssertion` (type erasure) so that + /// integration tests can inject a `StaticIssuerKeySource`-backed verifier + /// without requiring a live JWKS fetch. Production code always stores a + /// `FederatedAssertionVerifier>` here; the type + /// erased form costs one vtable dispatch per request, which is negligible + /// relative to the JWT crypto. + pub nip_fi_verifier: Option>, + + /// The shared JWKS source backing `nip_fi_verifier`, exposed so `main.rs` + /// can warm it at startup and drive the background refresh loop. + /// `None` iff `nip_fi_verifier` is `None`. + pub nip_fi_jwks_source: Option>, } impl AppState { @@ -866,6 +887,8 @@ impl AppState { let gif_http_client = crate::api::gifs::build_gif_http_client(); let admission_rate_limiter = Arc::new(RedisRateLimiter::new(redis_pool.clone())); let audit_enabled = audit_arc.is_some(); + // Build NIP-FI components before moving config into the state Arc. + let (nip_fi_verifier, nip_fi_jwks_source) = build_nip_fi_components(&config); let state = Self { config: Arc::new(config), db, @@ -955,6 +978,8 @@ impl AppState { // `crates/buzz-test-client` once those land). tracer: Arc::new(crate::conformance::NoopTracer), mesh: Arc::new(std::sync::OnceLock::new()), + nip_fi_verifier, + nip_fi_jwks_source, }; ( state, @@ -1369,6 +1394,50 @@ impl AuditShutdownHandle { } } +/// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. +/// +/// Returns `(None, None)` when the mode is `Off`. In `Enforce` or +/// `DenyProtected` mode, constructs a `ProductionJwksSource` (shared via `Arc`) +/// and a `FederatedAssertionVerifier` over a clone of that `Arc`. +/// The source starts empty; HTTP admission returns `authorization_unavailable` +/// (503) until the startup warm in `main.rs` succeeds. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +type NipFiComponents = ( + Option>, + Option>, +); + +fn build_nip_fi_components(config: &crate::config::Config) -> NipFiComponents { + use buzz_auth::{FederatedAssertionVerifier, HttpJwksFetcher, NipFiMode, ProductionJwksSource}; + + if matches!( + config.nip_fi.mode, + NipFiMode::Off | NipFiMode::DenyProtected + ) { + // Off: no enforcement. DenyProtected: verifier never consulted (always 503). + return (None, None); + } + + let source = + match ProductionJwksSource::new(config.nip_fi.jwks_configs.clone(), HttpJwksFetcher::new()) + { + Some(s) => Arc::new(s), + None => { + tracing::error!( + "nip-fi: ProductionJwksSource construction returned None despite \ + passing startup validation — HTTP enforcement unavailable" + ); + return (None, None); + } + }; + + let verifier: Arc = Arc::new(FederatedAssertionVerifier::new( + config.nip_fi.registry.clone(), + Arc::clone(&source), + )); + + (Some(verifier), Some(source)) +} + /// Log a single audit entry with metrics. Extracted so the normal loop /// and the post-cancel drain share the same logic. async fn log_audit_entry(audit: &buzz_audit::AuditService, entry: buzz_audit::NewAuditEntry) {