From 9b083957f6802fd8e0efc7414269714619139cf2 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 1 Oct 2026 18:52:48 -0400 Subject: [PATCH] refactor(buzz-relay): share NIP-FI assertion evaluation across adapters (#7990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Stack: #7990 β†’ #8028 The three production NIP-FI assertion-verification sites (the HTTP assertion guard in `router.rs`, `check_nip_fi_at_upgrade` in `nip_fi_upgrade.rs`, and `admit_nip_fi_http` in `nip_fi_http.rs`) each carried their own extract β†’ verify β†’ map-to-denial sequence, and HTTP admission and `enforce_nip_fi_key_pairing` each carried their own key-equality check. The admin disconnect route (`POST /api/nip-fi/disconnect`) kept a second copy of the header parser and hand-typed its denial bodies. Community binding and shadow mode both need to change exactly those decisions, so they now live in one place. **No behavior change.** Every denial class, status code, body, header and log line is preserved, including `deny_protected` (503), the startup-race 503, transport-before-verifier precedence, NIP-98-before-assertion ordering, and guard + handler double verification. - New relay-private `nip_fi_core` owns `extract_bearer_token` and `http_denial` (moved from `nip_fi_http`), `evaluate_attached_assertion` (the single `verify_assertion` call for all three adapters, returning a typed `AssertionRejection`), and `asserted_key_matches` (the pairing predicate; a claimless assertion never matches). `http_denial` and the disconnect route's plain-text renderer fall back to the bare denial status instead of panicking or answering an empty 200; both fallbacks are unreachable because every status and header value is a constant. - Adapters keep their own mode short-circuits, exemptions, ordering, metrics and `code()` debug logs; the guard still logs nothing. - The disconnect route parses its header with `extract_bearer_token`, renders header failures with `http_denial`, and renders every other rejection (malformed body, bad pubkey, no command verifier, every `CommandError`) through `CommandError::http_status()`/`response_body()`. Its check order (header β†’ body β†’ pubkey β†’ verifier present β†’ verify), `DenySetFull` warning and metric, success body and cross-pod publish are unchanged. The old Unicode whitespace check and the shared ASCII one agree on every reachable input, because `HeaderValue::to_str` only admits visible ASCII and tab. - The 12 HTTP admission callers, 3 WS upgrade placements, deny-map checks, expected-URL constructors and the command/admin proof path are untouched. - Characterization tests land before each refactor, green against the unmodified code, and pass unchanged after it. A routed test counts both verifications on one request (guard, then handler admission) and shows admission's own failure decides the response. Disconnect-route tests pin exact status, content type, challenge and body for every rejection, the observable check order, and that only `DenySetFull` increments `buzz_nip_fi_disconnect_capacity_rejections_total`. The test modules share one counting `ScriptedVerifier` from `nip_fi_core::tests`, and the session tests build connections through `connection::tests::test_conn`. `nip_fi_core::tests::` is added to the unit selectors in `Justfile` and `scripts/run-tests.sh`. πŸ€– Implemented by Hayt. --------- Signed-off-by: Will Pfleger Signed-off-by: Will Pfleger Co-authored-by: Duncan Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> --- Justfile | 2 +- crates/buzz-relay/src/api/git/transport.rs | 2 +- crates/buzz-relay/src/api/nip_fi.rs | 525 ++++++++++++++++----- crates/buzz-relay/src/connection.rs | 49 +- crates/buzz-relay/src/lib.rs | 2 + crates/buzz-relay/src/nip_fi_core.rs | 243 ++++++++++ crates/buzz-relay/src/nip_fi_http.rs | 270 +++++++---- crates/buzz-relay/src/nip_fi_session.rs | 104 ++-- crates/buzz-relay/src/nip_fi_upgrade.rs | 124 ++++- crates/buzz-relay/src/router.rs | 293 ++++++++++-- scripts/run-tests.sh | 3 + 11 files changed, 1301 insertions(+), 316 deletions(-) create mode 100644 crates/buzz-relay/src/nip_fi_core.rs diff --git a/Justfile b/Justfile index 1683153b56..495be73b77 100644 --- a/Justfile +++ b/Justfile @@ -475,7 +475,7 @@ test-unit: # because they live in the binary target; the nested # `tests::postgres_tests::` stays in the PostgreSQL lane. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses β€” bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index f745405143..996b3aa460 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -4628,7 +4628,7 @@ mod off_mode_precedence_tests { // 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. + // check in `nip_fi_core::evaluate_attached_assertion` β†’ 503, not 403. // [FI-TRACE-DENIAL-ORACLE: verifier required for all crypto cases] let state = { use buzz_auth::{ diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index bc3ad2eb5a..95bbac5245 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -42,15 +42,17 @@ use axum::{ body::Body, extract::State, http::{HeaderMap, Response, StatusCode}, + response::IntoResponse, }; use serde::Deserialize; use tracing::{debug, warn}; use buzz_auth::{ CommandError, CommandIssuerPolicy, CommandVerifier, IssuerCapacity, JwksFetcher, NipFiDenyMap, - NipFiMode, ProductionJwksSource, CLIENT_ATTACHED_HEADER, + NipFiMode, ProductionJwksSource, }; +use crate::nip_fi_core::{extract_bearer_token, http_denial}; use crate::state::AppState; /// Default per-issuer deny-set capacity when `deny_set_capacity` is absent. @@ -89,28 +91,23 @@ pub async fn disconnect( body: axum::body::Bytes, ) -> Response { // ── Extract the command JWT from the header ──────────────────────────── - let token = match extract_command_jwt(&headers) { + // Absent β†’ 401 with `WWW-Authenticate: Nostr`; any other malformation + // β†’ 403. [NIP-FI.md Β§Rejection table] + let token = match extract_bearer_token(&headers) { Ok(t) => t, - Err(status) => { - return if status == StatusCode::UNAUTHORIZED { - // [NIP-FI.md Β§Rejection table]: 401 MUST carry WWW-Authenticate: Nostr. - auth_required_response() - } else { - plain_response(status, "evidence rejected\n") - }; - } + Err(class) => return http_denial(class), }; // ── Parse the JSON body ─────────────────────────────────────────────── let req: DisconnectRequest = match serde_json::from_slice(&body) { Ok(r) => r, - Err(_) => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"), + Err(_) => return command_denial(CommandError::MalformedRequest), }; // body.pubkey must be lowercase hex of exactly 32 bytes. let body_pubkey = match parse_hex_pubkey(&req.pubkey) { Some(k) => k, - None => return plain_response(StatusCode::BAD_REQUEST, "bad request\n"), + None => return command_denial(CommandError::MalformedRequest), }; // ── Command verifier ────────────────────────────────────────────────── @@ -119,10 +116,7 @@ pub async fn disconnect( None => { // Mode is Off or not yet initialized. debug!("nip-fi disconnect: no command verifier configured"); - return plain_response( - StatusCode::SERVICE_UNAVAILABLE, - "authorization unavailable\n", - ); + return command_denial(CommandError::AuthorizationUnavailable); } }; @@ -178,23 +172,12 @@ pub async fn disconnect( disconnected_response() } - Err(CommandError::DenySetFull) => { - warn!("nip-fi disconnect: deny set full β€” command rejected, no sessions closed"); - metrics::counter!("buzz_nip_fi_disconnect_capacity_rejections_total").increment(1); - plain_response(StatusCode::SERVICE_UNAVAILABLE, "deny set full\n") - } - Err(CommandError::UntilExceedsCeiling) | Err(CommandError::MalformedRequest) => { - plain_response(StatusCode::BAD_REQUEST, "bad request\n") - } - Err(CommandError::AuthorizationUnavailable) => plain_response( - StatusCode::SERVICE_UNAVAILABLE, - "authorization unavailable\n", - ), - Err(CommandError::EvidenceRejected) => { - plain_response(StatusCode::FORBIDDEN, "evidence rejected\n") - } - Err(CommandError::AuthorizationDenied) => { - plain_response(StatusCode::FORBIDDEN, "authorization denied\n") + Err(err) => { + if err == CommandError::DenySetFull { + warn!("nip-fi disconnect: deny set full β€” command rejected, no sessions closed"); + metrics::counter!("buzz_nip_fi_disconnect_capacity_rejections_total").increment(1); + } + command_denial(err) } } } @@ -544,29 +527,6 @@ pub fn validate_command_issuer_config( // ── Helpers ─────────────────────────────────────────────────────────────────── -/// Extract the command JWS token from the `Nostr-Federated-Identity: Bearer` -/// header. Returns `Err(401)` if the header is absent, `Err(403)` otherwise. -/// -/// The same header is used for assertion tokens at upgrade and for command -/// tokens at the admin API β€” distinct roles on distinct paths, never mixed. -fn extract_command_jwt(headers: &HeaderMap) -> Result<&str, StatusCode> { - let mut values = headers.get_all(CLIENT_ATTACHED_HEADER).iter(); - let first = values.next().ok_or(StatusCode::UNAUTHORIZED)?; - // Repeated header β†’ reject. - if values.next().is_some() { - return Err(StatusCode::FORBIDDEN); - } - let raw = first.to_str().map_err(|_| StatusCode::FORBIDDEN)?; - if raw.contains(',') { - return Err(StatusCode::FORBIDDEN); - } - let token = raw.strip_prefix("Bearer ").ok_or(StatusCode::FORBIDDEN)?; - if token.is_empty() || token.contains(char::is_whitespace) { - return Err(StatusCode::FORBIDDEN); - } - Ok(token) -} - fn parse_hex_pubkey(raw: &str) -> Option { if raw.len() != 64 || !raw @@ -583,18 +543,14 @@ fn plain_response(status: StatusCode, body: &'static str) -> Response { .status(status) .header("Content-Type", "text/plain; charset=utf-8") .body(Body::from(body)) - .unwrap_or_else(|_| Response::new(Body::empty())) + .unwrap_or_else(|_| status.into_response()) } -/// Build the `401 authentication required` response with the mandatory -/// `WWW-Authenticate: Nostr` header. [NIP-FI.md Β§Rejection table] -fn auth_required_response() -> Response { - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header("Content-Type", "text/plain; charset=utf-8") - .header("WWW-Authenticate", "Nostr") - .body(Body::from("authentication required\n")) - .unwrap_or_else(|_| Response::new(Body::empty())) +/// Render a command rejection with its spec-exact status and body. +fn command_denial(err: CommandError) -> Response { + let status = + StatusCode::from_u16(err.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + plain_response(status, err.response_body()) } /// Spec-exact 200 success response. @@ -615,50 +571,6 @@ fn disconnected_response() -> Response { #[cfg(test)] mod tests { use super::*; - use axum::http::HeaderValue; - - fn headers_with(value: &str) -> HeaderMap { - let mut h = HeaderMap::new(); - h.insert( - CLIENT_ATTACHED_HEADER, - HeaderValue::from_str(value).unwrap(), - ); - h - } - - // ── JWT extraction contract ──────────────────────────────────────────── - - #[test] - fn absent_header_gives_401() { - let h = HeaderMap::new(); - assert_eq!(extract_command_jwt(&h), Err(StatusCode::UNAUTHORIZED)); - } - - #[test] - fn repeated_header_gives_403() { - let mut h = HeaderMap::new(); - h.append( - CLIENT_ATTACHED_HEADER, - HeaderValue::from_static("Bearer aaa.bbb.ccc"), - ); - h.append( - CLIENT_ATTACHED_HEADER, - HeaderValue::from_static("Bearer ddd.eee.fff"), - ); - assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN)); - } - - #[test] - fn non_bearer_gives_403() { - let h = headers_with("Token aaa.bbb.ccc"); - assert_eq!(extract_command_jwt(&h), Err(StatusCode::FORBIDDEN)); - } - - #[test] - fn valid_bearer_extracted() { - let h = headers_with("Bearer aaa.bbb.ccc"); - assert_eq!(extract_command_jwt(&h), Ok("aaa.bbb.ccc")); - } // ── Hex pubkey parsing ──────────────────────────────────────────────── @@ -704,21 +616,6 @@ mod tests { ); } - /// `401` MUST carry `WWW-Authenticate: Nostr` and the spec body. - #[tokio::test] - async fn auth_required_response_has_www_authenticate() { - let resp = auth_required_response(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - let www_auth = resp - .headers() - .get("WWW-Authenticate") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - assert_eq!(www_auth, "Nostr", "401 MUST carry WWW-Authenticate: Nostr"); - let body_bytes = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); - assert_eq!(body_bytes.as_ref(), b"authentication required\n"); - } - /// `403` error responses MUST NOT carry `WWW-Authenticate`. #[test] fn error_responses_have_no_www_authenticate() { @@ -763,7 +660,7 @@ mod route_integration_tests { }; use buzz_auth::{ CommandIssuerPolicy, CommandVerifier, IssuerCapacity, IssuerRegistry, NipFiDenyMap, - ProductionJwksSource, + ProductionJwksSource, CLIENT_ATTACHED_HEADER, }; use std::sync::Arc; use tower::ServiceExt; @@ -2587,6 +2484,382 @@ mod route_integration_tests { assert_eq!(got, want, "Off-mode legacy response must be byte-identical"); } + // ── Characterization: exact disconnect-route rejection contract ────────── + // + // Pin status, Content-Type, WWW-Authenticate and body bytes for every + // pre-success rejection, plus the observable check order + // (header β†’ JSON body β†’ pubkey β†’ verifier present β†’ verify). + + const PLAIN: &str = "text/plain; charset=utf-8"; + + async fn send_raw( + state: Arc, + header_values: Vec, + body: &[u8], + ) -> axum::response::Response { + let mut req = Request::builder() + .method("POST") + .uri(TEST_PATH) + .header("Content-Type", "application/json"); + for v in header_values { + req = req.header(CLIENT_ATTACHED_HEADER, v); + } + crate::router::build_router(state) + .oneshot(req.body(Body::from(body.to_vec())).unwrap()) + .await + .unwrap() + } + + async fn assert_plain_denial( + resp: axum::response::Response, + status: StatusCode, + body: &str, + why: &str, + ) { + // Only the 401 carries a challenge. [NIP-FI.md Β§Rejection table] + let challenge = (status == StatusCode::UNAUTHORIZED).then_some("Nostr"); + assert_eq!(resp.status(), status, "{why}"); + let header = |name| { + resp.headers() + .get(name) + .map(|v: &axum::http::HeaderValue| v.to_str().unwrap().to_owned()) + }; + assert_eq!(header("Content-Type").as_deref(), Some(PLAIN), "{why}"); + assert_eq!(header("WWW-Authenticate").as_deref(), challenge, "{why}"); + let got = axum::body::to_bytes(resp.into_body(), 256).await.unwrap(); + assert_eq!(got.as_ref(), body.as_bytes(), "{why}"); + } + + fn valid_body() -> &'static [u8] { + // A syntactically valid body: the pubkey is well-formed lowercase hex. + br#"{"pubkey":"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"}"# + } + + #[tokio::test] + async fn characterize_route_header_transport_rejections() { + use axum::http::HeaderValue; + let state = build_test_state(1000).await; + let missing = send_raw(Arc::clone(&state), vec![], valid_body()).await; + assert_plain_denial( + missing, + StatusCode::UNAUTHORIZED, + "authentication required\n", + "missing header", + ) + .await; + + let rejected: Vec<(&str, Vec)> = vec![ + ( + "repeated header", + vec![ + HeaderValue::from_static("Bearer aaa.bbb.ccc"), + HeaderValue::from_static("Bearer ddd.eee.fff"), + ], + ), + ( + "comma-joined value", + vec![HeaderValue::from_static("Bearer aaa.bbb.ccc,ddd.eee.fff")], + ), + ( + "non-Bearer scheme", + vec![HeaderValue::from_static("Token aaa.bbb.ccc")], + ), + ("empty token", vec![HeaderValue::from_static("Bearer ")]), + ( + "space in token", + vec![HeaderValue::from_static("Bearer aaa bbb")], + ), + ( + "tab in token", + vec![HeaderValue::from_static("Bearer aaa\tbbb")], + ), + // U+00A0 is `char::is_whitespace` but not ASCII whitespace; the + // obs-text bytes fail `HeaderValue::to_str`, so it rejects before + // any whitespace predicate runs. + ( + "non-ASCII whitespace in token", + vec![HeaderValue::from_bytes(b"Bearer aaa\xc2\xa0bbb").unwrap()], + ), + ]; + // A malformed body makes a dropped transport check observable: it + // would surface as 400 from body parsing, not the same 403 the + // verifier would give. + for (why, values) in rejected { + let resp = send_raw(Arc::clone(&state), values, b"not json").await; + assert_plain_denial(resp, StatusCode::FORBIDDEN, "evidence rejected\n", why).await; + } + } + + #[tokio::test] + async fn characterize_route_check_order_before_verify() { + use axum::http::HeaderValue; + let state = build_test_state(1000).await; + let bearer = || vec![HeaderValue::from_static("Bearer aaa.bbb.ccc")]; + + // Header is checked before the JSON body. + let resp = send_raw(Arc::clone(&state), vec![], b"not json").await; + assert_plain_denial( + resp, + StatusCode::UNAUTHORIZED, + "authentication required\n", + "missing header + malformed JSON", + ) + .await; + let resp = send_raw( + Arc::clone(&state), + vec![HeaderValue::from_static("Token x")], + br#"{"pubkey":"NOT-HEX"}"#, + ) + .await; + assert_plain_denial( + resp, + StatusCode::FORBIDDEN, + "evidence rejected\n", + "bad scheme + bad pubkey", + ) + .await; + + // Body shape is checked before the (garbage) token is verified. + for (why, body) in [ + ("malformed JSON", b"not json".as_slice()), + ("missing pubkey field", br#"{}"#.as_slice()), + ( + "uppercase pubkey", + br#"{"pubkey":"79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"}"# + .as_slice(), + ), + ("short pubkey", br#"{"pubkey":"abcd"}"#.as_slice()), + ] { + let resp = send_raw(Arc::clone(&state), bearer(), body).await; + assert_plain_denial(resp, StatusCode::BAD_REQUEST, "bad request\n", why).await; + } + + // No verifier: body/pubkey still precede the verifier check, and the + // verifier check precedes verification (the token is garbage). + let mut no_verifier = build_test_app_state(1000, crate::config::Config::for_test()).await; + no_verifier.nip_fi_command_verifier = None; + let no_verifier = Arc::new(no_verifier); + let resp = send_raw(Arc::clone(&no_verifier), bearer(), br#"{"pubkey":"abcd"}"#).await; + assert_plain_denial( + resp, + StatusCode::BAD_REQUEST, + "bad request\n", + "bad pubkey precedes verifier presence", + ) + .await; + let resp = send_raw(no_verifier, bearer(), valid_body()).await; + assert_plain_denial( + resp, + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + "no command verifier configured", + ) + .await; + } + + #[tokio::test] + async fn characterize_route_command_error_arms() { + let post = |state: Arc, token: String, target: String| async move { + do_request( + state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({ "pubkey": target })), + ) + .await + }; + let state = build_test_state(1).await; + + let t = target_hex(); + let tampered = format!("{}X", mint_token(&t, 300, serde_json::json!({}))); + let resp = post(Arc::clone(&state), tampered, t).await; + assert_plain_denial( + resp, + StatusCode::FORBIDDEN, + "evidence rejected\n", + "EvidenceRejected", + ) + .await; + + let t = target_hex(); + let token = mint_token(&t, 300, serde_json::json!({"sub": "intruder@example.com"})); + let resp = post(Arc::clone(&state), token, t).await; + assert_plain_denial( + resp, + StatusCode::FORBIDDEN, + "authorization denied\n", + "AuthorizationDenied", + ) + .await; + + let t = target_hex(); + let token = mint_token(&t, 10 * 365 * 24 * 3600, serde_json::json!({})); + let resp = post(Arc::clone(&state), token, t).await; + assert_plain_denial( + resp, + StatusCode::BAD_REQUEST, + "bad request\n", + "UntilExceedsCeiling", + ) + .await; + + // Fill the capacity-1 deny set, then the next distinct target is full. + let t = target_hex(); + let resp = post( + Arc::clone(&state), + mint_token(&t, 300, serde_json::json!({})), + t, + ) + .await; + assert_eq!(resp.status(), StatusCode::OK, "control: slot filled"); + let t = target_hex(); + let resp = post( + Arc::clone(&state), + mint_token(&t, 300, serde_json::json!({})), + t, + ) + .await; + assert_plain_denial( + resp, + StatusCode::SERVICE_UNAVAILABLE, + "deny set full\n", + "DenySetFull", + ) + .await; + + // An unseeded key source has no key set for the issuer. + let mut unseeded = build_test_app_state(1000, crate::config::Config::for_test()).await; + let key_source = Arc::new( + ProductionJwksSource::new(vec![test_jwks_config()], buzz_auth::HttpJwksFetcher::new()) + .expect("key source"), + ); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let deny_map = NipFiDenyMap::new( + 1000, + vec![IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 1000, + }], + ); + let policy = + CommandIssuerPolicy::new(TEST_ISS.to_owned(), 30, vec![TEST_SUB.to_owned()], 1000) + .expect("command policy"); + unseeded.nip_fi_command_verifier = Some(Arc::new(CommandVerifier::new( + registry, + key_source, + vec![policy], + deny_map, + ))); + let t = target_hex(); + let token = mint_token(&t, 300, serde_json::json!({})); + let resp = post(Arc::new(unseeded), token, t).await; + assert_plain_denial( + resp, + StatusCode::SERVICE_UNAVAILABLE, + "authorization unavailable\n", + "AuthorizationUnavailable", + ) + .await; + } + + /// `buzz_nip_fi_disconnect_capacity_rejections_total` counts exactly the + /// `DenySetFull` rejections and no other command-error arm. + /// + /// `#[test]` with a current-thread runtime, not `#[tokio::test]`: + /// `metrics::with_local_recorder` is thread-local and takes a sync closure, + /// so each request runs to completion on this thread inside its own + /// recorder's scope. + #[test] + fn capacity_rejection_counter_counts_only_deny_set_full() { + use axum::body::Bytes; + + fn capacity_rejections_during( + rt: &tokio::runtime::Runtime, + request: impl std::future::Future, + ) -> ((StatusCode, Bytes), u64) { + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let response = metrics::with_local_recorder(&recorder, || rt.block_on(request)); + let count = snapshotter + .snapshot() + .into_vec() + .into_iter() + .find(|(key, ..)| { + key.key().name() == "buzz_nip_fi_disconnect_capacity_rejections_total" + }) + .map_or(0, |(.., value)| match value { + metrics_util::debugging::DebugValue::Counter(n) => n, + other => panic!("capacity rejections must be a counter, got {other:?}"), + }); + (response, count) + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current_thread runtime"); + let state = rt.block_on(build_test_state(1)); + + let t = target_hex(); + let tampered = format!("{}X", mint_token(&t, 300, serde_json::json!({}))); + let intruder = mint_token(&t, 300, serde_json::json!({"sub": "intruder@example.com"})); + let far_until = mint_token(&t, 10 * 365 * 24 * 3600, serde_json::json!({})); + for (why, token, status, body) in [ + ( + "EvidenceRejected", + tampered, + StatusCode::FORBIDDEN, + "evidence rejected\n", + ), + ( + "AuthorizationDenied", + intruder, + StatusCode::FORBIDDEN, + "authorization denied\n", + ), + ( + "UntilExceedsCeiling", + far_until, + StatusCode::BAD_REQUEST, + "bad request\n", + ), + ] { + let (response, count) = + capacity_rejections_during(&rt, post_command(&state, &token, &t)); + assert_eq!( + response, + (status, Bytes::from_static(body.as_bytes())), + "{why}" + ); + assert_eq!(count, 0, "{why} must not count as a capacity rejection"); + } + + // Fill the capacity-1 deny set, then the next distinct target is full. + let t = target_hex(); + let (filled, _) = rt.block_on(post_command( + &state, + &mint_token(&t, 300, serde_json::json!({})), + &t, + )); + assert_eq!(filled, StatusCode::OK, "control: slot filled"); + let t = target_hex(); + let token = mint_token(&t, 300, serde_json::json!({})); + let (response, count) = capacity_rejections_during(&rt, post_command(&state, &token, &t)); + assert_eq!( + response, + ( + StatusCode::SERVICE_UNAVAILABLE, + Bytes::from_static(b"deny set full\n") + ), + "DenySetFull" + ); + assert_eq!(count, 1, "DenySetFull must count one capacity rejection"); + } + // ── Cross-pod command replay fence ─────────────────────────────────────── // // Each "pod" is its own AppState with its own deny map and sessions; the diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 7a892e126a..714136f0e5 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1315,16 +1315,32 @@ pub(crate) mod tests { use tokio::sync::Notify; use tokio_tungstenite::{connect_async, tungstenite::Message}; - /// A connection whose outbound frames a test can read back. + /// A test connection together with the receiving end of each outbound queue. + pub(crate) struct TestConn { + /// The connection under test. + pub(crate) conn: Arc, + /// Receives data frames (EVENT, NOTICE, OK, ...). + pub(crate) send_rx: mpsc::Receiver, + /// Receives control frames; capacity 8, as in production. + pub(crate) ctrl_rx: mpsc::Receiver, + /// Receives the terminal NIP-FI denial frame; capacity 1, as in production. + pub(crate) terminal_rx: mpsc::Receiver, + } + + /// Builds a connection in `auth` carrying the NIP-FI assertion presented at + /// upgrade, if any. The admission gate is off-mode and no session deadline + /// is set. /// /// Lives here, next to `ConnectionState`, so the crate has one place that - /// knows how to build one. Shared with `crate::rejection`'s tests. - pub(crate) fn test_conn_with_auth( + /// knows how to build one. Shared with `crate::rejection`'s and + /// `crate::nip_fi_session`'s tests. + pub(crate) fn test_conn( auth: AuthState, - ) -> (Arc, mpsc::Receiver) { + nip_fi_assertion: Option, + ) -> TestConn { let (send_tx, send_rx) = mpsc::channel(4); - let (ctrl_tx, _ctrl_rx) = mpsc::channel(4); - let (terminal_ctrl_tx, _terminal_ctrl_rx) = mpsc::channel(1); + let (ctrl_tx, ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, terminal_rx) = mpsc::channel(1); let cancel = CancellationToken::new(); let conn = ConnectionState { conn_id: Uuid::new_v4(), @@ -1341,12 +1357,26 @@ pub(crate) mod tests { cancel: cancel.clone(), backpressure_count: Arc::new(AtomicU8::new(0)), grace_limit: 3, - nip_fi_assertion: None, + nip_fi_assertion, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }; - (Arc::new(conn), send_rx) + TestConn { + conn: Arc::new(conn), + send_rx, + ctrl_rx, + terminal_rx, + } + } + + /// A connection without a NIP-FI assertion whose data frames a test can + /// read back. + pub(crate) fn test_conn_with_auth( + auth: AuthState, + ) -> (Arc, mpsc::Receiver) { + let TestConn { conn, send_rx, .. } = test_conn(auth, None); + (conn, send_rx) } /// An authenticated connection β€” the only state admission quotas apply to. @@ -1364,7 +1394,8 @@ pub(crate) mod tests { } } - fn pending_state() -> AuthState { + /// A connection awaiting its NIP-42 AUTH response. + pub(crate) fn pending_state() -> AuthState { AuthState::Pending { challenge: "test-challenge".to_owned(), started_at: Instant::now(), diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 39553ff2f0..795d4423df 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -6,6 +6,8 @@ mod admission; mod build_info; mod rejection; +/// Shared NIP-FI assertion evaluation, denial rendering, and key pairing. +pub(crate) mod nip_fi_core; /// NIP-FI session admission gate β€” per-connection effect-permit and quiescence barrier. pub(crate) mod nip_fi_gate; pub(crate) mod nip_fi_session; diff --git a/crates/buzz-relay/src/nip_fi_core.rs b/crates/buzz-relay/src/nip_fi_core.rs new file mode 100644 index 0000000000..4a2a2f3bf1 --- /dev/null +++ b/crates/buzz-relay/src/nip_fi_core.rs @@ -0,0 +1,243 @@ +//! Shared NIP-FI assertion evaluation for every relay ingress adapter. +//! +//! The HTTP guard, WebSocket upgrade, and HTTP admission each decide *when* +//! to evaluate an attached assertion (mode short-circuits, exemptions, NIP-98 +//! ordering) and what to do with the result. This module owns the part they +//! must agree on: extracting the `Nostr-Federated-Identity` bearer, verifying +//! it, mapping failures to a [`DenialClass`], rendering the exact HTTP denial, +//! and the key-pairing predicate. [FI-TRACE-AUTHORITY-UNIFORM] +//! +//! The admin disconnect route also uses [`extract_bearer_token`] and +//! [`http_denial`] for its command JWT, so its transport failures render +//! exactly like an assertion's. + +use axum::{ + body::Body, + http::{HeaderMap, Response, StatusCode}, + response::IntoResponse, +}; +use buzz_auth::{ + DenialClass, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, +}; + +// ── Assertion evaluation ────────────────────────────────────────────────────── + +/// Why an attached assertion was not accepted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AssertionRejection { + /// The header was absent or malformed. [FI-TRACE-TRANSPORT-CLOSED] + Transport(DenialClass), + /// No verifier is constructed yet (startup race); fail closed. + VerifierUnavailable, + /// The verifier rejected the token. + Verifier(VerifierError), +} + +impl AssertionRejection { + /// The public denial class for this rejection. + pub(crate) const fn denial_class(self) -> DenialClass { + match self { + Self::Transport(class) => class, + Self::VerifierUnavailable => DenialClass::AuthorizationUnavailable, + Self::Verifier(err) => err.denial_class(), + } + } +} + +/// Extract and verify the attached assertion. +/// +/// Transport extraction runs before the verifier-presence check, so a +/// malformed header is `evidence_rejected` even while no verifier exists. +pub(crate) fn evaluate_attached_assertion( + headers: &HeaderMap, + verifier: Option<&dyn VerifyAssertion>, +) -> Result { + let token = extract_bearer_token(headers).map_err(AssertionRejection::Transport)?; + let verifier = verifier.ok_or(AssertionRejection::VerifierUnavailable)?; + verifier + .verify_assertion(token) + .map_err(AssertionRejection::Verifier) +} + +/// The assertion's `nostr_pubkey` claim equals the key the client proved. +/// A claimless assertion never matches. [FI-INV-05] +pub(crate) fn asserted_key_matches( + assertion: &VerifiedAssertion, + proven_pubkey: nostr::PublicKey, +) -> bool { + assertion.asserted_key() == Some(proven_pubkey) +} + +// ── 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 { + // Every status and header value is a fixed constant, so the fallbacks are + // unreachable; they keep the response a denial rather than panicking. + let status = + StatusCode::from_u16(class.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut builder = Response::builder() + .status(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())) + .unwrap_or_else(|_| status.into_response()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use axum::http::HeaderValue; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Verifier returning a fixed result and counting calls. + /// + /// Lives here, next to the shared evaluator, so the crate has one NIP-FI + /// verifier fixture. Shared with the `nip_fi_http`, `nip_fi_upgrade`, and + /// `router` tests. + pub(crate) struct ScriptedVerifier { + result: Result, VerifierError>, + calls: AtomicUsize, + } + + impl ScriptedVerifier { + /// A verifier that answers every call with `result`, where `Ok(key)` + /// becomes an assertion for `key` expiring in an hour. + pub(crate) fn new(result: Result, VerifierError>) -> Self { + Self { + result, + calls: AtomicUsize::new(0), + } + } + + /// How many times `verify_assertion` has run. + pub(crate) fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + impl VerifyAssertion for ScriptedVerifier { + fn verify_assertion(&self, _token: &str) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + self.result.map(|key| { + VerifiedAssertion::for_test( + key, + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + ) + }) + } + } + + fn headers(value: &'static str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert(CLIENT_ATTACHED_HEADER, HeaderValue::from_static(value)); + h + } + + #[test] + fn missing_header_is_transport_missing_evidence() { + let v = ScriptedVerifier::new(Ok(None)); + assert_eq!( + evaluate_attached_assertion(&HeaderMap::new(), Some(&v)).unwrap_err(), + AssertionRejection::Transport(DenialClass::MissingEvidence) + ); + } + + #[test] + fn transport_rejection_precedes_missing_verifier() { + // A comma-joined value is otherwise well-formed, so only the comma + // check separates it from the missing-verifier outcome. + for value in ["junk", "Bearer a.b.c,d.e.f"] { + let err = evaluate_attached_assertion(&headers(value), None).unwrap_err(); + assert_eq!( + err, + AssertionRejection::Transport(DenialClass::EvidenceRejected), + "{value}" + ); + } + } + + #[test] + fn missing_verifier_is_authorization_unavailable() { + let err = evaluate_attached_assertion(&headers("Bearer a.b.c"), None).unwrap_err(); + assert_eq!(err, AssertionRejection::VerifierUnavailable); + assert_eq!(err.denial_class(), DenialClass::AuthorizationUnavailable); + } + + #[test] + fn verifier_error_keeps_its_denial_class() { + for err in [ + VerifierError::KeySourceUnavailable, + VerifierError::InvalidSignatureOrClaims, + ] { + let v = ScriptedVerifier::new(Err(err)); + let rejection = + evaluate_attached_assertion(&headers("Bearer a.b.c"), Some(&v)).unwrap_err(); + assert_eq!(rejection, AssertionRejection::Verifier(err)); + assert_eq!(rejection.denial_class(), err.denial_class()); + } + } + + #[test] + fn asserted_key_matches_only_the_proven_key() { + let proven = nostr::Keys::generate().public_key(); + let other = nostr::Keys::generate().public_key(); + let at = |k| { + VerifiedAssertion::for_test(k, vec![chrono::Utc::now() + chrono::Duration::hours(1)]) + }; + assert!(asserted_key_matches(&at(Some(proven)), proven)); + assert!(!asserted_key_matches(&at(Some(other)), proven)); + assert!(!asserted_key_matches(&at(None), proven)); + } +} diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index 75b301480f..186e174270 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -26,8 +26,8 @@ //! ## Carrier / precedence //! //! Per NIP-FI.md Β§Client-attached transport: -//! - Assertion: `Nostr-Federated-Identity: Bearer ` (this -//! module's responsibility). +//! - Assertion: `Nostr-Federated-Identity: Bearer ` (extracted +//! and verified by [`crate::nip_fi_core`]). //! - 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 @@ -54,15 +54,17 @@ use axum::{ body::Body, - http::{HeaderMap, Response, StatusCode}, -}; -use buzz_auth::{ - DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion, CLIENT_ATTACHED_HEADER, + http::{HeaderMap, Response}, }; +use buzz_auth::{DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion}; use chrono::{DateTime, Utc}; use nostr::PublicKey; use std::fmt; +use crate::nip_fi_core::{ + asserted_key_matches, evaluate_attached_assertion, http_denial, AssertionRejection, +}; + // ── Deny-map seam ───────────────────────────────────────────────────────────── /// Narrow interface consumed by HTTP enforcement. Production implements it @@ -343,37 +345,29 @@ where // 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()) + // Steps 4–5: extract and verify the assertion. + let assertion = evaluate_attached_assertion(headers, verifier).map_err(|rejection| { + if let AssertionRejection::Verifier(e) = rejection { + tracing::debug!(code = e.code(), "nip-fi assertion denied at http ingress"); + } + http_denial(rejection.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)); - } + if !asserted_key_matches(&assertion, 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. @@ -397,69 +391,6 @@ where }) } -// ── 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, and the shared deny map from @@ -500,6 +431,10 @@ mod tests { // throughout this module β€” it IS the HTTP response returned from tests. #![allow(clippy::result_large_err)] use super::*; + use crate::nip_fi_core::extract_bearer_token; + use crate::nip_fi_core::tests::ScriptedVerifier; + use axum::http::StatusCode; + use buzz_auth::CLIENT_ATTACHED_HEADER; /// Test fixture: a deny map with no entries. struct AlwaysAdmitStubDenyMap; @@ -1271,4 +1206,147 @@ mod tests { "proven_pubkey must be the one returned by the closure" ); } + + // ── Characterization: assertion evaluation and key equality ────────────── + // + // These pin the HTTP admission half of the shared evaluator contract, so a + // refactor that moves evaluation or pairing elsewhere cannot change a + // status, a body, or which step runs first. + + fn bearer_headers(value: &'static str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(CLIENT_ATTACHED_HEADER, HeaderValue::from_static(value)); + headers + } + + fn enforce_outcome( + headers: &HeaderMap, + proven: PublicKey, + verifier: Option<&dyn VerifyAssertion>, + ) -> Result, Response> { + admit_nip_fi_http( + headers, + || Ok(Nip98Proof::new(proven, ())), + verifier, + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ) + } + + fn denial_parts(outcome: Result, Response>) -> (StatusCode, Vec) { + match outcome { + Err(resp) => (resp.status(), body_bytes(resp)), + Ok(_) => panic!("expected a denial"), + } + } + + // Pins: verifier errors map through `VerifierError::denial_class` β€” + // dependency-unavailable β†’ 503, everything else β†’ 403 evidence rejected. + // Mutation: mapping every verifier error to EvidenceRejected fails the + // 503 row. + #[test] + fn characterize_http_verifier_error_classes() { + use buzz_auth::VerifierError; + let headers = bearer_headers("Bearer a.b.c"); + let rows = [ + ( + VerifierError::KeySourceUnavailable, + StatusCode::SERVICE_UNAVAILABLE, + &b"authorization unavailable\n"[..], + ), + ( + VerifierError::StatusWitnessUnavailable, + StatusCode::SERVICE_UNAVAILABLE, + &b"authorization unavailable\n"[..], + ), + ( + VerifierError::InvalidSignatureOrClaims, + StatusCode::FORBIDDEN, + &b"evidence rejected\n"[..], + ), + ( + VerifierError::Expired, + StatusCode::FORBIDDEN, + &b"evidence rejected\n"[..], + ), + ]; + for (err, status, body) in rows { + let verifier = ScriptedVerifier::new(Err(err)); + let (got_status, got_body) = + denial_parts(enforce_outcome(&headers, any_pubkey(), Some(&verifier))); + assert_eq!(got_status, status, "{err:?}"); + assert_eq!(got_body, body, "{err:?}"); + } + } + + // Pins: transport extraction runs before the verifier-presence check, so a + // malformed header with no verifier is 403, not 503. + // Mutation: checking `verifier.is_none()` before `extract_bearer_token` + // turns this into 503. + #[test] + fn characterize_http_transport_precedes_verifier_presence() { + let (status, body) = + denial_parts(enforce_outcome(&bearer_headers("junk"), any_pubkey(), None)); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, b"evidence rejected\n"); + } + + // Pins: a claimless assertion (no `nostr_pubkey`) is a pairing denial β€” + // 403 authorization denied, byte-identical to a key mismatch. [FI-INV-05] + // Mutation: treating `None` as a match admits instead. + #[test] + fn characterize_http_claimless_assertion_is_authorization_denied() { + let verifier = ScriptedVerifier::new(Ok(None)); + let (status, body) = denial_parts(enforce_outcome( + &bearer_headers("Bearer a.b.c"), + any_pubkey(), + Some(&verifier), + )); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, b"authorization denied\n"); + } + + // Pins: a matching assertion is admitted, verified exactly once, and the + // admission carries the proven key and the verified assertion. + // Mutation: dropping the assertion from the admission, or verifying twice + // in the handler path, fails the assertions. + #[test] + fn characterize_http_matching_assertion_admits_after_one_verify() { + let proven = any_pubkey(); + let verifier = ScriptedVerifier::new(Ok(Some(proven))); + let admission = + match enforce_outcome(&bearer_headers("Bearer a.b.c"), proven, Some(&verifier)) { + Ok(a) => a, + Err(resp) => panic!("matching assertion must admit, got {}", resp.status()), + }; + assert_eq!(verifier.calls(), 1); + assert_eq!(*admission.proven_pubkey(), proven); + assert_eq!( + admission.assertion().and_then(|a| a.asserted_key()), + Some(proven) + ); + } + + // Pins: NIP-98 proof runs before assertion extraction. A failed NIP-98 + // proof with a missing assertion header is 401 from the NIP-98 step and + // the verifier never runs. This is the reverse of NIP-FI.md Β§Admission + // procedure. On guarded routes clients still see the spec order, because + // the router's assertion guard verifies the assertion before the handler. + // Mutation: moving assertion evaluation ahead of the NIP-98 closure makes + // the verifier run (call count 1) for a valid-looking header. + #[test] + fn characterize_http_nip98_precedes_assertion_evaluation() { + let verifier = ScriptedVerifier::new(Ok(Some(any_pubkey()))); + let outcome = admit_nip_fi_http::<_, (), _>( + &bearer_headers("Bearer a.b.c"), + || Err(http_denial(DenialClass::MissingEvidence)), + Some(&verifier as &dyn VerifyAssertion), + NipFiMode::Enforce, + &AlwaysAdmitStubDenyMap, + ); + let (status, body) = denial_parts(outcome); + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body, b"authentication required\n"); + assert_eq!(verifier.calls(), 0, "verifier must not run before NIP-98"); + } } diff --git a/crates/buzz-relay/src/nip_fi_session.rs b/crates/buzz-relay/src/nip_fi_session.rs index fc9926ffb9..8fff0f9d46 100644 --- a/crates/buzz-relay/src/nip_fi_session.rs +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -85,7 +85,7 @@ pub(crate) async fn enforce_nip_fi_key_pairing( }; // Matching key β†’ pass. - if matches!(assertion.asserted_key(), Some(k) if k == proven_pubkey) { + if crate::nip_fi_core::asserted_key_matches(assertion, proven_pubkey) { return PairingOutcome::Paired; } @@ -224,12 +224,12 @@ pub(crate) fn spawn_nip_fi_expiry_task( #[cfg(test)] mod tests { use super::*; + use crate::connection::tests::{pending_state, test_conn, TestConn}; use chrono::Utc; use nostr::Keys; use std::sync::Arc; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; - use uuid::Uuid; // ── B3: terminal denial frame survives saturated ctrl_tx ────────────────── // @@ -251,51 +251,26 @@ mod tests { let assertion = buzz_auth::VerifiedAssertion::for_test(Some(keys.public_key()), vec![deadline]); - let (send_tx, _send_rx) = mpsc::channel(4); - let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); - let (terminal_ctrl_tx, mut terminal_rx) = mpsc::channel::(1); + let TestConn { + conn, + ctrl_rx: _ctrl_rx, + mut terminal_rx, + .. + } = test_conn(pending_state(), Some(assertion)); // Saturate ctrl_tx to capacity 8. for i in 0..8u8 { - ctrl_tx + conn.ctrl_tx .try_send(WsMessage::Text(format!("ordinary-{i}").into())) .expect("ctrl_tx has capacity 8"); } assert!( - ctrl_tx + conn.ctrl_tx .try_send(WsMessage::Text("overflow".into())) .is_err(), "ctrl_tx must be full before the test exercises the denial path" ); - let conn = Arc::new(crate::connection::ConnectionState { - conn_id: Uuid::new_v4(), - tenant: buzz_core::tenant::TenantContext::resolved( - buzz_core::CommunityId::from_uuid(Uuid::nil()), - "test.local".to_string(), - ), - remote_addr: "127.0.0.1:1234".parse().unwrap(), - auth_state: std::sync::Mutex::new(crate::connection::AuthState::Pending { - challenge: "test-challenge".to_string(), - started_at: std::time::Instant::now(), - }), - subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), - send_tx, - ctrl_tx, - terminal_ctrl_tx, - cancel: CancellationToken::new(), - backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), - grace_limit: 3, - nip_fi_assertion: Some(assertion), - session_deadline: None, - nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( - CancellationToken::new(), - ), - community_control: crate::state::CommunityConnectionControl::new( - CancellationToken::new(), - ), - }); - // Use a different key as the proven pubkey β†’ forced mismatch. let wrong_pubkey = Keys::generate().public_key(); let outcome = enforce_nip_fi_key_pairing( @@ -472,4 +447,63 @@ mod tests { assert!(denial_frames(&mut terminal_rx).is_empty()); } + + // ── Characterization: WS key-pairing predicate ─────────────────────────── + + fn root_conn_with( + assertion: Option, + ) -> ( + Arc, + mpsc::Receiver, + ) { + let TestConn { + conn, terminal_rx, .. + } = test_conn(pending_state(), assertion); + (conn, terminal_rx) + } + + fn assertion_for(key: Option) -> buzz_auth::VerifiedAssertion { + buzz_auth::VerifiedAssertion::for_test(key, vec![Utc::now() + chrono::Duration::hours(1)]) + } + + // Pins: a claimless assertion (no `nostr_pubkey`) is a pairing denial on + // WS with the full terminal effect (denial frame + cancel). [FI-INV-05] + // Mutation: treating a missing claim as a match returns Paired. + #[tokio::test] + async fn characterize_pairing_claimless_assertion_denies() { + let (conn, mut terminal_rx) = root_conn_with(Some(assertion_for(None))); + let outcome = enforce_nip_fi_key_pairing( + conn.nip_fi_assertion.as_ref(), + Keys::generate().public_key(), + PairingDenialTarget::Root(conn.as_ref()), + ) + .await; + assert_eq!(outcome, PairingOutcome::Denied); + assert!(conn.cancel.is_cancelled()); + assert!( + terminal_rx.try_recv().is_ok(), + "denial frame must be queued" + ); + } + + // Pins: a matching claim pairs with no side effects, and no assertion + // (Off mode) pairs unconditionally. + // Mutation: inverting the equality, or denying when no assertion is + // present, fails these rows. + #[tokio::test] + async fn characterize_pairing_matching_or_absent_assertion_pairs() { + let proven = Keys::generate().public_key(); + for assertion in [Some(assertion_for(Some(proven))), None] { + let (conn, mut terminal_rx) = root_conn_with(assertion); + let outcome = enforce_nip_fi_key_pairing( + conn.nip_fi_assertion.as_ref(), + proven, + PairingDenialTarget::Root(conn.as_ref()), + ) + .await; + assert_eq!(outcome, PairingOutcome::Paired); + assert!(!conn.cancel.is_cancelled()); + assert!(terminal_rx.try_recv().is_err(), "no denial frame on a pair"); + } + } } diff --git a/crates/buzz-relay/src/nip_fi_upgrade.rs b/crates/buzz-relay/src/nip_fi_upgrade.rs index 3dd6413216..430d85f234 100644 --- a/crates/buzz-relay/src/nip_fi_upgrade.rs +++ b/crates/buzz-relay/src/nip_fi_upgrade.rs @@ -1,7 +1,8 @@ //! NIP-FI assertion validation at WebSocket upgrade. //! -//! Header parsing and the HTTP denial contract are shared with HTTP ingress: -//! both come from [`crate::nip_fi_http`], so the transports cannot drift. +//! Assertion evaluation and the HTTP denial contract are shared with HTTP +//! ingress: both come from [`crate::nip_fi_core`], so the transports cannot +//! drift. //! //! Per [NIP-FI.md](../../../docs/nips/NIP-FI.md) Β§Client-attached transport: //! - Exactly one `Nostr-Federated-Identity: Bearer ` field. @@ -14,7 +15,7 @@ use axum::body::Body; use axum::http::{HeaderMap, Response}; use buzz_auth::{DenialClass, NipFiMode, VerifiedAssertion, VerifyAssertion}; -use crate::nip_fi_http::{extract_bearer_token, http_denial}; +use crate::nip_fi_core::{evaluate_attached_assertion, http_denial, AssertionRejection}; /// Outcome of NIP-FI assertion validation at upgrade time. pub(crate) enum NipFiUpgradeOutcome { @@ -54,24 +55,13 @@ pub(crate) fn check_nip_fi_at_upgrade( } // Enforce mode: validate the assertion. - let token = match extract_bearer_token(headers) { - Ok(t) => t, - Err(class) => return NipFiUpgradeOutcome::Denied(http_denial(class)), - }; - - let verifier = match verifier { - Some(v) => v, - None => { - // Verifier not yet constructed (startup race); fail closed. - return NipFiUpgradeOutcome::Denied(http_denial(DenialClass::AuthorizationUnavailable)); - } - }; - - match verifier.verify_assertion(token) { + match evaluate_attached_assertion(headers, verifier) { Ok(assertion) => NipFiUpgradeOutcome::Admitted(assertion), - Err(err) => { - tracing::debug!(code = err.code(), "nip-fi assertion denied at upgrade"); - NipFiUpgradeOutcome::Denied(http_denial(err.denial_class())) + Err(rejection) => { + if let AssertionRejection::Verifier(err) = rejection { + tracing::debug!(code = err.code(), "nip-fi assertion denied at upgrade"); + } + NipFiUpgradeOutcome::Denied(http_denial(rejection.denial_class())) } } } @@ -79,6 +69,8 @@ pub(crate) fn check_nip_fi_at_upgrade( #[cfg(test)] mod tests { use super::*; + use crate::nip_fi_core::extract_bearer_token; + use crate::nip_fi_core::tests::ScriptedVerifier; use axum::http::{HeaderValue, StatusCode}; use buzz_auth::CLIENT_ATTACHED_HEADER; @@ -422,4 +414,96 @@ mod tests { _ => panic!("DenyProtected must return Denied(503), not NotRequired or Admitted"), } } + + // ── Characterization: upgrade evaluation contract ──────────────────────── + + fn denied_parts(outcome: NipFiUpgradeOutcome) -> (StatusCode, Vec) { + match outcome { + NipFiUpgradeOutcome::Denied(resp) => (resp.status(), body_bytes(resp)), + _ => panic!("expected Denied"), + } + } + + // Pins: upgrade maps verifier errors through `VerifierError::denial_class` + // (503 for unavailable dependencies, 403 evidence rejected otherwise). + // Mutation: mapping every verifier error to EvidenceRejected fails the + // 503 row. + #[test] + fn characterize_upgrade_verifier_error_classes() { + use buzz_auth::VerifierError; + let rows = [ + ( + VerifierError::KeySourceUnavailable, + StatusCode::SERVICE_UNAVAILABLE, + &b"authorization unavailable\n"[..], + ), + ( + VerifierError::InvalidSignatureOrClaims, + StatusCode::FORBIDDEN, + &b"evidence rejected\n"[..], + ), + ]; + for (err, status, body) in rows { + let verifier = ScriptedVerifier::new(Err(err)); + let (got_status, got_body) = denied_parts(check_nip_fi_at_upgrade( + &headers_with("Bearer a.b.c"), + Some(&verifier), + NipFiMode::Enforce, + )); + assert_eq!(got_status, status, "{err:?}"); + assert_eq!(got_body, body, "{err:?}"); + } + } + + // Pins: transport extraction precedes the verifier-presence check. + // Mutation: checking the verifier first turns this 403 into 503. + #[test] + fn characterize_upgrade_transport_precedes_verifier_presence() { + let (status, body) = denied_parts(check_nip_fi_at_upgrade( + &headers_with("junk"), + None, + NipFiMode::Enforce, + )); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, b"evidence rejected\n"); + } + + // Pins: upgrade does NOT perform key pairing β€” a claimless assertion is + // admitted here and carried into the session, where NIP-42 pairing denies + // it later. Mutation: adding a pairing check at upgrade denies instead. + #[test] + fn characterize_upgrade_admits_claimless_assertion_for_later_pairing() { + let verifier = ScriptedVerifier::new(Ok(None)); + match check_nip_fi_at_upgrade( + &headers_with("Bearer a.b.c"), + Some(&verifier), + NipFiMode::Enforce, + ) { + NipFiUpgradeOutcome::Admitted(assertion) => { + assert_eq!(assertion.asserted_key(), None); + } + _ => panic!("claimless assertion must be admitted at upgrade"), + } + assert_eq!(verifier.calls(), 1); + } + + // Pins: Off and DenyProtected short-circuit before the verifier runs, even + // with a valid-looking header. Mutation: evaluating before the mode checks + // makes the call count non-zero. + #[test] + fn characterize_upgrade_mode_short_circuits_skip_verifier() { + let verifier = ScriptedVerifier::new(Ok(Some(nostr::Keys::generate().public_key()))); + let headers = headers_with("Bearer a.b.c"); + assert!(matches!( + check_nip_fi_at_upgrade(&headers, Some(&verifier), NipFiMode::Off), + NipFiUpgradeOutcome::NotRequired + )); + let (status, _) = denied_parts(check_nip_fi_at_upgrade( + &headers, + Some(&verifier), + NipFiMode::DenyProtected, + )); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(verifier.calls(), 0); + } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index b20ee7d43e..13fb6afe49 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,7 +24,7 @@ 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::nip_fi_core::http_denial; use crate::readiness::{self, DependencySnapshot, ReadinessReason}; use crate::state::AppState; @@ -88,7 +88,7 @@ use crate::state::AppState; // 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. +// evaluate the assertion through `nip_fi_core.rs`; the guard fires first. /// Path prefixes that are exempt from NIP-FI assertion enforcement. /// @@ -177,7 +177,6 @@ async fn nip_fi_assertion_guard( 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] @@ -234,31 +233,18 @@ async fn nip_fi_assertion_guard( 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) { + // Enforce mode: full offline assertion verification (transport, then + // 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-TRANSPORT-CLOSED] [FI-TRACE-AUTHORITY-UNIFORM] + match crate::nip_fi_core::evaluate_attached_assertion( + request.headers(), + state.nip_fi_verifier.as_deref(), + ) { Ok(_) => next.run(request).await, - Err(e) => http_denial(e.denial_class()), + Err(rejection) => http_denial(rejection.denial_class()), } } @@ -975,6 +961,7 @@ mod tests { use tracing_subscriber::prelude::*; use super::*; + use crate::nip_fi_core::tests::ScriptedVerifier; use crate::readiness::DependencyReport; struct ScriptedDependencyEvaluator { @@ -2602,6 +2589,152 @@ mod tests { "F6: response body must be NIP-11 JSON with `supported_nips` field; got {body}" ); } + + /// Verifier whose first call admits `key` and whose second call + /// returns `second`. Counts every call. + struct TwoStepVerifier { + key: nostr::PublicKey, + second: Result<(), buzz_auth::VerifierError>, + calls: std::sync::atomic::AtomicUsize, + } + impl buzz_auth::VerifyAssertion for TwoStepVerifier { + fn verify_assertion( + &self, + _token: &str, + ) -> Result { + let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n >= 1 { + self.second?; + } + Ok(buzz_auth::VerifiedAssertion::for_test( + Some(self.key), + vec![chrono::Utc::now() + chrono::Duration::hours(1)], + )) + } + } + + /// Send a NIP-98-signed, assertion-carrying GET for a workflow's runs + /// through the real router on a seeded Host. Returns the response and + /// the verifier call count. + async fn routed_workflow_runs( + second: Result<(), buzz_auth::VerifierError>, + ) -> (axum::http::StatusCode, Vec, usize) { + use axum::body::Body; + use axum::http::Request; + use base64::Engine as _; + use tower::ServiceExt; + use uuid::Uuid; + + let base = real_db_state() + .await + .expect("PostgreSQL must be available β€” set BUZZ_TEST_DATABASE_URL"); + let pool = base.db.pool().clone(); + let community_id = Uuid::new_v4(); + let host = format!("reverify-{}.example", community_id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(community_id) + .bind(&host) + .execute(&pool) + .await + .expect("seed community"); + + let keys = nostr::Keys::generate(); + let verifier = Arc::new(TwoStepVerifier { + key: keys.public_key(), + second, + calls: std::sync::atomic::AtomicUsize::new(0), + }); + let mut state = (*base).clone(); + let mut config = (*state.config).clone(); + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + state.config = Arc::new(config); + state.nip_fi_verifier = Some(verifier.clone()); + + let path = format!("/workflows/{}/runs", Uuid::new_v4()); + let scheme = if state.config.relay_url.trim_start().starts_with("wss://") { + "https" + } else { + "http" + }; + let url = format!("{scheme}://{host}{path}"); + let event = nostr::EventBuilder::new(nostr::Kind::HttpAuth, "") + .tags([ + nostr::Tag::parse(["u", url.as_str()]).expect("u tag"), + nostr::Tag::parse(["method", "GET"]).expect("method tag"), + ]) + .sign_with_keys(&keys) + .expect("sign NIP-98"); + let auth = format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&event).expect("event json")) + ); + + let req = Request::get(&path) + .header(axum::http::header::HOST, &host) + .header(axum::http::header::AUTHORIZATION, auth) + .header("Nostr-Federated-Identity", "Bearer a.b.c") + .body(Body::empty()) + .expect("request"); + let resp = build_router(Arc::new(state)) + .oneshot(req) + .await + .expect("router response"); + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 64 * 1024) + .await + .expect("body") + .to_vec(); + let calls = verifier.calls.load(std::sync::atomic::Ordering::SeqCst); + (status, body, calls) + } + + // Pins guard + handler double verification on one routed request: the + // guard's verify succeeds, handler admission re-verifies and its + // failure decides the response. + // Mutation: reusing the guard's verdict in admission (skipping the + // second verify) admits the request and the count is 1. + #[tokio::test] + #[ignore = "requires Postgres β€” runs in postgres-ci nextest lane"] + async fn characterize_routed_request_reverifies_in_admission() { + use buzz_auth::VerifierError; + let rows = [ + ( + VerifierError::Expired, + axum::http::StatusCode::FORBIDDEN, + &b"evidence rejected\n"[..], + ), + ( + VerifierError::KeySourceUnavailable, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + &b"authorization unavailable\n"[..], + ), + ]; + for (err, status, body) in rows { + let (got_status, got_body, calls) = routed_workflow_runs(Err(err)).await; + assert_eq!(calls, 2, "guard and admission each verify once: {err:?}"); + assert_eq!(got_status, status, "{err:?}"); + assert_eq!(got_body, body, "{err:?}"); + } + } + + // Control: both verifies succeed, so the request passes NIP-FI + // admission with two verifies and is answered by the handler's next + // step, rate-limit admission, which fails closed because this + // fixture's Redis is unreachable. + #[tokio::test] + #[ignore = "requires Postgres β€” runs in postgres-ci nextest lane"] + async fn characterize_routed_request_passes_after_two_verifies() { + let (status, body, calls) = routed_workflow_runs(Ok(())).await; + assert_eq!(calls, 2); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = + serde_json::from_slice(&body).expect("rate-limit response is JSON"); + assert_eq!( + body, + serde_json::json!({"error": "rate-limited: shared admission unavailable"}) + ); + } } // ── nip_fi_assertion_guard: fail-closed classification tests ───────────── @@ -3176,7 +3309,7 @@ mod tests { // 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 crate::nip_fi_core::extract_bearer_token; use axum::http::HeaderMap; use buzz_auth::CLIENT_ATTACHED_HEADER; @@ -3551,4 +3684,108 @@ mod tests { [FI-TRACE-DENIAL-ORACLE]" ); } + + // ── Characterization: HTTP guard evaluation contract ───────────────────── + + const GUARD_PROTECTED_PATH: &str = "/workflows/wf/runs"; + + async fn guard_state_with( + result: Result, buzz_auth::VerifierError>, + ) -> (Arc, Arc) { + let verifier = Arc::new(ScriptedVerifier::new(result)); + let mut state = (*nip_fi_enforce_state().await).clone(); + state.nip_fi_verifier = Some(verifier.clone()); + (Arc::new(state), verifier) + } + + async fn status_and_body(resp: axum::response::Response) -> (axum::http::StatusCode, Vec) { + let status = resp.status(); + let body = axum::body::to_bytes(resp.into_body(), 4096) + .await + .expect("body bytes"); + (status, body.to_vec()) + } + + // Pins: the guard maps verifier errors through + // `VerifierError::denial_class` β€” 503 for an unavailable dependency, 403 + // evidence rejected otherwise β€” before any handler runs. + // Mutation: mapping every verifier error to EvidenceRejected fails the 503 + // row; deleting the guard's verify call lets the handler answer instead. + #[tokio::test] + async fn characterize_guard_verifier_error_classes() { + use buzz_auth::VerifierError; + let rows = [ + ( + VerifierError::KeySourceUnavailable, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + &b"authorization unavailable\n"[..], + ), + ( + VerifierError::InvalidSignatureOrClaims, + axum::http::StatusCode::FORBIDDEN, + &b"evidence rejected\n"[..], + ), + ]; + for (err, status, body) in rows { + let (state, verifier) = guard_state_with(Err(err)).await; + let resp = nip_fi_gate_response( + state, + GUARD_PROTECTED_PATH, + Some("Nostr-Federated-Identity"), + Some("Bearer a.b.c"), + ) + .await; + assert_eq!( + status_and_body(resp).await, + (status, body.to_vec()), + "{err:?}" + ); + assert_eq!( + verifier.calls(), + 1, + "guard verifies exactly once and the handler never runs: {err:?}" + ); + } + } + + // Pins: transport extraction precedes the verifier-presence check in the + // guard (the enforce fixture has no verifier). + // Mutation: checking the verifier first turns this 403 into 503. + #[tokio::test] + async fn characterize_guard_transport_precedes_verifier_presence() { + let resp = nip_fi_gate_response( + nip_fi_enforce_state().await, + GUARD_PROTECTED_PATH, + Some("Nostr-Federated-Identity"), + Some("junk"), + ) + .await; + assert_eq!( + status_and_body(resp).await, + ( + axum::http::StatusCode::FORBIDDEN, + b"evidence rejected\n".to_vec() + ) + ); + } + + // Pins: the guard verifies but does not pair keys. A claimless assertion + // passes the guard (one verify) and reaches the handler, whose path + // extractor rejects the non-UUID workflow id with 400 before any NIP-98 + // or handler-side assertion work. + // Mutation: adding key pairing to the guard turns this into 403 + // authorization denied. + #[tokio::test] + async fn characterize_guard_does_not_pair_keys() { + let (state, verifier) = guard_state_with(Ok(None)).await; + let resp = nip_fi_gate_response( + state, + GUARD_PROTECTED_PATH, + Some("Nostr-Federated-Identity"), + Some("Bearer a.b.c"), + ) + .await; + assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!(verifier.calls(), 1); + } } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index c302ad9f93..3b4a59743f 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -219,6 +219,9 @@ run_unit_tests() { run_test_step "buzz-relay router tests" \ cargo test -p buzz-relay --lib router::tests:: -- --nocapture + run_test_step "buzz-relay NIP-FI shared core tests" \ + cargo test -p buzz-relay --lib nip_fi_core::tests:: -- --nocapture + run_test_step "buzz-relay NIP-FI HTTP ingress tests" \ cargo test -p buzz-relay --lib nip_fi_http::tests:: -- --nocapture