diff --git a/Cargo.lock b/Cargo.lock index a92813aacc3..a74ff2993cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1353,7 +1353,10 @@ dependencies = [ "futures-util", "hex", "hmac 0.13.0", + "http-body", + "http-body-util", "infer", + "jsonwebtoken", "mesh-llm-host-runtime", "mesh-llm-sdk", "metrics", @@ -1373,6 +1376,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..eb486e9b9b9 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,10 @@ pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] pub use nip98_replay::AlwaysFreshReplayGuard; #[cfg(any(test, feature = "test-utils"))] +pub use nip_fi::jwks::ScriptedJwksFetcher; +#[cfg(any(test, feature = "test-utils"))] +pub use nip_fi::StaticIssuerKeySource; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index a5ed98167cb..90953dd8ebb 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -149,6 +149,13 @@ pub fn verify_nip98_event( // sign without one. Rejecting duplicates closes the real attack: a valid-first // /contradictory-second pair would let `.find()` accept the first and silently // ignore the second, bypassing the body-hash check. + // + // Digest format contract: the content value must be exactly 64 lowercase + // hex characters (a valid sha256 digest). A one-element `["payload"]` tag + // with no content, or a malformed digest, must be rejected — the tag + // *claims* body-hash binding but provides no valid digest. An absent tag + // makes no claim; body-bearing routes that opt out of payload binding + // (e.g. `/events`, `/query`, `/count`) fall here legitimately. { let count = event .tags @@ -161,11 +168,40 @@ pub fn verify_nip98_event( ))); } } - // Keep a present-but-malformed tag distinct from an absent (optional) tag. - if let (Some(payload_tag), Some(body_bytes)) = (event.tags.find(TagKind::Payload), body) { - let payload_hex = payload_tag.content().ok_or_else(|| { - AuthError::Nip98Invalid("payload tag is missing its SHA-256 hash".to_string()) - })?; + // Validate the payload tag digest when the tag is present. + // + // A present tag *claims* body-hash binding. A missing hash value (one-element + // tag or empty string content) is structurally invalid — the claim is made but + // no digest is provided — and must be rejected. An absent tag makes no claim; + // body-bearing routes that opt out of payload binding (e.g. `/events`, `/query`, + // `/count`) fall here legitimately. + // + // When the tag is present, the content must be exactly 64 lowercase hex chars. + let payload_tag = if let Some(tag) = event.tags.find(TagKind::Payload) { + let hex_str = tag.content().unwrap_or(""); + if hex_str.is_empty() { + return Err(AuthError::Nip98Invalid( + "payload tag is missing its SHA-256 hash".to_string(), + )); + } + // Must be exactly 64 lowercase hex chars (valid sha256 digest). + // Use a static diagnostic — attacker-controlled tag content must not + // appear in error messages (length, format code, or unicode boundary + // slicing could panic or leak attacker data). + if hex_str.len() != 64 + || !hex_str.chars().all(|c| c.is_ascii_hexdigit()) + || hex_str.chars().any(|c| c.is_ascii_uppercase()) + { + return Err(AuthError::Nip98Invalid( + "payload tag digest must be exactly 64 lowercase hex chars (sha256)".to_string(), + )); + } + Some(hex_str) + } else { + None + }; + + if let (Some(payload_hex), Some(body_bytes)) = (payload_tag, body) { let computed: [u8; 32] = Sha256::digest(body_bytes).into(); let computed_hex = hex::encode(computed); if computed_hex != payload_hex { @@ -479,6 +515,112 @@ mod tests { ); } + // ── F1 regression: payload tag present with no or empty digest ───────── + // + // The old code did `.and_then(|t| t.content())` which returned `None` for a + // one-element `["payload"]` tag — silently skipping the body-hash check. + // A client could sign an event with `["payload"]` (no digest), present any + // body, and the verifier would not check the body against the tag. + // + // Fix: a present tag with no content (or empty string) is rejected as + // structurally invalid — the claim is made but no digest is provided. + // An absent tag makes no claim and is accepted. A present tag with content + // must be exactly 64 lowercase hex chars; invalid format rejects the event. + // + // Mutation evidence: removing the format check makes `unwrap_err()` panic. + + #[test] + fn payload_tag_malformed_digest_rejected() { + // A payload tag present with a value that is NOT 64 lowercase hex chars + // must be rejected — it is a structurally invalid event. + use nostr::Tag; + let keys = Keys::generate(); + let body = b"any body"; + + // Too short. + let json = make_nip98_event_raw_tags( + &keys, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", "deadbeef"]).unwrap(), // 8 chars, not 64 + ], + ); + let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "payload tag with short digest must be rejected: {result:?}" + ); + + // Uppercase hex (structurally invalid per NIP-98 lowercase-hex contract). + let keys2 = Keys::generate(); + let hash: [u8; 32] = Sha256::digest(body).into(); + let upper_hex = hex::encode(hash).to_uppercase(); + let json2 = make_nip98_event_raw_tags( + &keys2, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &upper_hex]).unwrap(), + ], + ); + let result2 = verify_nip98_event(&json2, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result2, Err(AuthError::Nip98Invalid(_))), + "payload tag with uppercase hex must be rejected: {result2:?}" + ); + + // Non-hex content. + let keys3 = Keys::generate(); + let non_hex = "z".repeat(64); + let json3 = make_nip98_event_raw_tags( + &keys3, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &non_hex]).unwrap(), + ], + ); + let result3 = verify_nip98_event(&json3, TEST_URL, TEST_METHOD, Some(body)); + assert!( + matches!(result3, Err(AuthError::Nip98Invalid(_))), + "payload tag with non-hex content must be rejected: {result3:?}" + ); + } + + #[test] + fn payload_tag_multibyte_boundary_does_not_panic() { + // Regression for R2 (Thufir round-1): the old code did + // `&hex_str[..hex_str.len().min(80)]` + // on attacker-controlled tag content. 79 ASCII 'a' chars followed by + // 'é' (U+00E9, 2 UTF-8 bytes) produces a 81-byte str; truncating at + // byte 80 splits the multibyte character and panics with a byte-index + // boundary panic. + // + // Mutation evidence: restoring the old byte-slice panic reproduces the + // panic here rather than returning an ordinary `AuthError`. + use nostr::Tag; + let keys = Keys::generate(); + // 79 lowercase 'a' chars + 'é' (2 UTF-8 bytes) = 81 bytes, not 64 chars. + let malformed = format!("{}{}", "a".repeat(79), "é"); + assert_eq!(malformed.len(), 81, "precondition: 81 UTF-8 bytes"); + assert_eq!(malformed.chars().count(), 80, "precondition: 80 chars"); + let json = make_nip98_event_raw_tags( + &keys, + vec![ + Tag::parse(["u", TEST_URL]).unwrap(), + Tag::parse(["method", TEST_METHOD]).unwrap(), + Tag::parse(["payload", &malformed]).unwrap(), + ], + ); + // Must return an ordinary AuthError, not panic. + let result = verify_nip98_event(&json, TEST_URL, TEST_METHOD, None); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "multibyte-boundary malformed payload tag must return AuthError, not panic: {result:?}" + ); + } + #[test] fn loopback_aliases_are_distinct_hosts() { // Under multi-tenant, the `u`-tag host is the row-zero community 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/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index 618ee6b0696..7e28fed26e7 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -546,8 +546,8 @@ impl ProductionJwksSource { /// **Test-only.** Construct with an injectable clock so tests can advance /// `now` past snapshot hard deadlines without wall-clock sleep. - #[cfg(test)] - pub(crate) fn new_with_clock( + #[cfg(any(test, feature = "test-utils"))] + pub fn new_with_clock( configs: Vec, fetcher: F, now_fn: Arc DateTime + Send + Sync>, @@ -736,3 +736,55 @@ impl std::fmt::Debug for ProductionJwksSource { #[cfg(test)] mod tests; + +/// A scripted JWKS fetcher for integration tests outside this crate. +/// +/// Returns pre-queued responses in FIFO order. When the queue is +/// exhausted every subsequent call returns `NetworkError`. The queued +/// values are immediate `Result` — latency cannot +/// be added inside the fetcher itself. To simulate nonzero fetch latency, +/// add a `tokio::time::sleep` in the outer callback that wraps the fetcher +/// call (see `composition_nonzero_latency_and_not_due_cache_hit`). +/// +/// Sealed for `JwksFetcher` so callers never need to name the sealed trait. +#[cfg(any(test, feature = "test-utils"))] +pub struct ScriptedJwksFetcher { + /// Remaining responses, front = next to return. Thread-safe. + pub responses: std::sync::Arc< + std::sync::Mutex>>, + >, + /// Incremented on each call regardless of outcome. Thread-safe. + pub call_count: std::sync::Arc, +} + +#[cfg(any(test, feature = "test-utils"))] +impl ScriptedJwksFetcher { + /// Create a new `ScriptedJwksFetcher` with the given queued responses (FIFO). + pub fn new(responses: impl IntoIterator>) -> Self { + Self { + responses: std::sync::Arc::new(std::sync::Mutex::new(responses.into_iter().collect())), + call_count: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl super::verifier::sealed::Sealed for ScriptedJwksFetcher {} + +#[cfg(any(test, feature = "test-utils"))] +impl JwksFetcher for ScriptedJwksFetcher { + fn fetch_jwks<'a>( + &'a self, + _uri: &'a str, + ) -> impl std::future::Future> + Send + 'a { + self.call_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let result = self + .responses + .lock() + .unwrap() + .pop_front() + .unwrap_or(Err(JwksFetchError::NetworkError)); + async move { result } + } +} 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 d022bcab01c..9bfdbd98691 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,11 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] +# `use_pem` enables EncodingKey::from_ec_pem for minting ES256 test assertions +# in settings_tests.rs (key-pairing build_router tests). +jsonwebtoken = { version = "10.4.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +http-body = "1" +http-body-util = "0.1" mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.76.0-rc9", 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.76.0-rc9", 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 +101,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 4714ce13438..13437d9c0d8 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -10,18 +10,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}; mod thread_roots; mod thread_window; @@ -75,6 +76,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. +/// +/// Most callers use [`make_nip98_closure_for_admission`] (admitted surfaces), +/// [`verify_nip98_exempt_invite_claim`] / [`verify_nip98_exempt_operator`] +/// (explicitly-named exempt paths), or the `pub(crate)` form below for +/// git-settings and other crate-local specialized handlers. pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, @@ -94,6 +100,11 @@ pub(crate) fn verify_bridge_auth_with_options( require_payload: bool, ) -> BridgeAuthResult { // Try NIP-98 first (Authorization: Nostr ) + // + // Cardinality is enforced at the NIP-FI admission boundary + // (`admit_nip_fi_http`) for Enforce mode. Off-mode passes + // through legacy first-value behavior per [FI-INV-15]. + if let Some(auth_str) = headers .get("authorization") .and_then(|v| v.to_str().ok()) @@ -151,6 +162,100 @@ 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 completing the mode-appropriate admission +/// path (pairing and deny-map run only in Enforce). +/// +/// [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 @@ -778,11 +883,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 @@ -798,20 +905,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 — @@ -883,7 +1006,7 @@ pub async fn submit_event( } } - outcome.into_response() + Ok(outcome.into_response().into_response()) } /// Log-context outcome for a single [`submit_event`] call. @@ -1086,11 +1209,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 @@ -1107,20 +1232,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 @@ -1159,7 +1297,7 @@ pub async fn query_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`query_events`], run once NIP-98 auth succeeds. @@ -1699,11 +1837,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 @@ -1719,20 +1859,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 @@ -1769,7 +1922,7 @@ pub async fn count_events( ); } } - result + Ok(result.into_response()) } /// Filter execution for [`count_events`], run once NIP-98 auth succeeds. @@ -2498,12 +2651,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()) @@ -2515,6 +2669,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 { @@ -2522,12 +2677,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( @@ -2544,6 +2716,7 @@ async fn authorize_moderation_read( StatusCode::FORBIDDEN, "restricted: moderator access required", ) + .into_response() })?; Ok(tenant) @@ -2571,16 +2744,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(), @@ -2588,8 +2773,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). @@ -2597,32 +2784,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 { @@ -4726,6 +4935,2223 @@ mod postgres_tests { ); } + // ── 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 tests below exercise the *outer* assertion guard in `router.rs` + // (router.rs:232-234): in Enforce mode, a missing or crypto-invalid + // `Nostr-Federated-Identity` token is rejected BEFORE the handler runs. + // + // These tests do NOT prove per-handler `admit_nip_fi_http_on_state` wiring + // — deleting a handler's admission call would not change these results. + // The cardinality test (`r3_cardinality_actual_caller_query_off_passes_enforce_denies`) + // exercises the handler-level gate with a valid assertion. Per-handler + // key-pairing and deny-map are tested in `settings_tests.rs` and the + // crypto-seam test above. + + /// 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; + // Pin the GIF provider absent: `Config::from_env()` imports + // `BUZZ_KLIPY_API_KEY`, and the GIF positive control's exact 404 + // (`gifs.rs` "GIF search is not configured") depends on `klipy = None`. + config.klipy = None; + // 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)) + } + + // EC P-256 test key constants shared by positive-control and cardinality tests. + // Private key (PKCS#8 PEM) + public key coordinates (JWK x/y/kid). + // Used by `nip_fi_enforce_test_state_with_verifier()` and + // `signed_assertion_for_pubkey()`. + const HANDLER_TEST_ISSUER: &str = "https://issuer.example"; + const HANDLER_TEST_AUDIENCE: &str = "https://relay.example"; + const HANDLER_TEST_KID: &str = "test-key-1"; + const HANDLER_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Build a NIP-FI Enforce AppState with a real injected P-256 verifier. + /// + /// Used by positive-control tests (same-key admission proves the handler + /// was reached, not just deny-all). Same key material as used in the + /// cardinality test and the `signed_assertion_for_pubkey` helper below. + async fn nip_fi_enforce_test_state_with_verifier() -> Option> { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut state = (*nip_fi_enforce_test_state().await?).clone(); + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": HANDLER_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(HANDLER_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{HANDLER_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + HANDLER_TEST_ISSUER.to_owned(), + vec![HANDLER_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + 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); + Some(Arc::new(state)) + } + + /// Mint a signed NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`, + /// using the shared HANDLER_TEST_* key material. + fn signed_assertion_for_pubkey(pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": HANDLER_TEST_ISSUER, + "aud": HANDLER_TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(HANDLER_TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(HANDLER_TEST_EC_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Build a HeaderMap containing a valid NIP-98 Authorization header + + /// a valid NIP-FI assertion for the same key. + fn same_key_nip98_and_assertion_headers( + keys: &Keys, + url: &str, + method: &str, + body: &[u8], + ) -> axum::http::HeaderMap { + let mut headers = make_nip98_headers(keys, url, method, body); + let assertion = signed_assertion_for_pubkey(&keys.public_key().to_hex()); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + headers + } + + /// 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() + } + + /// Drive a single oneshot request through the full relay router and return + /// `(status, response_headers, body_bytes)` for exact-byte assertions. + async fn oneshot_request_full( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> (axum::http::StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::{to_bytes, 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); + } + let resp = crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let resp_headers = resp.headers().clone(); + let resp_body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, resp_headers, resp_body) + } + + // ── F4: bridge POST /events — enforce mode, no assertion → 401 ────────── + // + // Exercises the OUTER assertion guard in `router.rs` (not the per-handler + // gate): build_router with no Nostr-Federated-Identity header → guard fires + // before the handler runs → 401 `authentication required\n`. + // + // Falsifying mutation: removing the outer `nip_fi_assertion_guard` layer + // from `build_router` does NOT change this test — the per-handler + // `admit_nip_fi_http_on_state` call in `submit_event` also denies 401 + // `authentication required\n` when no assertion is present. This test + // proves the outer guard fires (and its error path is exercised), not + // that it is the sole denial point for missing-assertion requests. + #[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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/events: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/events: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/events: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── 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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/query: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/query: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/query: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── 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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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" + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/count: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/count: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/count: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── 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`. + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. + // + // Note: removing only the outer guard does NOT change this test — the + // handler's own `admit_nip_fi_http_on_state` also fires 401 on missing + // assertion. This test witnesses the outer guard fires first and its + // error path is exercised; it does not claim the outer guard is the sole + // denial point. The same-key positive (below) is the complement witness. + // + // The exact body/CT/challenge oracles discriminate any implementation that + // returns a different status or body (e.g. application-level 403 if both + // admission layers were removed). + #[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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "/moderation/reports: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "/moderation/reports: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "/moderation/reports: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── 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`). + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. + // + // Note: removing only the outer guard does NOT change this test — the + // handler's own `admit_nip_fi_http_on_state` in `gifs::authenticate` also + // fires 401 on missing assertion. This test witnesses the outer guard fires + // first and its error path is exercised; it does not claim the outer guard is + // the sole denial point. The same-key positive (below) is the complement witness. + // + // The exact body/CT/challenge oracles discriminate any implementation that + // returns a different status or body (e.g. 404 if BOTH admission layers were + // removed and Klipy config was absent). + #[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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401.", + crate::api::gifs::SEARCH_PATH + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GIF search: exact MissingEvidence body must be 'authentication required\\n'. \ + Note: GIF 404 → NIP-FI 401 is a known exception (gifs.rs → bridge → api_error() \ + for the Off path); Enforce must still produce exact NIP-FI bytes." + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "GIF search: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "GIF search: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── F4: workflow runs — enforce mode, no assertion → 401 ──────────────── + // + // Shared witness for both workflow routes (`authorize_workflow_read` + // calls `admit_nip_fi_http_on_state`). + // + // The 401 is produced by the OUTER `nip_fi_assertion_guard` layer in + // `build_router`: no `Nostr-Federated-Identity` header → MissingEvidence → + // 401 `authentication required\n`. The per-handler gate is unreachable. + // + // Removing only the outer guard does not change this 401: the request then + // reaches `authorize_workflow_read`, whose `admit_nip_fi_http_on_state` + // (workflows.rs) denies the same missing assertion with the same + // MissingEvidence bytes. The handler-level gate is witnessed separately by + // the same-key positive and mismatched-key controls below. + #[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, resp_headers, body) = rt.block_on(oneshot_request_full( + 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]; outer nip_fi_assertion_guard fires on missing \ + Nostr-Federated-Identity header → MissingEvidence → 401." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "{path}: exact MissingEvidence body must be 'authentication required\\n'" + ); + let ct = resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "{path}: 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "{path}: 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── GIF search — Enforce mode, same-key admission → reaches handler ─────── + // + // Positive control for `nip_fi_enforce_gif_search_no_assertion_is_401`: + // a valid NIP-FI assertion + valid same-key NIP-98 MUST pass admission and + // reach the GIF handler. The handler returns 404 (GIF search not configured) + // — which is NOT 401/403, proving the NIP-FI gate did not deny the request. + // + // Falsifying mutation: make the NIP-FI verifier always-deny → same-key + // request returns 403 AuthorizationDenied → 404 assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_same_key_admission_succeeds() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-gif-positive-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // `nip_fi_enforce_test_state` pins `config.klipy = None`. + let keys = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "POST", b"{}"); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + headers, + b"{}", + )); + + // Admission passes → handler fires → GIF config absent → exact 404. + // Falsifying mutation: make verifier always-deny → 403 AuthorizationDenied. + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "GIF search same-key positive: NIP-FI MUST admit and handler MUST return 404 \ + (GIF provider not configured). \ + If 401: NIP-FI MissingEvidence — outer guard or assertion check denying. \ + If 403: NIP-FI AuthorizationDenied — verifier or pairing denying. \ + Body: {body:?}" + ); + // Verify exact body: api_error(NOT_FOUND, "GIF search is not configured") → JSON. + let body_json: serde_json::Value = + serde_json::from_slice(&body).expect("404 body must be valid JSON"); + assert_eq!( + body_json.get("error").and_then(|v| v.as_str()), + Some("GIF search is not configured"), + "GIF search same-key positive: exact 404 body must be JSON \ + {{\"error\":\"GIF search is not configured\"}}. \ + Falsifying mutation: make handler always-deny → 403 body differs." + ); + } + + // ── Moderation reports — Enforce mode, same-key admission → reaches handler ─ + // + // Positive control: a valid NIP-FI assertion + same-key NIP-98 on the + // registered `/moderation/reports` route passes admission and reaches + // `authorize_moderation_action`. The unprivileged caller gets the + // application 403 JSON `{"error":"restricted: moderator access required"}` + // (`authorize_moderation_read` → `api_error`), which is distinguishable + // from the NIP-FI text/plain `authorization denied\n` denial. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_reports_same_key_admission_succeeds() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-mod-positive-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // The caller holds no moderation role (`ensure_user` creates a member + // row only), so `authorize_moderation_action(ViewQueue)` fails and + // `authorize_moderation_read` maps it to an application JSON 403. + let keys = Keys::generate(); + rt.block_on(async { + state + .db + .ensure_user( + state + .db + .ensure_configured_community(&host) + .await + .expect("community") + .id, + keys.public_key().as_bytes(), + ) + .await + .expect("ensure_user"); + }); + + let path = "/moderation/reports"; + let url = format!("https://{host}{path}"); + let headers = same_key_nip98_and_assertion_headers(&keys, &url, "GET", b""); + + let (status, resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", path, &host, headers, b"", + )); + + // Admission passes — the caller is NOT a moderator so moderation returns + // 403 with exact application body "restricted: moderator access required". + // This is an application-level 403, not a NIP-FI denial. + // + // Distinguishing mutations: + // - NIP-FI always-deny → "authorization denied\n" (text/plain) ≠ JSON body. + // - Remove moderation authz check → 200 with empty results ≠ 403. + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Moderation same-key positive: handler MUST reach moderation authz → \ + 403 (caller is not a moderator). \ + If 401: NIP-FI MissingEvidence — assertion check denying. \ + If 200: moderation authz check was removed." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "Moderation same-key positive: application 403 must be JSON" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Moderation same-key positive: application 403 carries no challenge" + ); + assert_eq!( + body.as_ref(), + br#"{"error":"restricted: moderator access required"}"#, + "Moderation same-key positive: exact 403 body must be JSON \ + {{\"error\":\"restricted: moderator access required\"}}. \ + If 'authorization denied\\n': NIP-FI AuthDenied — verifier or pairing denying. \ + Falsifying mutation: make verifier always-deny → text/plain body." + ); + } + + // ── Workflow runs — Enforce mode, same-key admission → reaches handler ──── + // + // Positive control for `nip_fi_enforce_workflow_runs_no_assertion_is_401`: + // valid NIP-FI assertion + same-key NIP-98 passes admission and reaches the + // workflow handler. The handler returns 404 (no workflow with this UUID). + // + // Falsifying mutation: make the NIP-FI verifier always-deny → 403 instead + // of 404 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_runs_same_key_admission_succeeds() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-wf-positive-{}.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 headers = same_key_nip98_and_assertion_headers(&keys, &url, "GET", b""); + + let (status, _resp_headers, _body) = rt.block_on(oneshot_request_full( + state, "GET", &path, &host, headers, b"", + )); + + // Admission passes → workflow not found → 404. + // NIP-FI denial would return 401 or 403 — neither is expected here. + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "Workflow runs same-key positive: admission MUST pass and handler MUST \ + return 404 (workflow not found). \ + 401 = NIP-FI MissingEvidence; 403 = NIP-FI AuthDenied/Cardinality. \ + Falsifying mutation: make verifier always-deny → 403 instead of 404." + ); + } + + // ── Caller key-pairing witness: GIF mismatched key → 403 AuthorizationDenied ─ + // + // Valid assertion signed for key_a, NIP-98 signed by key_b. The key-pairing + // check in `admit_nip_fi_http_on_state` fires → 403 `authorization denied\n`. + // + // This is the handler-level denial witness: the same-key positive above proves + // admission passes when keys match; this proves the pairing check fires when + // they don't. Together they bound removing the pairing check from both sides. + // + // Falsifying mutation: remove key-pairing check from `admit_nip_fi_http` → + // mismatched keys pass admission → 404 (GIF not configured) ≠ 403. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_gif_search_mismatched_key_is_403() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-gif-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // key_nip98: signs the NIP-98 Authorization header. + // key_assertion: signs the NIP-FI assertion (different pubkey → pairing mismatch). + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let url = format!("https://{host}{}", crate::api::gifs::SEARCH_PATH); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "POST", b"{}"); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, + "POST", + crate::api::gifs::SEARCH_PATH, + &host, + headers, + b"{}", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "GIF mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 404 (GIF not configured) returned instead." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "GIF mismatched key 403 MUST carry exact body 'authorization denied\\n'." + ); + } + + // ── Caller key-pairing witness: moderation mismatched key → 403 ───────── + // + // Mirror of the GIF case through the moderation route. + // Falsifying mutation: remove key-pairing check → admission passes → 403 from + // moderation authz (not NIP-FI) with different JSON body. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_moderation_mismatched_key_is_403() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-mod-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let path = "/moderation/reports"; + let url = format!("https://{host}{path}"); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", path, &host, headers, b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Moderation mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 403 from moderation authz (different JSON body)." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "Moderation mismatched key 403 MUST carry exact body 'authorization denied\\n'. \ + If JSON 403: key-pairing was skipped, moderation authz fired instead." + ); + } + + // ── Caller key-pairing witness: workflow mismatched key → 403 ─────────── + // + // Mirror of the GIF/moderation cases through the workflow route. + // Falsifying mutation: remove key-pairing check → admission passes → 404 (no + // such workflow) rather than 403 AuthorizationDenied. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_workflow_mismatched_key_is_403() { + 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_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-wf-mismatch-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_nip98 = Keys::generate(); + let key_assertion = Keys::generate(); + let workflow_id = uuid::Uuid::new_v4(); + let path = format!("/workflows/{workflow_id}/runs"); + let url = format!("https://{host}{path}"); + let assertion = signed_assertion_for_pubkey(&key_assertion.public_key().to_hex()); + + let mut headers = make_nip98_headers(&key_nip98, &url, "GET", b""); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, _resp_headers, body) = rt.block_on(oneshot_request_full( + state, "GET", &path, &host, headers, b"", + )); + + assert_eq!( + status, + axum::http::StatusCode::FORBIDDEN, + "Workflow mismatched key MUST return 403 AuthorizationDenied. \ + Falsifying mutation: remove key-pairing check → admission passes \ + → 404 (workflow not found) returned instead." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "Workflow mismatched key 403 MUST carry exact body 'authorization denied\\n'." + ); + } + + // ── 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"); + + // The request carries a valid NIP-98 event signed for the community's + // actual URL (https://{host}/query), so the 503 cannot be attributed to + // a proof failure. + // + // In DenyProtected the router's `nip_fi_assertion_guard` returns 503 for + // this non-exempt route before the handler runs; `admit_nip_fi_http` + // would also return 503 as its first step, before NIP-98 or the + // assertion verifier. Neither path runs NIP-98 first. + 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." + ); + } + + // ── R3 cardinality regression: actual-caller (/query) ──────────────────── + // + // Proves that the cardinality gate in `admit_nip_fi_http` fires on actual + // HTTP routes, not just the unit-level `admit_nip_fi_http` tests. + // + // The unit tests in nip_fi_http.rs prove the gate logic; this test proves + // the gate is actually wired into the `/query` route through the full router. + // + // ## Off-mode auth-required compatibility control + // + // Off mode must NOT reject duplicate Authorization headers — `verify_bridge_auth` + // used `.get()` (first-value) before NIP-FI. FI-INV-15 requires that Off mode + // preserves this behavior. The state is built with `require_auth_token = true` + // so the NIP-98 layer is active; single valid NIP-98 succeeds (200 []); a + // valid-first / malformed-second duplicate also uses the first value and + // succeeds (same 200 []). The cardinality gate is bypassed in Off mode: + // neither the single nor the duplicate case returns 403. + // + // Falsifying mutation: add a cardinality check before legacy auth in Off + // mode → duplicate case returns 403 EvidenceRejected → assertion fires. + // + // ## Enforce-mode cardinality denial + // + // Enforce mode + a valid assertion + two Authorization headers must return + // 403 EvidenceRejected from the cardinality gate BEFORE NIP-98 is parsed. + // The assertion guard passes with a valid signed token; the cardinality check + // inside `admit_nip_fi_http` then fires because `auth_count == 2`. + // + // Negative control: without a valid assertion the middleware 401s first and + // the cardinality gate is never reached — the old test exercised the wrong path. + // + // Falsifying mutation: remove the cardinality gate in Enforce mode → the + // two-header request passes cardinality, NIP-98 proceeds with keys2/url2 + // matching → pairing succeeds → handler returns 200 [] (same as single-header + // positive control) → body "evidence rejected\n" assertion fires. + // + // ## Single-header same-key positive control + // + // A single Authorization header with the same valid assertion must NOT produce + // a cardinality denial. Without this, an always-denying implementation passes + // the two-header test. Exact success: status 200, body []. + #[test] + #[ignore = "requires Postgres and Redis"] + fn r3_cardinality_actual_caller_query_off_passes_enforce_denies() { + 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"); + + // ── Off mode: require_auth_token=true, first-value semantics ───────── + // + // Build an Off state with `require_auth_token = true` so the NIP-98 gate + // is active and can actually validate the token. This differs from the + // shared `nip_fi_off_test_state()` helper which uses `require_auth_token=false`. + // + // With auth required: + // Case 1: single valid NIP-98 → auth passes → handler → 200 []. + // Case 2: valid-first + malformed-second ("Nostr AAAA") → Off mode uses + // first-value semantics (.get() on Authorization) → same 200 []. + // + // Identity of the two results proves first-value semantics preserved. + // Neither is 403: proves cardinality gate is not applied in Off mode. + let Some(off_state) = rt.block_on(async { + 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::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, _) = 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)) + }) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-cardinality-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(off_state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let url = format!("https://{host}/query"); + // Build a valid single NIP-98 header value. + let nip98_header_value = { + let mut h = make_nip98_headers(&keys, &url, "POST", b"[]"); + h.remove(axum::http::header::AUTHORIZATION) + .expect("authorization header") + }; + // Malformed second header: valid Nostr scheme prefix, invalid payload. + // "Nostr AAAA" decodes as 3 zero bytes — not a valid JSON Nostr event. + let malformed_nostr_header: axum::http::HeaderValue = + "Nostr AAAA".parse().expect("valid header bytes"); + + // Case 1: single valid NIP-98 → auth passes → 200 []. + let (single_off_status, _, single_off_body) = rt.block_on(oneshot_request_full( + Arc::clone(&off_state), + "POST", + "/query", + &host, + { + let mut h = axum::http::HeaderMap::new(); + h.append( + axum::http::header::AUTHORIZATION, + nip98_header_value.clone(), + ); + h + }, + b"[]", + )); + assert_eq!( + single_off_status, + axum::http::StatusCode::OK, + "Off mode: single valid NIP-98 MUST reach the handler and return 200. \ + [FI-INV-15]" + ); + assert_eq!( + single_off_body.as_ref(), + b"[]", + "Off mode: single valid NIP-98 MUST return empty events array for empty filter set." + ); + + // Case 2: valid-first + malformed-second → Off uses first-value → same 200 []. + // Identical values cannot distinguish first-value from last-value selection — + // valid-first/invalid-second proves the first value is used, not the last. + let (dup_off_status, _, dup_off_body) = rt.block_on(oneshot_request_full( + off_state, + "POST", + "/query", + &host, + { + let mut h = axum::http::HeaderMap::new(); + h.append( + axum::http::header::AUTHORIZATION, + nip98_header_value.clone(), + ); + h.append( + axum::http::header::AUTHORIZATION, + malformed_nostr_header.clone(), + ); + h + }, + b"[]", + )); + assert_ne!( + dup_off_status, + axum::http::StatusCode::FORBIDDEN, + "Off mode: duplicate Authorization headers MUST NOT produce 403 EvidenceRejected \ + from the cardinality gate [FI-INV-15]. Off mode must preserve first-value legacy \ + behavior — cardinality denial is an Enforce-only contract. \ + Falsifying mutation: add cardinality check in Off mode → 403 → assertion fires." + ); + assert_eq!( + single_off_status, dup_off_status, + "Off mode: duplicate-header result must equal single-header result — \ + the first valid header is used (first-value semantics), \ + not treated as a cardinality violation." + ); + assert_eq!( + single_off_body, dup_off_body, + "Off mode: single and dup bodies must match — first-value semantics \ + means the malformed second header is silently discarded." + ); + + // ── Enforce mode: build a state with a real injected verifier ───────── + // + // The verifier is required so the assertion guard can validate the signed + // token and forward the request. Without a verifier, the middleware 401s + // before the cardinality gate inside `admit_nip_fi_http` can fire. + let Some(mut enforce_state) = rt.block_on(async { + 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 (enforce)"); + }; + + // Inject the real verifier with the static test key. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + // PKCS#8 private key matching TEST_JWK_X/Y — same key used by + // nip_fi_guard_rejects_crypto_invalid_assertion_before_handler_fires. + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + 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, + 3600, + 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]), + )); + enforce_state.nip_fi_verifier = Some(verifier); + let enforce_state = Arc::new(enforce_state); + + let host2 = format!( + "nip-fi-cardinality-enf-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(enforce_state.db.ensure_configured_community(&host2)) + .expect("ensure community"); + + // Mint a valid signed assertion for an arbitrary test pubkey. + let assertion_pubkey_hex = nostr::Keys::generate().public_key().to_hex(); + let valid_assertion = { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": assertion_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + }; + + // Pre-condition: verifier accepts the token. + assert!( + enforce_state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(&valid_assertion) + .is_ok(), + "pre-condition: valid assertion must be accepted by the verifier" + ); + + // Use the same key for both NIP-98 and the assertion's nostr_pubkey so + // the pairing check succeeds and the request reaches the query handler. + let keys2 = Keys::generate(); + let url2 = format!("https://{host2}/query"); + let nip98_val2 = { + let mut h = make_nip98_headers(&keys2, &url2, "POST", b"[]"); + h.remove(axum::http::header::AUTHORIZATION) + .expect("authorization header") + }; + + // Mint a same-key assertion: nostr_pubkey = keys2's public key. + let same_key_assertion = { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": keys2.public_key().to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign same-key assertion") + }; + // Pre-condition: same-key assertion is accepted. + assert!( + enforce_state + .nip_fi_verifier + .as_deref() + .expect("verifier injected") + .verify_assertion(&same_key_assertion) + .is_ok(), + "pre-condition: same-key assertion must be accepted" + ); + + // ── Same-key positive control: 1 Authorization header + same-key assertion ─ + // + // One Authorization header passes the cardinality gate; the NIP-98 key + // matches the assertion's nostr_pubkey → pairing succeeds → handler reached. + // + // Falsifying mutation: always return 403 from cardinality → this test + // returns 403 EvidenceRejected → assertion fires. + let mut single_headers = axum::http::HeaderMap::new(); + single_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + single_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}") + .parse() + .expect("valid header"), + ); + + let single_resp = rt.block_on(async { + use axum::body::{to_bytes, Body}; + use tower::ServiceExt; + let mut builder = axum::http::Request::builder() + .method("POST") + .uri("/query") + .header("host", &host2); + for (name, value) in &single_headers { + builder = builder.header(name, value); + } + let resp = crate::router::build_router(Arc::clone(&enforce_state)) + .oneshot( + builder + .body(Body::from(b"[]".to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, body) + }); + + // Single same-key: cardinality passes, pairing passes; handler reached. + // Exact success: 200 [] (empty filter set on fresh community has no events). + // Falsifying mutation: always-denying cardinality → 403 EvidenceRejected. + assert_eq!( + single_resp.0, + axum::http::StatusCode::OK, + "Single Authorization header + same-key assertion MUST return 200. \ + Falsifying mutation: lower the gate threshold to 1 → 403 EvidenceRejected." + ); + assert_eq!( + single_resp.1.as_ref(), + b"[]", + "Single Authorization header + same-key assertion MUST return empty events array \ + for empty filter set on a fresh community." + ); + + // ── Enforce mode: duplicate header + same-key assertion → cardinality 403 ─ + let mut enforce_headers = axum::http::HeaderMap::new(); + enforce_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + enforce_headers.append(axum::http::header::AUTHORIZATION, nip98_val2.clone()); + enforce_headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}") + .parse() + .expect("valid header"), + ); + + let (enforce_status, enforce_resp_headers, enforce_body) = + rt.block_on(oneshot_request_full( + Arc::clone(&enforce_state), + "POST", + "/query", + &host2, + enforce_headers, + b"[]", + )); + + assert_eq!( + enforce_status, + axum::http::StatusCode::FORBIDDEN, + "Enforce mode: duplicate Authorization headers must yield 403 EvidenceRejected \ + from cardinality gate [FI-TRACE-DENIAL-ORACLE]. \ + Falsifying mutation: remove cardinality gate → NIP-98 closure runs → \ + handler returns 200 [] (same as single-header positive control)." + ); + assert_eq!( + enforce_body.as_ref(), + b"evidence rejected\n", + "Enforce mode: cardinality denial body must be exact contract bytes 'evidence rejected\\n'" + ); + let enforce_ct = enforce_resp_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + enforce_ct, "text/plain; charset=utf-8", + "Enforce mode: cardinality 403 Content-Type must be 'text/plain; charset=utf-8'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + enforce_resp_headers + .get(axum::http::header::WWW_AUTHENTICATE) + .is_none(), + "Enforce mode: cardinality 403 MUST NOT emit WWW-Authenticate — \ + the client has a token but it is malformed, not absent. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + /// T3c — log fidelity for canvas CAS conflict: the terminal attribution line /// must log `status=409`, not 400, when the relay emits a canvas CAS 409. /// 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/settings.rs b/crates/buzz-relay/src/api/git/settings.rs index 2e1f99811f6..ec2250ea087 100644 --- a/crates/buzz-relay/src/api/git/settings.rs +++ b/crates/buzz-relay/src/api/git/settings.rs @@ -29,6 +29,7 @@ use super::{ }; use crate::{ api::{api_error, bridge, relay_members}, + nip_fi_http::admit_nip_fi_http_on_state, state::AppState, }; @@ -174,37 +175,54 @@ async fn authenticate( .await .map_err(|_| error(StatusCode::NOT_FOUND, "repository not found"))?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let auth = bridge::verify_bridge_auth_with_options( + let method = if body.is_some() { "POST" } else { "GET" }; + let require_payload = body.is_some(); + + // NIP-FI admission: runs NIP-98 extraction, assertion verification, and + // key pairing in fixed order. The control-plane route has strict binding: + // - `require_auth_token = true` (NIP-98 always required; no X-Pubkey fallback) + // - `require_payload = body.is_some()` (POST bodies must be hash-bound) + // + // The router assertion guard already verified the assertion cryptographically; + // this call pairs the proven NIP-98 pubkey with the assertion's claimed key + // and checks the deny map. [FI-TRACE-AUTHORITY-UNIFORM] + let admission = admit_nip_fi_http_on_state( + state, headers, - if body.is_some() { "POST" } else { "GET" }, - &url, - body, - true, - body.is_some(), - ) - .map_err(IntoResponse::into_response)?; - bridge::enforce_http_admission(state, &tenant, &auth.pubkey) + bridge::make_nip98_closure_for_admission( + headers.clone(), + method, + url, + body.map(|b| b.to_vec()), + true, // require_auth_token: always required for control-plane + require_payload, + ), + )?; + 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(IntoResponse::into_response)?; - bridge::check_nip98_replay(state, &tenant, auth.event_id_bytes) + bridge::check_nip98_replay(state, &tenant, event_id_bytes) .await .map_err(IntoResponse::into_response)?; let tag = relay_members::extract_auth_tag_header(headers); relay_members::enforce_relay_membership( state, tenant.community(), - auth.pubkey.as_bytes(), + pubkey.as_bytes(), tag, - auth.signed_created_at, + signed_created_at, ) .await .map_err(IntoResponse::into_response)?; deny_banned_git_principal( &state.db, tenant.community(), - &auth.pubkey, + &pubkey, tag, - auth.signed_created_at, + signed_created_at, ) .await?; // Admission ignores kind= restrictions by design (NIP-AA). Repository @@ -223,15 +241,11 @@ async fn authenticate( }) }) .and_then(|tag| { - relay_members::extract_nip_oa_owner( - auth.pubkey.as_bytes(), - Some(tag), - auth.signed_created_at, - ) + relay_members::extract_nip_oa_owner(pubkey.as_bytes(), Some(tag), signed_created_at) }); Ok(SettingsAuth { tenant, - caller: auth.pubkey, + caller: pubkey, delegated_owner, }) } diff --git a/crates/buzz-relay/src/api/git/settings_tests.rs b/crates/buzz-relay/src/api/git/settings_tests.rs index 55c04dddcf7..3a7318bab27 100644 --- a/crates/buzz-relay/src/api/git/settings_tests.rs +++ b/crates/buzz-relay/src/api/git/settings_tests.rs @@ -1,6 +1,1139 @@ //! Live route/store/clone regressions. Require explicit isolated service URLs; //! never fall back to a developer's Desktop database. +// ── NIP-FI admission seam — settings route ────────────────────────────────── +// +// Proves that `authenticate()` in `git/settings.rs` routes through +// `admit_nip_fi_http_on_state`, not the raw bridge verifier. +// +// Falsifying mutation: replace the `admit_nip_fi_http_on_state(...)` call in +// `authenticate()` with the old raw `verify_bridge_auth_with_options(...)`. +// With that mutation, a valid NIP-98 proof for key B + assertion for key A +// (mismatched keys) would be admitted — the handler never checks key pairing. +// Without the mutation the request is denied 401 `authentication required\n` +// (MissingEvidence: no `Nostr-Federated-Identity` assertion header). +// +// The test here is: valid NIP-98 + Enforce mode + no assertion → 401 from +// `admit_nip_fi_http_on_state` (the same body the guard would produce if the +// guard itself fired). The important invariant is that the HANDLER calls +// admission — the guard also fires, and both 401, so this is observationally +// equivalent to having only the guard. However, the handler call is required +// by NIP-FI.md:516-533 for key pairing, which cannot be verified at the guard. +// The `#[ignore]` comment explains why a full key-pairing test needs JWT infra. +#[cfg(test)] +mod postgres_tests { + use super::super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use base64::Engine; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use tower::ServiceExt; + + struct AlwaysFreshReplayGuard; + + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(true) }) + } + } + + /// Build a minimal Enforce-mode AppState that reaches the settings route. + /// + /// No issuers configured → `nip_fi_verifier = None` (DenyProtected startup path). + /// The test fires before the verifier is needed: missing assertion → 401 + /// `MissingEvidence` before the verifier is consulted. + async fn enforce_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 = "ws://nip-fi-settings-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, _audit_shutdown) = 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)) + } + + fn nip98_get_token(keys: &Keys, url: &str) -> String { + let tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", "GET"]).expect("method tag"), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).expect("nonce tag"), + ]; + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + // ── R1 NIP-FI admission seam: settings GET, Enforce, no assertion → 401 ── + // + // Falsifying mutation: remove the `admit_nip_fi_http_on_state(...)` call + // from `authenticate()` in `git/settings.rs`, replacing it with the old + // raw bridge verifier. With the old verifier, a valid NIP-98 token for any + // community member would be admitted without key pairing — the response + // would be 200 or a different status. With the NIP-FI call present and no + // assertion header, `admit_nip_fi_http_on_state` maps the absent header to + // MissingEvidence (401, "authentication required\n", `WWW-Authenticate: Nostr`). + // + // Note: the outer router guard also fires on missing assertion, so a + // missing-assertion test is not sufficient to distinguish "handler calls + // admission" from "guard fires first". A full key-pairing test requires a + // real JWT infrastructure with a live JWKS endpoint — that lives in the + // integration test suite. This seam test focuses on the code path change + // (verify_bridge_auth_with_options → admit_nip_fi_http_on_state) and confirms + // the settings route is reachable in Enforce mode with valid NIP-98 auth. + #[test] + #[ignore = "requires Postgres"] + fn nip_fi_enforce_settings_get_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(enforce_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-settings-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + // Use a dummy path (repo won't exist, but NIP-FI admission fires before the repo lookup). + let path = format!( + "/git/{}/test-repo/default-branch", + keys.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let token = nip98_get_token(&keys, &url); + + let (status, body) = rt.block_on(async { + use axum::body::to_bytes; + let response = super::super::super::transport::git_router(state) + .oneshot( + Request::builder() + .method("GET") + .uri(&path) + .header("host", &host) + .header("authorization", &token) + // No Nostr-Federated-Identity header — this is the no-assertion case. + .body(Body::empty()) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, body) + }); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "NIP-FI Enforce: settings GET with valid NIP-98 + no assertion must deny 401 \ + [FI-TRACE-AUTHORITY-UNIFORM]. Falsifying mutation: replace \ + admit_nip_fi_http_on_state() in authenticate() with the raw bridge verifier — \ + a mismatched-key request would then be admitted." + ); + // MissingEvidence body: "authentication required\n" + assert_eq!( + body.as_ref(), + b"authentication required\n", + "missing assertion must produce MissingEvidence body, not a NIP-98 auth challenge \ + or other error" + ); + } + + // ── NIP-FI settings via build_router: key-pairing, OFF, POST protection ── + // + // These tests exercise the settings route through `build_router` (the full + // relay router), which includes the `nip_fi_assertion_guard` middleware. + // The key-pairing tests require a real `FederatedAssertionVerifier` seeded + // with a static test key, so assertions signed by a known PKCS#8 key can + // carry a chosen `nostr_pubkey` claim. + // + // ## Test key constants + // + // Same P-256 key as buzz-auth/src/nip_fi/verifier/tests.rs so + // the construction pattern can be reviewed against a known-good example. + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + const TEST_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const TEST_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + const TEST_KID: &str = "test-key-1"; + const TEST_ISSUER: &str = "https://issuer.test"; + const TEST_AUDIENCE: &str = "https://relay.test"; + + /// Build an Enforce-mode state with a real `FederatedAssertionVerifier` + /// seeded with the static test key. Used for key-pairing tests. + async fn enforce_state_with_verifier() -> Option> { + use buzz_auth::{ + FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, IssuerRegistry, + StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + 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 = "ws://nip-fi-settings-pairing-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, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + + // Build the verifier with a static key. + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", + "crv": "P-256", + "use": "sig", + "alg": "ES256", + "kid": TEST_KID, + "x": TEST_JWK_X, + "y": TEST_JWK_Y + }] + })) + .expect("valid test JWKS"); + + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = buzz_auth::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, + 3600, + 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); + + Some(Arc::new(state)) + } + + /// Build an Off-mode state (no verifier needed). + async fn off_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 = "ws://nip-fi-settings-off-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) = 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)) + } + + /// Mint a signed ES256 NIP-FI assertion with the given `nostr_pubkey` claim. + /// + /// Uses the same static PKCS#8 PEM and key constants as the verifier above. + fn mint_assertion(nostr_pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": nostr_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + // NIP-FI dedicated assertion type + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Drive a GET request through `build_router` for the settings path. + /// Returns `(status, response_headers, body)` — helpers that previously + /// discarded headers have been updated so callers can assert the full + /// contract (Content-Type, WWW-Authenticate, etc.). + async fn settings_get_via_build_router( + state: Arc, + host: &str, + path: &str, + auth_token: &str, + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::to_bytes; + use axum::http::Request; + use tower::ServiceExt; + let mut builder = Request::builder() + .method("GET") + .uri(path) + .header("host", host) + .header("authorization", auth_token); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + let response = crate::router::build_router(state) + .oneshot( + builder + .body(axum::body::Body::empty()) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let resp_headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, resp_headers, body) + } + + /// Drive a POST request through `build_router` for the settings path. + /// Returns `(status, response_headers, body)`. + async fn settings_post_via_build_router( + state: Arc, + host: &str, + path: &str, + auth_token: &str, + body_bytes: &[u8], + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + use axum::body::to_bytes; + use axum::http::Request; + use tower::ServiceExt; + let mut builder = Request::builder() + .method("POST") + .uri(path) + .header("host", host) + .header("authorization", auth_token) + .header("content-type", "application/json"); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + let response = crate::router::build_router(state) + .oneshot( + builder + .body(axum::body::Body::from(body_bytes.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = response.status(); + let resp_headers = response.headers().clone(); + let body = to_bytes(response.into_body(), 4096) + .await + .unwrap_or_default(); + (status, resp_headers, body) + } + + fn nip98_token_for_method(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { + use sha2::{Digest, Sha256}; + let mut tags = vec![ + Tag::parse(["u", url]).expect("u tag"), + Tag::parse(["method", method]).expect("method tag"), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).expect("nonce tag"), + ]; + if let Some(b) = body { + let hex = hex::encode(Sha256::digest(b)); + tags.push(Tag::parse(["payload", &hex]).expect("payload tag")); + } + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign NIP-98 event"); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ) + } + + // ── Settings via build_router: Enforce + valid-assertion-key-A + NIP-98-key-B → 403 ── + // + // The assertion claims key-A (`nostr_pubkey = pubkey_a`). The NIP-98 is + // signed by key-B. `admit_nip_fi_http` Step 6 (key pairing) fires → 403 + // authorization_denied. + // + // Falsifying mutation: remove the key-pairing check in `admit_nip_fi_http` + // (the `Some(k) if k == proven_pubkey` match arm). The admission succeeds + // → the handler returns a non-403 response (404 for a missing repo) → + // this test's `assert_eq(403)` fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_key_mismatch_denied_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-pairing-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key_a = Keys::generate(); + let key_b = Keys::generate(); + + // Assertion claims key-A. + let assertion = mint_assertion(&key_a.public_key().to_hex()); + // NIP-98 signed by key-B. + let path = format!( + "/git/{}/test-repo/default-branch", + key_a.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key_b, &url); + + let (status, resp_headers, body) = rt.block_on(settings_get_via_build_router( + state, + &host, + &path, + &auth, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: assertion-for-A + NIP-98-for-B must deny 403 authorization_denied \ + [FI-INV-05]. Falsifying mutation: remove key-pairing check in admit_nip_fi_http → \ + admission succeeds → non-403 response." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "key mismatch denial MUST produce authorization_denied body bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "key mismatch 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "key mismatch 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: Enforce + same-key assertion + NIP-98 → not-403 ── + // + // Positive control: assertion and NIP-98 both prove the same key → key + // pairing passes. The handler proceeds to the repository lookup → 404 + // (no such repo) or 200. Either way, NOT 403 authorization_denied. + // + // Without this positive control an always-denying implementation would + // satisfy the negative tests above while being broken. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_same_key_not_denied() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-samekey-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + + // Assertion claims the same key that signs the NIP-98. + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key, &url); + + let (status, _resp_headers, body) = rt.block_on(settings_get_via_build_router( + state, + &host, + &path, + &auth, + Some(&assertion), + )); + + // Admission passes → handler proceeds to repo lookup → repo does not + // exist in the test DB → 404. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "NIP-FI Enforce: same-key assertion + NIP-98 MUST reach handler → 404 \ + (repo does not exist). \ + If 403: key pairing is wrongly denying, or authorize_management denied. \ + If 401: NIP-FI outer guard is wrongly denying a valid assertion. \ + Body: {body:?}" + ); + } + + // ── Settings via build_router: POST — wrong method (GET token) → 403 ─────── + // + // A GET NIP-98 token WITH a payload hash matching the POST body (method=GET, + // payload tag present) on a POST request: method mismatch fails NIP-98 verification. + // In NIP-FI Enforce mode, + // `admit_nip_fi_http` maps a present-but-failing NIP-98 to + // `EvidenceRejected` (403 "evidence rejected\n"), not 401. + // + // Falsifying mutation: change `EvidenceRejected` to `MissingEvidence` in + // `admit_nip_fi_http` → returns 401 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_wrong_method_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-wrong-method-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + + // GET token WITH the correct POST body hash: Authorization header IS + // present, payload tag exists (passes bridge.rs require_payload check), + // but method=GET on a POST request fails NIP-98 method verification → + // EvidenceRejected (403). Using nip98_get_token (no payload tag) would + // have the token rejected earlier (missing-payload at bridge.rs:125-137) + // before method validation runs. + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let get_token = nip98_token_for_method(&key, &url, "GET", Some(post_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &get_token, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: GET token on a POST settings request must deny 403 EvidenceRejected \ + (present but invalid Authorization → EvidenceRejected, not MissingEvidence). \ + Falsifying mutation: flip the DenialClass mapping for present-but-failing NIP-98 \ + to MissingEvidence → 401 → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "EvidenceRejected body must be exact contract bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "EvidenceRejected 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "EvidenceRejected 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: POST — correct method, no payload tag → 403 ─ + // + // A POST NIP-98 token without a payload tag is present but invalid + // (settings POST requires a hash-bound body per NIP-FI.md:619-637). + // In Enforce mode, present-but-failing NIP-98 → EvidenceRejected (403). + // + // Falsifying mutation: set `require_payload = false` in `authenticate()` + // for POST → payload-tag check skipped → NIP-98 succeeds → non-403 status. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_missing_payload_tag_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-no-payload-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + + // POST token with correct method but no payload tag: settings handler + // requires a hash-bound body; missing tag → NIP-98 fails → 403. + let post_token_no_payload = nip98_token_for_method(&key, &url, "POST", None); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token_no_payload, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "NIP-FI Enforce: POST token without payload tag must deny 403 EvidenceRejected \ + (present but missing payload tag → NIP-98 failure → EvidenceRejected). \ + Falsifying mutation: remove require_payload=true from authenticate() → tag \ + check skipped → different status." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "EvidenceRejected body must be exact contract bytes" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "Missing-payload 403 Content-Type MUST be text/plain; charset=utf-8." + ); + } + + // ── Settings via build_router: POST — same-key, valid payload → reaches handler ─ + // + // A correctly signed POST NIP-98 token (method=POST, payload tag matching + // the body) with a matching assertion passes NIP-FI admission and reaches + // the handler. The handler returns a non-NIP-FI error (404 repo not found + // or similar), proving admission succeeded. + // + // Positive control: without this, an always-denying admission implementation + // could pass all three POST tests above without testing real admission. + // + // Falsifying mutation: remove `admit_nip_fi_http_on_state` from + // `authenticate()` → admission skipped → but request still reaches handler + // (non-denial result), so the positive control would not fire. Combined + // with the negative controls above, the full set distinguishes correct + // admission from both always-deny and always-admit implementations. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_same_key_valid_payload_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-ok-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + // Correct POST token: method=POST + payload tag hash-bound to the body. + let post_token = nip98_token_for_method(&key, &url, "POST", Some(post_body)); + + let (status, _resp_headers, _body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + Some(&assertion), + )); + + // Admission passes; handler reaches `authorize_git_read` which returns + // 404 (no repo in this fresh community). NIP-FI denial codes are 401/403, + // not 404 — so 404 proves the NIP-FI gate passed and the handler ran. + // + // Note: quota checking (`enforce_http_admission`) follows NIP-FI admission + // at settings.rs:204-206. A 503 from Redis/quota outage would follow + // admission, not precede it — but Redis availability is verified by the + // non-503 assertion below. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "NIP-FI Enforce: same-key valid POST token MUST reach the handler and return 404 \ + (repo not found in fresh community). \ + 401 = NIP-FI admission blocked; 403 = key pairing failed; \ + either means admission did not pass. \ + Falsifying mutation: make verifier always-deny → 403 instead of 404." + ); + assert_ne!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "NIP-FI Enforce: same-key valid POST token MUST NOT return 503. \ + 503 means quota or Redis outage after NIP-FI admission — \ + ensure Redis is reachable for this test." + ); + } + + // ── Settings via build_router: Off mode + valid NIP-98 → not blocked ───── + // + // Off mode must not apply NIP-FI admission. A valid NIP-98 GET request + // (no assertion) reaches the handler and gets a non-NIP-FI result. + // + // Falsifying mutation: change Off-mode to Enforce → NIP-FI guard fires → + // 401 MissingEvidence → assertion fires (non-401 expected). + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_off_mode_valid_nip98_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(off_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-off-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let auth = nip98_get_token(&key, &url); + + let (status, _resp_headers, _body) = rt.block_on(settings_get_via_build_router( + state, &host, &path, &auth, + None, // No assertion — Off mode must not require one. + )); + + // In Off mode: NIP-FI guard does not fire; request reaches handler. + // The handler returns 404 (no repo in fresh community) — a non-NIP-FI response. + // 404 proves the request was not blocked by NIP-FI admission. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off mode: a valid NIP-98 GET with no assertion MUST reach the handler and \ + return 404 (repo not found in fresh community). \ + 401 means NIP-FI fired (Off mode incorrectly applying active-mode guard). \ + Falsifying mutation: set mode=Enforce → guard fires → 401." + ); + } + + // ── Settings via build_router: Enforce + POST + key-mismatch → 403 ──────── + // + // A valid assertion for key-A, but the Authorization header is NIP-98-signed + // by key-B (different key), is a pairing mismatch. In NIP-FI Enforce mode, + // `admit_nip_fi_http` maps `AuthorizationDenied` to 403 `authorization denied\n`. + // + // This proves the pairing gate fires BEFORE any repo lookup — the path + // `/git/{key-B-hex}/test-repo/default-branch` uses key-B as owner, so + // a mismatch is caught at admission. + // + // Falsifying mutation: remove the pairing check from `admit_nip_fi_http` → + // the request reaches the repo-not-found handler → 404 → assertion fires. + // + // Complement: the `settings_build_router_enforce_key_mismatch_denied_403` + // test above covers GET key-mismatch with the GET proof; this covers POST + // key-mismatch with a payload-bound proof. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_key_mismatch_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-mismatch-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + // key_a: the assertion's nostr_pubkey (the "identity" identity). + // key_b: the NIP-98 signer (different key — mismatch). + let key_a = Keys::generate(); + let key_b = Keys::generate(); + let assertion = mint_assertion(&key_a.public_key().to_hex()); + + // Path uses key_a as owner to make it a plausible repo path. + let path = format!( + "/git/{}/test-repo/default-branch", + key_a.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + + // key_b signs the NIP-98 token; assertion claims key_a. Mismatch. + let post_token = nip98_token_for_method(&key_b, &url, "POST", Some(post_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Enforce mode POST: key-mismatch (key_b NIP-98 vs key_a assertion) MUST return 403. \ + Falsifying mutation: remove pairing check → request reaches handler → 404." + ); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "key-mismatch POST 403 body must be exact 'authorization denied\\n'" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "key-mismatch POST 403 Content-Type must be text/plain; charset=utf-8" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "key-mismatch POST 403 MUST NOT carry WWW-Authenticate" + ); + } + + // ── Settings via build_router: Enforce + POST + wrong payload hash → 403 ─ + // + // A valid assertion for key-A, NIP-98 signed by key-A (same key), but the + // NIP-98 token's `payload` tag has a SHA-256 that does NOT match the actual + // request body. `admit_nip_fi_http` enforces payload binding and denies with + // `EvidenceRejected` 403. + // + // This proves the payload-hash verification is active independently of key + // pairing — a wrong hash is caught before any repo lookup. + // + // Falsifying mutation: remove payload-hash verification from + // `make_nip98_closure_for_admission` → wrong hash passes → handler reached + // → 404 instead of 403 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_enforce_post_wrong_payload_hash_is_403() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(enforce_state_with_verifier()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-post-hash-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let assertion = mint_assertion(&key.public_key().to_hex()); + + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let actual_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let wrong_body = b"{\"branch\":\"wrong-branch\",\"expected_manifest\":\"xyz\"}"; + + // Token is signed against wrong_body's hash, but we send actual_body. + // The token claims the hash of wrong_body, so the payload tag doesn't + // match actual_body → EvidenceRejected. + let wrong_hash_token = nip98_token_for_method(&key, &url, "POST", Some(wrong_body)); + + let (status, resp_headers, body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &wrong_hash_token, + actual_body, + Some(&assertion), + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Enforce mode POST: wrong payload hash (token bound to different body) MUST return 403. \ + Falsifying mutation: remove payload-hash check → wrong hash passes → \ + request reaches handler → 404 instead of 403." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "Wrong payload hash 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "Wrong payload hash 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Wrong payload hash 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── Settings via build_router: Off mode + valid NIP-98 POST → reaches handler ─ + // + // Off mode must not apply NIP-FI admission on POST. A valid NIP-98 POST + // (no assertion) reaches the handler and gets a non-NIP-FI result (404). + // + // Falsifying mutation: change Off mode to Enforce → NIP-FI guard fires → + // 401 MissingEvidence → assertion fires (expected 404). + #[test] + #[ignore = "requires Postgres"] + fn settings_build_router_off_mode_post_reaches_handler() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + + let Some(state) = rt.block_on(off_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-settings-off-post-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let key = Keys::generate(); + let path = format!( + "/git/{}/test-repo/default-branch", + key.public_key().to_hex() + ); + let url = format!("http://{host}{path}"); + let post_body = b"{\"branch\":\"main\",\"expected_manifest\":\"abc\"}"; + let post_token = nip98_token_for_method(&key, &url, "POST", Some(post_body)); + + let (status, _resp_headers, _body) = rt.block_on(settings_post_via_build_router( + state, + &host, + &path, + &post_token, + post_body, + None, // No assertion — Off mode must not require one. + )); + + // In Off mode: NIP-FI guard does not fire → reaches handler → 404 (no repo). + assert_eq!( + status, + StatusCode::NOT_FOUND, + "Off mode POST: a valid NIP-98 POST with no assertion MUST reach the handler \ + and return 404 (repo not found in fresh community). \ + 401 means NIP-FI fired (Off mode incorrectly applying active-mode guard). \ + Falsifying mutation: set mode=Enforce → guard fires → 401." + ); + } +} // mod postgres_tests + mod external_infra { use super::super::*; use axum::{ @@ -877,4 +2010,313 @@ mod external_infra { assert_ne!(f.set(&f.owner, "main", None).await.0, StatusCode::OK); assert_eq!(f.snapshot().await.digest, before); } + + // ── NIP-FI state re-read: denied assertion does NOT advance stored digest ─ + // + // Verifies that a POST to `set_default_branch` denied by NIP-FI admission + // (key mismatch) leaves the stored snapshot digest unchanged. + // + // Proof structure: + // 1. Snapshot the current digest before any NIP-FI requests. + // 2. POST with a key-mismatch assertion (assertion key ≠ NIP-98 key) → + // 403 EvidenceRejected. The handler is never reached. + // 3. POST with an invalid proof (syntactically malformed token) → + // 403 EvidenceRejected. The handler is never reached. + // 4. Re-read the snapshot → digest is unchanged. + // 5. POST with a same-key assertion (admission passes) → 200 OK (changed/not-changed). + // 6. Re-read the snapshot → digest IS advanced if changed=true. + // + // This is the NIP-FI denial-no-write witness. Source ordering at + // settings.rs:189-207 supports current correctness; this test is the + // regression guard. + // + // Falsifying mutation: call `DefaultBranchSnapshot::set` before NIP-FI + // admission check → denied requests would modify stored state → + // digest changes → step 4 assertion fires. + // + // Uses `build_router` (not `git_router`) so NIP-FI admission is exercised + // at the router level. + #[tokio::test] + #[ignore = "requires isolated Postgres, Redis and MinIO"] + async fn nip_fi_denied_assertion_does_not_advance_snapshot_digest() { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm, EncodingKey, Header}; + + let f = Fixture::new().await; + let snapshot_before = f.snapshot().await; + let digest_before = snapshot_before.digest.clone(); + + // ── Inject NIP-FI Enforce + static verifier into the fixture state ── + // + // EC P-256 test key (PKCS#8 PEM) + matching public JWK. + const NIP_FI_ISSUER: &str = "https://nip-fi-settings-test.invalid"; + const NIP_FI_AUDIENCE: &str = "https://relay.settings-test.invalid"; + const NIP_FI_KID: &str = "settings-test-key-1"; + const NIP_FI_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + // Public key coordinates for the JWK (matches the private key above). + const NIP_FI_JWK_X: &str = "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI"; + const NIP_FI_JWK_Y: &str = "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": NIP_FI_KID, + "x": NIP_FI_JWK_X, + "y": NIP_FI_JWK_Y + }] + })) + .expect("valid test JWKS"); + let hard_deadline = chrono::Utc::now() + chrono::Duration::seconds(3600); + let key_set = + AssertionKeySet::new_for_test(NIP_FI_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{NIP_FI_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + NIP_FI_ISSUER.to_owned(), + vec![NIP_FI_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + 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]), + )); + + let mut enforced_state = (*f.state).clone(); + Arc::make_mut(&mut enforced_state.config).nip_fi.mode = buzz_auth::NipFiMode::Enforce; + enforced_state.nip_fi_verifier = Some(verifier); + let enforced_state = Arc::new(enforced_state); + + // Helper: mint a NIP-FI assertion whose `nostr_pubkey` = `pubkey_hex`. + let mint_assertion = |pubkey_hex: &str| -> String { + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": NIP_FI_ISSUER, + "aud": NIP_FI_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(NIP_FI_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(NIP_FI_EC_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + }; + + // Helper: build a NIP-98 POST token for the settings endpoint. + let settings_path = f.path(); + let settings_url = format!("http://{}{settings_path}", f.tenant.host()); + let post_body = + serde_json::json!({"branch": "main", "expected_manifest": digest_before}).to_string(); + let post_body_bytes = post_body.as_bytes(); + + let build_post_request = |auth_token: String, assertion: Option| { + let mut builder = Request::builder() + .method("POST") + .uri(&settings_path) + .header("host", f.tenant.host()) + .header("authorization", auth_token) + .header("content-type", "application/json"); + if let Some(a) = assertion { + builder = builder.header(buzz_auth::CLIENT_ATTACHED_HEADER, format!("Bearer {a}")); + } + builder + .body(Body::from(post_body_bytes.to_vec())) + .expect("build request") + }; + + // ── Step 2: key-mismatch assertion (key_a vs key_owner) → 403 ──────── + let key_a = Keys::generate(); // assertion identity ≠ NIP-98 signer + let assertion_key_a = mint_assertion(&key_a.public_key().to_hex()); + // NIP-98 signed by owner (f.owner), assertion claims key_a — mismatch. + let mismatch_token = token(&f.owner, "POST", &settings_url, Some(&post_body)); + let (status_mismatch, body_mismatch) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(mismatch_token, Some(assertion_key_a))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_mismatch, + StatusCode::FORBIDDEN, + "Key-mismatch assertion MUST deny 403 (AuthorizationDenied). \ + The handler must NOT be reached." + ); + assert_eq!( + body_mismatch["error"].as_str().unwrap_or(""), + "authorization denied\n", + "Key-mismatch 403 body MUST be exact 'authorization denied\\n'. \ + Falsifying mutation: remove key-pairing check → handler reached → \ + different body." + ); + + // ── Step 3: malformed token → 403 EvidenceRejected ─────────────────── + let bad_token = "Nostr !!!bad!!!".to_string(); + let assertion_owner = mint_assertion(&f.owner.public_key().to_hex()); + let (status_malformed, body_malformed) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(bad_token, Some(assertion_owner.clone()))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_malformed, + StatusCode::FORBIDDEN, + "Malformed NIP-98 token MUST deny 403 (EvidenceRejected). \ + The handler must NOT be reached." + ); + assert_eq!( + body_malformed["error"].as_str().unwrap_or(""), + "evidence rejected\n", + "Malformed NIP-98 token 403 body MUST be exact 'evidence rejected\\n'. \ + Falsifying mutation: remap EvidenceRejected to MissingEvidence → \ + returns 401 instead of 403." + ); + + // ── Step 3b: wrong-payload-hash token → 403 EvidenceRejected ───────── + // Same key (owner) + valid owner assertion + NIP-98 token whose + // payload hash is computed from a DIFFERENT body ("wrong body"), but + // the request sends `post_body_bytes`. `admit_nip_fi_http` verifies the + // payload tag before reaching the handler: hash mismatch → EvidenceRejected + // 403. + // + // This is the isolated wrong-payload-hash witness. It proves the hash + // check fires independently of key pairing. + // + // Falsifying mutation: remove payload-hash verification from + // `make_nip98_closure_for_admission` → wrong-hash token passes → + // handler reached → non-403 result. + let wrong_hash_token = token( + &f.owner, + "POST", + &settings_url, + Some("wrong body for hash mismatch"), + ); + let assertion_owner_3b = mint_assertion(&f.owner.public_key().to_hex()); + let (status_wrong_hash, body_wrong_hash) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request( + wrong_hash_token, + Some(assertion_owner_3b), + )) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_wrong_hash, + StatusCode::FORBIDDEN, + "Wrong-hash NIP-98 POST MUST deny 403 (EvidenceRejected). \ + Token payload hash is bound to 'wrong body for hash mismatch', \ + but actual request body is post_body_bytes — hash mismatch. \ + Falsifying mutation: remove payload-hash check → handler reached." + ); + assert_eq!( + body_wrong_hash["error"].as_str().unwrap_or(""), + "evidence rejected\n", + "Wrong-hash 403 body MUST be exact 'evidence rejected\\n'. \ + Falsifying mutation: remap payload-hash EvidenceRejected → handler \ + reached → different body." + ); + + // ── Step 4: digest unchanged after all three denials ───────────────── + // Three NIP-FI denials (key-mismatch, malformed, wrong-hash) MUST NOT + // advance the stored snapshot digest. All three are stopped before the + // handler runs. + let digest_after_denials = f.snapshot().await.digest; + assert_eq!( + digest_after_denials, digest_before, + "Snapshot digest MUST be unchanged after NIP-FI denials. \ + Key-mismatch, malformed-token, and wrong-hash denials must NOT advance \ + stored state. Falsifying mutation: call set_default_branch before \ + NIP-FI check → digest changes." + ); + + // ── Step 5: owner POST → 200 OK, changed=true, new digest ────────────── + // Owner NIP-98 + owner assertion → pairing passes → handler reached. + // `authorize_management` at settings.rs:273-292 authorizes the repository + // author OR a named maintainer. The fixture signs the announcement with + // `f.owner` (see fixture:1267-1342), so `named_manager(&auth.caller)` is + // true and the request is authorized. The POST requests `branch: "main"`, + // which exists in the seeded git store, so `set_default_branch` runs and + // returns `changed: true` with a new HEAD digest. + // + // Falsifying mutation A: remove NIP-FI admission from the settings handler + // → the mismatched-key token above would have reached the handler (the outer + // guard verifies but does not pair keys; malformed tokens stay rejected there) + // → set_default_branch called → Step 4's assert_eq!(digest) + // fires before we get here. + // Falsifying mutation B: always-deny pairing → 403 AuthorizationDenied → + // status != 200 → Step-5 assert_eq!(status_ok, OK) fires. + let owner_token = token(&f.owner, "POST", &settings_url, Some(&post_body)); + let assertion_owner_step5 = mint_assertion(&f.owner.public_key().to_hex()); + let (status_ok, body_ok) = response( + crate::router::build_router(Arc::clone(&enforced_state)) + .oneshot(build_post_request(owner_token, Some(assertion_owner_step5))) + .await + .expect("router oneshot"), + ) + .await; + assert_eq!( + status_ok, + StatusCode::OK, + "Owner POST with valid NIP-FI assertion MUST return 200. \ + authorize_management authorizes the repo author; the seeded 'main' \ + branch exists and expected_manifest matches digest_before. \ + If 403: NIP-FI key-pairing or authorize_management is denying the owner. \ + If 401: NIP-FI outer guard is wrongly denying a valid assertion." + ); + let ok_json: serde_json::Value = body_ok.clone(); + assert_eq!( + ok_json.get("changed").and_then(|v| v.as_bool()), + Some(true), + "Owner POST 200 body MUST contain changed=true (branch was updated to 'main'). \ + Falsifying mutation: set_default_branch never called → changed=false." + ); + + // ── Step 6: digest changed after successful owner POST ──────────────── + let after_ok = f.snapshot().await; + assert_eq!( + after_ok.manifest.head, "refs/heads/main", + "Owner POST MUST persist HEAD = refs/heads/main" + ); + assert_eq!( + after_ok.manifest.parent.as_ref(), + Some(&digest_before), + "Owner POST MUST link the new manifest to the pre-POST digest" + ); + let digest_after_ok = after_ok.digest; + assert_ne!( + digest_after_ok, digest_before, + "Digest MUST change after successful owner POST (branch updated to 'main'). \ + The three denials above left digest_before unchanged; the owner POST \ + updated the branch → new digest. \ + Falsifying mutation: set_default_branch skipped → digest unchanged." + ); + } } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 704bbf1c1d6..f872608d05d 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -62,7 +62,8 @@ const UPLOAD_PACK_MAX_DECODED_BYTES: u64 = 64 * 1024 * 1024; /// NIP-98 auth extractor for git routes. /// /// Validates the `Authorization: Nostr ` header before the request body -/// is read. Same pattern as `AuthenticatedUpload` in media.rs. +/// is read. Same pattern as `UploadContext` in media.rs: auth is deferred out of +/// the extractor so NIP-FI admission can map failures to canonical denial bytes. /// /// Authorization model: reads (ref advertisement, upload-pack) require the /// caller's *current* active membership in the repo's bound channel — see @@ -79,44 +80,31 @@ 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, ) -> Result { let method = parts.method.as_str(); - let auth_header = parts - .headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Nostr realm=\"buzz\", method=\"{method}\""), - ) - .body(Body::from("missing Authorization header")) - .unwrap() - })?; - - let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header( - "WWW-Authenticate", - format!("Nostr realm=\"buzz\", method=\"{method}\""), - ) - .body(Body::from("expected Authorization: Nostr ")) - .unwrap() - })?; - - let event_bytes = base64::engine::general_purpose::STANDARD - .decode(token) - .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; - let event_json = String::from_utf8(event_bytes) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + // Off mode: parse and validate the Authorization header BEFORE tenant + // lookup. [FI-INV-15] — Off mode preserves pre-NIP-FI error precedence: + // missing credentials → 401 + WWW-Authenticate challenge; + // malformed credentials (bad base64, bad UTF-8) → 401 without challenge; + // regardless of whether the Host resolves to a known community. No + // database work for syntactically bad requests in Off mode. + // + // Enforce: the header syntax is validated + // inside the NIP-FI admission closure below, where proof failures are + // mapped to NIP-FI denial bytes. (DenyProtected returns 503 at the + // start of admission without running the closure.) Tenant lookup still happens before + // admission (immediately after this block) because the signed `u` tag + // must be verified against the tenant-bound host, not a process-global + // domain. + let mode = state.config.nip_fi.mode; + if matches!(mode, buzz_auth::NipFiMode::Off) { + parse_git_auth_header(&parts.headers, method)?; + } // Row zero for Git HTTP: bind the request Host to a server-resolved // tenant before URL verification. We still do not trust forwarded @@ -142,21 +130,15 @@ impl axum::extract::FromRequestParts> for GitAuth { ) .ok_or_else(|| (StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response())?; - // Repo-root URL verification. - // - // The credential helper signs a NIP-98 token with: - // u = (e.g., http://host/git/{owner}/{repo}) + // NIP-FI admission: the NIP-98 extraction closure runs inside + // `admit_nip_fi_http_on_state` so all proof failures (missing header, + // invalid base64, bad signature) are mapped to NIP-FI denial bytes in + // Enforce mode, and cardinality is enforced uniformly. Off mode + // preserves legacy Git 401 responses per [FI-INV-15]; Off-mode header + // syntax was already validated above so the closure cannot fail on the + // syntax cases. + // [FI-TRACE-AUTHORITY-UNIFORM, FI-TRACE-DENIAL-ORACLE] // - // Git's credential protocol does NOT pass query strings to helpers, so - // service-scoping (`?service=...`) cannot be implemented at the NIP-98 - // level without protocol changes. The token is repo-scoped, not service-scoped. - // - // Security is still provided by: - // - ±60s timestamp window (limits replay) - // - HTTPS in production (prevents token theft) - // - Pre-receive hook for push authorization (role + protection rules) - // - Endpoint routing (clone/push are different HTTP paths) - // Skip HTTP method check for git routes. // // Git's credential helper signs with `method=GET` (the initial /info/refs request) @@ -165,42 +147,58 @@ impl axum::extract::FromRequestParts> for GitAuth { // Security is provided by: service-binding in the URL (clone vs push scoped), // ±60s timestamp, and the pre-receive hook for push authorization. // We pass the method from the event itself so verify_nip98_event always accepts. - let event_method = serde_json::from_str::(&event_json) - .ok() - .and_then(|v| { - v["tags"] - .as_array()? - .iter() - .find(|t| t[0].as_str() == Some("method"))?[1] - .as_str() - .map(str::to_owned) - }) - .unwrap_or_else(|| method.to_owned()); - - // SECURITY: method intentionally not verified for git routes. The tautological - // check (event.method == event.method) is deliberate — see comment block above. - // Git's credential protocol signs once with GET and reuses for POST. The URL tag - // provides the real security boundary (±60s timestamp + URL lock + HTTPS). - + // // body=None: can't buffer streaming pack data to verify payload hash. // Token is time-bounded (±60s) and URL-locked — acceptable trade-off. - let pubkey = - buzz_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None) + let headers_clone = parts.headers.clone(); + let method_str = method.to_owned(); + let admission = crate::nip_fi_http::admit_nip_fi_http_on_state( + state, + &parts.headers, + move || -> Result, Response> { + // In Off mode `parse_git_auth_header` already ran above and + // succeeded, so re-parsing here is purely for the return value. + // In Enforce mode it runs for the first time inside this closure + // (on the auth-rejection path the NIP-FI layer maps the error). + let (event_json, method_for_verify) = + parse_git_auth_header_full(&headers_clone, &method_str)?; + + // SECURITY: method intentionally not verified for git routes. The tautological + // check (event.method == event.method) is deliberate — see comment block above. + // Git's credential protocol signs once with GET and reuses for POST. The URL tag + // provides the real security boundary (±60s timestamp + URL lock + HTTPS). + let pubkey = buzz_auth::nip98::verify_nip98_event( + &event_json, + &expected_url, + &method_for_verify, + None, + ) .map_err(|e| { - warn!(error = %e, "git NIP-98 auth failed"); - (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() - })?; - - // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. - // Git's credential protocol reuses one signed token across multiple requests - // in a session (info_refs GET → upload-pack/receive-pack POST). Rejecting - // replayed event IDs would break normal clone/push operations. - // The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient - // replay protection for v1. Per-request signing requires protocol changes. + warn!(error = %e, "git NIP-98 auth failed"); + (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() + })?; + + // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. + // Git's credential protocol reuses one signed token across multiple requests + // in a session (info_refs GET -> upload-pack/receive-pack POST). Rejecting + // replayed event IDs would break normal clone/push operations. + // The +-60s timestamp window + URL scoping + HTTPS transport provide sufficient + // replay protection for v1. Per-request signing requires protocol changes. + + let event: nostr::Event = serde_json::from_str(&event_json).map_err(|_| { + (StatusCode::UNAUTHORIZED, "invalid auth event").into_response() + })?; + let signed_auth_created_at = event.created_at.as_secs(); + + Ok(crate::nip_fi_http::Nip98Proof::new( + pubkey, + (event, signed_auth_created_at), + )) + }, + )?; - let event: nostr::Event = serde_json::from_str(&event_json) - .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; - let signed_auth_created_at = event.created_at.as_secs(); + let pubkey = *admission.proven_pubkey(); + let (event, signed_auth_created_at) = admission.into_extra(); // Relay membership gate (NIP-43). Git cannot carry a standalone // x-auth-tag header through the credential-helper protocol, so agents @@ -324,6 +322,86 @@ fn enforce_git_ban_cascade( } } +/// Parse and syntax-validate the `Authorization: Nostr ` header for +/// Git HTTP requests. Returns `Ok(())` on success (the caller only needs to +/// know whether the syntax is valid); on failure returns a `Response` that +/// already carries the correct 401 + `WWW-Authenticate` challenge. +/// +/// Shared by the Off-mode early-exit path and the full extraction below. +/// Keeps the response bytes identical between the two call sites. +/// +/// [FI-INV-15] — Off mode must return 401 + challenge for missing/malformed +/// credentials before any tenant lookup. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +fn parse_git_auth_header(headers: &axum::http::HeaderMap, method: &str) -> Result<(), Response> { + parse_git_auth_header_full(headers, method).map(|_| ()) +} + +/// Full extraction: parse the Authorization header and return +/// `(event_json, method_for_verify)`. `method_for_verify` is taken from the +/// NIP-98 event's `method` tag when present, falling back to the HTTP method; +/// this is the "tautological" bypass that lets git clients reuse a GET token +/// for the subsequent POST. +/// +/// Returns `Err(Response)` for missing/malformed-scheme → 401 + `WWW-Authenticate`; +/// bad-base64/bad-utf-8 → 401 without `WWW-Authenticate` (use `into_response()` +/// which does not add the challenge header). +#[allow(clippy::result_large_err)] +fn parse_git_auth_header_full( + headers: &axum::http::HeaderMap, + method: &str, +) -> Result<(String, String), Response> { + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"buzz\", method=\"{method}\""), + ) + .body(Body::from("missing Authorization header")) + .unwrap() + })?; + + let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"buzz\", method=\"{method}\""), + ) + .body(Body::from("expected Authorization: Nostr ")) + .unwrap() + })?; + + let event_bytes = base64::engine::general_purpose::STANDARD + .decode(token) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; + let event_json = String::from_utf8(event_bytes) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + + // Extract `method` from the NIP-98 event tag; fall back to HTTP method. + // SECURITY: method intentionally not verified for git routes (see GitAuth + // comment block). Git's credential helper signs once with GET and reuses + // for POST; the tautological self-comparison is deliberate. + let method_for_verify = serde_json::from_str::(&event_json) + .ok() + .and_then(|v| { + v["tags"] + .as_array()? + .iter() + .find(|t| t[0].as_str() == Some("method"))?[1] + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(|| method.to_owned()); + + Ok((event_json, method_for_verify)) +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -3783,3 +3861,1483 @@ mod sec005_postgres_tests { assert_eq!(body, "authorization unavailable"); } } + +// ── R4 Off-mode precedence regression tests ─────────────────────────────── +// +// Proves that in Off mode, missing/malformed Authorization headers are rejected +// with 401 + WWW-Authenticate BEFORE any tenant lookup. [FI-INV-15] +// +// The `parse_git_auth_header` helper is the single source of this behavior; +// these tests cover all four cases Thufir specified. +// +// Mutation evidence for all tests: replacing the Off-mode early-exit +// (`if matches!(mode, NipFiMode::Off) { parse_git_auth_header(...)?; }`) +// with a no-op causes the request to proceed to `bind_community()`. On an +// unmapped host that returns 404 `repository not found` — the test's +// status assertion fires (404 ≠ 401). On a mapped host the parse +// runs inside the NIP-FI closure, but at that point the `admit_nip_fi_http` +// wrapper (Off mode) propagates the legacy response — so the status still +// matches. Only the unmapped-host cases truly distinguish the regression. +// Both cases are included so the full invariant (credential-before-DB) is +// visible in the test record. +#[cfg(test)] +mod off_mode_precedence_tests { + use super::*; + use axum::http::HeaderMap; + + fn make_headers(auth: Option<&str>) -> HeaderMap { + let mut h = HeaderMap::new(); + if let Some(a) = auth { + h.insert( + header::AUTHORIZATION, + a.parse().expect("valid header value"), + ); + } + h + } + + // ── Case A: missing Authorization header ───────────────────────────── + + /// Off mode, no Authorization header → 401 + WWW-Authenticate challenge. + /// + /// Scope: this test exercises `parse_git_auth_header` directly, not the + /// full `GitAuth::from_request_parts` path. It proves the parser + /// rejects a missing header with the correct status and challenge. + /// + /// Falsifying mutation: return `Ok(())` from `parse_git_auth_header` + /// when no Authorization header is present → `unwrap_err()` panics. + #[test] + fn off_mode_missing_auth_header_returns_401_with_challenge() { + let headers = make_headers(None); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: missing Authorization must yield 401, not a tenant-lookup result" + ); + let challenge = err + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + challenge.contains("Nostr realm=\"buzz\""), + "Off mode: missing Authorization must include WWW-Authenticate: Nostr challenge; got {challenge:?}" + ); + } + + // ── Case B: wrong scheme (not "Nostr ") ────────────────────────────── + + /// Off mode, wrong Authorization scheme → 401 + challenge. + /// + /// Scope: parser-only test (calls `parse_git_auth_header` directly). + /// Falsifying mutations: return `Ok(())` for non-Nostr schemes, or + /// emit 403 instead of 401 — status assertion fires; or omit the + /// WWW-Authenticate header — challenge assertion fires. + #[test] + fn off_mode_wrong_scheme_returns_401_with_challenge() { + let headers = make_headers(Some("Bearer sometoken")); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: wrong auth scheme must yield 401" + ); + let challenge = err + .headers() + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert!( + challenge.contains("Nostr realm=\"buzz\""), + "Off mode: wrong scheme must include Nostr challenge" + ); + } + + // ── Case C: invalid base64 ──────────────────────────────────────────── + + /// Off mode, Authorization: Nostr → 401. + /// + /// Scope: parser-only test. Falsifying mutation: accept invalid + /// base64 and return `Ok(())` → `unwrap_err()` panics. + #[test] + fn off_mode_invalid_base64_returns_401() { + let headers = make_headers(Some("Nostr !!!not-base64!!!")); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: invalid base64 must yield 401" + ); + } + + // ── Case D: valid base64 but invalid UTF-8 bytes ───────────────────── + + /// Off mode, Authorization: Nostr → 401. + /// + /// Scope: parser-only test. Falsifying mutation: skip UTF-8 check, + /// return `Ok(())` → `unwrap_err()` panics. + #[test] + fn off_mode_invalid_utf8_returns_401() { + // 0xC3 0x28 is invalid UTF-8. + let bad_utf8 = base64::engine::general_purpose::STANDARD.encode([0xC3u8, 0x28]); + let headers = make_headers(Some(&format!("Nostr {bad_utf8}"))); + let err = parse_git_auth_header(&headers, "GET").unwrap_err(); + assert_eq!( + err.status(), + StatusCode::UNAUTHORIZED, + "Off mode: non-UTF-8 base64 payload must yield 401" + ); + } + + // ── Positive control ────────────────────────────────────────────────── + + /// Valid Nostr base64 JSON payload passes syntax validation. + /// + /// Scope: parser-only test. A structurally correct credential passes + /// `parse_git_auth_header`, allowing the request to proceed to tenant + /// lookup in the full path. + /// + /// Falsifying mutation: always return `Err(...)` from + /// `parse_git_auth_header` → `is_ok()` fails and the assertion fires. + /// Without this positive control, an always-denying parser could pass + /// all four negative cases above while also breaking valid requests. + #[test] + fn off_mode_valid_nostr_token_passes_syntax_check() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let keys = Keys::generate(); + let tags = vec![ + Tag::parse(["u", "http://example.local/git/abc/def"]).unwrap(), + Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = EventBuilder::new(Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&event).unwrap()) + ); + let headers = make_headers(Some(&token)); + assert!( + parse_git_auth_header(&headers, "GET").is_ok(), + "Off mode: a valid Nostr token must pass syntax validation" + ); + } + + // ── Router-level Off-precedence tests (require Postgres) ───────────── + // + // These tests go through `git_router` → `GitAuth::from_request_parts` + // and prove that the Off-mode early-exit at transport.rs:100-102 fires + // BEFORE `bind_community()`. + // + // Key falsifiability: the unmapped-host cases assert 401. Deleting lines + // 100-102 causes `bind_community()` to run for the unmapped host and + // return 404 — the status assertions fire. Parser-only unit tests above + // cannot prove this ordering because they never call `from_request_parts`. + #[cfg(test)] + mod postgres_tests { + use super::*; + use axum::body::to_bytes; + use tower::ServiceExt; + + const UNMAPPED_HOST: &str = "off-prec-unmapped.git.test.invalid"; + // Valid 64-hex owner (all zeros except last digit = 1) so validate_repo_id + // passes owner validation in the handler body. The NIP-FI gate fires in + // GitAuth::from_request_parts BEFORE validate_repo_id in Enforce mode; + // the valid owner ensures tests that probe post-auth behavior see the + // correct downstream path. + const OWNER_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const GIT_PATH: &str = concat!( + "/git/0000000000000000000000000000000000000000000000000000000000000001", + "/myrepo/info/refs?service=git-upload-pack" + ); + + async fn off_mode_state() -> Option> { + let mut config = crate::config::Config::from_env().ok()?; + config.nip_fi.mode = buzz_auth::NipFiMode::Off; + config.require_auth_token = false; + config.require_relay_membership = false; + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = sqlx::PgPool::connect(&config.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 (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Some(Arc::new(state)) + } + + async fn git_request( + state: Arc, + host: &str, + auth: Option<&str>, + ) -> (axum::http::StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut builder = axum::http::Request::builder() + .method("GET") + .uri(GIT_PATH) + .header("host", host); + if let Some(a) = auth { + builder = builder.header("authorization", a); + } + let req = builder + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, headers, body) + } + + // ── Unmapped host — missing auth: must be 401 before DB lookup ──── + // + // An unmapped host has no community row. If the Off-mode early-exit + // is removed, `bind_community()` returns 404 for this host. + // The assertion fires because 404 ≠ 401. + // + // Falsifying mutation: delete the `if matches!(mode, Off)` block + // (transport.rs:101-104) → unmapped host proceeds to `bind_community()` → 404. + // + // NOTE: missing-auth and wrong-scheme carry WWW-Authenticate; bad-base64 + // and bad-UTF8 do NOT (those use `into_response()` without the header). + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_missing_auth_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = git_request(state, UNMAPPED_HOST, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + missing auth must yield 401 BEFORE \ + bind_community (not 404). \ + Falsifying mutation: delete Off-mode early-exit (transport.rs:101-104) \ + → bind_community returns 404 for unmapped host → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"missing Authorization header", + "missing-auth 401 body must be exact 'missing Authorization header'" + ); + // FI-INV-15: Off-mode bytes match origin/main, whose legacy 401 + // builder sets no Content-Type on this response. + assert!( + headers.get("content-type").is_none(), + "missing-auth 401 must carry no Content-Type (legacy Off bytes); got {:?}", + headers.get("content-type") + ); + let challenge = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge, + "Nostr realm=\"buzz\", method=\"GET\"", + "missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}" + ); + } + + // ── Unmapped host — wrong scheme: must be 401 before DB lookup ──── + // + // Same falsifiability as the missing-auth case. + // + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_wrong_scheme_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some("Bearer token")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + wrong auth scheme must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"expected Authorization: Nostr ", + "wrong-scheme 401 body must be 'expected Authorization: Nostr '" + ); + assert!( + headers.get("content-type").is_none(), + "wrong-scheme 401 must carry no Content-Type (legacy Off bytes); got {:?}", + headers.get("content-type") + ); + let challenge_ws = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge_ws, "Nostr realm=\"buzz\", method=\"GET\"", + "wrong-scheme 401 must carry exact WWW-Authenticate: \ + Nostr realm=\"buzz\", method=\"GET\"; got {challenge_ws:?}" + ); + } + + // ── Unmapped host — invalid base64: must be 401 before DB lookup ── + // + // bad-base64 and bad-UTF8 use `into_response()` — NO WWW-Authenticate. + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_invalid_base64_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some("Nostr !!!not-base64!!!")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + invalid base64 must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"invalid base64", + "invalid-base64 401 body must be exact 'invalid base64'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "invalid-base64 401 MUST NOT carry WWW-Authenticate \ + (uses into_response(), not the WWW-Authenticate builder path)" + ); + } + + // ── Unmapped host — bad UTF-8 payload: must be 401 before DB lookup ─ + // + // A Nostr token where base64 decodes to non-UTF8 bytes triggers the + // UTF-8 guard in `parse_git_auth_header_full` (transport.rs:379-380). + // Body: "invalid utf-8"; NO WWW-Authenticate (uses `into_response()`). + // + // Falsifying mutation: delete transport.rs:101-104 → 404. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_bad_utf8_returns_401_before_db() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + // base64-encode non-UTF8 bytes (0xff 0xfe is an invalid UTF-8 start) + let bad_utf8_b64 = base64::engine::general_purpose::STANDARD.encode(b"\xff\xfe\x00"); + let (status, headers, body) = + git_request(state, UNMAPPED_HOST, Some(&format!("Nostr {bad_utf8_b64}"))).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + unmapped host + bad-UTF8 payload must yield 401 before \ + bind_community. \ + Falsifying mutation: delete transport.rs:101-104 → 404." + ); + assert_eq!( + body.as_ref(), + b"invalid utf-8", + "bad-UTF8 401 body must be exact 'invalid utf-8'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "bad-UTF8 401 MUST NOT carry WWW-Authenticate \ + (uses into_response(), not the WWW-Authenticate builder path)" + ); + } + + // ── Mapped host — missing auth: compatibility control ────────────── + // + // Proves the same missing-auth behavior holds for mapped hosts. + // Compatibility control: the Off-mode early-exit (`transport.rs:101-104`) + // and the Enforce-mode NIP-FI closure both route through + // `parse_git_auth_header_full()`, which produces the same 401 + challenge. + // + // Falsifying mutation: replace `parse_git_auth_header` with always-pass + // → missing auth is not caught → request proceeds to URL verification + // → different status or body. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_mapped_host_missing_auth_returns_401() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "off-prec-mapped-{}.git.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let (status, headers, body) = git_request(state, &host, None).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + mapped host + missing auth must yield 401 from Off-mode early-exit \ + (compatibility control: same response via NIP-FI closure in Enforce mode). \ + Falsifying mutation: skip parse_git_auth_header for missing auth → different error." + ); + assert_eq!( + body.as_ref(), + b"missing Authorization header", + "mapped-host missing-auth 401 body must be 'missing Authorization header'" + ); + let challenge = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + challenge, "Nostr realm=\"buzz\", method=\"GET\"", + "mapped-host missing-auth 401 must carry exact WWW-Authenticate: Nostr realm=\"buzz\", method=\"GET\"; got {challenge:?}" + ); + } + + // ── Mapped host — invalid base64: compatibility control ─────────── + // + // Proves the bad-base64 check holds for mapped hosts. + // `parse_git_auth_header_full()` handles bad base64 and returns 401 (no + // WWW-Authenticate). This is a compatibility assertion: both the Off-mode + // early-exit and the Enforce-mode NIP-FI closure call the same function, so + // the same 401 body is produced in both modes. + // + // Falsifying mutation: remove `parse_git_auth_header` error for bad base64 + // → bad-base64 request passes syntax check → URL verification fails differently. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_mapped_host_invalid_base64_returns_401() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "off-prec-mapped-b64-{}.git.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + let (status, headers, body) = + git_request(state, &host, Some("Nostr !!!not-base64!!!")).await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Off mode + mapped host + invalid base64 must yield 401. \ + Falsifying mutation: delete transport.rs:101-104 → different error." + ); + assert_eq!( + body.as_ref(), + b"invalid base64", + "mapped-host invalid-base64 401 body must be 'invalid base64'" + ); + assert!( + headers.get("www-authenticate").is_none(), + "invalid-base64 401 MUST NOT carry WWW-Authenticate" + ); + } + + // ── Positive control: valid syntax with unmapped host reaches DB ── + // + // A syntactically valid Nostr token PASSES the Off-mode early-exit + // and proceeds to `bind_community()`. The unmapped host then yields + // 404 — proving the early-exit was NOT the blocker. + // + // Falsifying mutation: always-deny `parse_git_auth_header` regardless + // of input → this control returns 401 instead of 404 → assertion fires. + // The negative cases above prove the opposite direction. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn off_mode_unmapped_host_valid_syntax_reaches_db_and_returns_404() { + let Some(state) = off_mode_state().await else { + panic!("local Postgres not reachable"); + }; + let keys = nostr::Keys::generate(); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{UNMAPPED_HOST}{GIT_PATH}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + let (status, _headers, _body) = git_request(state, UNMAPPED_HOST, Some(&token)).await; + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "Off mode + unmapped host + valid syntax: parser passes → \ + bind_community fires → 404. \ + Falsifying mutation: always-deny parser → 401 instead of 404." + ); + } + + // ── Enforce mode: missing assertion on info/refs → exact body/CT/challenge ─ + // + // Proves that git routes (info/refs, upload-pack, receive-pack) produce the + // exact contract bytes for MissingEvidence in Enforce mode. All three routes + // share `GitAuth::from_request_parts`; the pack routes are additionally + // tested in `enforce_mode_git_pack_routes_missing_assertion_exact_bytes`. + // + // A Nostr-scheme Authorization header is present (syntactically valid) + // so the Off-mode early-exit passes; no Nostr-Federated-Identity header + // is sent, so `extract_bearer_token` returns MissingEvidence → 401. + // + // Falsifying mutation: remove the `admit_nip_fi_http_on_state` call + // from `GitAuth::from_request_parts` → `GitAuth` falls back to the + // legacy NIP-98 verifier → valid proof is accepted → request reaches + // `validate_repo_id` which succeeds (OWNER_HEX is valid 64-hex), then + // `authorize_git_read` which denies (repo not member of a channel) → + // different status or body → assertion fires. + // + // Why no assertion header: in Enforce mode with no verifier configured + // (startup race) an assertion present + no verifier would return 503. + // The MissingEvidence path (no assertion header) is the correct gate + // test for git routes and is the most discriminating falsifiable case. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn enforce_mode_git_info_refs_missing_assertion_exact_bytes() { + use buzz_auth::NipFiMode; + + let mut config = match crate::config::Config::from_env() { + Ok(c) => c, + Err(_) => panic!("local Postgres not reachable (enforce git): config"), + }; + config.nip_fi.mode = NipFiMode::Enforce; + config.require_auth_token = false; + config.require_relay_membership = false; + // Pin relay_url to a ws:// value so git_expected_url() deterministically + // derives the http:// scheme for the NIP-98 `u` tag. + config.relay_url = "ws://nip-fi-git-test.invalid".to_string(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = match sqlx::PgPool::connect(&config.database_url).await { + Ok(p) => p, + Err(_) => panic!("local Postgres not reachable (enforce git)"), + }; + 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)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + 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).expect("media storage"); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + // Register a mapped host so bind_community succeeds → the NIP-FI + // gate is the first denial point after Off-mode early-exit. + let host = format!( + "nip-fi-git-enf-{}.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + + // Build a syntactically valid Nostr token signed for the repo-root URL. + // `git_expected_url()` strips `/info/refs?service=…` and keeps only + // the repository path prefix: `http://{host}/git/{OWNER_HEX}/myrepo`. + // No Nostr-Federated-Identity header → MissingEvidence in Enforce. + let keys = nostr::Keys::generate(); + // GIT_PATH strips "/info/refs?..." suffix → repo root used for URL signing. + // OWNER_HEX is valid 64-hex so validate_repo_id would pass if auth succeeded. + let git_repo_root = format!("/git/{OWNER_HEX}/myrepo"); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{git_repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let auth_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + + let (status, headers, body) = git_request(state, &host, Some(&auth_token)).await; + + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "Enforce mode + info/refs + missing assertion MUST deny 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth → \ + legacy NIP-98 verifier accepts the valid proof → request proceeds past \ + the admission gate → different status or body → assertion fires." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "Enforce mode: git MissingEvidence body must be exact 'authentication required\\n' \ + [FI-TRACE-DENIAL-ORACLE]. Different body means the legacy git error path fired \ + instead of the NIP-FI gate." + ); + let ct = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "Enforce mode: git 401 content-type must be text/plain; charset=utf-8" + ); + let www_auth = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "Enforce mode: git 401 must carry WWW-Authenticate: Nostr" + ); + } + + // ── Enforce mode: pack routes (upload-pack + receive-pack) missing assertion ─ + // + // Parameterized test: both POST pack routes share `GitAuth::from_request_parts` + // and must produce the same exact denial bytes as `info/refs` when the + // Nostr-Federated-Identity assertion header is absent. + // + // For each route: + // - missing assertion → 401 MissingEvidence (exact body + CT + challenge) + // - EvidenceRejected (invalid base64 Nostr token) → 401 (NIP-FI maps it) + // - duplicate Authorization headers → 403 cardinality in Enforce mode + // + // Falsifying mutation: remove `admit_nip_fi_http_on_state` from + // `GitAuth::from_request_parts` → NIP-98 validates the token, no assertion + // check, request reaches `validate_repo_id` → succeeds (OWNER_HEX valid) + // → `authorize_git_read` denies (no channel membership) → 403 or 404 + // → 401 assertion fires. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn enforce_mode_git_pack_routes_missing_assertion_exact_bytes() { + use buzz_auth::NipFiMode; + + let mut config = match crate::config::Config::from_env() { + Ok(c) => c, + Err(_) => panic!("local Postgres not reachable (pack routes): config"), + }; + config.nip_fi.mode = NipFiMode::Enforce; + config.require_auth_token = false; + config.require_relay_membership = false; + config.relay_url = "ws://nip-fi-git-pack-test.invalid".to_string(); + config.redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 + + let pool = match sqlx::PgPool::connect(&config.database_url).await { + Ok(p) => p, + Err(_) => panic!("local Postgres not reachable (pack routes)"), + }; + 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)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub"), + ); + 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).expect("media storage"); + let (state, _) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Inject a static NIP-FI verifier into the Enforce state so that + // every case requiring cryptographic assertion validation (including + // the malformed-assertion Case 3) reaches the verifier rather than + // hitting the absent-verifier 503. Without this, Case 3 sends + // `Bearer !!!not-valid-base64!!!` which passes `extract_bearer_token` + // (scheme/cardinality checks only) and then trips the absent-verifier + // check at nip_fi_http.rs:332-335 → 503, not 403. + // [FI-TRACE-DENIAL-ORACLE: verifier required for all crypto cases] + let state = { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm, EncodingKey}; + + const GIT_TEST_ISSUER: &str = "https://git-pack-test.issuer.invalid"; + const GIT_TEST_AUDIENCE: &str = "https://git-pack-test.relay.invalid"; + const GIT_TEST_KID: &str = "git-pack-test-key-1"; + const GIT_TEST_EC_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": GIT_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( + GIT_TEST_ISSUER.to_owned(), + 1, + jwks, + hard_deadline, + ) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{GIT_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + GIT_TEST_ISSUER.to_owned(), + vec![GIT_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + 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]), + )); + + // Encode the test key for use in Case 4 assertions. + // Store in a Mutex so it can be read from the outer scope later. + let enc_key = + EncodingKey::from_ec_pem(GIT_TEST_EC_PEM.as_bytes()).expect("valid EC PEM"); + + let mut s = state; + s.nip_fi_verifier = Some(verifier); + ( + Arc::new(s), + GIT_TEST_ISSUER, + GIT_TEST_AUDIENCE, + GIT_TEST_KID, + enc_key, + ) + }; + let (state, git_test_issuer, git_test_audience, git_test_kid, git_enc_key) = state; + + let host = format!( + "nip-fi-git-pack-{}.test.invalid", + uuid::Uuid::new_v4().simple() + ); + state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community"); + + // Build a valid NIP-98 token signed for the upload-pack repo root. + // Git credential helper signs once with GET and reuses for POST pack requests. + let keys = nostr::Keys::generate(); + let repo_root = format!("/git/{OWNER_HEX}/myrepo"); + let tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(tags) + .sign_with_keys(&keys) + .unwrap(); + let nip98_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).unwrap()) + ); + + // Helper: send a pack-route POST request and return (status, headers, body). + let send_pack_request = + |state: Arc, + route: &'static str, + auth_headers: Vec<(&'static str, String)>| { + let host = host.clone(); + async move { + let uri = format!("/git/{OWNER_HEX}/myrepo/{route}"); + let mut builder = axum::http::Request::builder() + .method("POST") + .uri(&uri) + .header("host", &host) + .header( + "content-type", + if route == "git-upload-pack" { + "application/x-git-upload-pack-request" + } else { + "application/x-git-receive-pack-request" + }, + ); + for (name, value) in &auth_headers { + builder = builder.header(*name, value); + } + let req = builder + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (status, headers, body) + } + }; + + // ── Same-key material: built once, shared by Cases 5–7 and Case 4 ── + // + // Hoisted here so Cases 5–7 (inside the per-route loop below) can + // reference `same_key_assertion` without a forward-reference error. + // Case 4 reuses the same variables — no duplication. + { + use jsonwebtoken::{Algorithm, Header}; + let test_keys_outer = nostr::Keys::generate(); + let test_pubkey_hex_outer = test_keys_outer.public_key().to_hex(); + let now_outer = chrono::Utc::now().timestamp(); + let claims_outer = serde_json::json!({ + "iss": git_test_issuer, + "aud": git_test_audience, + "iat": now_outer, + "exp": now_outer + 600, + "sub": "test-subject", + "nostr_pubkey": test_pubkey_hex_outer, + }); + let mut hdr_outer = Header::new(Algorithm::ES256); + hdr_outer.kid = Some(git_test_kid.to_owned()); + hdr_outer.typ = Some("nip-fi+jwt".to_owned()); + let same_key_assertion = + jsonwebtoken::encode(&hdr_outer, &claims_outer, &git_enc_key) + .expect("sign assertion"); + + for route in &["git-upload-pack", "git-receive-pack"] { + let route: &'static str = route; + + // ── Case 1: missing assertion → 401 MissingEvidence ───────── + let (status, headers, body) = send_pack_request( + Arc::clone(&state), + route, + vec![("authorization", nip98_token.clone())], + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::UNAUTHORIZED, + "{route}: missing assertion MUST deny 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from GitAuth \ + → legacy NIP-98 accepts → reaches validate_repo_id (valid owner) \ + → authorize_git_read denies → different status/body." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "{route}: MissingEvidence body must be exact 'authentication required\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let ct = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + ct, "text/plain; charset=utf-8", + "{route}: 401 content-type must be 'text/plain; charset=utf-8'" + ); + let www_auth = headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, "Nostr", + "{route}: 401 must carry WWW-Authenticate: Nostr" + ); + + // ── Case 2: duplicate Authorization → 403 cardinality ─────── + // Two NIP-98 tokens → cardinality gate fires before NIP-FI assertion check. + // No assertion header needed — cardinality fires first. + let (dup_status, dup_headers, dup_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ("authorization", nip98_token.clone()), + ], + ) + .await; + assert_eq!( + dup_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: duplicate Authorization headers MUST deny 403 EvidenceRejected \ + (cardinality gate). \ + Falsifying mutation: remove cardinality check from admit_nip_fi_http \ + → request reaches NIP-FI assertion check → different denial." + ); + assert_eq!( + dup_body.as_ref(), + b"evidence rejected\n", + "{route}: cardinality 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let dup_ct = dup_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + dup_ct, "text/plain; charset=utf-8", + "{route}: cardinality 403 content-type must be 'text/plain; charset=utf-8'" + ); + assert!( + dup_headers.get("www-authenticate").is_none(), + "{route}: cardinality 403 MUST NOT carry WWW-Authenticate \ + (client has a token, it's malformed — not absent)" + ); + + // ── Case 3: invalid base64 assertion → 403 EvidenceRejected ─ + // A syntactically invalid Nostr-Federated-Identity value (non-base64 + // after the "Nostr " prefix) → EvidenceRejected → 403. + // This is distinct from MissingEvidence (absent header → 401). + // + // Falsifying mutation: skip assertion validation for malformed tokens → + // request reaches handler → different status/body. + let (inv_status, inv_headers, inv_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + "Bearer !!!not-valid-base64!!!".to_string(), + ), + ], + ) + .await; + assert_eq!( + inv_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: invalid base64 assertion MUST deny 403 EvidenceRejected. \ + Falsifying mutation: skip assertion parsing on bad input → \ + handler reached → different status/body." + ); + assert_eq!( + inv_body.as_ref(), + b"evidence rejected\n", + "{route}: invalid assertion body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + let inv_ct = inv_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + inv_ct, "text/plain; charset=utf-8", + "{route}: invalid assertion 403 content-type must be 'text/plain; charset=utf-8'" + ); + assert!( + inv_headers.get("www-authenticate").is_none(), + "{route}: invalid assertion 403 MUST NOT carry WWW-Authenticate \ + (client has a token, it's malformed — not absent)" + ); + + // ── Case 5: missing proof + valid assertion → 401 MissingEvidence ─ + // + // Valid NFI assertion present, but NO Authorization (NIP-98) header. + // NIP-98 extraction closure returns MissingEvidence (no Authorization) → + // maps to 401 `authentication required\n`. + // Proves proof validation is not bypassed by a valid assertion. + // + // Falsifying mutation: make the NIP-98 closure skip missing-auth → + // NIP-98 proves something other than 401 → assertion fires. + let (miss_proof_status, _miss_proof_headers, miss_proof_body) = + send_pack_request( + Arc::clone(&state), + route, + vec![( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + )], + ) + .await; + assert_eq!( + miss_proof_status, + axum::http::StatusCode::UNAUTHORIZED, + "{route}: missing proof + valid assertion MUST deny 401 MissingEvidence. \ + A valid assertion does NOT bypass NIP-98 proof requirement. \ + Falsifying mutation: skip NIP-98 when assertion present → handler reached." + ); + assert_eq!( + miss_proof_body.as_ref(), + b"authentication required\n", + "{route}: missing-proof 401 body must be exact 'authentication required\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + + // ── Case 6: malformed proof + valid assertion → 403 EvidenceRejected ─ + // + // Valid NFI assertion + syntactically malformed NIP-98 (`Nostr !!!bad!!!`). + // NIP-98 closure fails (bad base64) → maps to 403 EvidenceRejected. + // Proves malformed-proof detection is not bypassed by a valid assertion. + // + // Falsifying mutation: accept malformed NIP-98 when assertion present → + // admission bypassed → response is not 403 EvidenceRejected. + let (mal_proof_status, _mal_proof_headers, mal_proof_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", "Nostr !!!not-valid-base64!!!".to_string()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + mal_proof_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: malformed proof + valid assertion MUST deny 403 EvidenceRejected. \ + Falsifying mutation: skip NIP-98 validation when assertion present → \ + admission bypassed → not 403." + ); + assert_eq!( + mal_proof_body.as_ref(), + b"evidence rejected\n", + "{route}: malformed-proof 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + + // ── Case 7: duplicate proof + valid assertion → 403 cardinality ─ + // + // Two Authorization headers + valid NFI assertion. The cardinality gate + // fires before NIP-98 extraction (it runs on Authorization count). + // 403 EvidenceRejected — same result as Case 2 (dup without assertion), + // proving the assertion does not gate the cardinality check. + // + // `nip98_token` is signed by `keys` while `same_key_assertion` names + // `test_keys_outer`, so disabling cardinality predicts key pairing + // denial: 403 `authorization denied\n`. The exact + // `evidence rejected\n` body below distinguishes that mutation. + let (dup_proof_status, _dup_proof_headers, dup_proof_body) = send_pack_request( + Arc::clone(&state), + route, + vec![ + ("authorization", nip98_token.clone()), + ("authorization", nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + dup_proof_status, + axum::http::StatusCode::FORBIDDEN, + "{route}: duplicate proof + valid assertion MUST deny 403 cardinality. \ + Disabling cardinality → key pairing 403 'authorization denied\\n'." + ); + assert_eq!( + dup_proof_body.as_ref(), + b"evidence rejected\n", + "{route}: dup-proof 403 body must be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── Case 4: same-key admission → passes NIP-FI, reaches handler ── + // + // The verifier was injected into `state` at the top of this test. + // Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey = + // test_keys.public_key(). Key pairing passes → request reaches + // `validate_repo_id` → `authorize_git_read`. + // + // The repo does not exist in the test database, so `authorize_git_read` + // returns 404 "repository not found" — not a NIP-FI code. + // + // Falsifying mutation: replace the pairing check with always-deny → + // 403 `authorization denied\n` → status/body checks fire. + // + // Also covers `info/refs` (GET) with the same assertion; the route + // shares `GitAuth::from_request_parts` and `authorize_git_read`. + { + use jsonwebtoken::{Algorithm, Header}; + + // Same-key: NIP-98 signed by test_keys; assertion nostr_pubkey = + // test_keys.public_key(). Pairing passes. + let test_keys = nostr::Keys::generate(); + let test_pubkey_hex = test_keys.public_key().to_hex(); + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": git_test_issuer, + "aud": git_test_audience, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": test_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(git_test_kid.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let same_key_assertion = jsonwebtoken::encode(&header, &claims, &git_enc_key) + .expect("sign assertion"); + + // Build same-key NIP-98 token for upload-pack repo root. + let admitted_nip98_tags = vec![ + nostr::Tag::parse(["u", &format!("http://{host}{repo_root}")]).unwrap(), + nostr::Tag::parse(["method", "GET"]).unwrap(), + ]; + let admitted_event = nostr::EventBuilder::new(nostr::Kind::Custom(27235), "") + .tags(admitted_nip98_tags) + .sign_with_keys(&test_keys) + .unwrap(); + let admitted_nip98_token = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&admitted_event).unwrap()) + ); + + // ── git-upload-pack (POST): same-key admission → 404 ───────── + // + // upload-pack calls authorize_git_read which queries DB for + // kind:30617 announcement. Repo absent → 404 "repository not found". + // + // Falsifying mutation: key-pairing always-deny → 403 + // `authorization denied\n` → body check fires. + { + let (s_up, _h_up, b_up) = send_pack_request( + Arc::clone(&state), + "git-upload-pack", + vec![ + ("authorization", admitted_nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + assert_eq!( + s_up, + axum::http::StatusCode::NOT_FOUND, + "git-upload-pack: same-key admission MUST reach authorize_git_read \ + → 404 (repo absent). \ + If 401/403: NIP-FI denial — check verifier injection and key pairing. \ + Body: {b_up:?}" + ); + assert_eq!( + b_up.as_ref(), + b"repository not found", + "git-upload-pack: same-key admitted 404 body must be exact \ + 'repository not found'. \ + Falsifying mutation: key pairing always-deny → 403 \ + 'authorization denied\\n'." + ); + } + + // ── git-receive-pack (POST): same-key admission → git busy 503 ─ + // + // Every `git_semaphore` permit is held, so an admitted request + // stops at `receive_pack` → `acquire_git_permit`, which returns + // exactly 503, `Retry-After: 5`, body `git service busy`, no + // Content-Type, no challenge — before hydration, the + // subprocess, or finalize. Any NIP-FI denial (401 + // `authentication required\n`, 403 `evidence rejected\n` / + // `authorization denied\n`, 503 `authorization unavailable\n`) + // happens in `GitAuth` before the handler and cannot produce + // these bytes. + { + let held: Vec<_> = std::iter::from_fn(|| { + Arc::clone(&state.git_semaphore).try_acquire_owned().ok() + }) + .collect(); + assert!( + !held.is_empty(), + "fixture must hold at least one git permit" + ); + let (s_rp, h_rp, b_rp) = send_pack_request( + Arc::clone(&state), + "git-receive-pack", + vec![ + ("authorization", admitted_nip98_token.clone()), + ( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ), + ], + ) + .await; + drop(held); + assert_eq!( + s_rp, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "git-receive-pack: admitted request MUST reach acquire_git_permit \ + → 503 busy. Body: {b_rp:?}" + ); + assert_eq!( + h_rp.get("retry-after").and_then(|v| v.to_str().ok()), + Some("5"), + "git-receive-pack: busy 503 carries Retry-After: 5" + ); + assert!( + h_rp.get("content-type").is_none(), + "git-receive-pack: busy 503 carries no Content-Type" + ); + assert!( + h_rp.get("www-authenticate").is_none(), + "git-receive-pack: busy 503 carries no challenge" + ); + assert_eq!( + b_rp.as_ref(), + b"git service busy", + "git-receive-pack: exact busy body from acquire_git_permit" + ); + } + + // ── info/refs (GET): shares GitAuth + authorize_git_read ────── + // + // info/refs is a GET with ?service=git-upload-pack sharing + // `GitAuth::from_request_parts` and `authorize_git_read`: missing + // assertion, missing / malformed / duplicate proof with a valid + // assertion, and the same-key positive. + { + let uri = + format!("/git/{OWNER_HEX}/myrepo/info/refs?service=git-upload-pack"); + // ── info/refs Case 1: missing assertion → 401 ──────────── + let (s, _, b) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header("authorization", &nip98_token) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let hd = resp.headers().clone(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, hd, bd) + }; + assert_eq!( + s, + axum::http::StatusCode::UNAUTHORIZED, + "info/refs: missing assertion MUST deny 401. Body: {b:?}" + ); + assert_eq!( + b.as_ref(), + b"authentication required\n", + "info/refs: missing assertion 401 body must be 'authentication required\\n'." + ); + + // ── info/refs Case 5: missing proof + valid assertion → 401 ─ + let (s5, _, b5) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, (), bd) + }; + assert_eq!( + s5, + axum::http::StatusCode::UNAUTHORIZED, + "info/refs: missing proof + valid assertion MUST deny 401. Body: {b5:?}" + ); + assert_eq!( + b5.as_ref(), + b"authentication required\n", + "info/refs: missing-proof 401 body must be 'authentication required\\n'." + ); + + // ── info/refs Cases 6/7: malformed / duplicate proof + + // valid same-key assertion → 403 `evidence rejected\n` ─ + for (authorization, case) in [ + ( + vec!["Nostr !!!not-valid-base64!!!".to_string()], + "malformed proof", + ), + ( + vec![admitted_nip98_token.clone(), admitted_nip98_token.clone()], + "duplicate proof", + ), + ] { + let mut builder = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ); + for value in &authorization { + builder = builder.header("authorization", value); + } + let resp = git_router(Arc::clone(&state)) + .oneshot(builder.body(axum::body::Body::empty()).expect("build")) + .await + .expect("router oneshot"); + let st = resp.status(); + let hd = resp.headers().clone(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + assert_eq!( + st, + axum::http::StatusCode::FORBIDDEN, + "info/refs {case}: MUST deny 403. Body: {bd:?}" + ); + assert_eq!( + hd.get("content-type").and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "info/refs {case}: 403 Content-Type" + ); + assert!( + hd.get("www-authenticate").is_none(), + "info/refs {case}: 403 carries no challenge" + ); + assert_eq!( + bd.as_ref(), + b"evidence rejected\n", + "info/refs {case}: exact EvidenceRejected body" + ); + } + + // ── info/refs Case 4 (same-key positive) → 404 ─────────── + let (s4, _, b4) = { + let req = axum::http::Request::builder() + .method("GET") + .uri(&uri) + .header("host", &host) + .header("authorization", &admitted_nip98_token) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {same_key_assertion}"), + ) + .body(axum::body::Body::empty()) + .expect("build request"); + let resp = git_router(Arc::clone(&state)) + .oneshot(req) + .await + .expect("router oneshot"); + let st = resp.status(); + let bd = to_bytes(resp.into_body(), 4096).await.unwrap_or_default(); + (st, (), bd) + }; + assert_eq!( + s4, + axum::http::StatusCode::NOT_FOUND, + "info/refs: same-key admission MUST reach handler → \ + 404 (repo not found). Body: {b4:?}" + ); + assert_eq!( + b4.as_ref(), + b"repository not found", + "info/refs: same-key admitted 404 body must be 'repository not found'. \ + Falsifying mutation: key pairing always-deny → 403 body." + ); + } + } + } // closes same-key outer block (test_keys_outer / same_key_assertion) + } + } +} diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6714281f40f..8e135ecaaeb 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,239 @@ mod postgres_tests { let response = get_page(state, "/api/join-policy/privacy").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + // ── NIP-FI seam tests: POST /api/invites ────────────────────────────────── + // + // Gate under test: `admit_nip_fi_http_on_state` in `mint_invite_checked`. + // The router's assertion guard runs first and only verifies the assertion, + // so the handler's own admission is what enforces key pairing. + // + // Infrastructure: `#[ignore = "requires Postgres"]` + tokio::test on the + // invites harness; NIP-FI Enforce is enabled by patching the config after + // state construction. + + // Static P-256 test key, shared with the bridge/media/settings NIP-FI tests. + const NIP_FI_TEST_ISSUER: &str = "https://issuer.example"; + const NIP_FI_TEST_AUDIENCE: &str = "https://relay.example"; + const NIP_FI_TEST_KID: &str = "test-key-1"; + const NIP_FI_TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Clone `base` into an Enforce-mode state. With `with_verifier`, a real + /// ES256 verifier over the static test key is injected so a signed + /// assertion passes the router guard and reaches the handler. + fn nip_fi_enforce_state(base: &AppState, with_verifier: bool) -> Arc { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + let mut state = base.clone(); + let mut config = (*state.config).clone(); + config.require_auth_token = true; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + state.config = Arc::new(config); + if !with_verifier { + return Arc::new(state); + } + + let jwks: JwkSet = serde_json::from_value(serde_json::json!({ + "keys": [{ + "kty": "EC", "crv": "P-256", "use": "sig", "alg": "ES256", + "kid": NIP_FI_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(NIP_FI_TEST_ISSUER.to_owned(), 1, jwks, hard_deadline) + .expect("valid test key set"); + let jwks_contract = buzz_auth::JwksSourceContract::new( + format!("{NIP_FI_TEST_ISSUER}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid jwks contract"); + let policy = IssuerPolicy::new( + NIP_FI_TEST_ISSUER.to_owned(), + vec![NIP_FI_TEST_AUDIENCE.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![Algorithm::ES256], + 60, + 3600, + 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); + Arc::new(state) + } + + /// Mint a signed NIP-FI assertion binding `keys`' pubkey. + fn nip_fi_signed_assertion(keys: &Keys) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": NIP_FI_TEST_ISSUER, + "aud": NIP_FI_TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": keys.public_key().to_hex(), + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(NIP_FI_TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = EncodingKey::from_ec_pem(NIP_FI_TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// POST `/api/invites` with a NIP-98 proof from `nip98_keys` and, when + /// given, a NIP-FI assertion; returns `(status, body)`. + async fn nip_fi_post_mint( + state: Arc, + host: &str, + nip98_keys: &Keys, + assertion: Option, + ) -> (StatusCode, Vec) { + let url = format!("https://{host}/api/invites"); + let mut request = Request::builder() + .method("POST") + .uri("/api/invites") + .header(header::HOST, host) + .header( + header::AUTHORIZATION, + nip98_auth_header(nip98_keys, &url, b"{}"), + ) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(token) = assertion { + request = request.header("Nostr-Federated-Identity", format!("Bearer {token}")); + } + let response = build_router(state) + .oneshot(request.body(Body::from("{}")).expect("request")) + .await + .expect("response"); + let status = response.status(); + let body = to_bytes(response.into_body(), 1024 * 1024) + .await + .expect("read body") + .to_vec(); + (status, body) + } + + /// Valid NIP-98 + no assertion → the router's outer assertion guard denies + /// 401 before the handler runs. This witnesses the guard only; handler + /// pairing is covered by `nip_fi_enforce_mint_invite_key_mismatch_is_403`. + /// + #[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; + }; + // No verifier: the guard's missing-assertion check fires before any + // verifier lookup. + let state = nip_fi_enforce_state(&state_base, false); + + let (status, _) = nip_fi_post_mint(state, &host, &Keys::generate(), None).await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "NIP-FI enforce mode: POST /api/invites with valid NIP-98 + no assertion MUST be \ + denied 401 by the router's outer assertion guard [FI-TRACE-HTTP-INGRESS]" + ); + } + + /// Valid assertion for key A + NIP-98 proof for key B → the request passes + /// the router guard (which only verifies the assertion) and the handler's + /// `admit_nip_fi_http_on_state` denies on key pairing with exactly 403 + /// `authorization denied\n`. Key B is a seeded owner, so without the + /// handler's admission the mint would succeed. + /// + /// Falsifying mutation: replace the handler's `admit_nip_fi_http_on_state` + /// with NIP-98-only admission (`admit_nip_fi_http` in Off mode) — the + /// owner's mint succeeds with 200. + /// + /// [FI-INV-05] [FI-TRACE-ASSERTION-KEY-MISMATCH] + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_key_mismatch_is_403() { + let host = format!("nip-fi-invites-pair-{}.local", Uuid::new_v4().simple()); + let state_base = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let asserted = Keys::generate(); + let owner = Keys::generate(); + seed_nip_fi_owner(&state_base, &host, &owner).await; + let state = nip_fi_enforce_state(&state_base, true); + + let (status, body) = nip_fi_post_mint( + state, + &host, + &owner, + Some(nip_fi_signed_assertion(&asserted)), + ) + .await; + assert_eq!( + (status, body.as_slice()), + (StatusCode::FORBIDDEN, b"authorization denied\n".as_slice()), + "assertion for key A + NIP-98 for key B MUST reach mint_invite_checked and be \ + denied by its NIP-FI key pairing" + ); + } + + /// Same-key control for the mismatch test: assertion and NIP-98 both for + /// the seeded owner → admission succeeds and the handler mints normally. + /// Proves the handler does not deny every assertion-bearing request. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn nip_fi_enforce_mint_invite_same_key_mints() { + let host = format!("nip-fi-invites-ctrl-{}.local", Uuid::new_v4().simple()); + let state_base = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let owner = Keys::generate(); + seed_nip_fi_owner(&state_base, &host, &owner).await; + let state = nip_fi_enforce_state(&state_base, true); + + let (status, body) = + nip_fi_post_mint(state, &host, &owner, Some(nip_fi_signed_assertion(&owner))).await; + assert_eq!( + status, + StatusCode::OK, + "matching assertion and NIP-98 keys must be admitted and mint: {}", + String::from_utf8_lossy(&body) + ); + let json: Value = serde_json::from_slice(&body).expect("mint response JSON"); + assert!(json.get("code").and_then(Value::as_str).is_some(), "{json}"); + } + + async fn seed_nip_fi_owner(state: &AppState, host: &str, owner: &Keys) { + let community = state + .db + .lookup_community_by_host(host) + .await + .expect("lookup") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed owner"); + } } diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 780532ec5d0..62e5945b3d5 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -24,13 +24,45 @@ use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInf use crate::state::AppState; -/// Axum extractor that validates Blossom auth, the BUD-11 hash binding, and -/// relay membership (NIP-43, when enabled) from headers BEFORE the request -/// body is read. This prevents unauthenticated clients from forcing the -/// server to buffer up to 50MB of body data. +/// Lightweight pre-auth upload context: tenant + route mode only. /// -/// Axum processes `FromRequestParts` extractors before `FromRequest` (body) -/// extractors, so auth rejection happens before any body buffering. +/// Used as the first-phase extractor for `upload_blob`. Blossom auth +/// extraction is deliberately NOT done here so it can run inside the +/// NIP-FI admission closure, ensuring that in Enforce mode a missing or +/// malformed Authorization header is mapped to the correct NIP-FI denial +/// bytes (MissingEvidence/EvidenceRejected) rather than legacy +/// `MediaError` JSON 401/403. [FI-TRACE-AUTHORITY-UNIFORM] +// pub(crate) so axum can resolve the extractor from the pub handler signature. +pub(crate) struct UploadContext { + tenant: TenantContext, + route_mode: UploadRouteMode, +} + +impl FromRequestParts> for UploadContext { + type Rejection = MediaError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let headers = &parts.headers; + + // Row zero: bind tenant from the request host. Fail-closed: + // unmapped host → 404. + let raw_host = headers + .get(header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| MediaError::NotFound)?; + + let route_mode = upload_route_mode(parts.uri.path())?; + + Ok(UploadContext { tenant, route_mode }) + } +} + pub(crate) struct AuthenticatedUpload { auth_event: nostr::Event, /// Community resolved from the request host at extraction time (row zero for @@ -60,10 +92,6 @@ fn upload_route_mode(path: &str) -> Result { } } -struct MediaReadAuth { - tenant: TenantContext, -} - const MEDIA_UPLOAD_RATE_WINDOW: Duration = Duration::from_secs(60); struct UploadPermit { @@ -137,108 +165,6 @@ fn acquire_upload_permit( }) } -impl FromRequestParts> for AuthenticatedUpload { - type Rejection = MediaError; - - async fn from_request_parts( - parts: &mut Parts, - state: &Arc, - ) -> Result { - let headers = &parts.headers; - - // 1. Row zero: bind this upload to its community from the request host, - // identical to the WS door in `router.rs` and the bridge door in - // `bridge.rs`. Fail-closed: an unmapped host or lookup failure is a - // generic `NotFound` (404) — never a default tenant, never echoing the - // host, so an unauthenticated caller cannot probe which communities - // exist on this deployment. - // - // This MUST run before Blossom auth verification (step 2) so the - // `server`-tag check validates against the *bound tenant host*, not a - // process-global domain — a relay process serves many tenant hosts, and - // the stock CLI tags its own configured relay host (conformance row 52). - // Binding only reads the Host header — no request body is buffered — so - // doing it first preserves the pre-body auth-rejection guarantee. - let raw_host = headers - .get(header::HOST) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let tenant = crate::tenant::bind_community(&state.db, raw_host) - .await - .map_err(|_| MediaError::NotFound)?; - - let route_mode = upload_route_mode(parts.uri.path())?; - - // 2. Extract and validate Blossom auth event against the bound host. - let auth_event = extract_blossom_auth(headers)?; - // Use the permissive window (3600s) here because we don't know the - // content type yet. The upload functions re-verify with the correct - // per-type window (600s for images, 3600s for video) after the body - // has been consumed and the SHA-256 computed. - buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(tenant.host()), 3600)?; - - // 3. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload) - let claimed_hash = headers - .get("x-sha-256") - .and_then(|v| v.to_str().ok()) - .ok_or(MediaError::MissingTag("x-sha-256"))?; - - // Validate format: exactly 64 lowercase hex characters - if claimed_hash.len() != 64 - || !claimed_hash - .chars() - .all(|c| matches!(c, '0'..='9' | 'a'..='f')) - { - return Err(MediaError::HashMismatch); - } - - // 4. Validate X-SHA-256 matches at least one x tag in the auth event - let has_matching_x = auth_event - .tags - .iter() - .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash))); - if !has_matching_x { - return Err(MediaError::HashMismatch); - } - - // 5. Relay membership gate (NIP-43). Blossom auth proves the signer - // authorized this exact upload hash for this server; NIP-43 answers - // whether that Nostr key may use this community's media store. This is - // the only upload authority: independent of bearer-token / api_tokens - // storage and of `require_auth_token` (which governs the REST API, not - // media). On open relays (membership disabled) any valid Blossom signer - // may upload, matching the WS door's admission policy. - let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); - crate::api::relay_members::enforce_relay_membership( - state, - tenant.community(), - auth_event.pubkey.as_bytes(), - auth_tag, - Some(auth_event.created_at.as_secs()), - ) - .await - .map_err(|_| MediaError::RelayMembershipRequired)?; - - if upload_rate_limited(state, tenant.community(), &auth_event.pubkey) { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") - .increment(1); - return Err(MediaError::UploadRateLimitExceeded); - } - let upload_permit = acquire_upload_permit(state, tenant.community(), &auth_event.pubkey) - .inspect_err(|_| { - metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") - .increment(1); - })?; - - Ok(AuthenticatedUpload { - auth_event, - tenant, - route_mode, - _upload_permit: upload_permit, - }) - } -} - /// Build per-event upload attribution when upload records are enabled /// (`BUZZ_MEDIA_UPLOAD_RECORDS`). Returns `None` when the feature is off — /// the upload pipeline then writes no `_uploads/` record at all. @@ -300,12 +226,9 @@ fn serving_lease_lost(error: anyhow::Error) -> MediaError { /// PUT `/upload` or the temporary media-only `/media/upload` alias. /// -/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body -/// is read, preventing unauthenticated clients from forcing body buffering. -// AuthenticatedUpload is pub(crate) — it's an internal extractor type, never -// exposed outside this crate. The warning is benign: axum resolves it at -// compile time via trait bounds, not by name. -#[allow(private_interfaces)] +/// Auth is extracted inside the NIP-FI admission closure so that in active +/// modes a missing or malformed Authorization header produces the contract's +/// NIP-FI denial bytes rather than legacy `MediaError` JSON. [FI-TRACE-AUTHORITY-UNIFORM] /// /// Expects: /// - `Authorization: Nostr ` — Blossom auth @@ -317,8 +240,122 @@ 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. +// UploadContext is pub(crate) — it's an internal extractor type, never exposed +// outside this crate. The warning is benign: axum resolves it at compile time +// via trait bounds, not by name. +#[allow(private_interfaces)] +#[allow(clippy::result_large_err)] // Response is the natural error type for axum closures pub async fn upload_blob( State(state): State>, + ctx: UploadContext, + headers: HeaderMap, + body: axum::body::Body, +) -> axum::response::Response { + use crate::nip_fi_http::{admit_nip_fi_http_on_state, Nip98Proof}; + use axum::response::IntoResponse as _; + + // NIP-FI admission with Blossom extraction as the NIP-98 closure. + // In Enforce mode: extraction failure → NIP-FI denial bytes (MissingEvidence/ + // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. + // + // The closure must verify the auth event against the tenant host BEFORE + // returning the proven pubkey to the admission gate — same ordering invariant + // as the read path. [FI-TRACE-AUTHORITY-UNIFORM] + let tenant_host = ctx.tenant.host().to_owned(); + let headers_clone = headers.clone(); + let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { + let auth_event = extract_blossom_auth(&headers_clone).map_err(|e| e.into_response())?; + // Permissive window (3600s): content type unknown until body arrives. + buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(&tenant_host), 3600) + .map_err(|e| e.into_response())?; + let pubkey = auth_event.pubkey; + Ok(Nip98Proof::new(pubkey, auth_event)) + }) { + Ok(a) => a, + Err(resp) => return resp, + }; + let auth_event = admission.into_extra(); + + // Post-admission: validate X-SHA-256 header and hash binding. + // These are Blossom-protocol checks, not NIP-FI — Off mode still enforces + // them because they protect body integrity, not the assertion boundary. + let claimed_hash = match headers.get("x-sha-256").and_then(|v| v.to_str().ok()) { + Some(h) => h.to_owned(), + None => return MediaError::MissingTag("x-sha-256").into_response(), + }; + if claimed_hash.len() != 64 + || !claimed_hash + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return MediaError::HashMismatch.into_response(); + } + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(&claimed_hash))); + if !has_matching_x { + return MediaError::HashMismatch.into_response(); + } + + // Post-admission: relay membership gate (NIP-43). + let auth_tag = crate::api::relay_members::extract_auth_tag_header(&headers); + if let Err(e) = crate::api::relay_members::enforce_relay_membership( + &state, + ctx.tenant.community(), + auth_event.pubkey.as_bytes(), + auth_tag, + Some(auth_event.created_at.as_secs()), + ) + .await + .map(|_| ()) + .map_err(|_| MediaError::RelayMembershipRequired) + { + return e.into_response(); + } + + // Post-admission: rate limit and concurrency permit. + if upload_rate_limited(&state, ctx.tenant.community(), &auth_event.pubkey) { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "rate_limit") + .increment(1); + return MediaError::UploadRateLimitExceeded.into_response(); + } + let upload_permit = match acquire_upload_permit( + &state, + ctx.tenant.community(), + &auth_event.pubkey, + ) + .inspect_err(|_| { + metrics::counter!("buzz_media_upload_rejections_total", "reason" => "concurrency") + .increment(1); + }) { + Ok(p) => p, + Err(e) => return e.into_response(), + }; + + let auth = AuthenticatedUpload { + auth_event, + tenant: ctx.tenant, + route_mode: ctx.route_mode, + _upload_permit: upload_permit, + }; + 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, @@ -524,17 +561,41 @@ async fn bind_media_read_tenant( .map_err(|_| MediaError::NotFound) } -async fn authenticate_media_read( - state: &AppState, +/// Extract and signature-verify the Blossom auth event for a GET/HEAD read. +/// +/// This is the NIP-98 extraction step for media reads: it parses the +/// `Authorization: Nostr ` header, decodes and verifies the NIP-98 +/// event, and checks the Blossom GET auth binding (sha256 and server tags). +/// +/// Used as the NIP-98 closure inside `admit_nip_fi_http_on_state` so that in +/// active NIP-FI modes a missing/malformed Authorization header is mapped to +/// the correct NIP-FI DenialClass instead of a legacy `MediaError` JSON 401. +/// Off mode propagates `MediaError` unchanged ([FI-INV-15]). +/// +/// [FI-TRACE-AUTHORITY-UNIFORM] +fn extract_blossom_read_proof( headers: &HeaderMap, - sha256_ext: &str, -) -> Result { - let tenant = bind_media_read_tenant(state, headers).await?; - + sha256: &str, + tenant_host: &str, +) -> Result, MediaError> { let auth_event = extract_blossom_auth(headers)?; - let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); - buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; + buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant_host), 3600)?; + let pubkey = auth_event.pubkey; + Ok(crate::nip_fi_http::Nip98Proof::new(pubkey, auth_event)) +} +/// Post-admission membership gate for media reads. +/// +/// Called after `admit_nip_fi_http_on_state` succeeds so that membership +/// is checked against the NIP-FI-verified pubkey rather than a raw +/// header value. Separated from extraction so it can run after admission +/// in both Off and active modes. +async fn enforce_blossom_read_membership( + state: &AppState, + tenant: &TenantContext, + auth_event: &nostr::Event, + headers: &HeaderMap, +) -> Result<(), MediaError> { let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, @@ -544,9 +605,8 @@ async fn authenticate_media_read( Some(auth_event.created_at.as_secs()), ) .await - .map_err(|_| MediaError::RelayMembershipRequired)?; - - Ok(MediaReadAuth { tenant }) + .map(|_| ()) + .map_err(|_| MediaError::RelayMembershipRequired) } fn blob_cache_control() -> &'static str { @@ -632,14 +692,39 @@ 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, req_headers: HeaderMap, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &req_headers, &sha256_ext).await?; - serve_blob_for_tenant(&state, &media_auth.tenant, &sha256_ext, &req_headers).await + // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow + // so that in Enforce mode a missing/malformed Authorization header produces + // NIP-FI denial bytes (not legacy MediaError JSON). [FI-TRACE-AUTHORITY-UNIFORM] + let tenant = bind_media_read_tenant(&state, &req_headers).await?; + let sha256 = sha256_ext + .split('.') + .next() + .unwrap_or(&sha256_ext) + .to_owned(); + let tenant_host = tenant.host().to_owned(); + let headers_clone = req_headers.clone(); + // NIP-FI admission with Blossom extraction as the NIP-98 closure. + // In Enforce mode: extraction failure → NIP-FI denial bytes (MissingEvidence/ + // EvidenceRejected). In Off mode: MediaError propagates unchanged [FI-INV-15]. + use crate::nip_fi_http::admit_nip_fi_http_on_state; + let admission = match admit_nip_fi_http_on_state(&state, &req_headers, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + .map_err(|e| e.into_response()) + }) { + Ok(a) => a, + Err(resp) => return Ok(resp), + }; + let auth_event = admission.into_extra(); + // Post-admission: membership gate. + enforce_blossom_read_membership(&state, &tenant, &auth_event, &req_headers).await?; + serve_blob_for_tenant(&state, &tenant, &sha256_ext, &req_headers).await } /// Serve a validated blob from an already-authorized tenant context. @@ -897,14 +982,34 @@ 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, Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; - let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; - let tenant = media_auth.tenant; + // Row zero: bind tenant. Blossom auth extraction and NIP-FI admission follow + // so that in Enforce mode a missing/malformed Authorization header produces + // NIP-FI denial bytes (not legacy MediaError JSON). [FI-TRACE-AUTHORITY-UNIFORM] + let tenant = bind_media_read_tenant(&state, &headers).await?; + let sha256 = sha256_ext + .split('.') + .next() + .unwrap_or(&sha256_ext) + .to_owned(); + let tenant_host = tenant.host().to_owned(); + let headers_clone = headers.clone(); + use crate::nip_fi_http::admit_nip_fi_http_on_state; + let admission = match admit_nip_fi_http_on_state(&state, &headers, move || { + extract_blossom_read_proof(&headers_clone, &sha256, &tenant_host) + .map_err(|e| e.into_response()) + }) { + Ok(a) => a, + Err(resp) => return Ok(resp), + }; + let auth_event = admission.into_extra(); + enforce_blossom_read_membership(&state, &tenant, &auth_event, &headers).await?; let cache_control = blob_cache_control(); // Sidecar gate FIRST — reject before any blob I/O. @@ -1538,4 +1643,1724 @@ mod tests { fn test_parse_byte_range_zero_start() { assert_eq!(parse_byte_range("bytes=0-0", 1000), Some((0, 0))); } + + // ── NIP-FI media regression tests ──────────────────────────────────────── + // + // These postgres-backed tests prove the NIP-FI admission gate is wired into + // the media upload and read routes at the router level. They all require a + // live Postgres + Redis and are tagged #[ignore = "requires Postgres"]. + // + // Verified contract rows (NIP-FI.md rejection table): + // Enforce mode, missing Blossom auth: 401 `authentication required\n` + // text/plain; charset=utf-8 + // WWW-Authenticate: Nostr + // Enforce mode, malformed auth: 403 `evidence rejected\n` + // text/plain; charset=utf-8 + // no challenge + // Enforce mode, duplicate auth header: 403 `evidence rejected\n` (cardinality) + // Off mode, missing Blossom auth: 401 `{"error":"authentication failed"}` + // application/json + // no WWW-Authenticate + // + // [FI-TRACE-AUTHORITY-UNIFORM, FI-INV-15] + #[cfg(test)] + mod postgres_tests { + use super::*; + use std::sync::Arc; + + use axum::body::{to_bytes, Body}; + use axum::http::{Request, StatusCode}; + use buzz_auth::NipFiMode; + use nostr::{EventBuilder, Keys, Kind, Tag}; + use tower::ServiceExt; + + // ── Test infrastructure ──────────────────────────────────────────────── + + /// Always-fresh replay guard (no Redis needed) — same pattern as bridge tests. + struct AlwaysFreshReplayGuard; + impl buzz_auth::Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark_in_scope<'a>( + &'a self, + _scope: &'a str, + _event_id: &'a nostr::EventId, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + + Send + + 'a, + >, + > { + Box::pin(async { Ok(true) }) + } + } + + /// Constants for the static P-256 test key, matching the cardinality test in bridge.rs. + const TEST_ISSUER: &str = "https://issuer.example"; + const TEST_AUDIENCE: &str = "https://relay.example"; + const TEST_KID: &str = "test-key-1"; + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\ + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\n\ + WZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\n\ + zhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n\ + -----END PRIVATE KEY-----\n"; + + /// Build an AppState with NIP-FI Enforce and a real injected P-256 verifier. + async fn media_enforce_test_state() -> Option> { + use buzz_auth::{ + AssertionKeySet, FederatedAssertionVerifier, FreshnessClass, IssuerPolicy, + IssuerRegistry, StaticIssuerKeySource, TokenClass, VerifyAssertion, + }; + use jsonwebtoken::{jwk::JwkSet, Algorithm}; + + 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-media-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = 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, + Keys::generate(), + media_storage, + ); + state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); + + // Inject a real P-256 verifier so the assertion guard can forward requests. + 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, + 3600, + 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); + Some(Arc::new(state)) + } + + /// Build an AppState with NIP-FI Off. + async fn media_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-media-off.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + config.nip_fi.mode = 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, _) = 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)) + } + + /// Mint a valid Blossom upload auth header value. + fn blossom_upload_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let now = nostr::Timestamp::now().as_secs(); + let exp = now + 300; + let event = EventBuilder::new(Kind::from(24242), "Upload blob") + .tags(vec![ + Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", host]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]) + .sign_with_keys(keys) + .expect("sign blossom upload auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + /// Mint a valid Blossom get auth header value. + fn blossom_get_auth_value(keys: &Keys, host: &str, sha256_hex: &str) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; + use nostr::JsonUtil as _; + let now = nostr::Timestamp::now().as_secs(); + let exp = now + 300; + let event = EventBuilder::new(Kind::from(24242), "Get blob") + .tags(vec![ + Tag::parse(["t", "get"]).unwrap(), + Tag::parse(["expiration", &exp.to_string()]).unwrap(), + Tag::parse(["server", host]).unwrap(), + Tag::parse(["x", sha256_hex]).unwrap(), + ]) + .sign_with_keys(keys) + .expect("sign blossom get auth"); + format!("Nostr {}", B64.encode(event.as_json().as_bytes())) + } + + /// Mint a signed NIP-FI assertion whose `nostr_pubkey` matches `keys`. + fn signed_assertion(nostr_pubkey_hex: &str) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "iat": now, + "exp": now + 600, + "sub": "test-subject", + "nostr_pubkey": nostr_pubkey_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("nip-fi+jwt".to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).expect("valid test EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign assertion") + } + + /// Drive a oneshot request through the full relay router; return + /// `(status, resp_headers, body_bytes)`. + async fn media_oneshot( + state: Arc, + method: &str, + uri: &str, + host: &str, + headers: axum::http::HeaderMap, + body: &[u8], + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("host", host); + for (name, value) in &headers { + builder = builder.header(name, value); + } + let resp = crate::router::build_router(state) + .oneshot( + builder + .body(Body::from(body.to_vec())) + .expect("build request"), + ) + .await + .expect("router oneshot"); + let status = resp.status(); + let resp_headers = resp.headers().clone(); + let resp_body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, resp_headers, resp_body) + } + + /// Upload body that both upload routes reject deterministically before + /// any storage call: the ID3 magic sniffs as `audio/mpeg`, which + /// `validate_file_content` refuses on `/upload` and `upload_blob_result` + /// refuses as `DisallowedContentType` on `/media/upload`. Both map to + /// 415 JSON (`buzz-media/src/error.rs`), after admission, the serving + /// lease, and the body read, but before any storage call. + const AUDIO_BODY: &[u8] = b"ID3\x04\x00\x00\x00\x00\x00\x00"; + const AUDIO_REJECTION: &[u8] = br#"{"error":"disallowed content type: audio/mpeg"}"#; + + fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest as _; + hex::encode(sha2::Sha256::digest(bytes)) + } + + /// Assert an exact response: status, Content-Type, challenge, and body. + fn assert_exact_response( + (status, headers, body): &(StatusCode, axum::http::HeaderMap, bytes::Bytes), + expected_status: StatusCode, + expected_content_type: &str, + expected_challenge: Option<&str>, + expected_body: &[u8], + context: &str, + ) { + assert_eq!(*status, expected_status, "{context}: status; body {body:?}"); + assert_eq!( + headers.get("content-type").and_then(|v| v.to_str().ok()), + Some(expected_content_type), + "{context}: Content-Type" + ); + assert_eq!( + headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()), + expected_challenge, + "{context}: WWW-Authenticate" + ); + assert_eq!(body.as_ref(), expected_body, "{context}: body"); + } + + // ── Upload proof matrix: Enforce mode, both upload routes ─────────── + // + // `/upload` and the legacy `/media/upload` alias both route to + // `upload_blob`. Every Enforce case carries a valid assertion, so the + // outer guard forwards the request and the handler's + // `admit_nip_fi_http_on_state` produces the denial. Removing the + // handler admission (or routing an alias around it) lets the legacy + // Blossom extractor answer with `{"error":"authentication failed"}` + // JSON instead of these NIP-FI text/plain bytes. + const UPLOAD_ROUTES: [&str; 2] = ["/upload", "/media/upload"]; + + fn upload_request( + rt: &tokio::runtime::Runtime, + state: &Arc, + route: &str, + host: &str, + authorization: &[&str], + assertion: Option<&str>, + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "x-sha-256", + sha256_hex(AUDIO_BODY).parse().expect("valid header"), + ); + for value in authorization { + headers.append( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header bytes"), + ); + } + if let Some(assertion) = assertion { + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + } + rt.block_on(media_oneshot( + Arc::clone(state), + "PUT", + route, + host, + headers, + AUDIO_BODY, + )) + } + + fn media_fixture( + state_fn: impl std::future::Future>>, + ) -> (tokio::runtime::Runtime, Arc, String) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let Some(state) = rt.block_on(state_fn) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-up-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + (rt, state, host) + } + + /// Valid assertion, no Authorization → 401 MissingEvidence with challenge. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_missing_proof_is_401_nip_fi() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[], Some(&assertion)), + StatusCode::UNAUTHORIZED, + "text/plain; charset=utf-8", + Some("Nostr"), + b"authentication required\n", + &format!("{route}: missing proof"), + ); + } + } + + /// Valid assertion, malformed Authorization → 403 EvidenceRejected. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_malformed_proof_is_403_nip_fi() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request( + &rt, + &state, + route, + &host, + &["Nostr !!!not-valid-base64!!!"], + Some(&assertion), + ), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("{route}: malformed proof"), + ); + } + } + + /// Valid same-key assertion, the same valid proof twice → 403 from the + /// cardinality gate. Removing the gate admits the first proof and the + /// request reaches the exact 415 of the same-key control below. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_duplicate_proof_is_403_cardinality() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request( + &rt, + &state, + route, + &host, + &[&proof, &proof], + Some(&assertion), + ), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"evidence rejected\n", + &format!("{route}: duplicate proof"), + ); + } + } + + /// Valid assertion for `key_a`, valid proof signed by `key_b` → the + /// handler's key-pairing check denies 403 `authorization denied\n`. + /// Removing key pairing lets the request reach the exact 415 below. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_mismatched_key_is_handler_403() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let assertion = signed_assertion(&Keys::generate().public_key().to_hex()); + let proof = + blossom_upload_auth_value(&Keys::generate(), &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + &format!("{route}: mismatched key"), + ); + } + } + + /// Same-key assertion + proof → admission passes and the request reaches + /// content validation: exact 415 `AUDIO_REJECTION` on both routes, with + /// no dependence on storage. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_same_key_admission_reaches_content_validation() { + let (rt, state, host) = media_fixture(media_enforce_test_state()); + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let proof = blossom_upload_auth_value(&keys, &host, &sha256_hex(AUDIO_BODY)); + for route in UPLOAD_ROUTES { + assert_exact_response( + &upload_request(&rt, &state, route, &host, &[&proof], Some(&assertion)), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("{route}: same-key admission"), + ); + } + } + + /// Off mode skips the cardinality gate and `HeaderMap::get` takes the + /// first Authorization value. A single valid proof and a + /// valid-first/malformed-second pair both reach the exact 415; applying + /// cardinality in Off mode would return 403, and last-value selection + /// would return the legacy 401 JSON. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_duplicate_auth_takes_first_value() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let proof = + blossom_upload_auth_value(&Keys::generate(), &host, &sha256_hex(AUDIO_BODY)); + for (authorization, context) in [ + (vec![proof.as_str()], "single proof"), + ( + vec![proof.as_str(), "Nostr !!!not-valid-base64!!!"], + "valid-first/malformed-second", + ), + ] { + assert_exact_response( + &upload_request(&rt, &state, "/upload", &host, &authorization, None), + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + &format!("Off {context}"), + ); + } + } + + // ── Upload: Off mode, missing Blossom auth → legacy MediaError JSON ── + + /// Off mode + PUT /upload with no Authorization header must return the + /// legacy MediaError JSON 401: `{"error":"authentication failed"}`, + /// application/json, and NO `WWW-Authenticate` header. + /// + /// FI-INV-15: Off mode must propagate legacy MediaError responses + /// unchanged. The NIP-FI denial bytes (text/plain + challenge) MUST NOT + /// appear in Off mode. + /// + /// Falsifying mutation: change Off mode to run NIP-FI admission on + /// missing-auth → 401 `authentication required\n` text/plain with + /// WWW-Authenticate → body and content-type assertions fire. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_missing_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "d".repeat(64); + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-sha-256", sha256.parse().expect("valid header")); + // No Authorization header. + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "PUT", + "/upload", + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode + missing Blossom auth MUST return 401 legacy MediaError (FI-INV-15)." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off mode 401 body MUST be exact legacy JSON bytes \ + '{{\"error\":\"authentication failed\"}}' [FI-INV-15]. \ + NIP-FI text/plain bytes would indicate Off mode is incorrectly applying \ + active-mode denial." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off mode 401 Content-Type MUST be application/json (legacy MediaError) [FI-INV-15]." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off mode 401 MUST NOT carry WWW-Authenticate [FI-INV-15]." + ); + } + + // ── GET /media: Enforce mode, missing Blossom auth → 401 NIP-FI ───── + + /// Enforce mode + GET /media/{sha256} with no Authorization header must + /// return 401 `authentication required\n` + `WWW-Authenticate: Nostr`. + /// + /// Falsifying mutation: remove `admit_nip_fi_http_on_state` from + /// `get_blob` → the legacy Blossom extractor fires → 401 JSON body, no + /// challenge → body and header assertions fire. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_missing_proof_is_401_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + // Assertion present but NO Blossom Authorization header. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + GET /media + missing Blossom auth MUST return 401 MissingEvidence. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from get_blob → \ + legacy Blossom extractor fires → JSON body, no challenge." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GET Enforce 401 body MUST be NIP-FI bytes 'authentication required\\n'." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce 401 Content-Type MUST be text/plain; charset=utf-8." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "GET Enforce 401 MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Off mode, missing Blossom auth → legacy JSON 401 ──── + + /// Off mode + GET /media/{sha256} with no Authorization header must + /// return the legacy MediaError JSON 401 (FI-INV-15). + #[test] + #[ignore = "requires Postgres"] + fn get_blob_off_missing_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "f".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + Default::default(), + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off GET missing auth → 401" + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off GET 401 body MUST be legacy JSON bytes [FI-INV-15]." + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off GET 401 Content-Type MUST be application/json (legacy MediaError) [FI-INV-15]." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off GET 401 MUST NOT carry WWW-Authenticate [FI-INV-15]." + ); + } + + // ── PUT /upload: Off mode, malformed Authorization → legacy JSON 401 ─ + // + // Off mode does NOT apply NIP-FI cardinality or assertion checks. A + // malformed Nostr token still fails Blossom extraction and the legacy + // MediaError response (application/json 401) is returned unchanged. + // + // This proves Off mode propagates Blossom errors as legacy JSON — not + // the NIP-FI text/plain denial bytes that active modes would produce. + // + // Falsifying mutation A: map Blossom errors to NIP-FI denial bytes in + // Off mode → body changes to "evidence rejected\n" → assertion fires. + // Falsifying mutation B: swap Off → Enforce → cardinality/assertion gates + // fire → NIP-FI 403 body → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn upload_off_malformed_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "a".repeat(64); + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-sha-256", sha256.parse().expect("valid header")); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!malformed!!!".parse().expect("valid header bytes"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "PUT", + "/upload", + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode PUT /upload + malformed Authorization MUST return 401 \ + legacy MediaError (FI-INV-15). \ + Falsifying mutation: map Blossom errors to NIP-FI bytes in Off mode \ + → NIP-FI body/CT." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off mode malformed-auth 401 body MUST be exact legacy JSON bytes \ + (not NIP-FI text/plain). [FI-INV-15]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off mode malformed-auth 401 Content-Type MUST be application/json \ + (legacy MediaError). [FI-INV-15]" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off mode malformed-auth 401 MUST NOT carry WWW-Authenticate. [FI-INV-15]" + ); + } + + // ── GET /media: Off mode, malformed Authorization → legacy JSON 401 ─── + // + // Mirror of the upload Off+malformed case for the GET path. + // + // Falsifying mutation: map Blossom errors to NIP-FI bytes in Off mode + // on GET → NIP-FI body/CT → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_off_malformed_auth_is_legacy_json_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_off_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-off-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "c".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!malformed!!!".parse().expect("valid header bytes"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Off mode GET /media + malformed Authorization MUST return 401 \ + legacy MediaError (FI-INV-15). \ + Falsifying mutation: remap Blossom error to NIP-FI bytes in Off mode." + ); + assert_eq!( + body.as_ref(), + br#"{"error":"authentication failed"}"#, + "Off GET malformed-auth 401 body MUST be exact legacy JSON bytes \ + (not NIP-FI text/plain). [FI-INV-15]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "application/json", + "Off GET malformed-auth 401 Content-Type MUST be application/json. [FI-INV-15]" + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "Off GET malformed-auth 401 MUST NOT carry WWW-Authenticate. [FI-INV-15]" + ); + } + + // ── GET/HEAD /media: Off mode, legacy bytes on both read methods ────── + // + // Off mode skips the cardinality gate and `HeaderMap::get` takes the + // first Authorization value, so a single valid get proof and a + // valid-first/malformed-second pair both pass the legacy extractor and + // reach `serve_blob_for_tenant` / `head_blob`, whose sidecar gate + // returns `MediaError::NotFound` for the unstored blob → 404 + // `{"error":"not found"}` JSON (`buzz-media/src/error.rs`). With no + // Authorization at all the legacy extractor returns 401 + // `{"error":"authentication failed"}` JSON. HEAD carries the same + // status and headers with the body stripped by axum. + // + // Applying cardinality in Off mode would return NIP-FI 403 text/plain; + // last-value selection would return the legacy 401 for the duplicate. + fn media_read_off( + rt: &tokio::runtime::Runtime, + state: &Arc, + method: &str, + path: &str, + host: &str, + authorization: &[&str], + ) -> (StatusCode, axum::http::HeaderMap, bytes::Bytes) { + let mut headers = axum::http::HeaderMap::new(); + for value in authorization { + headers.append( + axum::http::header::AUTHORIZATION, + value.parse().expect("valid header bytes"), + ); + } + rt.block_on(media_oneshot( + Arc::clone(state), + method, + path, + host, + headers, + b"", + )) + } + + #[test] + #[ignore = "requires Postgres"] + fn media_read_off_duplicate_auth_takes_first_value() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let sha256 = "d0".repeat(32); + let path = format!("/media/{sha256}.jpg"); + let proof = blossom_get_auth_value(&Keys::generate(), &host, &sha256); + for (method, body) in [ + ("GET", br#"{"error":"not found"}"#.as_slice()), + ("HEAD", b"".as_slice()), + ] { + for (authorization, context) in [ + (vec![proof.as_str()], "single proof"), + ( + vec![proof.as_str(), "Nostr !!!not-valid-base64!!!"], + "valid-first/malformed-second", + ), + ] { + assert_exact_response( + &media_read_off(&rt, &state, method, &path, &host, &authorization), + StatusCode::NOT_FOUND, + "application/json", + None, + body, + &format!("Off {method} {context}"), + ); + } + } + } + + /// Off HEAD witness: missing Authorization → the legacy 401 JSON + /// headers, no challenge, body stripped. Running NIP-FI denial in Off + /// mode would add `WWW-Authenticate: Nostr` and text/plain. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_off_missing_auth_is_legacy_json_401() { + let (rt, state, host) = media_fixture(media_off_test_state()); + let path = format!("/media/{}.jpg", "f".repeat(64)); + assert_exact_response( + &media_read_off(&rt, &state, "HEAD", &path, &host, &[]), + StatusCode::UNAUTHORIZED, + "application/json", + None, + b"", + "Off HEAD missing auth", + ); + } + + // ── HEAD /media: Enforce mode, missing Blossom auth → 401 NIP-FI ──── + + /// Enforce mode + HEAD /media/{sha256} with no Authorization header must + /// return 401 + `WWW-Authenticate: Nostr`. HEAD suppresses the body per + /// RFC 9110; we check status and headers only. + /// + /// Falsifying mutation: remove `admit_nip_fi_http_on_state` from + /// `head_blob` → legacy path fires → 401 but no challenge → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_missing_proof_is_401_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + HEAD /media + missing Blossom auth MUST return 401. \ + Falsifying mutation: remove admit_nip_fi_http_on_state from head_blob → \ + legacy path fires → no WWW-Authenticate." + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes: {:?}", + head_body.len(), + &head_body.as_ref()[..head_body.len().min(64)] + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce 401 Content-Type MUST be text/plain; charset=utf-8." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "HEAD Enforce 401 MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Enforce mode, valid Blossom but NO assertion → 401 ── + + /// Enforce mode + GET /media/{sha256} with a valid Blossom auth but NO + /// `Nostr-Federated-Identity` header must return 401 `authentication + /// required\n` — both the outer guard (`router.rs`) and the per-handler + /// `admit_nip_fi_http_on_state` in `get_blob()` deny at the same point. + /// + /// This is distinct from the "missing Blossom" case: here the Blossom + /// proof IS present, but no assertion was supplied. Either the outer + /// guard or the handler-level check produces MissingEvidence → 401. + /// + /// Falsifying mutation: remove BOTH the outer guard and the handler-level + /// `admit_nip_fi_http_on_state` from `get_blob` → valid Blossom accepted + /// → proceeds to membership/storage → different status → assertion fires. + /// Removing only one layer is insufficient: the other still denies 401. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_valid_blossom_no_assertion_is_401() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let auth_val = blossom_get_auth_value(&keys, &host, &sha256); + + // Valid Blossom auth but NO Nostr-Federated-Identity header. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + auth_val.parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "Enforce mode + valid Blossom + NO NIP-FI assertion MUST return 401. \ + Falsifying mutation: remove assertion guard from get_blob → valid Blossom \ + accepted → proceeds to membership/storage → 404 or membership 403." + ); + assert_eq!( + body.as_ref(), + b"authentication required\n", + "GET Enforce 401 (no assertion) body MUST be NIP-FI bytes." + ); + assert_eq!( + resp_headers + .get("www-authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "Nostr", + "GET Enforce 401 (no assertion) MUST carry WWW-Authenticate: Nostr." + ); + } + + // ── GET /media: Enforce mode, malformed proof → 403 EvidenceRejected ─ + // Authorization header triggers EvidenceRejected before the NIP-FI + // assertion check. 403 + exact body + CT + no challenge. + // + // The fixture supplies a valid assertion so the outer guard forwards + // the request and the handler's Blossom extraction failure is mapped + // to EvidenceRejected. Removing handler admission from `get_blob` + // lets the legacy extractor answer with 401 `{"error":"authentication + // failed"}` JSON instead. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_malformed_proof_is_403_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "c".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + // Malformed: valid Nostr scheme prefix, invalid base64 payload. + // "!!!" is not valid base64 and decodes to an error in the verifier. + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!bad!!!".parse().expect("valid header bytes"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + signed_assertion(&Keys::generate().public_key().to_hex()) + ) + .parse() + .expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "GET Enforce + malformed Nostr token MUST return 403 EvidenceRejected. \ + Removing handler admission → legacy 401 JSON instead." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "GET Enforce malformed 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce malformed 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "GET Enforce malformed 403 MUST NOT carry WWW-Authenticate. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── GET /media: Enforce mode, duplicate Authorization → 403 cardinality ─ + // + // Two identical Blossom Authorization headers in Enforce mode trigger + // the cardinality gate inside `admit_nip_fi_http_on_state`. + // 403 + exact body + CT + no challenge. + // + // The fixture supplies a same-key valid assertion so cardinality is the + // only denial source. Removing the cardinality gate admits the first + // proof and the request reaches the sidecar gate → 404 NotFound (the + // same-key control below), not 403 EvidenceRejected. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_duplicate_proof_is_403_cardinality() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "e".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let keys = Keys::generate(); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + let assertion = signed_assertion(&keys.public_key().to_hex()); + + let mut headers = axum::http::HeaderMap::new(); + // Two identical Blossom Authorization headers → cardinality 2. + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "GET Enforce + duplicate Authorization MUST return 403 EvidenceRejected. \ + Removing the cardinality gate → admitted → sidecar 404 instead." + ); + assert_eq!( + body.as_ref(), + b"evidence rejected\n", + "GET Enforce cardinality 403 body MUST be exact 'evidence rejected\\n'. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "GET Enforce cardinality 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "GET Enforce cardinality 403 MUST NOT carry WWW-Authenticate. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } + + // ── GET /media: Enforce mode, same-key success → 404 sidecar not found ─ + // + // A valid NIP-FI assertion + valid Blossom get proof (same key) passes + // admission and proceeds to the sidecar lookup. The sidecar blob does + // not exist in the test state → 404. This proves admission was NOT the + // denial point — an always-denying implementation would return 401/403, + // not 404. + // + // Falsifying mutation: lower the NIP-FI gate to always-deny → + // same-key request returns 401/403 (admission fails) → 404 assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn get_blob_enforce_same_key_admission_succeeds_returns_404() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "0".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid blossom header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}") + .parse() + .expect("valid assertion header"), + ); + + let (status, _resp_headers, _body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "GET", + &path, + &host, + headers, + b"", + )); + + // Admission passes → reaches sidecar lookup → blob absent → 404. + // 401 or 403 would indicate admission failure, not sidecar absence. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "GET Enforce same-key admission MUST pass NIP-FI and reach sidecar lookup → 404. \ + 401/403 means admission failed (always-deny implementation). \ + Falsifying mutation: make NIP-FI verifier always-deny → 403 instead of 404." + ); + } + + // ── HEAD /media: Enforce mode, malformed proof → 403 EvidenceRejected ─ + // + // Same contract as GET malformed, but for HEAD. RFC 9110 §9.3.2 suppresses + // the body; we assert status + CT + no challenge only. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_malformed_proof_is_403_nip_fi() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "1".repeat(64); + let path = format!("/media/{sha256}.jpg"); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + "Nostr !!!bad!!!".parse().expect("valid header bytes"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!( + "Bearer {}", + signed_assertion(&Keys::generate().public_key().to_hex()) + ) + .parse() + .expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "HEAD Enforce + malformed Nostr token MUST return 403 EvidenceRejected. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes.", + head_body.len() + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce malformed 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "HEAD Enforce malformed 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── HEAD /media: Enforce mode, duplicate Authorization → 403 cardinality ─ + // + // Same contract as GET cardinality, but for HEAD. Body suppressed. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_duplicate_proof_is_403_cardinality() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!("nip-fi-media-enf-{}.local", uuid::Uuid::new_v4().simple()); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let sha256 = "2".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let keys = Keys::generate(); + let blossom_val = blossom_get_auth_value(&keys, &host, &sha256); + let assertion = signed_assertion(&keys.public_key().to_hex()); + + let mut headers = axum::http::HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + blossom_val.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, head_body) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + assert_eq!( + status, + StatusCode::FORBIDDEN, + "HEAD Enforce + duplicate Authorization MUST return 403 EvidenceRejected. \ + [FI-TRACE-DENIAL-ORACLE]" + ); + assert!( + head_body.is_empty(), + "HEAD MUST suppress the response body (RFC 9110 §9.3.2). \ + Got {} bytes.", + head_body.len() + ); + assert_eq!( + resp_headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""), + "text/plain; charset=utf-8", + "HEAD Enforce cardinality 403 Content-Type MUST be text/plain; charset=utf-8." + ); + assert!( + resp_headers.get("www-authenticate").is_none(), + "HEAD Enforce cardinality 403 MUST NOT carry WWW-Authenticate." + ); + } + + // ── HEAD /media: Enforce mode, same-key admission → reaches handler ─ + // + // Same-key Blossom get-auth + assertion → admission passes → handler + // attempts sidecar lookup → `read_sidecar_mime` yields `None` → 404. + // + // Falsifying mutation: always-deny key pairing → 403 → assertion fires. + #[test] + #[ignore = "requires Postgres"] + fn head_blob_enforce_same_key_admission_succeeds_returns_404() { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-media-enf-hdpos-{}.local", + uuid::Uuid::new_v4().simple() + ); + rt.block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community"); + + let keys = Keys::generate(); + let sha256 = "5".repeat(64); + let path = format!("/media/{sha256}.jpg"); + let assertion = signed_assertion(&keys.public_key().to_hex()); + let blossom_token = blossom_get_auth_value(&keys, &host, &sha256); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + blossom_token.parse().expect("valid header"), + ); + headers.insert( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}").parse().expect("valid header"), + ); + + let (status, resp_headers, _body_bytes) = rt.block_on(media_oneshot( + Arc::clone(&state), + "HEAD", + &path, + &host, + headers, + b"", + )); + + // Admission passes → handler proceeds to sidecar lookup → blob absent → 404. + // 401/403 would indicate NIP-FI admission failure. + assert_eq!( + status, + StatusCode::NOT_FOUND, + "HEAD /media same-key admission MUST reach handler → 404 (blob not found). \ + If 401: NIP-FI MissingEvidence — outer guard or assertion check denying. \ + If 403: NIP-FI AuthorizationDenied — key pairing denying. \ + Resp headers: {resp_headers:?}. \ + Falsifying mutation: always-deny pairing → 403 instead of 404." + ); + } + + // ── Upload resource witness: handler denial precedes body read and permits ─ + // + // The outer guard (router.rs) forwards any request carrying a + // cryptographically valid assertion, so every denied request below + // carries one (for `key_a`) plus a valid Blossom upload proof signed by + // `key_b`, the real body hash, and the matching host. Only the + // handler's `admit_nip_fi_http_on_state` key-pairing check can deny it + // (403 `authorization denied\n`). + // + // Production order in `upload_blob`: admission → x-sha-256 checks → + // membership → rate limit → `acquire_upload_permit` → body read in + // `upload_blob_result`. + // + // - Denied, instrumented body: zero polls. Moving admission after the + // body read would poll it first. + // - Denied, all global permits held: still 403, not 429. Moving + // admission after `acquire_upload_permit` would return 429. + // - `media_uploads_in_flight` for `key_b` is identical before and after + // each denied request. This proves no leaked per-key accounting; it + // cannot observe a transient acquire-and-release. + // - Admitted controls (assertion for `key_b`, same proof) show the + // resource boundaries are reachable: with permits held → exact 429 + // `upload concurrency limit reached` (`acquire_upload_permit` → + // `buzz-media/src/error.rs`); with permits free → body polled and + // exact 415 `AUDIO_REJECTION`. + #[test] + #[ignore = "requires Postgres"] + fn upload_enforce_denial_does_not_poll_body_or_consume_permit() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CountingBody { + inner: Option, + polls: Arc, + } + impl http_body::Body for CountingBody { + type Data = bytes::Bytes; + type Error = std::convert::Infallible; + fn poll_frame( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> + { + self.polls.fetch_add(1, Ordering::SeqCst); + std::task::Poll::Ready(self.inner.take().map(|b| Ok(http_body::Frame::data(b)))) + } + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + + let Some(state) = rt.block_on(media_enforce_test_state()) else { + panic!("local Postgres not reachable"); + }; + let host = format!( + "nip-fi-media-witness-{}.local", + uuid::Uuid::new_v4().simple() + ); + let community = rt + .block_on(state.db.ensure_configured_community(&host)) + .expect("ensure community") + .id; + + let key_a = Keys::generate(); + let key_b = Keys::generate(); + let sha256 = sha256_hex(AUDIO_BODY); + let proof_b = blossom_upload_auth_value(&key_b, &host, &sha256); + let mismatched_assertion = signed_assertion(&key_a.public_key().to_hex()); + let same_key_assertion = signed_assertion(&key_b.public_key().to_hex()); + let accounting_key = (community, key_b.public_key().to_bytes()); + let in_flight = || { + state + .media_uploads_in_flight + .get(&accounting_key) + .map(|count| *count) + }; + + // Send PUT /upload with `assertion`, returning (status, headers, body, polls). + let send = |assertion: &str| { + let polls = Arc::new(AtomicUsize::new(0)); + let request = Request::builder() + .method("PUT") + .uri("/upload") + .header("host", &host) + .header("authorization", &proof_b) + .header("x-sha-256", &sha256) + .header( + buzz_auth::CLIENT_ATTACHED_HEADER, + format!("Bearer {assertion}"), + ) + .body(Body::new(CountingBody { + inner: Some(bytes::Bytes::from_static(AUDIO_BODY)), + polls: Arc::clone(&polls), + })) + .expect("build request"); + let response = rt.block_on(async { + let resp = crate::router::build_router(Arc::clone(&state)) + .oneshot(request) + .await + .expect("router oneshot"); + let status = resp.status(); + let headers = resp.headers().clone(); + let body = to_bytes(resp.into_body(), 8192).await.unwrap_or_default(); + (status, headers, body) + }); + (response, polls.load(Ordering::SeqCst)) + }; + let denied = |response: &(StatusCode, axum::http::HeaderMap, bytes::Bytes), + context: &str| { + assert_exact_response( + response, + StatusCode::FORBIDDEN, + "text/plain; charset=utf-8", + None, + b"authorization denied\n", + context, + ); + }; + + // ── Denied request, permits free: body never polled ───────────── + let before = in_flight(); + let (response, polls) = send(&mismatched_assertion); + denied(&response, "mismatched key, permits free"); + assert_eq!(polls, 0, "handler denial MUST precede the body read"); + assert_eq!( + in_flight(), + before, + "denial MUST leave per-key accounting unchanged" + ); + + // ── All global permits held ───────────────────────────────────── + let semaphore = Arc::clone(&state.media_upload_semaphore); + let held: Vec<_> = + std::iter::from_fn(|| semaphore.clone().try_acquire_owned().ok()).collect(); + assert!( + !held.is_empty(), + "fixture must hold at least one upload permit" + ); + + let before = in_flight(); + let (response, polls) = send(&mismatched_assertion); + denied(&response, "mismatched key, permits exhausted"); + assert_eq!(polls, 0, "handler denial MUST precede the body read"); + assert_eq!( + in_flight(), + before, + "denial MUST leave per-key accounting unchanged" + ); + + let (response, polls) = send(&same_key_assertion); + assert_exact_response( + &response, + StatusCode::TOO_MANY_REQUESTS, + "application/json", + None, + br#"{"error":"upload concurrency limit reached"}"#, + "admitted request, permits exhausted", + ); + assert_eq!(polls, 0, "permit rejection precedes the body read"); + drop(held); + + // ── Admitted request, permits free: body read, exact 415 ──────── + let (response, polls) = send(&same_key_assertion); + assert_exact_response( + &response, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "application/json", + None, + AUDIO_REJECTION, + "admitted request, permits free", + ); + assert!(polls > 0, "admitted upload MUST read the instrumented body"); + assert_eq!( + in_flight(), + None, + "admitted upload MUST release its per-key slot" + ); + } + } } 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 5d831b3f651..14ebf860f5b 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 { @@ -1265,6 +1273,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 114b51ee7f2..8211dcbacb2 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -528,6 +528,45 @@ 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" + ); + let issuer_ids: Vec = jwks_configs.iter().map(|c| c.issuer.clone()).collect(); + warm_nip_fi_jwks_snapshots(jwks_source, &issuer_ids).await; + // 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 { + nip_fi_jwks_refresh_loop( + refresh_configs + .iter() + .map(|c| (c.issuer.clone(), c.contract.refresh_interval_seconds())) + .collect(), + move |issuer| { + let src = Arc::clone(&refresh_source); + let iss = issuer.to_owned(); + Box::pin(async move { src.get_snapshot(&iss).await.is_some() }) + }, + refresh_cancel, + ) + .await; + }); + } + // 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 @@ -1280,6 +1319,104 @@ mod env_filter_tests { } } +/// Warm NIP-FI JWKS snapshots for all configured issuers at startup. +/// +/// Calls `source.get_snapshot(issuer)` once per configured issuer and logs +/// the outcome. On success, the snapshot is cached and the relay is ready to +/// validate federated assertions. On failure, the relay starts and HTTP-protected +/// routes deny with 503 until the background loop delivers a snapshot. +/// +/// Raw `iss` values are never logged (NIP-FI.md:777-779); only the +/// `issuer_index` diagnostic code appears in log output. +/// +/// Extracted from `run_relay_main` for unit-testability. +/// [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +async fn warm_nip_fi_jwks_snapshots( + source: &buzz_auth::ProductionJwksSource, + issuer_ids: &[String], +) { + for (idx, issuer) in issuer_ids.iter().enumerate() { + match source.get_snapshot(issuer).await { + Some(_) => { + // issuer_index is a non-identifying diagnostic code. + // Raw `iss` is excluded from logs per NIP-FI.md:777-779. + info!(issuer_index = idx, "NIP-FI: JWKS snapshot warmed"); + } + None => { + warn!( + issuer_index = idx, + "NIP-FI: JWKS warm failed — HTTP ingress will deny 503 until \ + a snapshot lands; background refresh will retry" + ); + } + } + } +} + +/// Background JWKS refresh loop for NIP-FI issuers. +/// +/// Sleeps until the nearest due issuer, runs the fetch for each overdue issuer, +/// then records the post-fetch instant as the new baseline. Scheduling from +/// the post-fetch instant keeps the interval at least `interval_secs` even under +/// nonzero network latency (pre-fetch scheduling would drift the interval +/// backward by the fetch latency on every cycle). +/// +/// `fetch` returns `true` if the snapshot was successfully refreshed, `false` +/// on fetch failure (the loop continues either way; hard-deadline enforcement +/// lives in the JWKS source itself). +/// +/// Extracted from `run_relay_main` for unit-testability. [FI-TRACE-DEPENDENCY-FAIL-CLOSED] +async fn nip_fi_jwks_refresh_loop( + // `(issuer_id, interval_seconds)` pairs, one per configured issuer. + issuers: Vec<(String, u64)>, + // Async fetch callback: `issuer → true (success) / false (failure)`. + mut fetch: F, + cancel: CancellationToken, +) where + F: FnMut(&str) -> Fut, + Fut: std::future::Future, +{ + let mut intervals: Vec<(String, u64, tokio::time::Instant)> = issuers + .into_iter() + .map(|(issuer, interval)| (issuer, interval, 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) => {} + _ = cancel.cancelled() => break, + } + let now = tokio::time::Instant::now(); + for (idx, (issuer, interval, last)) in intervals.iter_mut().enumerate() { + if now >= *last + std::time::Duration::from_secs(*interval) { + if !fetch(issuer).await { + // issuer_index is a non-identifying diagnostic code. + // Raw `iss` is excluded from logs per NIP-FI.md:777-779. + warn!( + issuer_index = idx, + "NIP-FI: background JWKS refresh returned no snapshot" + ); + } + // Schedule the NEXT refresh from when this fetch completed, + // not from the instant captured before the await. Scheduling + // from the pre-fetch snapshot drifts the interval backward by + // the fetch latency on every cycle; scheduling from post-fetch + // keeps the interval at least `interval_secs` even under + // nonzero network latency. The hard-deadline contract + // (jwks_hard_deadline_seconds) is enforced by the JWKS source + // itself, not by this timer. + *last = tokio::time::Instant::now(); + } + } + } +} + async fn run_community_revalidator( state: Arc, period: std::time::Duration, @@ -2106,7 +2243,7 @@ mod tests { use super::{ buzz_auto_migrate_enabled, connect_audit_pool, dropped_in_memory_keys, idle_timeout_secs, - refresh_legacy_active_gauge_recency, relay_keypair_from_config, + nip_fi_jwks_refresh_loop, refresh_legacy_active_gauge_recency, relay_keypair_from_config, run_periodic_until_cancelled, EmissionScope, InMemoryMetricKey, }; use buzz_db::DbConfig; @@ -2319,4 +2456,1019 @@ mod tests { assert_eq!(idle_timeout_secs(None, 300), 900); assert_eq!(idle_timeout_secs(Some(10), 1_000), 3_000); } + + // ── F4: JWKS refresh-interval anchoring ─────────────────────────────────── + // + // The fix: `*last = tokio::time::Instant::now()` is called AFTER the fetch + // awaits, not before (where `now` was captured pre-fetch). Under nonzero + // fetch latency, scheduling from pre-fetch would drift the interval backward + // on every cycle. + // + // Test matrix: + // A. Nonzero fetch latency: a 10s fetch inside a 60s interval → the next + // refresh is scheduled 60s after the fetch completes (70s from start), + // not 60s after the pre-fetch `now` (which would be ≈60s from start). + // B. Fetch failure still advances `last`, preventing a tight-loop. + // The loop continues; the third cycle fires at the correct deadline. + // C. Hard deadline with early-cache: when interval=60 and hard_deadline=90s, + // the loop fires at T=60; at T=70 (post-fetch) last=70, next due at + // T=130. The `hard_deadline` is enforced by `ProductionJwksSource`, NOT + // by the timer loop — the loop only tracks refresh cadence. + // D. The production adapter's `.is_some()` contract: "a live snapshot + // exists" (not "the last fetch succeeded"). After a failed refresh the + // previously-cached snapshot may still be live; `src.get_snapshot().is_some()` + // returns true in that case. The timer loop treats the boolean as an + // opaque "notify" / "warn" signal, not as a freshness oracle. + // + // Falsifying mutation: change `*last = tokio::time::Instant::now()` to + // `*last = now` (where `now` is the pre-await snapshot). Test A fails + // because the second refresh fires at T≈60s rather than T≈70s. + + /// Nonzero fetch latency: second refresh must be anchored to post-fetch instant. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_anchored_to_post_fetch_instant() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let second_fetch_instant: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + + let count_clone = Arc::clone(&fetch_count); + let instant_clone = Arc::clone(&second_fetch_instant); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + // 10s simulated fetch latency, 60s interval. + let fetch_latency = Duration::from_secs(10); + let interval_secs = 60u64; + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-a".to_string(), interval_secs)], + move |_issuer| { + let n = count_clone.fetch_add(1, Ordering::SeqCst); + let instant_ref = Arc::clone(&instant_clone); + let latency = fetch_latency; + Box::pin(async move { + // Simulate nonzero fetch latency. + tokio::time::sleep(latency).await; + if n == 1 { + // Record when the second fetch completes. + *instant_ref.lock().unwrap() = Some(tokio::time::Instant::now()); + } + true // success + }) + }, + cancel_task, + ) + .await; + }); + + // Time T=0: loop starts with last=now. + tokio::task::yield_now().await; + + // Advance to T=60s: first refresh becomes due. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + + // Advance through the 10s fetch latency to T=70s. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + // At T=70 the first fetch completes; last is now ~70s. + // A second refresh is due 60s later, at T=130. Verify it does NOT fire at T=120. + tokio::time::advance(Duration::from_secs(59)).await; // T=129 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "second refresh must NOT fire before post-fetch last + interval_secs; \ + at T=129 only one fetch should have completed. \ + Falsifying mutation: use pre-fetch `now` for `last` update → second fetch fires at T≈120" + ); + + // Advance to T=131: second refresh is now overdue (post-fetch last + 60 ≤ 131). + tokio::time::advance(Duration::from_secs(2)).await; // T=131 + tokio::task::yield_now().await; + // Sleep through the 10s fetch latency. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh must have fired by T=141 (post-fetch last ~70 + 60 + 10 fetch latency)" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + /// Fetch failure still advances `last`: no tight-loop and the third cycle fires + /// at the correct deadline. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_advances_last_on_failure() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let count_for_fetch = Arc::clone(&fetch_count); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-b".to_string(), 60)], + move |_| { + let c = Arc::clone(&count_for_fetch); + Box::pin(async move { + c.fetch_add(1, Ordering::SeqCst); + false // always fails + }) + }, + cancel_task, + ) + .await; + }); + + tokio::task::yield_now().await; + + // First fire at T=60. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "first refresh at T=60" + ); + + // Second fire at T=120: failure advances `last` so no tight-loop. + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh at T=120; failure must still advance last. \ + Falsifying mutation: omit `*last = Instant::now()` on failure → tight-loop" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + // ── Test C: hard-deadline is enforced by ProductionJwksSource, not the timer ── + // + // The timer loop is cadence-only: it fires at `last + interval_secs`. + // The `hard_deadline` in `ProductionJwksSource` is a separate contract that + // the timer loop does not enforce directly. This test proves the timer loop + // fires at T=60 (interval) and then again at approximately T=130 + // (post-fetch last ~70 + interval 60), with no spurious fires in between. + // + // A "cache-returns-early" fetch is simulated by the fetch returning `true` + // (a live snapshot is available). This is the `.is_some()` contract: the + // production adapter returns `true` when a snapshot exists, which may be a + // cached snapshot even after a transient failure — NOT "fetch succeeded". + // + // Falsifying mutation (timer): advancing the interval check to use + // `last + hard_deadline_secs` instead of `last + interval_secs` would + // cause the first fire to happen at T=90 instead of T=60; the T=60 assert + // would fire. This confirms the timer does not conflate hard_deadline with + // interval. + #[tokio::test(start_paused = true)] + async fn jwks_refresh_interval_is_cadence_only_not_hard_deadline() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let fetch_count = Arc::new(AtomicUsize::new(0)); + let count_clone = Arc::clone(&fetch_count); + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + + // interval=60s, simulating a hard_deadline of 90s at the source level. + // The timer loop receives only (issuer, interval=60) — it knows nothing + // about hard_deadline. The hard_deadline enforcement belongs to + // ProductionJwksSource, not here. + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![("issuer-c".to_string(), 60)], + move |_| { + let c = Arc::clone(&count_clone); + Box::pin(async move { + c.fetch_add(1, Ordering::SeqCst); + // Returns true = "a live snapshot exists" (cache-hit). + // This is the production .is_some() contract. + true + }) + }, + cancel_task, + ) + .await; + }); + + tokio::task::yield_now().await; + + // First fire at T=60 (interval). + tokio::time::advance(Duration::from_secs(60)).await; + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "first refresh fires at T=60 (interval boundary). \ + Falsifying mutation: if the loop used hard_deadline instead of interval → fires at T=90" + ); + + // No spurious fire at T=89 (before the hard-deadline would matter). + tokio::time::advance(Duration::from_secs(29)).await; // T=89 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 1, + "no spurious fire at T=89; next scheduled fire is at T=60+60=120 (cadence only)" + ); + + // Next fire at T=120 (post-fetch last=60 + interval=60). + tokio::time::advance(Duration::from_secs(31)).await; // T=120 + tokio::task::yield_now().await; + assert_eq!( + fetch_count.load(Ordering::SeqCst), + 2, + "second refresh fires at T=120; cadence-only scheduling confirmed" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } +} + +// ── F5: Composition — production timer + ProductionJwksSource ───────────────── +// +// Tests that `nip_fi_jwks_refresh_loop` and `ProductionJwksSource` compose +// correctly across four scenarios that individual tests cannot cover separately: +// +// 1. **Nonzero fetch latency**: a 10s simulated fetch inside a 60s interval; +// the second refresh is anchored to post-fetch instant (T≈130, not T≈120). +// 2. **Not-due cache hit**: at T=59 (one second before the interval) the source +// returns the cached snapshot immediately without fetching; fetch_count stays +// at 1. The timer loop's boolean return (`is_some()`) correctly reflects a +// live snapshot from the cache. +// 3. **Failure does not extend snapshot freshness**: after a failed refresh +// the source's hard_deadline is unchanged (no new snapshot was committed); +// the previous snapshot remains valid until its original deadline. +// 4. **Hard-deadline cleared by source**: the source clears an expired snapshot +// on the next `get_snapshot()` call; the timer loop then fires a second +// fetch and the source commits the fresh snapshot. +// +// ProductionJwksSource uses a controlled clock (`new_with_clock`) so tests +// advance time without wall-clock sleeps. The timer loop uses tokio's paused +// clock for its own `sleep_until`. +// +// Falsifying mutations: +// - Remove clock injection → test times out (wall time, unpaused). +// - Remove `*last = Instant::now()` post-fetch → second refresh fires at T≈120 +// (before T=130 assertion) — test A fails. +// - Return `true` from the failure path → false positive on the failure test. +// +// These tests are in `mod composition_tests` to isolate their `use` declarations. +#[cfg(test)] +mod composition_tests { + use buzz_auth::{ + IssuerJwksConfig, JwksFetchError, JwksSourceContract, ProductionJwksSource, + ScriptedJwksFetcher, + }; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use std::time::Duration; + use tokio_util::sync::CancellationToken; + + use super::nip_fi_jwks_refresh_loop; + use super::warm_nip_fi_jwks_snapshots; + + fn test_jwks(kid: &str) -> String { + format!( + r#"{{"keys":[{{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"{kid}"}}]}}"# + ) + } + + fn make_source_config(issuer: &str, refresh: u64, hard_deadline: u64) -> IssuerJwksConfig { + IssuerJwksConfig { + issuer: issuer.to_owned(), + contract: JwksSourceContract::new( + format!("https://{issuer}/.well-known/jwks.json"), + refresh, + hard_deadline, + ) + .expect("valid test contract"), + } + } + + // ── Composition A: nonzero fetch latency, not-due cache hit ───────────── + // + // Scenario: + // T=0: source has no snapshot. Timer fires at T=60, fetch takes 10s. + // T=59: cache NOT due (age < refresh=60) — source returns None (not yet + // populated) but the timer's boolean treats None as "no snapshot". + // Actually we advance to T=70 to pass the first fetch, then test + // the not-due case at T=129 (post-fetch last ≈70, next due ≈130). + // T=129: cache is NOT due (age = 129-70 = 59 < 60); timer has not re-fired. + // T=131: cache is due; second fetch fires and completes at T=141. + // + // The not-due case proves `ProductionJwksSource.get_snapshot()` returns the + // cached snapshot without fetching when `age < refresh_interval`. After the + // first fetch at T=70, the timer advances `last` to T=70; the next sleep + // waits until T=70+60=130. So at T=129 the timer has not re-fired: + // `callback_start_count` stays at 1 and `fetch_count` stays at 1. + // The test proves fetch_count stays at 1 at T=129 and advances to 2 by T=141. + #[tokio::test(start_paused = true)] + async fn composition_nonzero_latency_and_not_due_cache_hit() { + const ISSUER: &str = "comp-a.issuer.test"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + const FETCH_LATENCY_SECS: u64 = 10; + + // Controlled clock for the source: starts at T0. + let t0_secs = chrono::Utc::now().timestamp(); + let clock_secs = Arc::new(std::sync::atomic::AtomicI64::new(t0_secs)); + let clock2 = Arc::clone(&clock_secs); + let clock3 = Arc::clone(&clock_secs); // for the task closure + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + chrono::DateTime::from_timestamp(clock2.load(Ordering::SeqCst), 0) + .unwrap_or(chrono::DateTime::UNIX_EPOCH) + }); + + // Two responses: first succeeds (populates cache), second succeeds. + let fetcher = ScriptedJwksFetcher::new([Ok(test_jwks("key-a1")), Ok(test_jwks("key-a2"))]); + let fetcher_count = Arc::clone(&fetcher.call_count); + + // Track how many times the timer callback is *entered* (not just how many + // fetches complete). This distinguishes the pre-fetch-last timing mutation: + // restoring `last = now` before the fetch would cause the second callback to + // fire at T≈120 (not T≈130), because pre-fetch `last=T60` yields + // next_due = T60+60=T120; post-fetch `last=T70` yields next_due = T70+60=T130. + // At T=129 the second callback has already entered under the mutation → + // callback_start_count == 2. + let callback_start_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_start_count_task = Arc::clone(&callback_start_count); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![make_source_config(ISSUER, REFRESH, HARD_DEADLINE)], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(ISSUER.to_owned(), REFRESH)], + move |issuer| { + let s = Arc::clone(&source_task); + let issuer = issuer.to_owned(); + let clock_ref = Arc::clone(&clock3); + let start_ctr = Arc::clone(&callback_start_count_task); + Box::pin(async move { + // Record callback entry before any fetch work. + start_ctr.fetch_add(1, Ordering::SeqCst); + // Simulate nonzero fetch latency by advancing tokio time + // and the source clock by FETCH_LATENCY_SECS. + // The source clock advances so `fetched_at` is set correctly. + tokio::time::sleep(Duration::from_secs(FETCH_LATENCY_SECS)).await; + clock_ref.fetch_add(FETCH_LATENCY_SECS as i64, Ordering::SeqCst); + s.get_snapshot(&issuer).await.is_some() + }) + }, + cancel_task, + ) + .await; + }); + + // T=0: loop starts. + tokio::task::yield_now().await; + + // Advance to T=60: first refresh due. + tokio::time::advance(Duration::from_secs(60)).await; + clock_secs.store(t0_secs + 60, Ordering::SeqCst); + tokio::task::yield_now().await; + + // Advance through 10s fetch latency to T=70. + tokio::time::advance(Duration::from_secs(10)).await; + tokio::task::yield_now().await; + + // At T=70: first fetch completed; post-fetch last ≈70; next due ≈130. + // cache has a snapshot with fetched_at ≈70. + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: exactly one fetch at T=70" + ); + + // T=129: NOT due (age = 129-70 = 59 < 60). No second fetch. + tokio::time::advance(Duration::from_secs(59)).await; // T=129 + clock_secs.store(t0_secs + 129, Ordering::SeqCst); + tokio::task::yield_now().await; + + // T=129: assert both timing and cache-hit claims. + assert_eq!( + callback_start_count.load(Ordering::SeqCst), + 1, + "composition A: timer callback must NOT have been entered a second time at T=129. \ + Falsifying mutation: restore pre-fetch `last = now` in the refresh loop → \ + second callback fires at T≈120 (not T≈130: pre-fetch `last=T60` yields \ + next_due = T60+60=T120; post-fetch `last=T70` yields next_due = T70+60=T130; \ + the mutation makes the callback enter at T≈120, before T=129) → \ + callback_start_count == 2 at T=129." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: cache not-due at T=129 — no second fetch. Falsifying mutation: remove the not-due short-circuit from get_snapshot → second fetcher call at T=129 → count == 2." + ); + + // Verify the source serves the cached snapshot without fetching. + // get_snapshot advances the source clock by 0 (no latency here). + let snap = source.get_snapshot(ISSUER).await; + assert!( + snap.is_some(), + "composition A: cached snapshot must be live at T=129 (hard_deadline is T≈160)" + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition A: get_snapshot at T=129 must NOT trigger a new fetch" + ); + + // T=131: second refresh is due (post-fetch last ≈70 + 60 = 130 ≤ 131). + tokio::time::advance(Duration::from_secs(2)).await; // T=131 + clock_secs.store(t0_secs + 131, Ordering::SeqCst); + tokio::task::yield_now().await; + + // Advance through 10s fetch latency. + tokio::time::advance(Duration::from_secs(10)).await; // T=141 + clock_secs.store(t0_secs + 141, Ordering::SeqCst); + tokio::task::yield_now().await; + + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 2, + "composition A: second fetch by T=141 (post-fetch last ≈70 + interval 60 + fetch 10)" + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + } + + // ── Composition B: fetch failure does not extend snapshot freshness ────── + // + // A successful first fetch populates the snapshot (hard_deadline = T0+90). + // Verified claims (Claims 1-2 via direct source; Claim 3 via timer callback; + // Claim 4 via direct source recovery): + // + // 1. After a failed refresh at T=61, the previous snapshot is still served + // (it is before its hard_deadline: T0+61 < T0+90). + // 2. The generation is unchanged (no new snapshot committed on failure). + // 3. At T=122 (past hard_deadline T0+90), the timer callback returns false + // (source returns None — snapshot cleared, continuing failure cannot revive it). + // Confirmed via both the timer callback signal and a direct source call. + // 4. After a successful fetch at T=123, the snapshot is recovered — Some. + // + // Clock design: both layers share a single Tokio paused clock. + // - `nip_fi_jwks_refresh_loop` uses `tokio::time::sleep_until`. + // - `ProductionJwksSource` uses a `now_fn` bridged to the same paused clock + // via `t0_instant` / `t0_utc` offsets — advancing Tokio time drives both. + // + // Response queue (5 total): + // response[0]: ok — warm at T=0 (direct call) + // response[1]: fail — stale at T=61 (direct call, snapshot still live) + // response[2]: fail — timer callback due T=121, observes T=122 (source T=122 > deadline T=90 + // → snapshot cleared → fetch → None → callback returns false) + // response[3]: fail — direct call at T=122 (confirms still None; Claim 3 source) + // response[4]: ok — direct call at T=123 (recovery; Claim 4) + // + // Sequence: + // T=0: warm (response[0]). Timer NOT yet spawned (Claims 1-2 use direct calls). + // T=61: direct call → stale fail (response[1]) → live snapshot → Claims 1+2. + // T=61: spawn timer. Timer `last = T=61`. First callback due at T=61+60=T=121. + // T=122: advance Tokio past the T=121 due time; callback observes T=122. Source: T=122 > deadline=90 + // → snapshot cleared → fetch response[2]=fail → None → callback returns + // false → warn! emitted. callback_count=1. + // Wait for callback_count >= 1, then cancel. + // T=122: direct call (response[3]=fail) → None. Claim 3 confirmed. + // T=123: direct call (response[4]=ok) → Some. Claim 4 confirmed. + // + // Falsifying mutation: "store snapshot on failure with extended deadline" + // sets deadline to T0+61+90=T0+151. At observed T=122: + // - now=T0+122 >= deadline=T0+151 is FALSE → snapshot live, NOT cleared. + // - age_secs = T0+122 - T0+61 = 61 >= 60 → stale → fetch response[2]=fail. + // - Snapshot NOT cleared → fetch fails → but old snapshot kept (live) → Some. + // - Callback returns TRUE (not false) → callback_returned_false stays false + // - assertion fires: callback_returned_false must be true. + // At T=122 direct call: same logic → Some → Claim 3 (is_none()) assertion fires. + #[tokio::test(start_paused = true)] + async fn composition_failure_does_not_extend_snapshot_freshness() { + const ISSUER: &str = "comp-b.issuer.test"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + + // Bridge the source clock to the Tokio paused clock so both layers see + // identical time when tokio::time::advance() is called. + let t0_instant = tokio::time::Instant::now(); + let t0_utc = chrono::Utc::now(); // stable: paused runtime + let t0_instant_b = t0_instant; + let t0_utc_b = t0_utc; + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + let elapsed_secs = tokio::time::Instant::now() + .duration_since(t0_instant_b) + .as_secs() as i64; + t0_utc_b + + chrono::Duration::try_seconds(elapsed_secs) + .unwrap_or(chrono::Duration::zero()) + }); + + // Five responses: [ok, fail, fail, fail, ok] — see sequence above. + let fetcher = ScriptedJwksFetcher::new([ + Ok(test_jwks("key-b1")), + Err(JwksFetchError::NetworkError), + Err(JwksFetchError::NetworkError), + Err(JwksFetchError::NetworkError), + Ok(test_jwks("key-b2")), + ]); + let fetcher_count = Arc::clone(&fetcher.call_count); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![make_source_config(ISSUER, REFRESH, HARD_DEADLINE)], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + // ── T=0: warm the cache ───────────────────────────────────────────── + // Direct call: source sees T=0, no snapshot → fetch response[0]=ok → Some. + let snap_before = source.get_snapshot(ISSUER).await.expect("initial snapshot"); + let generation_before = snap_before.generation(); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 1, + "composition B: one fetch for initial warm (response[0]=ok)" + ); + + // ── T=61: stale direct call (Claims 1+2) ─────────────────────────── + // Age = 61 >= 60 → stale → fetch response[1]=fail. + // Snapshot still live (T=61 < hard_deadline=T=90) → Some returned. + tokio::time::advance(Duration::from_secs(61)).await; + tokio::task::yield_now().await; + + let snap_after_fail = source.get_snapshot(ISSUER).await; + + // Claim 1: failed refresh at T=61 still returns the previous live snapshot. + assert!( + snap_after_fail.is_some(), + "composition B: failed refresh at T=61 MUST still serve the cached snapshot \ + (T=61 < hard_deadline T=90). \ + Falsifying mutation: clear snapshot on failure → None at T=61." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 2, + "composition B: second fetch attempted (response[1]=fail)" + ); + + // Claim 2: generation unchanged (no new snapshot committed on failure). + let generation_after_fail = snap_after_fail.unwrap().generation(); + assert_eq!( + generation_before, generation_after_fail, + "composition B: fetch failure MUST NOT advance the snapshot generation — \ + the cached snapshot is unchanged. \ + Falsifying mutation: commit a new snapshot on failure → generation changes." + ); + + // ── T=61: spawn timer loop ────────────────────────────────────────── + // Spawned at T=61; timer records `last = T=61`. First callback due at T=121. + // + // The timer callback calls source.get_snapshot() and returns its is_some(). + // At T=121 (past hard_deadline T=90) it must return false (snapshot absent). + let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_count_task = Arc::clone(&callback_count); + let callback_returned_false = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let callback_returned_false_task = Arc::clone(&callback_returned_false); + + let cancel = CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(ISSUER.to_owned(), REFRESH)], + move |iss| { + let s = Arc::clone(&source_task); + let iss = iss.to_owned(); + let ctr = Arc::clone(&callback_count_task); + let flag = Arc::clone(&callback_returned_false_task); + Box::pin(async move { + let result = s.get_snapshot(&iss).await.is_some(); + if !result { + flag.store(true, Ordering::SeqCst); + } + ctr.fetch_add(1, Ordering::SeqCst); + result + }) + }, + cancel_task, + ) + .await; + }); + + // Yield once so the spawned task initializes and records `last = T=61`. + tokio::task::yield_now().await; + + // ── Advance to T=122 ──────────────────────────────────────────────── + // Timer is due at T=121 (last=T=61, next=T=61+60=T=121 ≤ T=122), but + // `tokio::time::advance` jumps to T=122 before the task runs, so the + // callback observes T=122 > hard_deadline=T=90 → snapshot cleared → + // fetch response[2]=fail → None. Callback returns false. + // callback_returned_false=true. callback_count=1. + // + // Falsifying mutation: extend deadline to T=61+90=T=151 on failure. + // At T=122: T=122 < T=151 → snapshot NOT cleared; age_secs=122-61=61 >= 60 + // → stale → fetch response[2]=fail → snapshot not cleared (still live) → Some. + // Callback returns true → callback_returned_false stays false → assertion fires. + tokio::time::advance(Duration::from_secs(61)).await; // T=61 → T=122 + // Bounded yield: let the spawned timer task run its callback (observed at T=122). + // At most 10_000 yields; if the callback never fires this diagnostic fails + // rather than hanging forever. A virtual tokio::time::timeout would also + // create a fake timer that never fires while the clock is paused — hence + // the explicit iteration bound. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 1 { + break; + } + if i == 9_999 { + panic!( + "composition B: callback_count never reached 1 after 10_000 yields. \ + The spawned refresh loop task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + + // Claim 3 via timer: callback_returned_false proves the timer observed None. + assert!( + callback_returned_false.load(Ordering::SeqCst), + "composition B: timer callback at T=122 MUST return false (source returns None \ + when snapshot is past hard_deadline=T=90 and fetch keeps failing). \ + Falsifying mutation: extend deadline on failure (T=61+90=T=151) → \ + snapshot still live at T=122 → callback returns true → this assertion fires." + ); + + cancel.cancel(); + task.await.expect("refresh loop task"); + + // ── Claim 3 via direct source call ────────────────────────────────── + // T=122: past hard_deadline=T=90 → snapshot cleared → fetch response[3]=fail → None. + // Confirms the source still returns None after the timer observed expiry. + let snap_at_expiry = source.get_snapshot(ISSUER).await; + assert!( + snap_at_expiry.is_none(), + "composition B: at T=122 (past hard_deadline T=90), get_snapshot MUST return None. \ + Snapshot must be cleared and continuing fetch failure cannot revive it. \ + Falsifying mutation: extend deadline on failure → snapshot live at T=122 → Some." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 4, // warm(1) + stale-fail(2) + timer-expiry(3) + direct-expiry(4) + "composition B: fourth fetch at T=122 (direct expiry verification, response[3]=fail)" + ); + + // ── Claim 4: recovery ──────────────────────────────────────────────── + // Advance to T=123 (still past hard_deadline=T=90, snapshot absent). + // get_snapshot forces a fetch → response[4]=ok → Some. + tokio::time::advance(Duration::from_secs(1)).await; // T=122 → T=123 + tokio::task::yield_now().await; + let snap_after_recovery = source.get_snapshot(ISSUER).await; + assert!( + snap_after_recovery.is_some(), + "composition B: recovery fetch at T=123 MUST return a new snapshot \ + (response[4]=ok). \ + Falsifying mutation: make get_snapshot always return Some (never trigger a fetch) \ + → response[4]=ok never consumed → generation unchanged → assert_ne!(generation) \ + below fires." + ); + assert_eq!( + fetcher_count.load(Ordering::SeqCst), + 5, + "composition B: fifth fetch at T=123 (recovery, response[4]=ok)" + ); + let generation_after_recovery = snap_after_recovery.unwrap().generation(); + // New JWKS content "key-b2" ≠ "key-b1" → generation must have advanced. + assert_ne!( + generation_before, generation_after_recovery, + "composition B: recovery fetch with different JWKS MUST advance the generation. \ + Falsifying mutation: never commit a new snapshot → generation unchanged." + ); + } + + // ── Composition C: privacy — issuer URL must not appear in log output ──── + // + // `ProductionJwksSource` logs `warn!(error = %err, ...)` on fetch failure + // and the timer loop logs `warn!(issuer_index = idx, ...)` on no-snapshot. + // The startup warm writers log `info!(issuer_index = idx, ...)` on success + // and `warn!(issuer_index = idx, ...)` on failure. + // None of these paths must echo the raw issuer URL or JWKS URI. + // + // This test drives four paths: + // 1. `warm_nip_fi_jwks_snapshots()` success → startup `info!` (no URI). + // 2. `warm_nip_fi_jwks_snapshots()` failure → startup `warn!` (no URI). + // 3. `ProductionJwksSource::get_snapshot()` fetch fail → + // library `warn!(error = %err, "nip-fi jwks fetch failed...")` (no URI). + // 4. `nip_fi_jwks_refresh_loop` no-snapshot → + // `warn!(issuer_index = idx, "NIP-FI: background JWKS refresh returned no snapshot")` + // (no URI). Requires source clock past hard_deadline so get_snapshot + // returns None (snapshot cleared) and the callback returns false. + // + // Falsifying mutation (timer path): add `issuer_uri = config.contract.jwks_uri()` + // to any warn! → sentinel appears in captured output → assertion fires. + // + // Clock design: both layers share a single paused Tokio clock. + // - `nip_fi_jwks_refresh_loop` uses `tokio::time::sleep_until` and + // `tokio::time::Instant::now()` — controlled by `start_paused` runtime. + // - `ProductionJwksSource` uses an injected `now_fn` — bridged to the + // same paused Tokio clock via `t0_instant` / `t0_utc` offsets so both + // layers observe the same time when Tokio time is advanced. + // + // Sequence (all times are Tokio-paused clock offsets from T=0): + // T=0: spawn timer loop; yield so it records `last = Instant::now() = T0`. + // First callback due at T=60. + // T=0: warm (paths 1+2) consumes responses[0]=ok, [1]=fail. + // Snapshot for issuer_ok: fetched_at=T0, hard_deadline=T90. + // All subsequent fetcher calls return NetworkError (queue exhausted). + // T=61: advance Tokio → timer due at T=60 runs at T=61 (still live: T61 < T90, + // stale age=61 → fetch → NetworkError → live snapshot returned; post-fetch + // last=T61). + // Path 3 direct call at T=61: same result → fetch-fail warn! ✓. + // T=121 (T=61+60): advance Tokio → timer fires at T=121 (second fire: + // last=T61, next_due=T121). Source clock at T=121: now=T121 > deadline=T90 + // → snapshot cleared → fetch fails → None → callback returns false → + // path-4 warn! ✓. Wait for callback count >= 2 then cancel. + // + // Falsifying mutation (path 4): bridge now_fn to a fixed clock at T=61 → + // at timer-fire T=121, source sees T=61 < T=90 → snapshot live → callback + // returns true → no warn! → path-4 assertion fails. + // + // Uses `#[test]` + manual runtime so `with_default` wraps all async execution. + #[test] + fn composition_log_does_not_leak_issuer_url() { + use std::io::Write; + + // Sentinel must be lowercase: JwksSourceContract::new() canonicalizes + // the URI and lowercases the host component. An uppercase sentinel would + // never match the canonicalized output even when the URI leaks. + const SENTINEL: &str = "sentinel-issuer-url-9f4e2b1a"; + const REFRESH: u64 = 60; + const HARD_DEADLINE: u64 = 90; + + // Two issuers: one warm-success, one warm-fail. + let issuer_ok = format!("https://{SENTINEL}.ok.example.invalid"); + let issuer_fail = format!("https://{SENTINEL}.fail.example.invalid"); + let jwks_uri_ok = format!("https://{SENTINEL}.cdn-ok.example.invalid/jwks.json"); + let jwks_uri_fail = format!("https://{SENTINEL}.cdn-fail.example.invalid/jwks.json"); + + // Capture log output. + let buf = Arc::new(std::sync::Mutex::new(Vec::::new())); + #[derive(Clone)] + struct MakeCapturing(Arc>>); + struct CapturingWriter(Arc>>); + impl Write for CapturingWriter { + fn write(&mut self, data: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(data); + Ok(data.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for MakeCapturing { + type Writer = CapturingWriter; + fn make_writer(&'a self) -> CapturingWriter { + CapturingWriter(Arc::clone(&self.0)) + } + } + let subscriber = tracing_subscriber::fmt() + .with_writer(MakeCapturing(Arc::clone(&buf))) + .with_ansi(false) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_time() + .start_paused(true) + .build() + .expect("runtime"); + + rt.block_on(async { + // Anchor the source clock to Tokio's paused time. + // Both layers (timer loop + source) share this clock: + // advancing Tokio time drives the source clock identically. + let t0_instant = tokio::time::Instant::now(); + let t0_utc = chrono::Utc::now(); // stable: paused runtime + let t0_instant_c = t0_instant; + let t0_utc_c = t0_utc; + let now_fn: Arc chrono::DateTime + Send + Sync> = + Arc::new(move || { + let elapsed_secs = tokio::time::Instant::now() + .duration_since(t0_instant_c) + .as_secs() as i64; + t0_utc_c + + chrono::Duration::try_seconds(elapsed_secs) + .unwrap_or(chrono::Duration::zero()) + }); + + // Queue: [warm-ok, warm-fail]; all subsequent calls → NetworkError. + let fetcher = ScriptedJwksFetcher::new([ + Ok(r#"{"keys":[{"kty":"EC","crv":"P-256","x":"f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU","y":"x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0","use":"sig","alg":"ES256","kid":"k1"}]}"#.to_string()), + Err(JwksFetchError::NetworkError), + ]); + + let source = Arc::new( + ProductionJwksSource::new_with_clock( + vec![ + IssuerJwksConfig { + issuer: issuer_ok.clone(), + contract: JwksSourceContract::new( + jwks_uri_ok.clone(), + REFRESH, + HARD_DEADLINE, + ) + .expect("valid test contract"), + }, + IssuerJwksConfig { + issuer: issuer_fail.clone(), + contract: JwksSourceContract::new( + jwks_uri_fail.clone(), + REFRESH, + HARD_DEADLINE, + ) + .expect("valid test contract"), + }, + ], + fetcher, + Arc::clone(&now_fn), + ) + .expect("valid source"), + ); + + // Count callback completions so we know when path 4 has fired. + // Callback 1 (at T=61): snapshot live → true (no warn!). + // Callback 2 (at T=121): snapshot past deadline → false → path-4 warn!. + let callback_count = + Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let callback_count_task = Arc::clone(&callback_count); + + // Spawn timer loop at T=0. `last = Instant::now() = T0`. + // First callback due at T=60 (runs at T=61); second at T=121 (post-fetch + // last=T61). + let cancel = tokio_util::sync::CancellationToken::new(); + let cancel_task = cancel.clone(); + let source_task = Arc::clone(&source); + let issuer_task = issuer_ok.clone(); + let task = tokio::spawn(async move { + nip_fi_jwks_refresh_loop( + vec![(issuer_task.clone(), REFRESH)], + move |iss| { + let s = Arc::clone(&source_task); + let iss = iss.to_owned(); + let ctr = Arc::clone(&callback_count_task); + Box::pin(async move { + let result = s.get_snapshot(&iss).await.is_some(); + ctr.fetch_add(1, Ordering::SeqCst); + result + }) + }, + cancel_task, + ) + .await; + }); + + // Yield once: spawned task initializes, records `last = T0`. + tokio::task::yield_now().await; + + // Paths 1+2: startup warm writers. + // Consumes responses[0]=ok (issuer_ok) and [1]=fail (issuer_fail). + // Emits: info!(issuer_index=0, "NIP-FI: JWKS snapshot warmed") + // warn!(issuer_index=1, "NIP-FI: JWKS warm failed…") + // After warm: issuer_ok snapshot has fetched_at=T0, hard_deadline=T0+90. + // Queue exhausted; all subsequent fetcher calls → NetworkError. + let issuer_ids = vec![issuer_ok.clone(), issuer_fail.clone()]; + warm_nip_fi_jwks_snapshots(&*source, &issuer_ids).await; + + // Path 3: library fetch-fail warn!. + // Advance to T=61 (past refresh interval=60, before hard_deadline=90). + // During the advance, the timer fires at T=60 but resolves at T=61: + // source clock=T61, age=61 >= 60 → stale → fetch → NetworkError + // → fetch-fail warn! [path 3 precursor] → live snapshot returned + // → callback 1 returns true (no path-4 warn!); last=T61. + // Then path 3 direct call at T=61 also produces fetch-fail warn! ✓. + tokio::time::advance(std::time::Duration::from_secs(61)).await; + // Bounded yield: allow the T=60 timer callback to run. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 1 { + break; + } + if i == 9_999 { + panic!( + "privacy C: callback_count never reached 1 after 10_000 yields at T=61. \ + The spawned privacy timer task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + // Path 3: direct call; produces `warn!(error = %err, "nip-fi jwks fetch failed…")`. + let _ = source.get_snapshot(&issuer_ok).await; + + // Path 4: timer loop no-snapshot warn!. + // Advance from T=61 to T=121 (60 more seconds). + // Timer fires at T=121 (post-fetch last=T61, next_due=T61+60=T121). + // Source clock via now_fn = T=121 >= hard_deadline=T=90: + // → snapshot cleared + // → fetch fails (NetworkError) → None + // → callback 2 returns false + // → `warn!(issuer_index=idx, "NIP-FI: background JWKS refresh + // returned no snapshot")` ✓. + // + // Falsifying mutation: bridge now_fn to a fixed clock at T=61 → + // at T=121 source sees T=61 < T=90 → snapshot live → callback true + // → no warn! → path-4 assertion fires. + tokio::time::advance(std::time::Duration::from_secs(60)).await; + // Bounded yield: wait for callback 2 (count >= 2) to confirm path-4 has run. + for i in 0..10_000usize { + if callback_count.load(Ordering::SeqCst) >= 2 { + break; + } + if i == 9_999 { + panic!( + "privacy C: callback_count never reached 2 after 10_000 yields at T=121. \ + The spawned privacy timer task may have panicked or stalled." + ); + } + tokio::task::yield_now().await; + } + cancel.cancel(); + let _ = task.await; + }); + }); + + let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap_or_default(); + + // Assert startup warm-success was captured (path 1). + assert!( + captured.contains("JWKS snapshot warmed"), + "Expected info! 'NIP-FI: JWKS snapshot warmed' from startup warm success path. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert startup warm-fail was captured (path 2). + assert!( + captured.contains("JWKS warm failed"), + "Expected warn! 'NIP-FI: JWKS warm failed' from startup warm failure path. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert library fetch-fail warn! was captured (path 3). + // Fired by the T=60 timer callback and/or the T=61 direct call — either path + // produces `warn!(error = %err, "nip-fi jwks fetch failed…")`. + assert!( + captured.contains("nip-fi jwks fetch failed"), + "Expected warn! 'nip-fi jwks fetch failed' from ProductionJwksSource. \ + The log capture infrastructure may be broken. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert timer background warn! was captured (path 4). + // Fired by callback 2 at Tokio T=121: source clock T=121 > deadline T=90 + // → get_snapshot returns None → callback returns false → warn! emitted. + // + // Falsifying mutation: bridge now_fn to a fixed T=61 clock → + // source at T=121 still sees T=61 < T=90 → snapshot live → callback true + // → no warn! → this assertion fails. + assert!( + captured.contains("background JWKS refresh returned no snapshot"), + "Expected timer warn! 'NIP-FI: background JWKS refresh returned no snapshot'. \ + Fired when callback 2 (Tokio T=121) finds source clock T=121 > hard_deadline T=90. \ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + // Assert no sentinel in any log output. + assert!( + !captured.contains(SENTINEL), + "NIP-FI logs MUST NOT contain the raw issuer URL or JWKS URI. \ + Sentinel '{SENTINEL}' found in captured output. \ + Falsifying mutation: add issuer_uri to any warn! call → sentinel appears.\n\ + Captured (first 500 chars):\n{}", + &captured[..captured.len().min(500)] + ); + } } 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..f60572f7adb --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -0,0 +1,619 @@ +//! 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| { + // Do not embed raw `e` — serde_json type-error messages can + // include the unexpected field value verbatim (issuer URLs, etc). + // Use classify() and positional info only. [NIP-FI.md:777-779] + ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS is not valid JSON: {:?} at line {} column {}", + e.classify(), + e.line(), + e.column(), + )) + })?; + + 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 (issuer_idx, entry) in issuer_entries.iter().enumerate() { + let (policy, jwks_config) = build_issuer(entry).map_err(|e| { + ConfigError::InvalidValue(format!( + // issuer_idx is a non-identifying diagnostic code. + // Raw `iss` is excluded per NIP-FI.md:777-779. + "BUZZ_NIP_FI_ISSUERS: issuer at index {issuer_idx}: {e}" + )) + })?; + 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 (got {} chars); \ + supported: ES256 ES384 RS256 RS384 RS512 PS256 PS384 PS512 EdDSA", + other.len() + )), + } +} + +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}" + ); + } + + /// A complete, valid Enforce issuer entry. Tests derive negative fixtures + /// from it by removing exactly one field. + fn valid_enforce_issuer() -> serde_json::Value { + serde_json::json!({ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }) + } + + #[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_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + // Success control: the complete fixture is accepted. + let valid = valid_enforce_issuer(); + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + serde_json::json!([valid]).to_string(), + ); + NipFiRelayConfig::from_env().expect("complete Enforce issuer config must be accepted"); + + // Same fixture minus only the per-issuer age bound. + let mut missing_age = valid_enforce_issuer(); + missing_age + .as_object_mut() + .unwrap() + .remove("maximum_assertion_age_seconds"); + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + serde_json::json!([missing_age]).to_string(), + ); + let err = NipFiRelayConfig::from_env() + .expect_err("Enforce issuer without maximum_assertion_age_seconds must fail closed"); + // The parser deliberately reports only the serde error class (never + // the message) so config values cannot leak; with the control above + // passing, the one-field difference is what produced this rejection. + // It must be the deserialization rejection, not a later policy-build + // failure (which a defaulted age would hit instead). + let msg = err.to_string(); + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS is not valid JSON: Data"), + "missing maximum_assertion_age_seconds must be rejected at deserialization: {msg}" + ); + } + + #[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")); + } + + // ── R5 privacy sentinel tests ───────────────────────────────────────────── + // + // These tests prove that parse errors on BUZZ_NIP_FI_ISSUERS do NOT echo + // raw issuer config values (URLs, audience strings, issuer identifiers) in + // the error messages. [NIP-FI.md:777-779] + // + // The test input embeds a unique sentinel string that should never appear in + // any error message. Failing this invariant would mean serde_json or another + // parser is leaking operator-supplied field values into error text. + // + // Falsifying mutation for all tests: remove the `.classify()` / `other.len()` + // wrapping in `from_env()` / `parse_algorithm()` and restore a raw `{e}` or + // `{s}` interpolation. The sentinel strings would appear in the error + // message and the assertion fires. + + /// Malformed issuer JSON: wrong-typed field must not leak the sentinel value. + /// + /// We use a valid JSON array with `skew_seconds` as a string (where the + /// deserializer expects a number). Raw serde would echo the actual string + /// value in a type-error message like `expected u64, got string "SENTINEL..."`. + /// The test asserts the sentinel does NOT appear — proving the code strips or + /// classifies the error rather than forwarding serde's message. + /// + /// This is stronger than using outright-malformed JSON, which serde never + /// echoes in the first place. A non-discriminating malformed-JSON sentinel + /// passes even if the code leaks values from valid-but-wrong-typed fields. + #[test] + fn malformed_issuer_json_error_does_not_leak_raw_value() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // A sentinel that serde would echo in a type-mismatch error if not suppressed. + const SENTINEL: &str = "SENTINEL_SKEW_VALUE_abc123xyz"; + // Valid array with `skew_seconds` as a string — deserializer expects u64. + // Raw serde error would be something like: + // "invalid type: string \"SENTINEL_SKEW_VALUE_abc123xyz\", expected u64" + let issuers_json = serde_json::json!([{ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": SENTINEL, // wrong type: serde echoes this value + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env().expect_err("wrong-typed field must fail"); + let msg = err.to_string(); + + assert!( + !msg.contains(SENTINEL), + "parse error MUST NOT echo the raw field value (privacy sentinel leaked): {msg}" + ); + // The error must still be non-empty and identify the config variable. + assert!( + msg.contains("BUZZ_NIP_FI_ISSUERS"), + "error must name the config variable: {msg}" + ); + } + + /// Invalid algorithm string error must not echo the raw value. + #[test] + fn invalid_algorithm_error_does_not_leak_raw_value() { + // parse_algorithm is private; we test it indirectly by passing a full + // issuer config with a sentinel algorithm name. + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + const SENTINEL_ALG: &str = "SENTINEL_ALGORITHM_HS256_SECRET"; + let issuers_json = serde_json::json!([{ + "issuer": "https://issuer.test", + "audiences": ["https://relay.test"], + "token_class": "nip-fi+jwt", + "algorithms": [SENTINEL_ALG], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://issuer.test/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env().expect_err("unknown algorithm must fail"); + let msg = err.to_string(); + + assert!( + !msg.contains(SENTINEL_ALG), + "algorithm error MUST NOT echo the raw algorithm value: {msg}" + ); + // The error must indicate what went wrong (non-empty, contains hint). + assert!(!msg.is_empty(), "error must be non-empty"); + } + + /// Policy-build rejection error must not leak the issuer URL. + #[test] + fn policy_build_rejection_error_does_not_leak_issuer_url() { + let _guard = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + const SENTINEL_ISSUER: &str = "https://sentinel-issuer-secret.example"; + // An issuer config with an empty audiences list → IssuerPolicy::new fails. + let issuers_json = serde_json::json!([{ + "issuer": SENTINEL_ISSUER, + "audiences": [], // empty → IssuerPolicy::new must fail + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "skew_seconds": 30, + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://sentinel-issuer-secret.example/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 3600 + }]) + .to_string(); + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_ISSUERS", &issuers_json); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + + let err = NipFiRelayConfig::from_env() + .expect_err("empty audiences must cause a policy build failure"); + let msg = err.to_string(); + + // The error must not leak the sentinel issuer URL. + assert!( + !msg.contains(SENTINEL_ISSUER), + "policy-build error MUST NOT echo the raw issuer URL: {msg}" + ); + // The error must be non-empty and mention the issuer index. + assert!( + msg.contains("index"), + "error must reference the issuer by index, not URL: {msg}" + ); + } +} 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..cd74a2d28c2 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -0,0 +1,1280 @@ +//! 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`]. This does not force a handler to call +//! it. In Enforce, a handler that skips the call and does its own NIP-98 is +//! still subject to the router's assertion guard, but a request with a valid +//! assertion passes without key pairing or a deny-map check. (Off skips the +//! guard entirely; DenyProtected denies without verifying.) +//! +//! ## 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 mode-appropriate NIP-FI admission path completed for one +/// HTTP request. +/// +/// Construction is private to [`admit_nip_fi_http`]. **No other code path +/// produces this type.** A value means the path for the configured mode ran: +/// +/// Off: NIP-98 extraction → admit (`assertion: None`, no pairing) +/// Enforce: NIP-98 extraction → assertion extraction → verify → pair → +/// deny-map → admit +/// +/// DenyProtected never produces one. Key pairing is guaranteed only in +/// Enforce. +/// +/// `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, in Enforce, 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 NIP-98 (and, in Enforce, by 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); in Enforce it also equals 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. DenyProtected mode: unconditional 503, before the `Authorization` +/// cardinality check, the NIP-98 closure, or the verifier run. +/// 2. Enforce mode: reject more than one `Authorization` field (403). +/// 3. Run `extract_nip98` — the caller's NIP-98 extraction closure. Returns +/// `(proven_pubkey, X)` on success, or a `Response` to emit on failure. +/// Off mode returns `Ok(NipFiAdmission { proven_pubkey, assertion: None, +/// extra: X })` here; Off-mode behavior is identical to pre-NIP-FI (no +/// assertion requirement). [FI-INV-15] +/// 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 })`. +/// +/// ## NIP-98 failure remapping in Enforce mode +/// +/// When the NIP-98 closure fails in Enforce mode, the closure typically returns +/// a legacy JSON 401/403 (`api_error`). NIP-FI.md §Admission procedure step 3 +/// requires NIP-FI DenialClass responses instead: +/// - Absent `Authorization` header → `MissingEvidence` (401 "authentication required") +/// - Present but malformed/invalid `Authorization` → `EvidenceRejected` (403) +/// +/// In Off mode the legacy response is returned unchanged ([FI-INV-15]). +/// [FI-TRACE-DENIAL-ORACLE] +/// +/// ## What the private constructor guarantees +/// +/// [`NipFiAdmission`] has a private constructor, so the only source of a +/// `NipFiAdmission` value is this function. It does not force a handler to +/// call this function. In Enforce, a handler that skips it and runs its own +/// NIP-98 is still subject to the router's assertion guard, but a request with +/// a valid assertion passes without key pairing or a deny-map check. Off skips +/// the guard entirely; DenyProtected denies without verifying. +/// +/// ## Off-mode semantics +/// +/// In Off mode the NIP-98 closure is always called (step 3). 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 — DenyProtected mode: unconditional 503. Checked first so no + // request shape (duplicate, missing, or invalid `Authorization`) can turn + // it into a 401/403, and so neither NIP-98 nor the verifier runs. + if matches!(mode, NipFiMode::DenyProtected) { + return Err(http_denial(DenialClass::AuthorizationUnavailable)); + } + + // Step 2 — cardinality gate: Enforce mode requires exactly one Authorization + // field per NIP-FI.md:695-700. Off mode preserves legacy first-value behavior + // (`.get()` silently takes the first) so no regression for Off deployments. + // + // Axum / hyper de-duplicates most header fields during HTTP/1.1 parsing, but + // RFC 7230 permits comma-separated combining or multiple header lines; + // `HeaderMap::get` silently takes only the FIRST value. Rejecting duplicates + // closes the attack where a relay-aware adversary slips a second credential + // past the NIP-98 verifier. [FI-INV-15] + if !matches!(mode, NipFiMode::Off) { + let auth_count = headers.get_all("authorization").iter().count(); + if auth_count > 1 { + return Err(http_denial(DenialClass::EvidenceRejected)); + } + } + + // Step 3: run NIP-98 extraction (Off and Enforce). + let nip98_result = extract_nip98(); + + // 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) { + // Off mode: propagate the closure result unchanged (legacy behavior). + let Nip98Proof { + pubkey: proven_pubkey, + extra, + } = nip98_result?; + return Ok(NipFiAdmission { + proven_pubkey, + assertion: None, + extra, + }); + } + + // Enforce mode: NIP-98 closure failure MUST + // produce a NIP-FI DenialClass response, not a legacy JSON error. + // [NIP-FI.md §Admission procedure step 3; FI-TRACE-DENIAL-ORACLE] + let Nip98Proof { + pubkey: proven_pubkey, + extra, + } = nip98_result.map_err(|_legacy| { + // Determine the appropriate denial class from Authorization header presence. + // Absent header → MissingEvidence (401); present-but-invalid → EvidenceRejected (403). + // [FI-TRACE-DENIAL-ORACLE] + let class = if headers.contains_key("authorization") { + DenialClass::EvidenceRejected + } else { + DenialClass::MissingEvidence + }; + http_denial(class) + })?; + + // 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. It +/// delegates to [`admit_nip_fi_http`], which alone constructs 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 are byte-identical at the admission boundary: + // a key-pairing mismatch and a deny-map hit must produce the same status, + // headers and body, so a client cannot tell which private state denied it. + // A matching-key request with a non-denying map is admitted, proving the + // deny-map fixture is what produced the second denial. + // [FI-TRACE-DENIAL-ORACLE] + // + // Mutation evidence: changing the deny-map branch to any other denial + // class makes the status/body equality assertion fail. + #[test] + fn authorization_denied_rows_are_byte_identical() { + use buzz_auth::VerifiedAssertion; + + struct FixedKeyVerifier(PublicKey); + impl VerifyAssertion for FixedKeyVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + Ok(VerifiedAssertion::new_for_test(self.0)) + } + } + struct FixedDenyMap(bool); + impl sealed::Sealed for FixedDenyMap {} + impl HttpDenyMap for FixedDenyMap { + fn is_denied(&self, _: &str, _: &PublicKey, _: DateTime) -> bool { + self.0 + } + } + + let pubkey_a = any_pubkey(); + let pubkey_b = any_pubkey(); + let verifier = FixedKeyVerifier(pubkey_a); + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer any.valid.looking.token"), + ); + let admit = |proven: PublicKey, deny_map: &FixedDenyMap| { + admit_nip_fi_http( + &headers, + || Ok(Nip98Proof::new(proven, ())), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Enforce, + deny_map, + ) + }; + let snapshot = |resp: Response| { + let status = resp.status(); + let headers = resp.headers().clone(); + (status, headers, body_bytes(resp)) + }; + + let Err(mismatch) = admit(pubkey_b, &FixedDenyMap(false)) else { + panic!("key mismatch must be denied"); + }; + let Err(denied) = admit(pubkey_a, &FixedDenyMap(true)) else { + panic!("deny-map hit must be denied"); + }; + let mismatch = snapshot(mismatch); + assert_eq!(mismatch.0, StatusCode::FORBIDDEN); + assert_eq!(mismatch.2, b"authorization denied\n"); + assert_eq!( + mismatch, + snapshot(denied), + "key-mismatch and deny-map denials must be byte-identical (status, headers, body)" + ); + + assert!( + admit(pubkey_a, &FixedDenyMap(false)).is_ok(), + "matching keys with a non-denying map must be admitted" + ); + } + + // ── 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); + } + + // ── F3: NIP-98 failure remapping in Enforce mode ───────────────────────── + // + // In Enforce mode, NIP-98 closure failure MUST produce NIP-FI DenialClass + // responses (not legacy JSON). The class depends on whether the + // Authorization header was present: + // - Absent header → MissingEvidence (401) + // - Present-but-invalid → EvidenceRejected (403) + // + // In Off mode the legacy response is propagated unchanged. + // + // Mutation evidence (absent-header path): replacing MissingEvidence with + // EvidenceRejected makes the `assert_eq!(status, 401)` assertion panic. + // Mutation evidence (present-header path): replacing EvidenceRejected with + // MissingEvidence makes the `assert_eq!(status, 403)` assertion panic. + + #[test] + fn enforce_nip98_failure_absent_auth_yields_missing_evidence() { + // Authorization header absent → NIP-98 closure fails → MissingEvidence (401). + let headers = HeaderMap::new(); // no Authorization header + let legacy_resp = http_denial(DenialClass::EvidenceRejected); // would be 403 if propagated + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "absent-header NIP-98 failure MUST yield 401 MissingEvidence in Enforce mode" + ); + assert_eq!(body_bytes(resp), b"authentication required\n"); + } + + #[test] + fn enforce_nip98_failure_present_auth_yields_evidence_rejected() { + // Authorization header present (but NIP-98 fails) → EvidenceRejected (403). + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr invalid_base64!!!"), + ); + let legacy_resp = http_denial(DenialClass::MissingEvidence); // would be 401 if propagated + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "present-but-invalid Authorization MUST yield 403 EvidenceRejected in Enforce mode" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + } + + #[test] + fn off_mode_nip98_failure_propagates_legacy_response() { + // Off mode: legacy response is returned unchanged ([FI-INV-15]). + // If this test breaks, Off mode is remapping errors it should leave alone. + let headers = HeaderMap::new(); // no Authorization header + let legacy_status = StatusCode::UNAUTHORIZED; + let legacy_resp = http_denial(DenialClass::MissingEvidence); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + legacy_status, + "Off mode MUST propagate legacy NIP-98 failure response unchanged" + ); + } + + // ── R6(a) regression: Off-mode preserves exact legacy JSON bytes / content-type ── + // + // Thufir R6 / Carl F3: the Off test in the existing suite checks only status, + // so it cannot establish that Off mode preserves the JSON body bytes and + // content-type header that the pre-NIP-FI paths produce. This test uses a + // synthetic "legacy JSON 401" response (matching what `api_error` in bridge.rs + // produces) and verifies the exact body bytes and content-type survive Off mode. + // + // Mutation evidence: if Off mode remapped the error to `http_denial()` format + // (`text/plain; charset=utf-8`), the content-type assertion fires. If it + // remapped the body to NIP-FI denial bytes, the body assertion fires. + #[test] + fn off_mode_preserves_exact_legacy_json_body_and_content_type() { + use axum::http::header::CONTENT_TYPE; + // Build a synthetic legacy JSON error response, as `api_error` does. + let legacy_body = b"{\"error\":\"NIP-98: missing Authorization\"}"; + let legacy_resp = axum::http::Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(legacy_body.as_ref())) + .unwrap(); + let headers = HeaderMap::new(); + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || Err(legacy_resp), + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + resp.headers() + .get(CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/json"), + "Off mode MUST preserve the legacy application/json content-type" + ); + assert_eq!( + body_bytes(resp), + legacy_body, + "Off mode MUST preserve exact legacy JSON error body bytes" + ); + } + + // ── admit_nip_fi_http — deny_protected ─────────────────────────────────── + + // DenyProtected → Err(503 authorization unavailable) for every request + // shape, without running the NIP-98 closure or the verifier. Every case + // carries a syntactically valid assertion bearer so the verifier would be + // reachable if the mode check moved below token extraction. + // + // Mutation evidence: moving the DenyProtected check below the cardinality + // gate makes the duplicate case return 403 (status assertion fails). + // Moving it below the closure makes the closure counter non-zero; moving + // it below the closure *and* the error remap also turns the failed-closure + // cases into 401/403. Moving it below verification makes the verifier + // counter non-zero on the successful-closure case. + #[test] + fn deny_protected_returns_503_before_nip98_or_verifier() { + use std::cell::Cell; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct CountingVerifier(AtomicUsize); + impl VerifyAssertion for CountingVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(VerifiedAssertion::new_for_test(any_pubkey())) + } + } + + let auth = |headers: &mut HeaderMap, value: &'static str| { + headers.append("authorization", HeaderValue::from_static(value)); + }; + let with_bearer = || { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + HeaderValue::from_static("Bearer header.payload.signature"), + ); + headers + }; + let mut duplicate = with_bearer(); + auth(&mut duplicate, "Nostr first"); + auth(&mut duplicate, "Nostr second"); + let missing = with_bearer(); + let mut present = with_bearer(); + auth(&mut present, "Nostr invalid"); + + // (case, headers, closure succeeds) + let cases: [(&str, &HeaderMap, bool); 4] = [ + ("duplicate Authorization", &duplicate, true), + ("missing Authorization", &missing, false), + ("failed NIP-98 closure", &present, false), + ("successful NIP-98 closure", &present, true), + ]; + for (case, headers, closure_ok) in cases { + let closure_calls = Cell::new(0u32); + let verifier = CountingVerifier(AtomicUsize::new(0)); + let outcome = admit_nip_fi_http::<_, (), _>( + headers, + || { + closure_calls.set(closure_calls.get() + 1); + if closure_ok { + Ok(Nip98Proof::new(any_pubkey(), ())) + } else { + Err(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(Body::from("legacy")) + .unwrap()) + } + }, + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::DenyProtected, + &AlwaysAdmitStubDenyMap, + ); + let Err(resp) = outcome else { + panic!("{case}: DenyProtected must deny with 503"); + }; + assert_eq!( + resp.status(), + StatusCode::SERVICE_UNAVAILABLE, + "{case}: DenyProtected status" + ); + assert_eq!( + body_bytes(resp), + b"authorization unavailable\n", + "{case}: DenyProtected body" + ); + assert_eq!( + closure_calls.get(), + 0, + "{case}: NIP-98 closure must not run" + ); + assert_eq!( + verifier.0.load(Ordering::SeqCst), + 0, + "{case}: verifier must not run" + ); + } + } + + // ── 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" + ); + } + + // ── R3 regression: Authorization cardinality ───────────────────────────── + // + // Thufir R3 / Carl F2: duplicate Authorization headers must be rejected in + // Enforce mode, and must be ACCEPTED in Off mode (FI-INV-15: + // Off behavior must match pre-NIP-FI base, which used `.get()` first-value). + // + // The cardinality gate is now in `admit_nip_fi_http`, not in + // `verify_bridge_auth_with_options`, ensuring Off-mode callers are never + // affected regardless of their `require_auth_token` flag. + // + // Mutation evidence (enforce branch): removing the cardinality gate makes + // a duplicate-header request proceed to NIP-98 extraction, which either + // succeeds (if both tokens are valid — impossible in these tests with a + // None verifier) or fails with a different status code. The test would + // still 403 in Enforce (extraction failure) but for the wrong reason; in + // DenyProtected it would 503; in Off it would either 401 (missing NIP-98) + // or pass. The combination uniquely identifies the gate. + // + // Mutation evidence (Off branch): if Off-mode also checked cardinality, the + // Off duplicate test would receive 403 instead of the legacy NIP-98 closure + // result (401 from the always-failing closure below). The assert fires. + + #[test] + fn enforce_duplicate_authorization_header_denied_403() { + // Enforce mode + two Authorization fields → 403 EvidenceRejected before + // NIP-98 extraction runs. + // + // Mutation: removing the `auth_count > 1` gate means the closure runs, + // extraction fails (invalid token), and admission maps the failure to + // 403 EvidenceRejected (header is present). Status is the same (403) + // but the body is different — the gate produces the standard + // `evidence rejected\n` bytes; NIP-98 failure in Enforce mode also + // produces `evidence rejected\n`. To distinguish, we verify the body + // comes from cardinality (gate fires before closure) rather than from + // the NIP-98 path: the closure must NEVER be called. + use std::sync::atomic::{AtomicBool, Ordering}; + let closure_ran = std::sync::Arc::new(AtomicBool::new(false)); + let closure_ran_clone = closure_ran.clone(); + let pubkey = any_pubkey(); + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr first.token"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr second.token"), + ); + + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || { + closure_ran_clone.store(true, Ordering::SeqCst); + Ok(Nip98Proof::new(pubkey, ())) + }, + None::<&dyn VerifyAssertion>, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let resp = outcome.unwrap_err(); + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "duplicate Authorization in Enforce MUST yield 403 EvidenceRejected" + ); + assert_eq!(body_bytes(resp), b"evidence rejected\n"); + assert!( + !closure_ran.load(Ordering::SeqCst), + "NIP-98 closure must NOT run when cardinality gate fires" + ); + } + + #[test] + fn off_mode_duplicate_authorization_header_passes_to_closure() { + // Off mode + two Authorization fields → closure runs, legacy behavior. + // + // FI-INV-15: Off mode must preserve pre-NIP-FI base behavior exactly. + // The base parser used `.get()` which silently accepted the first + // value from a multi-value header map. Off mode must NOT reject on + // cardinality — that would be a behavioral regression. + // + // Mutation evidence: adding a cardinality check in Off mode makes the + // closure never run and returns 403. The `closure_ran` assert fires. + use std::sync::atomic::{AtomicBool, Ordering}; + let closure_ran = std::sync::Arc::new(AtomicBool::new(false)); + let closure_ran_clone = closure_ran.clone(); + let pubkey = any_pubkey(); + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr first.token"), + ); + headers.append( + axum::http::header::AUTHORIZATION, + HeaderValue::from_static("Nostr second.token"), + ); + + // Closure succeeds → Off mode should admit. + let outcome = admit_nip_fi_http::<_, (), _>( + &headers, + || { + closure_ran_clone.store(true, Ordering::SeqCst); + Ok(Nip98Proof::new(pubkey, ())) + }, + None::<&dyn VerifyAssertion>, + NipFiMode::Off, + &AlwaysAdmitStubDenyMap, + ); + let admission = match outcome { + Ok(a) => a, + Err(resp) => panic!( + "Off mode MUST admit when closure succeeds, even with duplicate auth header; \ + got {} response", + resp.status() + ), + }; + assert!( + closure_ran.load(Ordering::SeqCst), + "NIP-98 closure MUST run in Off mode; cardinality gate must not fire" + ); + assert_eq!( + *admission.proven_pubkey(), + pubkey, + "proven_pubkey must be the one returned by the closure" + ); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 61aedf70be0..50af16d644e 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,9 +24,238 @@ 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 handler-level admission authority is `admit_nip_fi_http_on_state` in +// `nip_fi_http.rs`. Protected handlers call it with a NIP-98 extraction +// closure and it delegates to `admit_nip_fi_http`, which runs NIP-98 +// extraction → assertion verify → pairing → deny-map (Enforce) and alone +// constructs a `NipFiAdmission`. The private constructor does not force a +// handler to make the call. +// +// What is guaranteed: in Enforce, this guard rejects a missing or invalid +// assertion on every non-exempt route; in DenyProtected it returns 503 +// without verifying; in Off it is transparent. Key pairing +// (`asserted_key == proven_pubkey`) and the deny map run only in handlers +// that call `admit_nip_fi_http_on_state`. In Enforce, a handler that omits +// the call and does its own NIP-98 is still subject to the assertion guard, +// but a request with a valid assertion passes without pairing or a deny check. +// +// ## 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; + } + + // Admin SPA document routes are exempt when the request is on the admin + // host. The admin SPA serves its own documents at bare paths (`/reports`, + // `/reports/`, `/feedback`) — the browser navigates there directly. + // These paths carry no NIP-FI-protected tenant data; the actual data calls + // go to `/api/admin/v1/...` (already exempt via the `/api/admin/` prefix). + // + // The exemption is host-qualified: `/reports` on a tenant host is NOT + // exempt and stays protected. Off mode is already handled above. + // [FI-TRACE-AUTHORITY-UNIFORM] + if is_admin_spa_path(path) && api::admin::is_admin_host(&state, request.headers()) { + 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 +431,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)) @@ -1157,6 +1390,19 @@ mod tests { std::fs::write(dir.join("favicon.svg"), "").expect("favicon"); } + /// Write a distinct admin bundle with a unique sentinel so tests can + /// assert the exact expected bytes and distinguish admin from public HTML. + fn write_admin_bundle(dir: &std::path::Path) { + std::fs::create_dir_all(dir.join("assets")).expect("assets dir"); + std::fs::write( + dir.join("index.html"), + "", + ) + .expect("admin index.html"); + std::fs::write(dir.join("assets/app.js"), "export {};").expect("bundle asset"); + std::fs::write(dir.join("favicon.svg"), "").expect("favicon"); + } + async fn spa_response( state: Arc, host: &str, @@ -1376,4 +1622,656 @@ 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 that omits + // its gate cannot bypass the missing/invalid-assertion rejection. + // Key pairing and the deny map are NOT covered by this guard: they run + // only in handlers that call `admit_nip_fi_http_on_state`. + // + // ## 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" + ); + } + + // ── F6: admin SPA document paths are NOT broadly exempt ───────────────── + // + // Admin SPA document routes (`/reports`, `/reports/`, `/feedback`) are + // served by the SPA fallback on the admin host. They are NOT in + // `NIP_FI_EXEMPT_PREFIXES` — the broad exempt list would make `/reports` + // exempt on tenant hosts too, which is unintentional. Instead, the guard + // exempts them conditionally via a host-qualified `is_admin_spa_path` + + // `is_admin_host` check (see `nip_fi_assertion_guard`). + // + // This test proves two things: + // 1. `is_admin_spa_path` recognises the admin document routes. + // 2. These paths are NOT broadly exempt (no entry in NIP_FI_EXEMPT_PREFIXES) + // so tenant hosts remain protected. + // + // Mutation evidence: adding "/reports" to NIP_FI_EXEMPT_PREFIXES makes + // `is_exempt("/reports")` return true and the assertion below panics. + #[test] + fn admin_spa_paths_are_not_broadly_exempt_but_are_admin_spa_paths() { + // /reports and /feedback ARE admin SPA document paths. + assert!( + is_admin_spa_path("/reports"), + "/reports must be an admin SPA path (for host-qualified exemption)" + ); + assert!( + is_admin_spa_path("/reports/abc-123"), + "/reports/ must be an admin SPA path" + ); + assert!( + is_admin_spa_path("/feedback"), + "/feedback must be an admin SPA path" + ); + assert!( + is_admin_spa_path("/feedback/abc"), + "/feedback/ must be an admin SPA path" + ); + + // But they are NOT in NIP_FI_EXEMPT_PREFIXES (not broadly exempt). + // The guard exempts them only when the request is on the admin host. + assert!( + !is_exempt("/reports"), + "/reports must NOT be broadly exempt; exemption is host-qualified in the guard" + ); + assert!( + !is_exempt("/reports/abc-123"), + "/reports/ must NOT be broadly exempt" + ); + assert!( + !is_exempt("/feedback"), + "/feedback must NOT be broadly exempt" + ); + assert!( + !is_exempt("/feedback/abc"), + "/feedback/ must NOT be broadly exempt" + ); + } + + // ── F6: build_router guard navigation — host-qualified admin exemption ─── + // + // Proves that the `is_admin_spa_path(path) && is_admin_host(...)` check in + // `nip_fi_assertion_guard` (router.rs:215-217) does exactly what it says: + // + // • `/reports` on the admin host → HTML (guard exempts it, SPA fallback serves it) + // • `/reports` on a tenant host → 503 (guard NOT exempted; DenyProtected denies it) + // + // Falsifying mutation: remove the `is_admin_spa_path(path) && ...` branch at + // router.rs:215-217. The admin-host request then reaches the DenyProtected + // branch and returns 503 — the assertion below panics instead of returning + // HTML. The path-classification tests (`admin_spa_paths_are_not_broadly_exempt_*`) + // would still pass because they only test the helper functions, not the guard. + // + // DenyProtected is used here because it denies unconditionally without + // needing a verifier, making the test self-contained and infrastructure-free. + #[tokio::test] + async fn build_router_admin_spa_path_exempt_on_admin_host_denied_on_tenant_host() { + use buzz_auth::NipFiMode; + + let admin_dir = tempfile::tempdir().expect("admin bundle dir"); + let web_dir = tempfile::tempdir().expect("public bundle dir"); + write_bundle(admin_dir.path()); + write_bundle(web_dir.path()); + + // Build a DenyProtected-mode state using the same SPA helper, but with + // the NIP-FI mode overridden after config construction. + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.web_dir = Some(web_dir.path().to_path_buf()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.example".to_string(), + auth: crate::config::AdminAuth::Disabled, + web_dir: Some(admin_dir.path().to_path_buf()), + }); + config.nip_fi.mode = NipFiMode::DenyProtected; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + 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)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + 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).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let state = Arc::new(state); + + // Admin host: /reports must be exempted (guard lets it through → SPA serves HTML). + let admin_response = spa_response(state.clone(), "admin.example", "/reports").await; + assert_ne!( + admin_response.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "/reports on the admin host must NOT be denied 503 in DenyProtected mode; \ + the host-qualified admin SPA exemption in nip_fi_assertion_guard must fire. \ + Falsifying mutation: remove `is_admin_spa_path(path) && is_admin_host(...)` at router.rs:215-217" + ); + // The SPA fallback serves the index document. + assert_eq!( + admin_response.status(), + axum::http::StatusCode::OK, + "/reports on the admin host must be served as an SPA document" + ); + + // Tenant host: /reports is NOT exempt (guard denies it with 503 DenyProtected). + let tenant_response = spa_response(state.clone(), "tenant.example", "/reports").await; + assert_eq!( + tenant_response.status(), + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "/reports on a tenant host must be denied 503 in DenyProtected mode; \ + the guard exemption must NOT fire for non-admin hosts. \ + Falsifying mutation: remove the is_admin_host check — tenant host would \ + then match is_admin_spa_path and bypass the guard" + ); + } + + // ── F6 (extended): complete admin SPA matrix ───────────────────────────── + // + // Proves that the `is_admin_spa_path(path) && is_admin_host(...)` exemption + // covers all admin document routes (`/reports`, `/reports/`, `/feedback`) + // in both Enforce and DenyProtected modes, and that none of these are + // accidentally exempted on tenant hosts. + // + // The existing `build_router_admin_spa_path_exempt_on_admin_host_denied_on_tenant_host` + // test covers `/reports` in DenyProtected. This test fills the matrix. + // + // Falsifying mutation (coverage of all paths): replacing `is_admin_spa_path` + // with a hardcoded `/reports`-only check would cause the `/reports/` and + // `/feedback` rows to return 503 on the admin host → assertions fire. + #[tokio::test] + async fn build_router_admin_spa_full_matrix() { + use buzz_auth::NipFiMode; + + // Helper: build a state with a given mode. + // Returns (state, _admin_dir, _web_dir) — callers must keep the TempDirs + // alive for the lifetime of the test; they are dropped at end of scope. + async fn state_with_mode( + mode: NipFiMode, + admin_dir: &std::path::Path, + web_dir: &std::path::Path, + ) -> Arc { + write_admin_bundle(admin_dir); + write_bundle(web_dir); + + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.web_dir = Some(web_dir.to_path_buf()); + config.admin = Some(crate::config::AdminConfig { + host: "admin.matrix.example".to_string(), + auth: crate::config::AdminAuth::Disabled, + web_dir: Some(admin_dir.to_path_buf()), + }); + config.nip_fi.mode = mode; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + 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)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + 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).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + // Admin SPA paths to test. + let admin_paths = ["/reports", "/reports/abc-123-id", "/feedback"]; + + // ── DenyProtected matrix ────────────────────────────────────────────── + // + // Admin host + DenyProtected: guard exempts admin SPA paths → 200 HTML. + // Tenant host + DenyProtected: guard NOT exempted → 503 + exact body. + let deny_admin_dir = tempfile::tempdir().expect("deny admin bundle dir"); + let deny_web_dir = tempfile::tempdir().expect("deny public bundle dir"); + let deny_state = state_with_mode( + NipFiMode::DenyProtected, + deny_admin_dir.path(), + deny_web_dir.path(), + ) + .await; + for path in &admin_paths { + let admin_resp = spa_response(deny_state.clone(), "admin.matrix.example", path).await; + let admin_status = admin_resp.status(); + let admin_ct = admin_resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let admin_body = axum::body::to_bytes(admin_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + admin_status, + axum::http::StatusCode::OK, + "DenyProtected: {path} on admin host must be 200 (SPA exemption). \ + Falsifying mutation: remove is_admin_spa_path || restrict to /reports only → 503" + ); + assert_eq!( + admin_body.as_ref(), + b"", + "DenyProtected: {path} on admin host 200 must serve the exact admin HTML body; \ + distinct content distinguishes admin bundle from public bundle." + ); + assert_eq!( + admin_ct, "text/html; charset=utf-8", + "DenyProtected: {path} on admin host 200 Content-Type must be \ + 'text/html; charset=utf-8'; got '{admin_ct}'" + ); + + let tenant_resp = spa_response(deny_state.clone(), "tenant.matrix.example", path).await; + let tenant_status = tenant_resp.status(); + let tenant_resp_headers = tenant_resp.headers().clone(); + let tenant_body = axum::body::to_bytes(tenant_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + tenant_status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "DenyProtected: {path} on tenant host must be 503. \ + Exemption must not apply to non-admin hosts." + ); + assert_eq!( + tenant_body.as_ref(), + b"authorization unavailable\n", + "DenyProtected: {path} on tenant host 503 must have exact body \ + 'authorization unavailable\\n' (AuthorizationUnavailable contract)." + ); + let tenant_ct = tenant_resp_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + tenant_ct, "text/plain; charset=utf-8", + "DenyProtected: {path} on tenant host 503 Content-Type must be \ + 'text/plain; charset=utf-8'; got '{tenant_ct}'" + ); + assert!( + tenant_resp_headers.get("www-authenticate").is_none(), + "DenyProtected: {path} on tenant host 503 MUST NOT carry WWW-Authenticate \ + (DenyProtected unconditionally denies; challenge absent)" + ); + } + + // ── Enforce matrix ──────────────────────────────────────────────────── + // + // Admin host + Enforce + no verifier (None) → NIP-FI admission guard + // fires for non-exempt paths. Admin SPA paths are host-qualified exempt + // → 200 HTML. Tenant host → 401 (missing assertion, no verifier configured) + // with exact body "authentication required\n" and WWW-Authenticate: Nostr. + // + // Important: asserting only `!= 200` for the tenant case is insufficient — + // the fallthrough public 404 path also returns non-200. Exact 401 + header + // + body distinguishes the NIP-FI guard from a public 404 fallback. + let enforce_admin_dir = tempfile::tempdir().expect("enforce admin bundle dir"); + let enforce_web_dir = tempfile::tempdir().expect("enforce public bundle dir"); + let enforce_state = state_with_mode( + NipFiMode::Enforce, + enforce_admin_dir.path(), + enforce_web_dir.path(), + ) + .await; + for path in &admin_paths { + let admin_resp = + spa_response(enforce_state.clone(), "admin.matrix.example", path).await; + let admin_status = admin_resp.status(); + let admin_ct = admin_resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let admin_body = axum::body::to_bytes(admin_resp.into_body(), 8192) + .await + .unwrap_or_default(); + assert_eq!( + admin_status, + axum::http::StatusCode::OK, + "Enforce: {path} on admin host must be 200 (SPA exemption active in Enforce too). \ + Falsifying mutation: remove is_admin_spa_path exemption from Enforce branch → non-200" + ); + assert_eq!( + admin_body.as_ref(), + b"", + "Enforce: {path} on admin host 200 must serve the exact admin HTML body." + ); + assert_eq!( + admin_ct, "text/html; charset=utf-8", + "Enforce: {path} on admin host 200 Content-Type must be \ + 'text/html; charset=utf-8'; got '{admin_ct}'" + ); + + let tenant_resp = + spa_response(enforce_state.clone(), "tenant.matrix.example", path).await; + let tenant_status = tenant_resp.status(); + let tenant_headers = tenant_resp.headers().clone(); + let tenant_body = axum::body::to_bytes(tenant_resp.into_body(), 8192) + .await + .unwrap_or_default(); + // Must be 401, not just != 200. A 404 fallback would also satisfy != 200 + // but would indicate the NIP-FI guard was bypassed. + assert_eq!( + tenant_status, + axum::http::StatusCode::UNAUTHORIZED, + "Enforce: {path} on tenant host must be 401 MissingEvidence (not 200, not 404). \ + The NIP-FI guard must fire and produce an exact denial, not fall through to \ + the public 404 route. \ + Falsifying mutation: remove the guard call → 404 → assertion fires." + ); + assert_eq!( + tenant_body.as_ref(), + b"authentication required\n", + "Enforce: {path} on tenant host 401 must have exact body \ + 'authentication required\\n' (MissingEvidence contract)." + ); + let www_auth = tenant_headers + .get("WWW-Authenticate") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + www_auth, + "Nostr", + "Enforce: {path} on tenant host 401 must have WWW-Authenticate: Nostr header. \ + Falsifying mutation: remove challenge from MissingEvidence denial → assertion fires." + ); + let tenant_ct = tenant_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!( + tenant_ct, "text/plain; charset=utf-8", + "Enforce: {path} on tenant host 401 Content-Type must be \ + 'text/plain; charset=utf-8'; got '{tenant_ct}'" + ); + } + } + + // ── 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 cbfe7b7b304..72376402001 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,51 @@ impl AuditShutdownHandle { } } +/// Construct the NIP-FI assertion verifier + JWKS source from `config.nip_fi`. +/// +/// Returns `(None, None)` when the mode is `Off` or `DenyProtected` (the +/// verifier is never consulted there; admission always returns 503). In +/// `Enforce` 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) {