From 906500cfda58787ffb5f05ffba8cd0fd1b87d546 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 29 Sep 2026 15:15:30 -0400 Subject: [PATCH 01/14] feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) Adds the NIP-FI admin command endpoint, a bounded per-issuer deny-until-TTL map, and cross-pod disconnect fan-out. The deny check runs after each root and audio socket registers its proven identity, so a concurrent disconnect either finds the socket in its close scan or the check finds the entry. Hooks attach at the S3 admission and pairing points without changing S3 terminal-frame or close-code behavior. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Cargo.lock | 1 + Justfile | 17 +- crates/buzz-auth/Cargo.toml | 1 + crates/buzz-auth/src/lib.rs | 16 +- crates/buzz-auth/src/nip_fi/command.rs | 1300 ++++++++++ crates/buzz-auth/src/nip_fi/deny_map.rs | 1167 +++++++++ crates/buzz-auth/src/nip_fi/jwks/mod.rs | 33 +- crates/buzz-auth/src/nip_fi/mod.rs | 7 + crates/buzz-auth/src/nip_fi/verifier.rs | 31 +- crates/buzz-pubsub/src/conn_control.rs | 170 ++ crates/buzz-pubsub/src/lib.rs | 43 + crates/buzz-relay/src/api/admin/mod.rs | 24 + crates/buzz-relay/src/api/bridge.rs | 1 + crates/buzz-relay/src/api/mod.rs | 1 + crates/buzz-relay/src/api/nip_fi.rs | 2486 ++++++++++++++++++++ crates/buzz-relay/src/audio/handler.rs | 1118 ++++++++- crates/buzz-relay/src/connection.rs | 9 + crates/buzz-relay/src/handlers/auth.rs | 448 +++- crates/buzz-relay/src/handlers/count.rs | 1 + crates/buzz-relay/src/handlers/event.rs | 31 + crates/buzz-relay/src/handlers/req.rs | 11 + crates/buzz-relay/src/main.rs | 67 +- crates/buzz-relay/src/nip_fi_config.rs | 293 ++- crates/buzz-relay/src/nip_fi_http.rs | 102 +- crates/buzz-relay/src/nip_fi_session.rs | 3 + crates/buzz-relay/src/nip_fi_test_hooks.rs | 13 + crates/buzz-relay/src/router.rs | 321 +++ crates/buzz-relay/src/state.rs | 674 +++++- scripts/run-tests.sh | 14 + 29 files changed, 8317 insertions(+), 86 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/command.rs create mode 100644 crates/buzz-auth/src/nip_fi/deny_map.rs create mode 100644 crates/buzz-relay/src/api/nip_fi.rs diff --git a/Cargo.lock b/Cargo.lock index e57532c25cc..7c80fe2b862 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1066,6 +1066,7 @@ dependencies = [ "base64 0.22.1", "buzz-core", "chrono", + "dashmap", "futures-util", "hex", "jsonwebtoken", diff --git a/Justfile b/Justfile index a5f72d09323..53284f9b0d0 100644 --- a/Justfile +++ b/Justfile @@ -480,7 +480,8 @@ test-unit: # abnormal-stream-close fanout, and never-ready-sink writer witnesses — # are all selected by audio::join::tests and audio::handler::tests. # DB-backed audio join tests use #[ignore] and run in the postgres lane. - # NIP-FI (S3) relay witnesses: the wholly-new nip_fi_upgrade module, + # NIP-FI (S3/S4) relay witnesses: the wholly-new nip_fi_upgrade and + # api::nip_fi modules, # the auth metrics contract module, plus the exact NIP-FI tests added, # or whose assertions changed, in mixed modules (audio::room, # connection, handlers::*, state). nip_fi_config and router are @@ -517,7 +518,19 @@ test-unit: + test(=handlers::event::tests::p1b_agent_observer_event_barrier_expiry_blocks_fanout_and_ack) + test(=handlers::req::tests::p1a_huddle_liveness_req_barrier_expiry_blocks_query_and_emission) + test(=state::tests::f3_cancellation_during_check_terminates_socket_without_waiting_for_check) - + test(=state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit)' + + test(=state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit) + + test(=state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection) + + test(=state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped) + + test(=state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason) + + test(=state::tests::nip_fi_disconnect_audio_is_issuer_scoped) + + test(=state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason) + + test(=state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer) + + test(=state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket) + + test(=state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket) + + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + + test(=state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(/^api::nip_fi::/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/crates/buzz-auth/Cargo.toml b/crates/buzz-auth/Cargo.toml index 6cbe491e2c8..93833103dbc 100644 --- a/crates/buzz-auth/Cargo.toml +++ b/crates/buzz-auth/Cargo.toml @@ -20,6 +20,7 @@ tokio = { workspace = true, features = ["test-util"] } buzz-core = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } +dashmap = { workspace = true } jsonwebtoken = { workspace = true } nostr = { workspace = true } futures-util = { workspace = true } diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 28702f9c0b3..7dc2b59b532 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -47,13 +47,15 @@ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, - ClientSubjectPosture, ConfidentialAssertion, DenialClass, FederatedAssertionVerifier, - FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, - IssuerJwksConfig, IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, - JwksFetchError, JwksFetcher, JwksSourceContract, NipFiMode, NipFiStartupError, - ProductionJwksSource, RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, - TransportContractId, VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, - NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, + ClientSubjectPosture, CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, + CommandVerifier, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull, + FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, + HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy, + IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, + NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, + SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, + VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, + MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs new file mode 100644 index 00000000000..3e4627f035c --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -0,0 +1,1300 @@ +//! NIP-FI command JWT verification — `VerifyCommandJwt`. +//! +//! Implements the `VerifyCommandJwt` procedure from +//! [NIP-FI.md](../../../../docs/nips/NIP-FI.md) §Admin disconnect API. +//! +//! ## Procedure (steps numbered as in the spec) +//! +//! 1. Bounded decode + `typ` check (`nip-fi-command+jwt` only). +//! 2. Select issuer policy; verify signature with authenticated JWKS. +//! 3. Validate all pure claims: iss, aud, time bounds, method/path/cmd, +//! target_pubkey, and until ceiling. +//! 4. Principal authorization (issuer-configured authorized `sub` list). +//! 5. Signed-target / request-body agreement. +//! 6. Atomic jti reservation + deny-entry insertion (both-or-neither). +//! 7. Return [`CommandResult`]. +//! +//! Fail-closed: any failure returns an error without side effects. The jti is +//! burned and the deny entry is inserted only on success. + +use chrono::{DateTime, Utc}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use nostr::PublicKey; +use serde_json::{Map, Value}; + +use super::config::{IssuerPolicy, IssuerRegistry, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES}; +use super::deny_map::{NipFiDenyMap, ReserveError}; +use super::verifier::{ + enforce_compact_structure, enforce_signature_shape, parse_header, parse_numeric_date, + parse_unique_claims, select_unique_jwk, validate_jwk, AssertionKeySet, IssuerKeySource, + VerifierError, +}; + +/// The expected `typ` value for command JWTs ([NIP-FI.md §Command JWT]). +pub const COMMAND_JWT_TYP: &str = "nip-fi-command+jwt"; +/// The expected `cmd` claim value. +const COMMAND_CMD: &str = "disconnect"; +/// Normative upper bound on `maximum_command_age_seconds` per the spec. +pub const MAX_COMMAND_AGE_SECONDS: u64 = 60; + +// ── Per-issuer command policy ───────────────────────────────────────────────── + +/// The per-issuer configuration additions required by the command API. +/// +/// These supplement the base [`IssuerPolicy`]: `maximum_command_age` and the +/// set of authorized issuer principals (the `sub` values allowed to send +/// commands). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandIssuerPolicy { + issuer: String, + /// `0 < maximum_command_age_seconds <= 60`. + maximum_command_age_seconds: u64, + /// Non-empty set of authorized `sub` values. + authorized_principals: Vec, + /// Hard ceiling on the number of live deny entries for this issuer. + deny_set_capacity: usize, +} + +/// Why a [`CommandIssuerPolicy`] could not be constructed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CommandPolicyError { + /// `maximum_command_age_seconds` was 0 or > 60 (normative bound). + #[error("maximum_command_age_seconds must be in [1, 60]")] + InvalidCommandAge, + /// The authorized principals list was empty. + #[error("authorized_principals must be non-empty")] + EmptyAuthorizedPrincipals, + /// A principal string was empty or too long. + #[error("an authorized principal value is invalid")] + InvalidPrincipal, + /// `deny_set_capacity` was 0. + #[error("deny_set_capacity must be positive")] + ZeroCapacity, + /// The issuer string was empty. + #[error("issuer must be non-empty")] + EmptyIssuer, +} + +impl CommandIssuerPolicy { + /// Validate and construct a command policy. + pub fn new( + issuer: String, + maximum_command_age_seconds: u64, + authorized_principals: Vec, + deny_set_capacity: usize, + ) -> Result { + if issuer.is_empty() { + return Err(CommandPolicyError::EmptyIssuer); + } + if maximum_command_age_seconds == 0 || maximum_command_age_seconds > MAX_COMMAND_AGE_SECONDS + { + return Err(CommandPolicyError::InvalidCommandAge); + } + if authorized_principals.is_empty() { + return Err(CommandPolicyError::EmptyAuthorizedPrincipals); + } + if authorized_principals + .iter() + .any(|p| p.is_empty() || p.len() > MAX_SUBJECT_BYTES) + { + return Err(CommandPolicyError::InvalidPrincipal); + } + if deny_set_capacity == 0 { + return Err(CommandPolicyError::ZeroCapacity); + } + Ok(Self { + issuer, + maximum_command_age_seconds, + authorized_principals, + deny_set_capacity, + }) + } + + /// The exact `iss` this policy applies to. + pub fn issuer(&self) -> &str { + &self.issuer + } + + /// `0 < maximum_command_age_seconds <= 60`. + pub const fn maximum_command_age_seconds(&self) -> u64 { + self.maximum_command_age_seconds + } + + /// Non-empty set of authorized `sub` values. + pub fn authorized_principals(&self) -> &[String] { + &self.authorized_principals + } + + /// Hard ceiling on the number of live deny entries for this issuer. + pub const fn deny_set_capacity(&self) -> usize { + self.deny_set_capacity + } +} + +// ── Command verifier ────────────────────────────────────────────────────────── + +/// The sealed result of a successful `VerifyCommandJwt` call. +/// +/// Side effects (jti reservation + deny entry) have been committed atomically +/// before this is returned. The caller should proceed to close matching +/// sessions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandResult { + /// The target pubkey from the signed JWT (and verified against the body). + pub target_pubkey: PublicKey, + /// Issuer URI of the authorized caller. + pub caller_iss: String, + /// `sub` of the authorized caller. + pub caller_sub: String, + /// The `until` timestamp from the signed JWT. + pub until: DateTime, +} + +/// Errors from [`CommandVerifier::verify`]. +/// +/// Each variant maps to an exact HTTP status and response body per the spec. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum CommandError { + /// Malformed, invalid, or expired command JWT → 403. + #[error("evidence rejected")] + EvidenceRejected, + /// Principal not authorized; signed-target mismatch; replayed jti → 403. + #[error("authorization denied")] + AuthorizationDenied, + /// Per-issuer deny-set capacity exceeded → 503. Neither the jti nor the + /// deny entry was recorded; the caller may safely retry the same command. + #[error("deny set full")] + DenySetFull, + /// JWKS snapshot unavailable → 503. + #[error("authorization unavailable")] + AuthorizationUnavailable, + /// `until` exceeds the allowed ceiling → 400. + #[error("until exceeds ceiling")] + UntilExceedsCeiling, + /// Malformed request body → 400. + #[error("malformed request")] + MalformedRequest, +} + +impl CommandError { + /// HTTP status code for the command API endpoint response. + pub const fn http_status(self) -> u16 { + match self { + Self::EvidenceRejected | Self::AuthorizationDenied => 403, + Self::DenySetFull | Self::AuthorizationUnavailable => 503, + Self::UntilExceedsCeiling | Self::MalformedRequest => 400, + } + } + + /// Spec-exact response body bytes (trailing `\n` included). + pub const fn response_body(self) -> &'static str { + match self { + Self::EvidenceRejected => "evidence rejected\n", + Self::AuthorizationDenied => "authorization denied\n", + Self::DenySetFull => "deny set full\n", + Self::AuthorizationUnavailable => "authorization unavailable\n", + Self::UntilExceedsCeiling | Self::MalformedRequest => "bad request\n", + } + } +} + +/// The NIP-FI command JWT verifier. +/// +/// Holds a reference to the shared issuer registry (for policy + JWKS lookup), +/// the key source, the per-issuer command policies, and the deny map it writes +/// to. +/// +/// `S` must be `Clone` so the verifier can be shared cheaply via `Arc::clone`. +pub struct CommandVerifier { + registry: IssuerRegistry, + key_source: S, + /// Indexed by exact `iss`. + command_policies: std::collections::HashMap, + deny_map: NipFiDenyMap, +} + +impl CommandVerifier { + /// Construct a command verifier. + /// + /// `command_policies` must cover every issuer that may send commands; + /// an unlisted issuer is rejected as `EvidenceRejected`. + pub fn new( + registry: IssuerRegistry, + key_source: S, + command_policies: Vec, + deny_map: NipFiDenyMap, + ) -> Self { + let map = command_policies + .into_iter() + .map(|p| (p.issuer.clone(), p)) + .collect(); + Self { + registry, + key_source, + command_policies: map, + deny_map, + } + } + + /// A shared reference to the deny map this verifier writes to. + /// + /// S5 (HTTP enforcement) reads this same map from the shared `AppState` + /// without going through the verifier. The reference here is for callers + /// that need to pass the map into the WS admission check. + pub fn deny_map(&self) -> &NipFiDenyMap { + &self.deny_map + } + + /// Execute `VerifyCommandJwt` at current clock time. + /// + /// Parameters: + /// * `token` — compact JWS from `Nostr-Federated-Identity: Bearer`. + /// * `request_method` — HTTP method (expected `"POST"`). + /// * `request_path` — HTTP path (expected `"/api/nip-fi/disconnect"`). + /// * `body_pubkey` — the `pubkey` field parsed from the JSON body. + /// + /// On `Ok`, the jti is reserved and the deny entry is inserted. + /// On `Err`, no side effects have occurred (or, on `DenySetFull`, neither + /// mutation was applied, so retry is safe). + pub fn verify( + &self, + token: &str, + request_method: &str, + request_path: &str, + body_pubkey: &PublicKey, + ) -> Result { + self.verify_at(token, request_method, request_path, body_pubkey, Utc::now()) + } + + /// Verify with an injectable clock for deterministic testing. + pub fn verify_at( + &self, + token: &str, + request_method: &str, + request_path: &str, + body_pubkey: &PublicKey, + now: DateTime, + ) -> Result { + // ── Step 1: bounded decode + typ check ─────────────────────────────── + if token.is_empty() || token.len() > MAX_TOKEN_BYTES { + return Err(CommandError::EvidenceRejected); + } + enforce_compact_structure(token).map_err(|_| CommandError::EvidenceRejected)?; + let header = parse_header(token).map_err(|_| CommandError::EvidenceRejected)?; + enforce_signature_shape(token).map_err(|_| CommandError::EvidenceRejected)?; + + // typ MUST be exactly "nip-fi-command+jwt". + if header.typ.as_deref() != Some(COMMAND_JWT_TYP) { + return Err(CommandError::EvidenceRejected); + } + + // ── Step 2: select issuer policy; verify signature ──────────────────── + let claims = parse_unique_claims(token).map_err(|_| CommandError::EvidenceRejected)?; + + let signed_iss = claim_str(&claims, "iss").ok_or(CommandError::EvidenceRejected)?; + + let base_policy = self + .registry + .policy_for_issuer(signed_iss) + .ok_or(CommandError::EvidenceRejected)?; + + let cmd_policy = self + .command_policies + .get(signed_iss) + .ok_or(CommandError::EvidenceRejected)?; + + if !base_policy.algorithms().contains(&header.algorithm) { + return Err(CommandError::EvidenceRejected); + } + + let key_set = self + .key_source + .key_set(base_policy.issuer()) + .ok_or(CommandError::AuthorizationUnavailable)?; + if key_set.issuer() != base_policy.issuer() { + return Err(CommandError::EvidenceRejected); + } + + verify_jwt_signature(token, base_policy, &key_set, header.algorithm).map_err( + |e| match e { + VerifierError::KeySourceUnavailable | VerifierError::StatusWitnessUnavailable => { + CommandError::AuthorizationUnavailable + } + _ => CommandError::EvidenceRejected, + }, + )?; + + // ── Step 3: validate pure claims ────────────────────────────────────── + + // aud: at least one of the policy audiences must match. + let aud_ok = match claims.get("aud") { + Some(Value::String(s)) => base_policy.audiences().iter().any(|a| a == s), + Some(Value::Array(arr)) => arr.iter().any(|v| { + v.as_str() + .map(|s| base_policy.audiences().iter().any(|a| a == s)) + .unwrap_or(false) + }), + _ => false, + }; + if !aud_ok { + return Err(CommandError::EvidenceRejected); + } + + // Time bounds. + let iat = numeric_date(&claims, "iat")?; + let exp = numeric_date(&claims, "exp")?; + let skew = chrono::Duration::seconds(base_policy.skew_seconds() as i64); + let max_cmd_age = chrono::Duration::seconds(cmd_policy.maximum_command_age_seconds as i64); + let iat_plus_cmd_age = iat + .checked_add_signed(max_cmd_age) + .ok_or(CommandError::EvidenceRejected)?; + + // now < exp (equality is expired). + if now >= exp { + return Err(CommandError::EvidenceRejected); + } + // iat <= now + skew. + let now_plus_skew = now + .checked_add_signed(skew) + .ok_or(CommandError::EvidenceRejected)?; + if iat > now_plus_skew { + return Err(CommandError::EvidenceRejected); + } + // now < iat + maximum_command_age. + if now >= iat_plus_cmd_age { + return Err(CommandError::EvidenceRejected); + } + + // method / path / cmd (exact literal matches required by spec). + let method = claim_str(&claims, "method").ok_or(CommandError::EvidenceRejected)?; + let path = claim_str(&claims, "path").ok_or(CommandError::EvidenceRejected)?; + let cmd = claim_str(&claims, "cmd").ok_or(CommandError::EvidenceRejected)?; + if method != request_method { + return Err(CommandError::EvidenceRejected); + } + if path != request_path { + return Err(CommandError::EvidenceRejected); + } + if cmd != COMMAND_CMD { + return Err(CommandError::EvidenceRejected); + } + + // target_pubkey: lowercase hex of exactly 32 bytes. + let target_hex = + claim_str(&claims, "target_pubkey").ok_or(CommandError::EvidenceRejected)?; + let target_pubkey = parse_hex_pubkey(target_hex).ok_or(CommandError::EvidenceRejected)?; + + // until: NumericDate. + let until = numeric_date(&claims, "until")?; + + // Validate `until` ceiling: until <= now + skew + maximum_assertion_age. + let max_assertion_age = + chrono::Duration::seconds(base_policy.maximum_assertion_age_seconds() as i64); + let deny_ceiling = now_plus_skew + .checked_add_signed(max_assertion_age) + .ok_or(CommandError::EvidenceRejected)?; + if until > deny_ceiling { + return Err(CommandError::UntilExceedsCeiling); + } + + // jti: must be present and non-empty. + let jti = claim_str(&claims, "jti").ok_or(CommandError::EvidenceRejected)?; + + // ── Step 4: principal authorization ─────────────────────────────────── + // AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub). + let sub = claim_str(&claims, "sub").ok_or(CommandError::EvidenceRejected)?; + if !cmd_policy.authorized_principals.iter().any(|p| p == sub) { + return Err(CommandError::AuthorizationDenied); + } + + // ── Step 5: signed-target / request-body agreement ─────────────────── + if &target_pubkey != body_pubkey { + return Err(CommandError::AuthorizationDenied); + } + + // ── Steps 6+7: atomic jti reservation + deny-entry insertion ───────── + // + // effective_expiry = min(exp, iat + maximum_command_age). + let effective_expiry = exp.min(iat_plus_cmd_age); + + match self.deny_map.atomic_reserve_and_insert( + base_policy.issuer(), + jti, + effective_expiry, + &target_pubkey, + until, + now, + ) { + Ok(()) => {} + Err(ReserveError::JtiAlreadyReserved) => return Err(CommandError::AuthorizationDenied), + Err(ReserveError::CapacityExceeded) => return Err(CommandError::DenySetFull), + } + + Ok(CommandResult { + target_pubkey, + caller_iss: base_policy.issuer().to_owned(), + caller_sub: sub.to_owned(), + until, + }) + } +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn claim_str<'a>(claims: &'a Map, key: &str) -> Option<&'a str> { + claims.get(key)?.as_str().filter(|s| !s.is_empty()) +} + +fn numeric_date(claims: &Map, key: &str) -> Result, CommandError> { + let value = claims.get(key).ok_or(CommandError::EvidenceRejected)?; + parse_numeric_date(value).map_err(|_| CommandError::EvidenceRejected) +} + +fn parse_hex_pubkey(raw: &str) -> Option { + if raw.len() != 64 + || !raw + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return None; + } + PublicKey::from_hex(raw).ok() +} + +/// Verify the JWS signature using the JWKS from `key_set`. +/// +/// This reuses the same `select_unique_jwk_pub` / `validate_jwk_pub` helpers as +/// the assertion verifier so key selection and validation semantics are identical. +fn verify_jwt_signature( + token: &str, + policy: &IssuerPolicy, + key_set: &AssertionKeySet, + algorithm: Algorithm, +) -> Result<(), VerifierError> { + use base64::Engine; + + // Decode the header segment to extract `kid`. + let header_seg = token + .split('.') + .next() + .ok_or(VerifierError::MalformedToken)?; + let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(header_seg) + .map_err(|_| VerifierError::MalformedToken)?; + let header_obj: serde_json::Map = + serde_json::from_slice(&header_bytes).map_err(|_| VerifierError::MalformedToken)?; + let kid = header_obj + .get("kid") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .ok_or(VerifierError::MissingKeyId)?; + + let jwk = select_unique_jwk(key_set.jwks(), kid)?; + validate_jwk(jwk, algorithm)?; + let key = DecodingKey::from_jwk(jwk).map_err(|_| VerifierError::InvalidKey)?; + + let mut validation = Validation::new(algorithm); + validation.set_issuer(&[policy.issuer()]); + validation.set_audience(policy.audiences()); + validation.set_required_spec_claims(&["exp", "iat", "iss", "aud"]); + validation.validate_exp = false; + validation.validate_nbf = false; + decode::>(token, &key, &validation) + .map_err(|_| VerifierError::InvalidSignatureOrClaims)?; + Ok(()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::nip_fi::deny_map::IssuerCapacity; + use chrono::{Duration, Utc}; + + // ── CommandIssuerPolicy validation ──────────────────────────────────────── + // These tests exercise the construction-time validation contract: + // every misconfiguration is caught before any command is processed. + + #[test] + fn command_policy_rejects_zero_age() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 0, + vec!["admin@example.com".into()], + 1000, + ); + assert_eq!( + result, + Err(CommandPolicyError::InvalidCommandAge), + "maximum_command_age=0 must be rejected" + ); + } + + #[test] + fn command_policy_rejects_age_exceeding_60() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 61, + vec!["admin@example.com".into()], + 1000, + ); + assert_eq!( + result, + Err(CommandPolicyError::InvalidCommandAge), + "maximum_command_age=61 must be rejected (normative bound is 60)" + ); + } + + #[test] + fn command_policy_accepts_max_age_60() { + CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 60, + vec!["admin@example.com".into()], + 1000, + ) + .expect("maximum_command_age=60 must be accepted (normative upper bound)"); + } + + #[test] + fn command_policy_accepts_min_age_1() { + CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 1, + vec!["admin@example.com".into()], + 1000, + ) + .expect("maximum_command_age=1 must be accepted (normative lower bound)"); + } + + #[test] + fn command_policy_rejects_empty_principals() { + let result = + CommandIssuerPolicy::new("https://issuer.example.com".into(), 30, vec![], 1000); + assert_eq!( + result, + Err(CommandPolicyError::EmptyAuthorizedPrincipals), + "empty authorized_principals must be rejected" + ); + } + + #[test] + fn command_policy_rejects_zero_capacity() { + let result = CommandIssuerPolicy::new( + "https://issuer.example.com".into(), + 30, + vec!["admin@example.com".into()], + 0, + ); + assert_eq!( + result, + Err(CommandPolicyError::ZeroCapacity), + "deny_set_capacity=0 must be rejected" + ); + } + + #[test] + fn command_policy_rejects_empty_issuer() { + let result = + CommandIssuerPolicy::new(String::new(), 30, vec!["admin@example.com".into()], 1000); + assert_eq!( + result, + Err(CommandPolicyError::EmptyIssuer), + "empty issuer must be rejected" + ); + } + + // ── CommandError HTTP contract ──────────────────────────────────────────── + // Spec-exact status codes and bodies from the disconnect API response table. + + #[test] + fn command_error_http_contract_is_spec_exact() { + // Evidence failures → 403 "evidence rejected\n" + assert_eq!(CommandError::EvidenceRejected.http_status(), 403); + assert_eq!( + CommandError::EvidenceRejected.response_body(), + "evidence rejected\n" + ); + + // Authorization failures → 403 "authorization denied\n" + assert_eq!(CommandError::AuthorizationDenied.http_status(), 403); + assert_eq!( + CommandError::AuthorizationDenied.response_body(), + "authorization denied\n" + ); + + // Capacity → 503 "deny set full\n" + assert_eq!(CommandError::DenySetFull.http_status(), 503); + assert_eq!(CommandError::DenySetFull.response_body(), "deny set full\n"); + + // JWKS unavailable → 503 "authorization unavailable\n" + assert_eq!(CommandError::AuthorizationUnavailable.http_status(), 503); + assert_eq!( + CommandError::AuthorizationUnavailable.response_body(), + "authorization unavailable\n" + ); + + // until ceiling / malformed → 400 "bad request\n" + assert_eq!(CommandError::UntilExceedsCeiling.http_status(), 400); + assert_eq!( + CommandError::UntilExceedsCeiling.response_body(), + "bad request\n" + ); + assert_eq!(CommandError::MalformedRequest.http_status(), 400); + assert_eq!( + CommandError::MalformedRequest.response_body(), + "bad request\n" + ); + } + + // ── Mutation evidence: time-bound oracles ───────────────────────────────── + // + // The tests below exercise `verify_at` via real ES256-signed command JWTs. + // The same test key and JWKS helpers as `verifier/tests.rs` are used so + // key-selection and claim-validation semantics are identical. + + // FI-TRACE-DENY-SET oracle: past-until inserts with expired value. + #[test] + fn past_until_command_creates_no_future_denial_when_no_active_entry() { + use nostr::Keys; + + let deny_map = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: "https://issuer.example.com".to_owned(), + capacity: 100, + }], + ); + let k = Keys::generate().public_key(); + let now = Utc::now(); + let past = now - Duration::seconds(60); + + // Directly invoke the deny map: past-until on absent entry. + deny_map + .atomic_reserve_and_insert( + "https://issuer.example.com", + "jti-past", + past, + &k, + past, + now, + ) + .expect("past-until on absent entry must succeed (not a capacity error)"); + + // is_denied now must be false (entry is immediately expired). + assert!( + !deny_map.is_denied("https://issuer.example.com", &k, now), + "past-until command on absent entry creates no future denial [FI-TRACE-DENY-SET]" + ); + } + + // FI-TRACE-DENY-SET oracle: both delivery orders → max(until_A, until_B). + #[test] + fn deny_set_both_delivery_orders_give_max_until() { + use nostr::Keys; + + let iss = "https://issuer.example.com"; + let now = Utc::now(); + let k = Keys::generate().public_key(); + + // Order 1: longer first, shorter second. + { + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 100, + }], + ); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + m.atomic_reserve_and_insert(iss, "jti-a1", longer, &k, longer, now) + .unwrap(); + m.atomic_reserve_and_insert(iss, "jti-b1", shorter, &k, shorter, now) + .unwrap(); + // At t = 400s: still denied (longer survives shorter). + assert!( + m.is_denied(iss, &k, now + Duration::seconds(400)), + "Order 1 (longer first): deny at 400s must hold under merge rule" + ); + } + + // Order 2: shorter first, longer second. + { + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 100, + }], + ); + let shorter = now + Duration::seconds(300); + let longer = now + Duration::seconds(600); + m.atomic_reserve_and_insert(iss, "jti-a2", shorter, &k, shorter, now) + .unwrap(); + m.atomic_reserve_and_insert(iss, "jti-b2", longer, &k, longer, now) + .unwrap(); + // At t = 400s: still denied (shorter did not shorten the longer). + assert!( + m.is_denied(iss, &k, now + Duration::seconds(400)), + "Order 2 (shorter first): deny at 400s must hold — delivery order must not shorten longer deny" + ); + } + } + + // ── Full-path CommandVerifier::verify_at tests (real ES256 key) ─────────── + // + // Uses the same test key material as `verifier/tests.rs` so the key- + // selection and signature-validation paths are exercised identically. + // Each test carries a named mutation anchor: the assertion that goes wrong + // when the guarded code path is removed. + + const TEST_EC_PKCS8_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+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 = "cmd-test-key-1"; + const ISS: &str = "https://idp.example.com"; + const AUD: &str = "https://relay.example.com"; + const PRINCIPAL: &str = "admin@idp.example.com"; + const METHOD: &str = "POST"; + const PATH: &str = "/api/nip-fi/disconnect"; + + fn test_jwks_cmd() -> jsonwebtoken::jwk::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") + } + + fn test_issuer_policy() -> IssuerPolicy { + let contract = crate::nip_fi::jwks::JwksSourceContract::new( + format!("{ISS}/.well-known/jwks.json"), + 300, + 3600, + ) + .expect("valid contract"); + IssuerPolicy::new( + ISS.to_owned(), + vec![AUD.to_owned()], + crate::nip_fi::config::TokenClass::DedicatedNipFi, + crate::nip_fi::config::FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + contract, + ) + .expect("valid policy") + } + + fn test_command_policy() -> CommandIssuerPolicy { + CommandIssuerPolicy::new(ISS.to_owned(), 30, vec![PRINCIPAL.to_owned()], 1000) + .expect("valid cmd policy") + } + + fn test_command_verifier() -> CommandVerifier { + use crate::nip_fi::verifier::{AssertionKeySet, StaticIssuerKeySource}; + let future = Utc::now() + Duration::seconds(3600); + let key_set = AssertionKeySet::new(ISS.to_owned(), 1, test_jwks_cmd(), future) + .expect("valid key set"); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let deny_map = NipFiDenyMap::new( + 1000, + vec![IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 1000, + }], + ); + CommandVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + vec![test_command_policy()], + deny_map, + ) + } + + fn now_ts() -> i64 { + Utc::now().timestamp() + } + + fn target_key() -> nostr::PublicKey { + nostr::Keys::generate().public_key() + } + + /// Mint a real ES256 command JWT with optional claim overrides. + fn mint_cmd_jwt( + target: &nostr::PublicKey, + until_offset_secs: i64, + overrides: serde_json::Value, + ) -> String { + let now = now_ts(); + let mut claims = serde_json::json!({ + "iss": ISS, + "aud": AUD, + "sub": PRINCIPAL, + "iat": now, + "exp": now + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, + "path": PATH, + "cmd": "disconnect", + "target_pubkey": target.to_hex(), + "until": now + until_offset_secs, + }); + if let serde_json::Value::Object(ref ov) = overrides { + for (k, v) in ov { + claims[k] = v.clone(); + } + } + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some(COMMAND_JWT_TYP.to_owned()); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid EC PEM"); + jsonwebtoken::encode(&header, &claims, &key).expect("sign") + } + + // ── Happy path ──────────────────────────────────────────────────────────── + + #[test] + fn full_path_happy_path_returns_ok_and_inserts_deny() { + // Mutation anchor: removing the deny-entry insertion (step 6) makes + // is_denied return false even after Ok — caught here. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let result = cv.verify_at(&token, METHOD, PATH, &target, now); + assert!(result.is_ok(), "happy path must succeed: {result:?}"); + assert!( + cv.deny_map() + .is_denied(ISS, &target, now + Duration::seconds(1)), + "deny entry must be inserted on success [FI-TRACE-DENY-SET]" + ); + } + + // ── typ / signature ─────────────────────────────────────────────────────── + + #[test] + fn wrong_typ_rejects_as_evidence_rejected() { + // Mutation anchor: removing the typ check admits at+jwt tokens as commands. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some("at+jwt".to_owned()); + let claims = serde_json::json!({ + "iss": ISS, "aud": AUD, "sub": PRINCIPAL, + "iat": now_ts(), "exp": now_ts() + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, "path": PATH, "cmd": "disconnect", + "target_pubkey": target.to_hex(), "until": now_ts() + 300, + }); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()).unwrap(); + let token = jsonwebtoken::encode(&header, &claims, &key).unwrap(); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "wrong typ must be EvidenceRejected" + ); + } + + #[test] + fn corrupted_signature_rejects_as_evidence_rejected() { + // Mutation anchor: removing sig verification admits forged tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let mut token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let last = token.pop().unwrap(); + token.push(if last == 'A' { 'B' } else { 'A' }); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "corrupted signature must be EvidenceRejected" + ); + } + + // ── aud ─────────────────────────────────────────────────────────────────── + + #[test] + fn wrong_aud_rejects_as_evidence_rejected() { + // Mutation anchor: removing aud check admits tokens for other relays. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"aud": "https://other.relay.example.com"}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "wrong aud must be EvidenceRejected" + ); + } + + // ── Time bounds ─────────────────────────────────────────────────────────── + + #[test] + fn expired_token_rejects_as_evidence_rejected() { + // Mutation anchor: removing exp check admits expired tokens indefinitely. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let past = now_ts() - 600; + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": past, "exp": past + 55}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "expired token must be EvidenceRejected" + ); + } + + #[test] + fn future_iat_beyond_skew_rejects() { + // Mutation anchor: removing iat>now+skew check admits pre-issued tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let far_future_iat = now_ts() + 300; // 5 min future, skew=30s + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": far_future_iat, "exp": far_future_iat + 55}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "future iat > now+skew must be EvidenceRejected" + ); + } + + #[test] + fn token_older_than_command_age_rejects() { + // Mutation anchor: removing iat+cmd_age check admits stale commands. + let cv = test_command_verifier(); + let target = target_key(); + // iat at exactly max_command_age ago (30s) = now >= iat+30 → expired. + let stale_iat = now_ts() - 30; + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"iat": stale_iat, "exp": stale_iat + 55}), + ); + let now = Utc::now(); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "token at iat+max_cmd_age must be EvidenceRejected (equality is expired)" + ); + } + + // ── method / path / cmd ─────────────────────────────────────────────────── + + #[test] + fn wrong_method_rejects() { + // Mutation anchor: removing method check admits GET command tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"method": "GET"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + #[test] + fn wrong_path_rejects() { + // Mutation anchor: removing path check admits tokens for other endpoints. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"path": "/api/other"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + #[test] + fn wrong_cmd_value_rejects() { + // Mutation anchor: removing cmd check admits other command type tokens. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"cmd": "reconnect"})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected) + ); + } + + // ── target_pubkey / body agreement ─────────────────────────────────────── + + #[test] + fn body_target_mismatch_rejects_as_authorization_denied() { + // Mutation anchor: removing the target==body check lets attackers + // disconnect a different pubkey than they signed. + let cv = test_command_verifier(); + let signed_target = target_key(); + let body_target = target_key(); // different + let now = Utc::now(); + let token = mint_cmd_jwt(&signed_target, 300, serde_json::json!({})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &body_target, now), + Err(CommandError::AuthorizationDenied), + "target/body mismatch must be AuthorizationDenied" + ); + } + + // ── Authorization ───────────────────────────────────────────────────────── + + #[test] + fn unauthorized_sub_rejects_as_authorization_denied() { + // Mutation anchor: removing sub check lets any bearer of a valid JWT + // issue disconnect commands. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let token = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"sub": "not-admin@idp.example.com"}), + ); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::AuthorizationDenied), + "unauthorized sub must be AuthorizationDenied" + ); + } + + // ── until ceiling ───────────────────────────────────────────────────────── + + #[test] + fn until_exceeds_ceiling_returns_correct_error() { + // Mutation anchor: removing ceiling check allows unbounded deny duration. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let far_future = now_ts() + 10 * 365 * 24 * 3600; + let token = mint_cmd_jwt(&target, 0, serde_json::json!({"until": far_future})); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::UntilExceedsCeiling), + "until beyond ceiling must be UntilExceedsCeiling" + ); + } + + // ── jti is the LAST step ───────────────────────────────────────────────── + + #[test] + fn jti_replay_rejected_after_first_success() { + // Mutation anchor: moving jti before auth checks would burn the jti + // on a bad-auth token; replay would be indistinguishable from a new call. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let jti = uuid::Uuid::new_v4().to_string(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({"jti": jti})); + assert!(cv.verify_at(&token, METHOD, PATH, &target, now).is_ok()); + assert_eq!( + cv.verify_at(&token, METHOD, PATH, &target, now), + Err(CommandError::AuthorizationDenied), + "replayed jti must be AuthorizationDenied" + ); + } + + // ── 503 does NOT burn the jti ───────────────────────────────────────────── + + #[test] + fn capacity_503_does_not_burn_jti_retry_succeeds_after_slot_freed() { + // Mutation anchor: if jti were recorded before the capacity check, a + // retry after freeing capacity would be spuriously rejected as replay. + // The spec guarantees: 503 leaves the command replayable. + // + // Setup: capacity=1, first command fills the slot. + // Step 1: second command with jti_b → 503 (capacity full). + // Step 2: "expire" the first entry by using a future `now` that is + // past target_a's until; lazy eviction fires on the next mutation. + // Step 3: retry with the same jti_b → must succeed (jti was NOT burned). + use crate::nip_fi::verifier::{AssertionKeySet, StaticIssuerKeySource}; + + let future = Utc::now() + Duration::seconds(3600); + let key_set = AssertionKeySet::new(ISS.to_owned(), 1, test_jwks_cmd(), future) + .expect("valid key set"); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + // capacity = 1: one slot + let deny_map = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 1, + }], + ); + let cv = CommandVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + vec![test_command_policy()], + deny_map, + ); + + let target_a = target_key(); + let target_b = target_key(); + let t0 = Utc::now(); + + // Fill the single slot with target_a. Use until_offset_secs=3 so the + // deny entry expires at t0+3s, well before t1. + let token_a = mint_cmd_jwt(&target_a, 3, serde_json::json!({})); + assert!(cv.verify_at(&token_a, METHOD, PATH, &target_a, t0).is_ok()); + + // target_b → 503 (capacity full); jti_b is NOT burned. + let jti_b = uuid::Uuid::new_v4().to_string(); + let token_b = mint_cmd_jwt(&target_b, 300, serde_json::json!({"jti": jti_b})); + assert_eq!( + cv.verify_at(&token_b, METHOD, PATH, &target_b, t0), + Err(CommandError::DenySetFull), + "must be DenySetFull (503)" + ); + + // Advance `now` to t0+5s: target_a's entry (until=t0+3) is expired. + // Lazy eviction fires on the next mutation inside verify_at, freeing + // the slot. token_b is still within command age (iat=t0, max=30s). + let t1 = t0 + Duration::seconds(5); + + // Retry with the SAME jti_b at t1. Lazy eviction removes target_a's + // entry, freeing the slot. + // Must succeed: 503 must NOT have burned jti_b. + assert!( + cv.verify_at(&token_b, METHOD, PATH, &target_b, t1).is_ok(), + "retry with same jti after slot freed must succeed — 503 must NOT burn the jti" + ); + } + + // ── Signed fractional verify_at witness ────────────────────────────────── + // + // Proves that a real ES256-signed command JWT whose `until` NumericDate is + // fractional (whole seconds + 0.5) passes through CommandVerifier::verify_at + // with the fractional deadline intact in CommandResult.until, and that the + // inserted deny entry correctly honours the sub-second boundary. + // + // Mandatory reds: + // - truncating/zeroing fractional `until` in parse_numeric_date → returned + // deadline loses nanos; boundary assertions fail + // - bypassing verify_at with a synthetic CommandResult → this test goes + // through the real verifier path and the boundary assertions still fail + // when the map is queried with the wrong deadline + + #[test] + fn fractional_until_survives_verify_at_and_preserves_boundary() { + use chrono::TimeZone; + + let cv = test_command_verifier(); + let target = target_key(); + + // Construct a command JWT with until = T + 0.5s expressed as a fractional + // NumericDate float. The test key's maximum_assertion_age is 3600s so + // any `until` within now+3600+skew passes the ceiling check. + let now = Utc::now(); + + // Pick a whole-second base that is within the assertion-age ceiling. + let t_whole_secs = now.timestamp() + 300; // 5 min from now + let t_frac_secs: f64 = t_whole_secs as f64 + 0.5; // T + 500ms + + let claims = serde_json::json!({ + "iss": ISS, + "aud": AUD, + "sub": PRINCIPAL, + "iat": now.timestamp(), + "exp": now.timestamp() + 55, + "jti": uuid::Uuid::new_v4().to_string(), + "method": METHOD, + "path": PATH, + "cmd": "disconnect", + "target_pubkey": target.to_hex(), + "until": t_frac_secs, + }); + // Ensure `until` is encoded as a JSON number (float), not a string. + assert!( + claims["until"].is_f64(), + "until must be a JSON number for this test to exercise the fractional path" + ); + + let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(TEST_KID.to_owned()); + header.typ = Some(COMMAND_JWT_TYP.to_owned()); + let key = jsonwebtoken::EncodingKey::from_ec_pem(TEST_EC_PKCS8_PEM.as_bytes()) + .expect("valid EC PEM"); + let token = jsonwebtoken::encode(&header, &claims, &key).expect("sign"); + + // verify_at with `now` as the controlled clock. + let result = cv.verify_at(&token, METHOD, PATH, &target, now); + assert!( + result.is_ok(), + "fractional verify_at must succeed: {result:?}" + ); + let cmd = result.unwrap(); + + // The returned CommandResult.until must preserve the fractional nanos. + let expected_until = chrono::Utc + .timestamp_opt(t_whole_secs, 500_000_000) + .single() + .expect("representable timestamp"); + assert_eq!( + cmd.until, expected_until, + "CommandResult.until must retain the 500ms fractional nanos from the signed JWT" + ); + + // The deny entry inserted by verify_at must respect the sub-second boundary. + let deny_map = cv.deny_map(); + + // Denied immediately after T (T+1ns, well inside T+500ms). + let now_after_t = chrono::Utc.timestamp_opt(t_whole_secs, 1).single().unwrap(); + assert!( + deny_map.is_denied(ISS, &target, now_after_t), + "must be denied at T+1ns (deadline is T+500ms)" + ); + + // Denied at T+499_999_999ns (just before the boundary). + let now_before_boundary = chrono::Utc + .timestamp_opt(t_whole_secs, 499_999_999) + .single() + .unwrap(); + assert!( + deny_map.is_denied(ISS, &target, now_before_boundary), + "must be denied at deadline - 1ns" + ); + + // Admitted at exact equality (now == until): `now < until` is false at equality. + assert!( + !deny_map.is_denied(ISS, &target, expected_until), + "must be admitted at exact equality with the fractional deadline" + ); + + // Also admitted past the deadline. + let now_past = chrono::Utc + .timestamp_opt(t_whole_secs + 1, 0) + .single() + .unwrap(); + assert!( + !deny_map.is_denied(ISS, &target, now_past), + "must be admitted past the fractional deadline" + ); + } +} diff --git a/crates/buzz-auth/src/nip_fi/deny_map.rs b/crates/buzz-auth/src/nip_fi/deny_map.rs new file mode 100644 index 00000000000..3fa304293e3 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/deny_map.rs @@ -0,0 +1,1167 @@ +//! In-memory NIP-FI deny set. +//! +//! Holds `(iss, pubkey) → until` entries. No persistence — a relay restart +//! forgets active entries (Option B, as decided). The issuer re-push path is +//! documented as the mitigation but is not implemented here. +//! +//! ## Invariants +//! +//! * **Merge rule**: inserting a new `until` for an existing key retains +//! `max(existing_until, new_until)` — an accepted disconnect MUST NOT shorten +//! an active deny. [FI-TRACE-DENY-SET] +//! * **Past-`until` commands**: close sessions but MUST NOT create or shorten +//! entries. Concretely, when `new_until < now` the merge still applies the +//! `max` rule, which preserves any active entry and lets a "no active entry" +//! case insert with an already-expired value (immediately inactive). [FI-TRACE-DENY-SET] +//! * **Per-issuer capacity cap**: each issuer has a hard ceiling on live entries. +//! A capacity failure returns `Err(DenySetFull)` without inserting anything — +//! the spec requires `503` here and neither the jti nor the deny entry is +//! recorded. [FI-TRACE-DENY-SET] +//! * **Cross-issuer isolation**: capacity of issuer A MUST NOT affect issuer B. +//! * **Cross-pod capacity miss**: `merge_cross_pod_deny` returning `CapacityExceeded` +//! preserves the existing shard contents; the caller closes the delivered +//! target's sessions and reports/metrics the outcome. No issuer-wide denial +//! is synthesized. Async propagation loss with issuer re-push is the +//! sanctioned recovery. [NIP-FI.md:306-336] +//! * **jti reservation** and **deny-entry insertion** are performed atomically +//! in one lock scope (both or neither). [VerifyCommandJwt step 7] +//! * **Issuer-global scope**: the deny applies across all communities served +//! under that issuer. [FI-TRACE-DENY-SET] +//! * **Self-eviction**: expired entries are pruned lazily on each mutation and +//! on read (deny check), so the map does not grow without bound. + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use nostr::PublicKey; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Returned when the per-issuer deny-set capacity is exhausted. +/// +/// The caller MUST respond `503` and MUST NOT record the jti; the same signed +/// command remains replayable (the command identity was not consumed). +/// [VerifyCommandJwt step 7] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("deny set full for issuer")] +pub struct DenySetFull; + +// ── Per-issuer shard ────────────────────────────────────────────────────────── + +/// One issuer's worth of deny entries and jti deduplication state. +/// +/// The shard mutex is acquired once per `AtomicReserveJtiAndDenyEntry` call +/// so both mutations happen under the same lock (both-or-neither atomicity). +struct IssuerShard { + /// Active deny entries: hex-encoded pubkey → until. + entries: HashMap>, + /// Reserved jtis: jti string → effective_expiry. Expired jtis are evicted + /// lazily on each write so the map never grows to replay-corpus size. + jtis: HashMap>, + /// Maximum number of live deny entries for this issuer. + capacity: usize, + /// Maximum number of live jti reservations for this issuer. + /// Bounded separately so an issuer cannot exhaust memory by replaying + /// distinct jtis faster than they expire, even for already-denied keys. + /// Set to `capacity * 2` at construction for O(capacity) memory with + /// headroom for in-flight update commands on already-denied keys. + max_jti_count: usize, +} + +impl IssuerShard { + fn new(capacity: usize) -> Self { + Self { + entries: HashMap::new(), + jtis: HashMap::new(), + capacity, + // JTI resource bound: allow up to 2× capacity JTI reservations. + // This gives one active command per entry slot plus headroom for + // one in-flight update command per already-denied key without + // blocking normal operation. Still O(capacity) memory. + max_jti_count: capacity.saturating_mul(2).max(1), + } + } + + /// Evict expired entries and jtis. Called inside the lock on every write. + fn evict_expired(&mut self, now: DateTime) { + self.entries.retain(|_, until| *until > now); + self.jtis.retain(|_, exp| *exp > now); + } + + /// True if `(iss, pubkey_hex)` has an active deny entry (`now < until`). + fn is_denied(&self, pubkey_hex: &str, now: DateTime) -> bool { + self.entries + .get(pubkey_hex) + .map(|until| now < *until) + .unwrap_or(false) + } + + /// Attempt the atomic jti-reservation + deny-entry insertion. + /// + /// **Atomicity**: both HashMap inserts are precomputed before any write. + /// Eviction is done first (pure mutation of existing map, always safe), + /// then all fallible pre-conditions are checked, then both inserts happen + /// under the same lock scope. An unwind before the inserts leaves the + /// shard unchanged; an unwind mid-insert is not possible because HashMap + /// insert is infallible after capacity reservation. + fn atomic_reserve_and_insert( + &mut self, + jti: &str, + jti_effective_expiry: DateTime, + pubkey_hex: &str, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + self.evict_expired(now); + + // Replay check: jti already in set → AuthorizationDenied. + if self.jtis.contains_key(jti) { + return Err(ReserveError::JtiAlreadyReserved); + } + + // JTI resource bound: cap live jti reservations at max_jti_count so an + // issuer cannot exhaust memory by sending distinct jtis for already-denied + // keys faster than they expire. Uses CapacityExceeded so the caller + // responds 503 and the command remains replayable (jti not burned). + if self.jtis.len() >= self.max_jti_count { + return Err(ReserveError::CapacityExceeded); + } + + // Deny-entry capacity check: only count as new if no active entry exists. + // The merge rule never increases live entry count. + let is_update = self + .entries + .get(pubkey_hex) + .map(|existing| now < *existing) + .unwrap_or(false); + if !is_update && self.entries.len() >= self.capacity { + return Err(ReserveError::CapacityExceeded); + } + + // Prebuild both values before writing anything. + let jti_key = jti.to_owned(); + let entry_key = pubkey_hex.to_owned(); + let effective_until = match self.entries.get(pubkey_hex) { + Some(&existing) => existing.max(until), + None => until, + }; + + // Both mutations are infallible HashMap inserts; executed together + // so no intermediate observable state exists. + self.jtis.insert(jti_key, jti_effective_expiry); + self.entries.insert(entry_key, effective_until); + + Ok(()) + } + + /// Merge a remote deny entry without consuming a jti. + /// + /// Used for cross-pod propagation where replay idempotency is achieved by + /// the max(until) merge rule alone — no jti tracking needed. + /// Returns `Err(CapacityExceeded)` if the entry is new and the shard is full. + fn remote_merge( + &mut self, + pubkey_hex: &str, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + self.evict_expired(now); + + // Capacity check: only count as new if there is no active entry. + let is_update = self + .entries + .get(pubkey_hex) + .map(|existing| now < *existing) + .unwrap_or(false); + if !is_update && self.entries.len() >= self.capacity { + return Err(ReserveError::CapacityExceeded); + } + + // max(existing_until, until) merge. + let effective_until = match self.entries.get(pubkey_hex) { + Some(&existing) => existing.max(until), + None => until, + }; + self.entries.insert(pubkey_hex.to_owned(), effective_until); + Ok(()) + } +} + +/// Reasons an atomic reserve can fail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReserveError { + /// The jti was already reserved — replay attempt. + JtiAlreadyReserved, + /// Per-issuer capacity ceiling reached. + CapacityExceeded, +} + +/// Outcome of a cross-pod deny merge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossPodMergeResult { + /// Entry was inserted or updated (max-merge applied). + Merged, + /// Issuer is not locally configured; message rejected. + UnknownIssuer, + /// Per-issuer capacity ceiling reached; the missed entry was not recorded. + /// The caller should close any sessions matching the delivered target despite + /// the capacity miss, and report/metric the outcome. + CapacityExceeded, + /// Shard mutex is poisoned; issuer is fail-closed. + ShardPoisoned, +} + +// ── Public map ──────────────────────────────────────────────────────────────── + +/// Relay-wide in-memory NIP-FI deny set. +/// +/// One `Arc` is held in `AppState`; the HTTP disconnect endpoint +/// and the WS admission check share it. +/// +/// The deny-check interface is intentionally transport-agnostic — S5 (HTTP +/// enforcement) calls `is_denied` from HTTP admission without any WS coupling. +#[derive(Clone)] +pub struct NipFiDenyMap { + /// Per-issuer shards. Each shard owns its own Mutex so cross-issuer + /// capacity exhaustion is impossible to cause cross-issuer denial. + shards: Arc>>, + /// Default per-issuer capacity, used when no issuer-specific override exists. + default_capacity: usize, +} + +/// A per-issuer capacity override supplied at construction time. +#[derive(Debug, Clone)] +pub struct IssuerCapacity { + /// The exact issuer URI this capacity applies to. + pub issuer: String, + /// Maximum number of live deny entries for this issuer. + pub capacity: usize, +} + +impl NipFiDenyMap { + /// Construct a new deny map. + /// + /// `default_capacity` is the per-issuer entry ceiling used for any issuer + /// not listed in `issuer_capacities`. Must be > 0. + /// + /// A zero capacity would make every command a 503; callers must validate + /// before construction. + pub fn new(default_capacity: usize, issuer_capacities: Vec) -> Self { + let shards: DashMap> = DashMap::new(); + for ic in issuer_capacities { + shards.insert(ic.issuer, Mutex::new(IssuerShard::new(ic.capacity))); + } + Self { + shards: Arc::new(shards), + default_capacity, + } + } + + /// Returns `true` when `(iss, pubkey)` has an active deny entry at `now`. + /// + /// Used by S4 (WS admission step 6) and S5 (HTTP admission step 5). + /// [FI-TRACE-DENY-SET] + /// + /// Fails **closed**: a poisoned shard lock returns `true` (deny) so that a + /// damaged shard cannot silently admit a denied pubkey. + pub fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool { + let pubkey_hex = pubkey.to_hex(); + match self.shards.get(issuer) { + Some(shard) => shard + .lock() + .map(|guard| guard.is_denied(&pubkey_hex, now)) + .unwrap_or(true), // poisoned shard → fail closed (deny) + None => false, + } + } + + /// Atomically reserve `(iss, jti)` and insert/merge the deny entry. + /// + /// Both mutations happen under the same per-issuer lock (both-or-neither). + /// + /// * `Ok(())` — success. + /// * `Err(ReserveError::JtiAlreadyReserved)` — replay; map is unchanged, + /// caller responds `AuthorizationDenied`. + /// * `Err(ReserveError::CapacityExceeded)` — full; map is unchanged, + /// caller responds `503 deny set full`. + /// + /// [VerifyCommandJwt step 7] + pub(crate) fn atomic_reserve_and_insert( + &self, + issuer: &str, + jti: &str, + jti_effective_expiry: DateTime, + pubkey: &PublicKey, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + let pubkey_hex = pubkey.to_hex(); + let shard = self + .shards + .entry(issuer.to_owned()) + .or_insert_with(|| Mutex::new(IssuerShard::new(self.default_capacity))); + shard + .lock() + .map_err(|_| ReserveError::CapacityExceeded) // poisoned = fail closed + .and_then(|mut guard| { + guard.atomic_reserve_and_insert(jti, jti_effective_expiry, &pubkey_hex, until, now) + }) + } + + /// Merge a cross-pod deny entry (e.g. from Redis propagation). + /// + /// Idempotent: repeated delivery of the same `(issuer, pubkey, until)` is + /// a no-op due to the `max(until)` merge rule. No synthetic jti is + /// allocated — replay idempotency is structural, not tracked. + /// + /// Only merges into **locally-configured** issuer shards. An unknown + /// issuer returns [`CrossPodMergeResult::UnknownIssuer`] so the consumer + /// can reject without allocating state. + /// + /// On capacity exhaustion, returns [`CrossPodMergeResult::CapacityExceeded`] + /// without altering the shard. The caller is responsible for closing the + /// delivered target's sessions and reporting/metricing the outcome; no + /// issuer-wide denial is synthesized. [NIP-FI.md:306-336] + pub fn merge_cross_pod_deny( + &self, + issuer: &str, + pubkey: &PublicKey, + until: DateTime, + now: DateTime, + ) -> CrossPodMergeResult { + let pubkey_hex = pubkey.to_hex(); + // Only operate on pre-configured shards — never allocate for unknown issuers. + match self.shards.get(issuer) { + None => CrossPodMergeResult::UnknownIssuer, + Some(shard) => match shard.lock() { + Err(_) => { + // Shard is poisoned — we cannot obtain the lock. A poisoned + // mutex already causes `is_denied` to return `true` (the + // `unwrap_or(true)` path), so the issuer is implicitly + // fail-closed without any explicit write. + CrossPodMergeResult::ShardPoisoned + } + Ok(mut guard) => match guard.remote_merge(&pubkey_hex, until, now) { + Ok(()) => CrossPodMergeResult::Merged, + Err(ReserveError::CapacityExceeded) => { + // Cannot record the deny entry — return the outcome so + // the caller can close the delivered target's sessions + // and report/metric the capacity miss. No issuer-wide + // denial is synthesized; active entries are preserved. + // [NIP-FI.md:306-336] + CrossPodMergeResult::CapacityExceeded + } + Err(ReserveError::JtiAlreadyReserved) => { + // remote_merge never touches jtis; this arm is unreachable. + unreachable!("remote_merge does not use jti tracking") + } + }, + }, + } + } + + /// Hex encoding of `pubkey`, for downstream logging and wire use. + pub fn pubkey_hex(pubkey: &PublicKey) -> String { + pubkey.to_hex() + } + + /// Poisons `iss`'s shard mutex so tests outside this crate can drive the + /// fail-closed `ShardPoisoned` paths. + #[cfg(any(test, feature = "test-utils"))] + pub fn poison_shard_for_test(&self, iss: &str) { + let shards = Arc::clone(&self.shards); + let iss = iss.to_owned(); + let _ = std::thread::spawn(move || { + let shard = shards.get(&iss).expect("shard must exist"); + let _guard = shard.lock(); + panic!("intentional shard poison (test)"); + }) + .join(); + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use nostr::Keys; + + fn key() -> PublicKey { + Keys::generate().public_key() + } + + fn map() -> NipFiDenyMap { + NipFiDenyMap::new(100, vec![]) + } + + fn iss() -> &'static str { + "https://issuer.example.com" + } + + // ── FI-TRACE-DENY-SET: basic admit/deny ────────────────────────────────── + + #[test] + fn not_denied_when_no_entry() { + let m = map(); + assert!( + !m.is_denied(iss(), &key(), Utc::now()), + "no entry → admitted" + ); + } + + #[test] + fn denied_when_active_entry() { + let m = map(); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k, until, Utc::now()) + .expect("first insert"); + assert!(m.is_denied(iss(), &k, Utc::now()), "active entry → denied"); + } + + #[test] + fn admitted_after_until_expires() { + let m = map(); + let k = key(); + let until = Utc::now() - Duration::seconds(1); // already expired + m.atomic_reserve_and_insert(iss(), "jti-exp", until, &k, until, Utc::now()) + .expect("insert with past-until"); + // is_denied with `now` past `until` → not denied + assert!( + !m.is_denied(iss(), &k, Utc::now()), + "expired entry → admitted" + ); + } + + // ── FI-TRACE-DENY-SET: merge rule ──────────────────────────────────────── + + #[test] + fn merge_rule_longer_command_wins() { + let m = map(); + let k = key(); + let now = Utc::now(); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + + // Insert longer first. + m.atomic_reserve_and_insert(iss(), "jti-A", longer, &k, longer, now) + .expect("insert longer"); + // Insert shorter — must not shorten. + m.atomic_reserve_and_insert(iss(), "jti-B", shorter, &k, shorter, now) + .expect("insert shorter"); + + // Check just before shorter would expire (still in longer window). + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "merge rule: longer deny survives shorter command" + ); + } + + #[test] + fn merge_rule_longer_command_second_wins() { + let m = map(); + let k = key(); + let now = Utc::now(); + let shorter = now + Duration::seconds(300); + let longer = now + Duration::seconds(600); + + // Insert shorter first, then longer. + m.atomic_reserve_and_insert(iss(), "jti-A", shorter, &k, shorter, now) + .expect("insert shorter"); + m.atomic_reserve_and_insert(iss(), "jti-B", longer, &k, longer, now) + .expect("insert longer"); + + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "delivery order does not matter — longer wins regardless" + ); + } + + #[test] + fn past_until_command_over_active_entry_leaves_active_unchanged() { + let m = map(); + let k = key(); + let now = Utc::now(); + let active_until = now + Duration::seconds(600); + let past_until = now - Duration::seconds(60); + + // Active entry first. + m.atomic_reserve_and_insert(iss(), "jti-A", active_until, &k, active_until, now) + .expect("insert active"); + + // Past-until command: max(active_until, past_until) = active_until. + m.atomic_reserve_and_insert(iss(), "jti-B", past_until, &k, past_until, now) + .expect("past-until insert"); + + // Active entry unchanged. + let check_time = now + Duration::seconds(400); + assert!( + m.is_denied(iss(), &k, check_time), + "past-until command must not shorten active deny" + ); + } + + #[test] + fn past_until_command_absent_entry_inserts_expired() { + let m = map(); + let k = key(); + let now = Utc::now(); + let past_until = now - Duration::seconds(60); + + // Past-until, no existing entry → insert with expired value → immediately inactive. + m.atomic_reserve_and_insert(iss(), "jti-A", past_until, &k, past_until, now) + .expect("past-until on absent entry"); + + // Not denied (entry is immediately expired). + assert!( + !m.is_denied(iss(), &k, now), + "past-until with no prior entry creates no future denial" + ); + } + + // ── Replay prevention ──────────────────────────────────────────────────── + + #[test] + fn jti_replay_is_rejected() { + let m = map(); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + + m.atomic_reserve_and_insert(iss(), "jti-same", until, &k, until, Utc::now()) + .expect("first use"); + let result = m.atomic_reserve_and_insert(iss(), "jti-same", until, &k, until, Utc::now()); + assert_eq!( + result, + Err(ReserveError::JtiAlreadyReserved), + "replayed jti must be rejected" + ); + } + + // ── Capacity ───────────────────────────────────────────────────────────── + + #[test] + fn jti_resource_bound_limits_replay_state_for_already_denied_keys() { + // max_jti_count = capacity * 2 = 4 (for capacity=2). + // Fill all 4 JTI slots across 2 keys, then verify a fifth jti is rejected. + // This proves the bound is enforced even though entry capacity is not + // exhausted (only 2 entries for 2 keys, entry capacity is 2 — no new + // entries would be inserted). + // + // Mutation anchor: removing the JTI resource-bound check would let + // the jtis map grow without limit even though no new deny entries are + // added (because the update path bypasses the entry-capacity check). + let m = NipFiDenyMap::new( + 2, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 2, + }], + ); + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k1 = key(); + let k2 = key(); + + // 4 commands across 2 keys: fills all 4 JTI slots (2 per key, 2*2=4). + m.atomic_reserve_and_insert(iss(), "jti-k1-a", until, &k1, until, now) + .expect("k1 first command"); + m.atomic_reserve_and_insert( + iss(), + "jti-k1-b", + until + Duration::seconds(1), + &k1, + until + Duration::seconds(1), + now, + ) + .expect("k1 second command (update, within jti bound)"); + m.atomic_reserve_and_insert(iss(), "jti-k2-a", until, &k2, until, now) + .expect("k2 first command"); + m.atomic_reserve_and_insert( + iss(), + "jti-k2-b", + until + Duration::seconds(1), + &k2, + until + Duration::seconds(1), + now, + ) + .expect("k2 second command (update, within jti bound)"); + + // 5th JTI: max_jti_count=4 exhausted → CapacityExceeded. + let result = m.atomic_reserve_and_insert( + iss(), + "jti-k1-c", + until + Duration::seconds(2), + &k1, + until + Duration::seconds(2), + now, + ); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "jti resource bound must reject the fifth jti (max_jti_count=4 exhausted)" + ); + } + + #[test] + fn capacity_exceeded_returns_error_without_inserting() { + // Capacity = 2, three distinct pubkeys. + let m = NipFiDenyMap::new(2, vec![]); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + let k1 = key(); + let k2 = key(); + let k3 = key(); + + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k1, until, now) + .expect("k1"); + m.atomic_reserve_and_insert(iss(), "jti-2", until, &k2, until, now) + .expect("k2"); + let result = m.atomic_reserve_and_insert(iss(), "jti-3", until, &k3, until, now); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "third distinct key must be rejected when cap=2" + ); + // k3 is NOT denied (entry was not inserted). + assert!(!m.is_denied(iss(), &k3, now), "k3 must not be denied"); + } + + #[test] + fn update_to_existing_key_does_not_count_against_entry_capacity() { + // capacity=2: two entry slots, but only one is used. + // An update to the existing key uses the second JTI slot but does NOT + // add a second entry — verifies the entry-capacity check allows updates. + // (JTI capacity is a separate bound: capacity=2 gives max_jti_count=2, + // so the second JTI fits without hitting the JTI resource bound either.) + let m = NipFiDenyMap::new(2, vec![]); + let now = Utc::now(); + let k = key(); + let until_a = now + Duration::seconds(300); + let until_b = now + Duration::seconds(600); + + m.atomic_reserve_and_insert(iss(), "jti-a", until_a, &k, until_a, now) + .expect("first insert"); + // Same key, longer until — entry count stays at 1 (update), jti count goes to 2. + m.atomic_reserve_and_insert(iss(), "jti-b", until_b, &k, until_b, now) + .expect("update same key must succeed: only one entry used, entry-capacity is 2"); + + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(400)), + "updated entry is active" + ); + } + + #[test] + fn cross_issuer_capacity_is_independent() { + let iss_a = "https://a.example.com"; + let iss_b = "https://b.example.com"; + // issuer A has capacity 1 + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss_a.to_owned(), + capacity: 1, + }], + ); + + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k1 = key(); + let k2 = key(); + let k3 = key(); + + // Fill issuer A. + m.atomic_reserve_and_insert(iss_a, "jti-a1", until, &k1, until, now) + .expect("iss_a k1"); + // Issuer B is at default capacity (100) → must accept. + m.atomic_reserve_and_insert(iss_b, "jti-b1", until, &k2, until, now) + .expect("iss_b k2 must succeed independent of iss_a capacity"); + // Issuer A is at capacity 1 → must reject. + let result = m.atomic_reserve_and_insert(iss_a, "jti-a2", until, &k3, until, now); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "iss_a capacity exhaustion must not affect iss_b, and vice versa" + ); + } + + // ── Poison-path: is_denied must fail closed ─────────────────────────────── + + #[test] + fn poisoned_shard_is_denied_fails_closed() { + let iss = "https://poison.example.com"; + // Construct the map with a pre-registered shard for this issuer. + let m = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let m_clone = std::sync::Arc::clone(&m); + let k = key(); + + // Poison the real IssuerShard by spawning a thread that acquires the + // shard Mutex (which wraps a real IssuerShard) and then panics. + // A thread panic while holding a Mutex guard poisons the mutex. + let _ = std::thread::spawn(move || { + let shard_ref = m_clone.shards.get(iss).expect("shard must exist"); + let _guard = shard_ref.lock().expect("lock acquired"); + panic!("intentional poison"); + }) + .join(); // Err(_) expected — that's the proof the thread panicked. + + // The shard is now poisoned. is_denied must return true (fail closed). + // Mutation anchor: reverting unwrap_or(true) → unwrap_or(false) makes + // this assertion fail — that is the defect Thufir identified in pass 1. + assert!( + m.is_denied(iss, &k, Utc::now()), + "poisoned shard must return true from is_denied (fail closed)" + ); + + // Confirm the normal path still works on a clean map. + let clean = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let k2 = key(); + let until2 = Utc::now() + Duration::seconds(300); + clean + .atomic_reserve_and_insert(iss, "jti-clean", until2, &k2, until2, Utc::now()) + .expect("insert on clean map"); + assert!( + clean.is_denied(iss, &k2, Utc::now()), + "active entry on clean map must return true" + ); + } + + // ── remote_merge: idempotent cross-pod semantics ───────────────────────── + + #[test] + fn remote_merge_shorter_after_longer_does_not_shorten() { + // Map with iss() pre-registered so remote_merge can operate on it. + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 100, + }], + ); + let k = key(); + let now = Utc::now(); + let longer = now + Duration::seconds(600); + let shorter = now + Duration::seconds(300); + + // First merge: longer. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, longer, now), + CrossPodMergeResult::Merged + ); + // Second merge: shorter — must not shorten. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, shorter, now), + CrossPodMergeResult::Merged + ); + // At 400s: still denied (longer wins). + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(400)), + "shorter-after-longer remote merge must not shorten the deny" + ); + } + + #[test] + fn remote_merge_replay_is_idempotent() { + // Map with iss() pre-registered so remote_merge can operate on it. + let m = NipFiDenyMap::new( + 100, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 100, + }], + ); + let k = key(); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + // Deliver twice. + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, until, now), + CrossPodMergeResult::Merged + ); + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, until, now), + CrossPodMergeResult::Merged + ); + // Still denied at 200s (no spurious second-insert count growth). + assert!( + m.is_denied(iss(), &k, now + Duration::seconds(200)), + "replay must be idempotent" + ); + } + + #[test] + fn remote_merge_unknown_issuer_rejected() { + let m = map(); // default issuer is "https://issuer.example.com", not "unknown" + let k = key(); + let until = Utc::now() + Duration::seconds(300); + assert_eq!( + m.merge_cross_pod_deny("https://unknown.example.com", &k, until, Utc::now()), + CrossPodMergeResult::UnknownIssuer, + "unknown issuer must be rejected without allocating state" + ); + // No shard was created for the unknown issuer. + assert!( + m.shards.get("https://unknown.example.com").is_none(), + "no shard must be allocated for unknown issuer" + ); + } + + // ── remote_merge: capacity oracle (two-pod divergent model) ────────────── + // + // Verifies that ordinary remote capacity exhaustion leaves active entries + // intact, returns the capacity outcome, and does NOT synthesize issuer-wide + // denial or unrelated-key denial. Uses two capacity-1 maps modeling + // divergent pods with the same issuer. + // + // Mandatory reds: + // (a) guard/evict the active entry on capacity → original k1 entry gone; + // entry-retention assertion fails + // (b) synthesize issuer-wide denial (set blocked) → missed-target and + // unrelated-key is_denied assertions fail + // (c) admit at exact equality (use <= instead of <) → equality assertion fails + + #[test] + fn remote_merge_capacity_exceeded_preserves_active_entry_and_does_not_deny_missed_or_unrelated() + { + // Two capacity-1 maps modeling divergent pods (pod A, pod B). + // Pod A locally contains target k_a; pod B locally contains target k_b. + // Both have the same finite TTL. + let now = Utc::now(); + let until = now + Duration::seconds(300); + let k_a = key(); + let k_b = key(); + let k_unrelated = key(); + + let make_map = |local_key: &PublicKey| { + let m = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 1, + }], + ); + // Pre-fill with the local target. + m.atomic_reserve_and_insert(iss(), "jti-local", until, local_key, until, now) + .expect("local pre-fill must succeed"); + m + }; + + // Pod A map: has k_a, receives k_b cross-pod. + let map_a = make_map(&k_a); + let result_a = map_a.merge_cross_pod_deny(iss(), &k_b, until, now); + assert_eq!( + result_a, + CrossPodMergeResult::CapacityExceeded, + "cross-delivery of k_b to pod A (capacity=1, already holds k_a) must return CapacityExceeded" + ); + // Pod A still has its original k_a entry — capacity miss must not evict. + assert!( + map_a.is_denied(iss(), &k_a, now), + "pod A must still deny k_a after capacity miss" + ); + // Pod A must NOT deny the missed k_b via issuer-wide block. + assert!( + !map_a.is_denied(iss(), &k_b, now), + "pod A must NOT deny missed target k_b — no issuer-wide denial on capacity miss" + ); + // Pod A must NOT deny an unrelated key. + assert!( + !map_a.is_denied(iss(), &k_unrelated, now), + "pod A must NOT deny unrelated key after capacity miss" + ); + + // Pod B map: has k_b, receives k_a cross-pod. + let map_b = make_map(&k_b); + let result_b = map_b.merge_cross_pod_deny(iss(), &k_a, until, now); + assert_eq!( + result_b, + CrossPodMergeResult::CapacityExceeded, + "cross-delivery of k_a to pod B (capacity=1, already holds k_b) must return CapacityExceeded" + ); + assert!( + map_b.is_denied(iss(), &k_b, now), + "pod B must still deny k_b after capacity miss" + ); + assert!( + !map_b.is_denied(iss(), &k_a, now), + "pod B must NOT deny missed target k_a" + ); + + // At exact equality with the TTL both entries are admitted (now < until fails). + assert!( + !map_a.is_denied(iss(), &k_a, until), + "k_a must be admitted at exact equality with TTL" + ); + assert!( + !map_b.is_denied(iss(), &k_b, until), + "k_b must be admitted at exact equality with TTL" + ); + + // Delayed already-expired remote entry: may return capacity outcome but + // must not alter the live entry or deny the expired target / unrelated key. + let expired_until = now - Duration::seconds(1); + let map_c = make_map(&k_a); // pre-filled with k_a active + let result_c = map_c.merge_cross_pod_deny(iss(), &k_b, expired_until, now); + // Expired remote entry is treated as a new (already-expired) entry; since + // the shard is at capacity the remote_merge returns CapacityExceeded. + // The live k_a entry must remain; k_b and k_unrelated must not be denied. + assert!( + map_c.is_denied(iss(), &k_a, now), + "live k_a must survive a capacity-miss with expired remote target" + ); + assert!( + !map_c.is_denied(iss(), &k_b, now), + "expired k_b must not be map-denied after capacity miss" + ); + assert!( + !map_c.is_denied(iss(), &k_unrelated, now), + "unrelated key must not be denied after capacity miss with expired remote" + ); + // Confirm the result is CapacityExceeded (expired entry still counts as + // new against a full shard — it has no active existing entry). + assert_eq!( + result_c, + CrossPodMergeResult::CapacityExceeded, + "expired remote against full shard must return CapacityExceeded" + ); + } + + #[test] + fn remote_merge_poisoned_shard_returns_shard_poisoned() { + let iss = "https://poison-remote.example.com"; + let m = std::sync::Arc::new(NipFiDenyMap::new( + 10, + vec![IssuerCapacity { + issuer: iss.to_owned(), + capacity: 10, + }], + )); + let m_clone = std::sync::Arc::clone(&m); + let k = key(); + let until = Utc::now() + Duration::seconds(300); + + // Poison the shard. + let _ = std::thread::spawn(move || { + let shard_ref = m_clone.shards.get(iss).expect("shard must exist"); + let _guard = shard_ref.lock().expect("lock acquired"); + panic!("intentional poison for remote_merge test"); + }) + .join(); + + assert_eq!( + m.merge_cross_pod_deny(iss, &k, until, Utc::now()), + CrossPodMergeResult::ShardPoisoned, + "poisoned shard must return ShardPoisoned" + ); + } + + // ── Blocker 2: JTI replay-bound boundary tests ──────────────────────────── + + #[test] + fn concurrent_same_issuer_reservations_do_not_exceed_jti_budget() { + use std::sync::Barrier; + + // capacity=2 → JTI ceiling = 4. One pre-denied target key so the + // deny-entry capacity cannot become the limiting factor. + let m = Arc::new(NipFiDenyMap::new( + 2, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 2, + }], + )); + let now = Utc::now(); + let until = now + Duration::seconds(300); + + // Pre-deny one key so all subsequent threads are updates (bypass entry cap). + let pre_key = key(); + m.atomic_reserve_and_insert(iss(), "jti-pre", until, &pre_key, until, now) + .expect("pre-insert"); + + let n_threads: usize = 8; + let barrier = Arc::new(Barrier::new(n_threads)); + let mut handles = Vec::with_capacity(n_threads); + + for i in 0..n_threads { + let m_clone = Arc::clone(&m); + let b = Arc::clone(&barrier); + let k_clone = pre_key; + let jti = format!("concurrent-jti-{i}"); + handles.push(std::thread::spawn(move || { + b.wait(); // release all threads simultaneously + m_clone.atomic_reserve_and_insert(iss(), &jti, until, &k_clone, until, now) + })); + } + + let results: Vec<_> = handles + .into_iter() + .map(|h| h.join().expect("thread must not panic")) + .collect(); + + let successes = results.iter().filter(|r| r.is_ok()).count(); + let capacity_exceeded = results + .iter() + .filter(|r| matches!(r, Err(ReserveError::CapacityExceeded))) + .count(); + + // The pre-insert consumes 1 JTI slot; ceiling is 4; so exactly 3 of the + // concurrent threads succeed (4 - 1 = 3 remaining slots). + assert_eq!( + successes, + 3, + "exactly 3 concurrent successes allowed (4 JTI ceiling - 1 pre-used = 3), got {successes}" + ); + assert_eq!( + successes + capacity_exceeded, + n_threads, + "every result must be Ok or CapacityExceeded" + ); + + // Confirm the shard's JTI count is exactly at the ceiling. + let live_jtis = m.shards.get(iss()).unwrap().lock().unwrap().jtis.len(); + assert_eq!( + live_jtis, 4, + "shard must have exactly 4 live JTIs (ceiling), found {live_jtis}" + ); + } + + #[test] + fn jti_budget_rejection_is_unapplied_and_exact_jti_retries_after_expiry() { + use chrono::TimeZone; + + let cap = 2usize; + // capacity=2 → JTI ceiling=4. Use fixed synthetic timestamps. + let m = NipFiDenyMap::new( + cap, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: cap, + }], + ); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let k = key(); + let deny_deadline = t0 + Duration::seconds(10); // existing deny until t0+10s + + // Fill all 4 JTI slots: + // jti-0: expiry t0+2s (the short one — will expire first) + // jti-1..3: expiry t0+20s + m.atomic_reserve_and_insert( + iss(), + "jti-0", + t0 + Duration::seconds(2), + &k, + deny_deadline, + t0, + ) + .expect("slot 0"); + m.atomic_reserve_and_insert( + iss(), + "jti-1", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 1"); + m.atomic_reserve_and_insert( + iss(), + "jti-2", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 2"); + m.atomic_reserve_and_insert( + iss(), + "jti-3", + t0 + Duration::seconds(20), + &k, + deny_deadline, + t0, + ) + .expect("slot 3"); + + // Attempt candidate JTI X with a long deny deadline (t0+30s). + // JTI budget is full (4/4) → must fail with CapacityExceeded. + let x_jti_expiry = t0 + Duration::seconds(25); + let x_deny_deadline = t0 + Duration::seconds(30); + let result = + m.atomic_reserve_and_insert(iss(), "jti-X", x_jti_expiry, &k, x_deny_deadline, t0); + assert_eq!( + result, + Err(ReserveError::CapacityExceeded), + "JTI budget full → CapacityExceeded" + ); + + // jti-X must NOT be recorded in the JTI set. + { + let shard = m.shards.get(iss()).unwrap(); + let guard = shard.lock().unwrap(); + assert!( + !guard.jtis.contains_key("jti-X"), + "jti-X must be absent from JTI set after budget rejection" + ); + // The deny deadline must NOT have been extended (still t0+10s from + // the last successful insert — the max-merge should not have applied X). + let stored_until = *guard.entries.get(&k.to_hex()).unwrap(); + assert_eq!( + stored_until, deny_deadline, + "deny deadline must not be extended by a rejected JTI-budget command" + ); + } + + // At t0+15s: the deny deadline (t0+10s) has passed → key should be admitted. + let t_after_deny = t0 + Duration::seconds(15); + assert!( + !m.is_denied(iss(), &k, t_after_deny), + "key must be admitted at t0+15s (deny deadline t0+10s expired)" + ); + + // Now retry jti-X at t0+3s: jti-0 (expiry t0+2s) has expired, freeing a slot. + // jti-X itself is still valid (expiry t0+25s > t0+3s). + // Retry uses the same deny_deadline=t0+30s. + let t_retry = t0 + Duration::seconds(3); + let result_retry = + m.atomic_reserve_and_insert(iss(), "jti-X", x_jti_expiry, &k, x_deny_deadline, t_retry); + assert!( + result_retry.is_ok(), + "retry of jti-X after jti-0 expired must succeed, got: {result_retry:?}" + ); + + // jti-X is now in the JTI set. + { + let shard = m.shards.get(iss()).unwrap(); + let guard = shard.lock().unwrap(); + assert!( + guard.jtis.contains_key("jti-X"), + "jti-X must be present after successful retry" + ); + } + + // At t0+15s: deny deadline is now t0+30s (max of t0+10s and t0+30s) → denied. + assert!( + m.is_denied(iss(), &k, t_after_deny), + "key must be denied at t0+15s after successful retry (deadline now t0+30s)" + ); + } +} diff --git a/crates/buzz-auth/src/nip_fi/jwks/mod.rs b/crates/buzz-auth/src/nip_fi/jwks/mod.rs index 5927f93d062..f94cbef4eeb 100644 --- a/crates/buzz-auth/src/nip_fi/jwks/mod.rs +++ b/crates/buzz-auth/src/nip_fi/jwks/mod.rs @@ -577,7 +577,7 @@ impl ProductionJwksSource { }) } - /// **Test-only.** Construct with an injectable clock so tests can advance + /// **Test/dev-only.** Construct with an injectable clock so tests can advance /// `now` past snapshot hard deadlines without wall-clock sleep. #[cfg(any(test, feature = "test-utils"))] pub fn new_with_clock( @@ -606,6 +606,37 @@ impl ProductionJwksSource { }) } + /// **Test/dev-only.** Directly seed a pre-built JWKS snapshot for `issuer` + /// without making an HTTP request. Used by route integration tests to + /// construct a warmed `ProductionJwksSource` in a hermetic environment. + /// + /// Panics if `issuer` is not registered in the source. + #[cfg(any(test, feature = "dev"))] + pub async fn seed_snapshot_for_test(&self, issuer: &str, jwks: jsonwebtoken::jwk::JwkSet) { + use sha2::{Digest, Sha256}; + let body = serde_json::to_string(&jwks).expect("serialise test JWKS"); + let content_digest: [u8; 32] = Sha256::digest(body.as_bytes()).into(); + let config = self.configs.get(issuer).expect("issuer must be registered"); + let now = (self.now_fn)(); + let deadline_secs = i64::try_from(config.contract.key_snapshot_hard_deadline_seconds()) + .unwrap_or(i64::MAX / 2); + let hard_deadline = now + + chrono::Duration::try_seconds(deadline_secs) + .unwrap_or_else(|| chrono::Duration::seconds(i64::MAX / 2)); + let key_set = + super::verifier::AssertionKeySet::new(issuer.to_owned(), 1, jwks, hard_deadline) + .expect("valid test JWKS"); + let snapshot = CachedSnapshot { + key_set, + fetched_at: now, + hard_deadline, + content_digest, + }; + let slot = self.states.get(issuer).expect("issuer must be registered"); + slot.refresh.lock().await.generation_counter = 1; + *slot.write_published() = Some(snapshot); + } + async fn fetch_fresh( &self, issuer: &str, diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index 8d3667885a5..fb55dfee745 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -9,8 +9,10 @@ pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; +pub mod command; pub mod config; pub mod denial; +pub mod deny_map; pub mod discovery; pub mod jwks; pub mod startup; @@ -20,12 +22,17 @@ pub use assertion::{ CanonicalCapabilities, ConfidentialAssertion, FederatedIdentity, RevalidationDependencies, VerifiedAssertion, }; +pub use command::{ + CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, CommandVerifier, + COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, +}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; pub use denial::DenialClass; +pub use deny_map::{CrossPodMergeResult, DenySetFull, IssuerCapacity, NipFiDenyMap}; pub use discovery::{ AssertionFreshnessDiscovery, FederatedIdentityDiscovery, FreshnessClassDiscovery, }; diff --git a/crates/buzz-auth/src/nip_fi/verifier.rs b/crates/buzz-auth/src/nip_fi/verifier.rs index bc6afcf0f19..2587ee04a4d 100644 --- a/crates/buzz-auth/src/nip_fi/verifier.rs +++ b/crates/buzz-auth/src/nip_fi/verifier.rs @@ -157,6 +157,15 @@ impl AssertionKeySet { pub(crate) fn hard_deadline(&self) -> chrono::DateTime { self.hard_deadline } + + /// The authenticated JWKS for this issuer snapshot. + /// + /// `pub(super)` so the command verifier in the same `nip_fi` module can + /// look up keys by `kid` without duplicating the key-selection logic. + /// External consumers cannot access key material through this path. + pub(super) fn jwks(&self) -> &JwkSet { + &self.jwks + } } impl fmt::Debug for AssertionKeySet { @@ -598,10 +607,10 @@ impl VerifierError { } /// A minimally parsed JOSE header. -struct ParsedHeader { - algorithm: Algorithm, - kid: String, - typ: Option, +pub(super) struct ParsedHeader { + pub(super) algorithm: Algorithm, + pub(super) kid: String, + pub(super) typ: Option, } /// Reject any token that is not exactly three compact-JWS segments. @@ -614,7 +623,7 @@ struct ParsedHeader { /// base64url — is validated separately by [`enforce_signature_shape`] after /// header parsing, so that no structurally malformed token can defer to the /// key-source lookup and masquerade as a 503 outage (NIP-FI.md:151-171). -fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { +pub(super) fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { if token.split('.').count() == 3 { Ok(()) } else { @@ -631,7 +640,7 @@ fn enforce_compact_structure(token: &str) -> Result<(), VerifierError> { /// after [`parse_header`], so `alg=none`'s empty-signature token is already /// rejected at header parsing (unsupported algorithm) before this distinction /// matters (NIP-FI.md:151-171). -fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { +pub(super) fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { let signature = token .split('.') .nth(2) @@ -640,7 +649,7 @@ fn enforce_signature_shape(token: &str) -> Result<(), VerifierError> { base64url_decode(signature).map(|_| ()) } -fn parse_header(token: &str) -> Result { +pub(super) fn parse_header(token: &str) -> Result { let segment = token .split('.') .next() @@ -790,7 +799,7 @@ fn capture_capabilities( CanonicalCapabilities::from_pairs(entries) } -fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, VerifierError> { +pub(super) fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, VerifierError> { let mut matching = jwks .keys .iter() @@ -802,7 +811,7 @@ fn select_unique_jwk<'a>(jwks: &'a JwkSet, kid: &str) -> Result<&'a Jwk, Verifie Ok(jwk) } -fn validate_jwk(jwk: &Jwk, token_algorithm: Algorithm) -> Result<(), VerifierError> { +pub(super) fn validate_jwk(jwk: &Jwk, token_algorithm: Algorithm) -> Result<(), VerifierError> { let usage_ok = jwk .common .public_key_use @@ -914,7 +923,7 @@ fn optional_numeric_date( /// them) is converted with subsecond nanosecond precision. NaN, infinity, a /// non-number, and any magnitude outside the representable `i64`-seconds range /// deny as invalid time bounds. -fn parse_numeric_date(value: &Value) -> Result, VerifierError> { +pub(super) fn parse_numeric_date(value: &Value) -> Result, VerifierError> { // Integer NumericDate: exact, no float round-trip. if let Some(secs) = value.as_i64() { return Utc @@ -958,7 +967,7 @@ fn checked_add(at: DateTime, delta: chrono::Duration) -> Result Result, VerifierError> { +pub(super) fn parse_unique_claims(token: &str) -> Result, VerifierError> { let segment = token .split('.') .nth(1) diff --git a/crates/buzz-pubsub/src/conn_control.rs b/crates/buzz-pubsub/src/conn_control.rs index bc177cff139..875bfd805c0 100644 --- a/crates/buzz-pubsub/src/conn_control.rs +++ b/crates/buzz-pubsub/src/conn_control.rs @@ -34,6 +34,55 @@ pub fn conn_control_channel(ctx: &TenantContext) -> String { format!("{BUZZ_PREFIX}:{}:{CONN_CONTROL_SUFFIX}", ctx.community()) } +// ── NIP-FI global disconnect channel ───────────────────────────────────────── + +/// Global (issuer-scoped, not community-scoped) Redis pub/sub channel for NIP-FI +/// disconnect commands. A single channel covers all communities because NIP-FI +/// deny entries apply across the full issuer domain — the community a user is +/// connected to at that moment is irrelevant. +pub const NIP_FI_DISCONNECT_CHANNEL: &str = "buzz:nip-fi:disconnect"; + +/// A NIP-FI admin-disconnect command broadcast cross-pod after the local deny +/// entry is inserted. Every pod merges this entry into its own deny map and +/// closes any matching sessions (same `max(until)` rule as the local path). +/// +/// Transmitted asynchronously — the HTTP response does not wait on remote-pod +/// delivery; the spec's asynchronous-success semantics are preserved. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct NipFiDisconnect { + /// Exact `iss` URI of the issuer that originated the command. + pub issuer: String, + /// 32 raw bytes of the target Nostr public key. + pub pubkey_bytes: Vec, + /// `until` seconds since the Unix epoch (whole-second component). + pub until_unix: i64, + /// Nanosecond sub-second component of `until` (0..1_000_000_000). + /// Transmitted alongside `until_unix` so the full sub-second precision of + /// the signed command JWT is preserved across pod boundaries. + #[serde(default)] + pub until_unix_nanos: u32, +} + +/// Encode a [`NipFiDisconnect`] message to a JSON string for publication on +/// the Redis pub/sub channel. +/// +/// This is the single publication path — the HTTP handler and any future +/// publisher must call this function rather than serialising directly, so the +/// wire format is defined in one place and the round-trip oracle can cover it. +pub fn encode_nip_fi_disconnect(message: &NipFiDisconnect) -> Result { + serde_json::to_string(message) +} + +/// Decode a [`NipFiDisconnect`] message from a JSON string received from the +/// Redis pub/sub channel. +/// +/// This is the single consumption path — the subscriber and any future consumer +/// must call this function rather than deserialising directly, so the wire format +/// is defined in one place and the round-trip oracle can cover it. +pub fn decode_nip_fi_disconnect(payload: &str) -> Result { + serde_json::from_str(payload) +} + /// Parse a connection-control Redis channel into its scoped community id. pub fn parse_conn_control_channel(channel: &str) -> Option { let mut parts = channel.split(':'); @@ -161,6 +210,76 @@ async fn connect_and_subscribe( Ok(()) } +// ── NIP-FI disconnect subscriber ────────────────────────────────────────────── + +/// Subscribes to [`NIP_FI_DISCONNECT_CHANNEL`] and forwards commands to the +/// broadcast. Mirrors [`run_conn_control_subscriber`]: reconnect loop with +/// exponential backoff. Never returns. +pub async fn run_nip_fi_disconnect_subscriber( + redis_url: String, + broadcast_tx: broadcast::Sender, +) { + let mut backoff_secs = BACKOFF_INITIAL_SECS; + + loop { + match connect_and_subscribe_nip_fi(&redis_url, &broadcast_tx).await { + Ok(()) => { + backoff_secs = BACKOFF_INITIAL_SECS; + tracing::warn!( + "Redis NIP-FI disconnect stream ended (clean disconnect) — reconnecting in {backoff_secs}s" + ); + } + Err(e) => { + tracing::error!( + "Redis NIP-FI disconnect error: {e} — reconnecting in {backoff_secs}s" + ); + } + } + + tokio::time::sleep(tokio::time::Duration::from_secs(backoff_secs)).await; + backoff_secs = (backoff_secs * 2).min(BACKOFF_MAX_SECS); + } +} + +async fn connect_and_subscribe_nip_fi( + redis_url: &str, + broadcast_tx: &broadcast::Sender, +) -> Result<(), redis::RedisError> { + let client = redis::Client::open(redis_url)?; + let mut conn = client.get_async_pubsub().await?; + + conn.subscribe(NIP_FI_DISCONNECT_CHANNEL).await?; + + tracing::info!( + "Redis NIP-FI disconnect subscriber connected — listening on {NIP_FI_DISCONNECT_CHANNEL}" + ); + + let mut stream = conn.on_message(); + while let Some(msg) = stream.next().await { + let payload: String = match msg.get_payload() { + Ok(p) => p, + Err(e) => { + tracing::warn!("Failed to get NIP-FI disconnect payload: {e}"); + continue; + } + }; + + let command: NipFiDisconnect = match decode_nip_fi_disconnect(&payload) { + Ok(v) => v, + Err(e) => { + tracing::warn!("Failed to deserialize NIP-FI disconnect message: {e}"); + continue; + } + }; + + if broadcast_tx.send(command).is_err() { + tracing::trace!("No NIP-FI disconnect receivers — message dropped"); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -226,4 +345,55 @@ mod tests { let json = serde_json::to_string(&cmd).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), cmd); } + + // ── NipFiDisconnect serde ───────────────────────────────────────────────── + + #[test] + fn nip_fi_disconnect_serde_round_trips() { + let cmd = NipFiDisconnect { + issuer: "https://idp.example.com".to_string(), + pubkey_bytes: vec![0xabu8; 32], + until_unix: 9_999_999_999, + until_unix_nanos: 500_000_000, + }; + let json = serde_json::to_string(&cmd).unwrap(); + let decoded: NipFiDisconnect = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, cmd); + } + + #[test] + fn nip_fi_disconnect_nanos_default_to_zero_when_absent() { + // Old messages (without the until_unix_nanos field) must still deserialize. + let legacy_json = r#"{"issuer":"https://idp.example.com","pubkey_bytes":[171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171,171],"until_unix":9999999999}"#; + let decoded: NipFiDisconnect = serde_json::from_str(legacy_json).unwrap(); + assert_eq!( + decoded.until_unix_nanos, 0, + "missing nanos field must default to 0" + ); + assert_eq!(decoded.until_unix, 9_999_999_999); + } + + #[test] + fn nip_fi_disconnect_channel_is_global_not_community_scoped() { + // Must NOT contain a community UUID segment — it's issuer-global. + assert_eq!(NIP_FI_DISCONNECT_CHANNEL, "buzz:nip-fi:disconnect"); + assert!(!NIP_FI_DISCONNECT_CHANNEL.contains("conn-control")); + } + + #[test] + fn nip_fi_disconnect_malformed_payload_is_skipped() { + // Simulate what the subscriber does: malformed JSON produces an error + // and the message is skipped (no panic). + let malformed = r#"{"issuer": 42}"#; // wrong type for issuer + assert!(serde_json::from_str::(malformed).is_err()); + // Well-formed payload still parses. + let good = serde_json::to_string(&NipFiDisconnect { + issuer: "https://a.example.com".to_string(), + pubkey_bytes: vec![1u8; 32], + until_unix: 1_000_000, + until_unix_nanos: 0, + }) + .unwrap(); + assert!(serde_json::from_str::(&good).is_ok()); + } } diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 4f1690beefb..0240af66484 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -54,7 +54,9 @@ use tokio::sync::{broadcast, mpsc, Mutex}; use crate::cache_invalidation::{ cache_invalidation_channel, CacheInvalidation, ScopedCacheInvalidation, }; +pub use crate::conn_control::NipFiDisconnect; use crate::conn_control::{conn_control_channel, ConnControl, ScopedConnControl}; +pub use crate::conn_control::{decode_nip_fi_disconnect, encode_nip_fi_disconnect}; pub use crate::topic::{channel_key, global_key, EventTopic, EventTopicKey}; /// A Nostr event received on a scoped Redis event topic, broadcast to local subscribers. @@ -110,6 +112,7 @@ pub struct PubSubManager { broadcast_tx: broadcast::Sender, cache_invalidation_tx: broadcast::Sender, conn_control_tx: broadcast::Sender, + nip_fi_disconnect_tx: broadcast::Sender, } impl PubSubManager { @@ -126,6 +129,7 @@ impl PubSubManager { let (broadcast_tx, _) = broadcast::channel(4096); let (cache_invalidation_tx, _) = broadcast::channel(4096); let (conn_control_tx, _) = broadcast::channel(4096); + let (nip_fi_disconnect_tx, _) = broadcast::channel(4096); let (subscription_tx, subscription_rx) = mpsc::channel(4096); Ok(Self { @@ -138,6 +142,7 @@ impl PubSubManager { broadcast_tx, cache_invalidation_tx, conn_control_tx, + nip_fi_disconnect_tx, }) } @@ -180,6 +185,19 @@ impl PubSubManager { .await; } + /// Starts the NIP-FI disconnect subscriber loop with automatic + /// reconnection. Runs forever — spawn this in a background task. + /// + /// Every pod subscribes to this global channel; on receipt it merges the + /// deny entry and closes matching local sessions. + pub async fn run_nip_fi_disconnect_subscriber(self: Arc) { + conn_control::run_nip_fi_disconnect_subscriber( + self.redis_url.clone(), + self.nip_fi_disconnect_tx.clone(), + ) + .await; + } + /// Returns a new broadcast receiver for locally-published channel events. pub fn subscribe_local(&self) -> broadcast::Receiver { self.broadcast_tx.subscribe() @@ -265,6 +283,11 @@ impl PubSubManager { self.conn_control_tx.subscribe() } + /// Returns a new broadcast receiver for cross-pod NIP-FI disconnect commands. + pub fn subscribe_nip_fi_disconnect(&self) -> broadcast::Receiver { + self.nip_fi_disconnect_tx.subscribe() + } + /// Publish a cache-key drop to all pods. Fire-and-forget at the call site: /// the local cache is already dropped synchronously; this carries the same /// drop cross-pod. A dropped publish is backstopped by the REQ denial-path @@ -304,6 +327,26 @@ impl PubSubManager { Ok(subscriber_count) } + /// Publish a NIP-FI disconnect command to all pods on the global channel. + /// + /// Called after the local deny entry is inserted. Remote pods receive this + /// and apply the same `max(until)` merge + all-community session close. + /// Fire-and-forget: the HTTP response does not wait on delivery. + pub async fn publish_nip_fi_disconnect( + &self, + command: &NipFiDisconnect, + ) -> Result { + use crate::conn_control::{encode_nip_fi_disconnect, NIP_FI_DISCONNECT_CHANNEL}; + let mut conn = self.pool.get().await?; + let payload = encode_nip_fi_disconnect(command)?; + let subscriber_count: i64 = redis::cmd("PUBLISH") + .arg(NIP_FI_DISCONNECT_CHANNEL) + .arg(&payload) + .query_async(&mut conn) + .await?; + Ok(subscriber_count) + } + /// Publish an event to the Redis channel. Returns subscriber count. /// /// Routing note (NIP-ER author-private reminders): events are keyed by diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 0d45a69eed2..dcd8a8a0209 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -7646,12 +7646,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager @@ -9334,12 +9338,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager @@ -9599,12 +9607,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager @@ -10474,12 +10486,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager @@ -10734,12 +10750,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager @@ -10932,12 +10952,16 @@ mod postgres_tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, tokio_util::sync::CancellationToken::new(), cid, std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state .conn_manager diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index c3193066d33..ab91dd14b06 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -7856,6 +7856,7 @@ mod postgres_tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); (conn, send_rx) } diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 545954fb368..ed3a61ae610 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod invites; pub mod media; pub mod mesh_demo; pub mod nip05; +pub mod nip_fi; pub mod operator; pub mod workflows; diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs new file mode 100644 index 00000000000..24b55a0de21 --- /dev/null +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -0,0 +1,2486 @@ +//! NIP-FI admin disconnect endpoint — `POST /api/nip-fi/disconnect`. +//! +//! This module owns: +//! +//! * [`disconnect`] — the axum handler for `POST /api/nip-fi/disconnect`. +//! * [`build_nip_fi_command_components`] — startup initialization called by +//! `main.rs` to wire the deny map and command verifier into `AppState`. +//! +//! ## Transport invariant +//! +//! The NIP-FI admin API is **not** a protected HTTP surface. It MUST NOT be +//! subjected to the NIP-FI HTTP-ingress admission procedure. It carries +//! `Nostr-Federated-Identity` for a command JWS, not an identity assertion. +//! [NIP-FI.md §HTTP ingress, protected surfaces note] +//! +//! Authentication is entirely by the signed command JWT verified inside +//! [`buzz_auth::CommandVerifier::verify`]; no NIP-98 or relay-membership check +//! is performed. +//! +//! ## Environment variables +//! +//! The command API is enabled when `BUZZ_NIP_FI_MODE=enforce` and the issuer +//! JSON entries include the S4 fields. S4 fields are read from the same +//! `BUZZ_NIP_FI_ISSUERS` JSON array; each issuer entry optionally carries: +//! +//! ```json +//! { +//! "maximum_command_age_seconds": 30, +//! "authorized_principals": ["service-account@issuer.example.com"], +//! "deny_set_capacity": 50000 +//! } +//! ``` +//! +//! `maximum_command_age_seconds` and `authorized_principals` are required in +//! enforce mode if any issuer is command-capable. `deny_set_capacity` defaults +//! to [`DEFAULT_DENY_SET_CAPACITY`] when absent. + +use std::sync::Arc; + +use axum::{ + body::Body, + extract::State, + http::{HeaderMap, Response, StatusCode}, +}; +use serde::Deserialize; +use tracing::{debug, warn}; + +use buzz_auth::{ + CommandError, CommandIssuerPolicy, CommandVerifier, IssuerCapacity, JwksFetcher, NipFiDenyMap, + NipFiMode, ProductionJwksSource, CLIENT_ATTACHED_HEADER, +}; + +use crate::state::AppState; + +/// Default per-issuer deny-set capacity when `deny_set_capacity` is absent. +/// 50_000 entries × ~128 bytes ≈ 6.4 MB per issuer. +pub const DEFAULT_DENY_SET_CAPACITY: usize = 50_000; + +// ── Request / response shapes ──────────────────────────────────────────────── + +/// JSON body for `POST /api/nip-fi/disconnect`. +#[derive(Debug, Deserialize)] +pub struct DisconnectRequest { + /// Lowercase hex encoding of the 32-byte target Nostr public key. + pub pubkey: String, +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +/// `POST /api/nip-fi/disconnect` +/// +/// Executes `VerifyCommandJwt`, inserts the deny entry, and closes all live +/// sessions for the target pubkey across all communities. +/// +/// Response contract (from NIP-FI spec): +/// +/// | Condition | Status | Body | +/// |---|---|---| +/// | Authorized; action taken or no-op | `200` | `{"disconnected":true}` | +/// | Missing or invalid command JWT | `401`/`403` | per rejection table | +/// | Malformed request body or `until` exceeds ceiling | `400` | `"bad request\n"` | +/// | Deny set at capacity | `503` | `"deny set full\n"` | +/// +/// The endpoint is NOT a protected HTTP surface. [NIP-FI.md §HTTP ingress] +pub async fn disconnect( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Response { + // ── Extract the command JWT from the header ──────────────────────────── + let token = match extract_command_jwt(&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") + }; + } + }; + + // ── 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"), + }; + + // 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"), + }; + + // ── Command verifier ────────────────────────────────────────────────── + let verifier = match &state.nip_fi_command_verifier { + Some(v) => v.clone(), + 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", + ); + } + }; + + let result = verifier.verify(token, "POST", "/api/nip-fi/disconnect", &body_pubkey); + + match result { + Ok(cmd) => { + // ── Deny entry inserted; close sessions synchronously ───────── + let pubkey_bytes = cmd.target_pubkey.to_bytes(); + // Issuer-scoped: the deny entry is keyed by (caller_iss, k), so only + // sessions admitted under caller_iss are closed. [FI-TRACE-DENY-SET] + let closed = state + .conn_manager + .disconnect_nip_fi(&cmd.caller_iss, &pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(&cmd.caller_iss, &pubkey_bytes); + if closed > 0 { + // [FI-TRACE-PRIVACY-NONPUBLIC]: raw `iss` MUST NOT appear in + // logs, metrics, or traces. Log only a count. + debug!(closed, "nip-fi disconnect: closed sessions"); + } + metrics::counter!("buzz_nip_fi_disconnect_total").increment(1); + metrics::counter!( + "buzz_nip_fi_sessions_closed_total", + "reason" => "admin_disconnect" + ) + .increment(closed as u64); + + // Cross-pod propagation: publish to global NIP-FI Redis channel + // so remote pods can merge the deny entry and close their sessions. + // Asynchronous: HTTP response does not wait on remote delivery. + { + let pubsub = Arc::clone(&state.pubsub); + let msg = nip_fi_disconnect_message(&cmd); + tokio::spawn(async move { + if let Err(e) = pubsub.publish_nip_fi_disconnect(&msg).await { + // [FI-TRACE-PRIVACY-NONPUBLIC]: no iss or pubkey in logs + tracing::warn!("nip-fi: cross-pod propagation publish failed: {e}"); + metrics::counter!("buzz_nip_fi_disconnect_propagation_failures_total") + .increment(1); + } + }); + } + + 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") + } + } +} + +// ── Startup component builder ───────────────────────────────────────────────── + +/// Per-issuer command configuration parsed from the `BUZZ_NIP_FI_ISSUERS` JSON. +/// +/// Added to each entry in S4. All three fields are optional (absent = +/// command API disabled for that issuer / default capacity used). +#[derive(Debug, Default, Clone, serde::Deserialize)] +pub struct CommandIssuerEnvConfig { + /// Positive seconds, ≤ 60. Required for the command API to be enabled. + pub maximum_command_age_seconds: Option, + /// Non-empty list of authorized `sub` values. Required if command age is set. + pub authorized_principals: Option>, + /// Hard ceiling on live deny entries for this issuer. + /// Defaults to [`DEFAULT_DENY_SET_CAPACITY`] when absent. + pub deny_set_capacity: Option, +} + +/// The `NipFiDenyMap` + `CommandVerifier` pair built at startup. +pub struct NipFiCommandComponents { + /// The shared deny map consumed by WS admission and S5 HTTP admission. + pub deny_map: Arc, + /// The command verifier for the `POST /api/nip-fi/disconnect` endpoint. + pub command_verifier: Arc>>>, +} + +/// Outcome of applying a cross-pod NIP-FI disconnect message. +/// +/// Returned by [`apply_nip_fi_disconnect`]; used by the `main.rs` receive loop +/// and by tests to assert the production decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NipFiDisconnectApplyResult { + /// Command API is not enabled on this pod (deny map absent); message ignored. + Disabled, + /// Message was rejected before reaching the map (invalid pubkey, unknown + /// issuer, unrepresentable timestamp, or ceiling exceeded). + Rejected, + /// Message was applied; carries the map's merge result. + Applied(buzz_auth::CrossPodMergeResult), +} + +/// Build the [`buzz_pubsub::NipFiDisconnect`] bus message from a successfully +/// verified command. +/// +/// This is the single publisher mapping — the HTTP success path calls this +/// function to ensure the nanos precision is always captured correctly. +/// Reverting either the seconds or the nanos field must red the round-trip oracle. +pub fn nip_fi_disconnect_message(cmd: &buzz_auth::CommandResult) -> buzz_pubsub::NipFiDisconnect { + buzz_pubsub::NipFiDisconnect { + issuer: cmd.caller_iss.clone(), + pubkey_bytes: cmd.target_pubkey.to_bytes().to_vec(), + until_unix: cmd.until.timestamp(), + until_unix_nanos: cmd.until.timestamp_subsec_nanos(), + } +} + +/// Apply a received cross-pod NIP-FI disconnect message against the local +/// deny map and connection registry. +/// +/// This is the single consumer path — the `main.rs` receive loop calls this +/// function after receiving a message from the broadcast channel. Extracting +/// the logic here allows tests to call the exact production path end-to-end +/// without driving a live Redis subscriber. +/// +/// `now` is passed explicitly so tests can supply controlled timestamps. +pub fn apply_nip_fi_disconnect( + state: &crate::state::AppState, + message: &buzz_pubsub::NipFiDisconnect, + now: chrono::DateTime, +) -> NipFiDisconnectApplyResult { + let deny_map = match state.nip_fi_deny_map.as_deref() { + Some(m) => m, + None => return NipFiDisconnectApplyResult::Disabled, + }; + + // Validate pubkey bytes. + let pubkey = match nostr::PublicKey::from_slice(&message.pubkey_bytes) { + Ok(k) => k, + Err(_) => { + tracing::warn!( + len = message.pubkey_bytes.len(), + "nip-fi cross-pod: malformed pubkey bytes — rejected" + ); + return NipFiDisconnectApplyResult::Rejected; + } + }; + + // Validate that the issuer is locally configured. + if state + .config + .nip_fi + .registry + .policy_for_issuer(&message.issuer) + .is_none() + { + tracing::warn!("nip-fi cross-pod: unknown issuer (not locally configured) — rejected"); + return NipFiDisconnectApplyResult::Rejected; + } + + // Validate timestamp representability. + let until = match chrono::DateTime::from_timestamp(message.until_unix, message.until_unix_nanos) + { + Some(t) => t, + None => { + tracing::warn!( + until_unix = message.until_unix, + "nip-fi cross-pod: unrepresentable until timestamp — rejected" + ); + return NipFiDisconnectApplyResult::Rejected; + } + }; + + // Validate that `until` does not exceed the issuer's ceiling. + if let Some(policy) = state + .config + .nip_fi + .registry + .policy_for_issuer(&message.issuer) + { + let skew = chrono::Duration::seconds(policy.skew_seconds() as i64); + let max_age = chrono::Duration::seconds(policy.maximum_assertion_age_seconds() as i64); + if let Some(ceiling) = now + .checked_add_signed(skew) + .and_then(|t| t.checked_add_signed(max_age)) + { + if until > ceiling { + tracing::warn!("nip-fi cross-pod: until exceeds issuer ceiling — rejected"); + return NipFiDisconnectApplyResult::Rejected; + } + } + } + + // Merge the deny entry. + use buzz_auth::CrossPodMergeResult; + let merge_result = deny_map.merge_cross_pod_deny(&message.issuer, &pubkey, until, now); + + // Close sessions for all merge outcomes except UnknownIssuer. + let close_sessions = |reason: &str| { + let closed = state + .conn_manager + .disconnect_nip_fi(&message.issuer, &message.pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(&message.issuer, &message.pubkey_bytes); + if closed > 0 { + tracing::debug!(closed, reason = reason, "nip-fi cross-pod: closed sessions"); + } + }; + + match &merge_result { + CrossPodMergeResult::Merged => { + close_sessions("merged"); + } + CrossPodMergeResult::UnknownIssuer => { + tracing::warn!("nip-fi cross-pod: merge returned UnknownIssuer — rejected"); + } + CrossPodMergeResult::CapacityExceeded => { + tracing::warn!( + "nip-fi cross-pod: deny set full for issuer — closing targeted sessions without map entry (capacity miss; issuer re-push is the recovery path)" + ); + close_sessions("capacity-exceeded"); + metrics::counter!("buzz_nip_fi_cross_pod_capacity_exceeded_total").increment(1); + } + CrossPodMergeResult::ShardPoisoned => { + tracing::error!( + "nip-fi cross-pod: issuer shard is poisoned — sessions closed (fail-closed)" + ); + close_sessions("poisoned shard failsafe"); + metrics::counter!("buzz_nip_fi_cross_pod_shard_poison_total").increment(1); + } + } + + NipFiDisconnectApplyResult::Applied(merge_result) +} + +/// Build the NIP-FI command components from the issuer policies and key source. +/// +/// Called by `install_nip_fi_command_components` (and transitively `main.rs`). +/// Returns `Err` when any command config is invalid. In enforce mode, returns +/// `Err` when no command-capable issuers are present (assertion-only enforce is +/// not supported by this PR; every enforce issuer must carry command config). +/// +/// `issuer_command_configs` must be in the same order as `registry.all_policies()`. +pub fn build_nip_fi_command_components( + mode: NipFiMode, + registry: &buzz_auth::IssuerRegistry, + key_source: Arc>, + issuer_command_configs: &[(String, CommandIssuerEnvConfig)], +) -> Result>, String> { + if matches!(mode, NipFiMode::Off) { + return Ok(None); + } + + // Build per-issuer command policies and capacity overrides. + let mut command_policies: Vec = Vec::new(); + let mut issuer_capacities: Vec = Vec::new(); + let mut default_capacity = DEFAULT_DENY_SET_CAPACITY; + + for (idx, (issuer, cmd_cfg)) in issuer_command_configs.iter().enumerate() { + let age = match cmd_cfg.maximum_command_age_seconds { + Some(a) => a, + None => { + // In enforce mode every issuer must be command-capable; + // from_env() already guarantees this, but be defensive here too. + if matches!(mode, NipFiMode::Enforce) { + return Err(format!( + "nip-fi: enforce issuer [index {idx}] has no maximum_command_age_seconds — \ + assertion-only issuers are not supported in enforce mode" + )); + } + continue; // non-enforce mode: skip issuers without command config + } + }; + let principals = match &cmd_cfg.authorized_principals { + Some(p) if !p.is_empty() => p.clone(), + _ => { + // from_env() already rejects this; treat as a hard error here. + return Err(format!( + "nip-fi: issuer [index {idx}] has maximum_command_age_seconds but no \ + authorized_principals — startup validation should have caught this" + )); + } + }; + let capacity = cmd_cfg + .deny_set_capacity + .unwrap_or(DEFAULT_DENY_SET_CAPACITY); + + // Validate and construct the command policy — no warn-and-skip. + let policy = CommandIssuerPolicy::new(issuer.clone(), age, principals, capacity) + .map_err(|e| format!("nip-fi: issuer [index {idx}] invalid command policy: {e}"))?; + + issuer_capacities.push(IssuerCapacity { + issuer: issuer.clone(), + capacity, + }); + command_policies.push(policy); + + // Track the maximum capacity across issuers for the default slot. + if capacity > default_capacity { + default_capacity = capacity; + } + } + + if command_policies.is_empty() { + if matches!(mode, NipFiMode::Enforce) { + // Enforce with no command-capable issuers is a misconfiguration: + // from_env() guarantees every enforce issuer has command config, so + // an empty set here means something was skipped or the configs are wrong. + return Err( + "nip-fi: enforce mode requires at least one command-capable issuer; \ + no command policies were built — check issuer configuration" + .to_owned(), + ); + } + debug!("nip-fi: no command-capable issuers configured — command API disabled"); + return Ok(None); + } + + let deny_map = Arc::new(NipFiDenyMap::new(default_capacity, issuer_capacities)); + + let command_verifier = Arc::new(CommandVerifier::new( + registry.clone(), + key_source, + command_policies, + (*deny_map).clone(), + )); + + Ok(Some(NipFiCommandComponents { + deny_map, + command_verifier, + })) +} + +/// Result of a successful [`install_nip_fi_command_components`] call. +#[derive(Debug)] +pub struct NipFiCommandStartupReport { + /// Number of issuers wired into the command verifier. + pub command_issuers: usize, +} + +/// Install NIP-FI command components into the two `AppState` slots. +/// +/// This is the single production startup seam that owns: +/// - enforce-mode pre-flight check (returns `Err` for incomplete config) +/// - `build_nip_fi_command_components` invocation +/// - assignment of both `nip_fi_deny_map` and `nip_fi_command_verifier` +/// +/// It performs no JWKS I/O. `main.rs` owns the single warm + per-issuer +/// refresh lifecycle for the shared `key_source`; the command verifier reads +/// that cache lazily at verify time. `main.rs` passes +/// `&mut app_state.nip_fi_deny_map` and `&mut app_state.nip_fi_command_verifier`; +/// the slots are generic over the fetcher so tests can count fetches. +pub fn install_nip_fi_command_components( + deny_map_slot: &mut Option>, + command_verifier_slot: &mut Option>>>>, + mode: NipFiMode, + registry: &buzz_auth::IssuerRegistry, + key_source: Arc>, + command_configs: &[(String, CommandIssuerEnvConfig)], +) -> Result { + // Pre-flight: enforce mode with no command configs is always an error. + if matches!(mode, NipFiMode::Enforce) && command_configs.is_empty() { + return Err( + "NIP-FI install: enforce mode requires at least one command-capable issuer".to_owned(), + ); + } + + let components = build_nip_fi_command_components(mode, registry, key_source, command_configs)?; + + let command_issuers = if let Some(c) = components { + let n = command_configs.len(); + *deny_map_slot = Some(c.deny_map); + *command_verifier_slot = Some(c.command_verifier); + tracing::info!("NIP-FI S4: command API enabled ({n} issuer(s))"); + n + } else { + 0 + }; + + Ok(NipFiCommandStartupReport { command_issuers }) +} + +/// Validate a command issuer config entry without constructing a policy. +/// +/// Called by `nip_fi_config.rs` at startup before `build_nip_fi_command_components` +/// so that invalid config is rejected at `Config::from_env()`, not at serve time. +/// Returns `Err` with a non-sensitive message (no raw issuer URI). +pub fn validate_command_issuer_config( + idx: usize, + age_seconds: u64, + principals: &[String], + capacity: usize, +) -> Result<(), String> { + CommandIssuerPolicy::new( + // Use a sentinel issuer for validation only — no URI written to any log. + format!("https://validate-sentinel-{idx}.internal"), + age_seconds, + principals.to_vec(), + capacity, + ) + .map(|_| ()) + .map_err(|e| format!("issuer [index {idx}] invalid command policy: {e}")) +} + +// ── 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 + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return None; + } + nostr::PublicKey::from_hex(raw).ok() +} + +fn plain_response(status: StatusCode, body: &'static str) -> Response { + Response::builder() + .status(status) + .header("Content-Type", "text/plain; charset=utf-8") + .body(Body::from(body)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +/// 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())) +} + +/// Spec-exact 200 success response. +/// +/// The spec body is `{"disconnected": true}` (note the space after `:`). +/// `serde_json::to_vec` produces `{"disconnected":true}` without the space. +/// We produce the literal bytes directly to stay byte-exact. +fn disconnected_response() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "application/json") + .body(Body::from("{\"disconnected\": true}")) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[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 ──────────────────────────────────────────────── + + #[test] + fn uppercase_hex_rejected() { + let upper = "A".repeat(64); + assert!(parse_hex_pubkey(&upper).is_none()); + } + + #[test] + fn wrong_length_rejected() { + let short = "a".repeat(63); + let long = "a".repeat(65); + assert!(parse_hex_pubkey(&short).is_none()); + assert!(parse_hex_pubkey(&long).is_none()); + } + + // ── CommandIssuerEnvConfig default capacity ──────────────────────────── + + // (The previous constant-assertion test was removed: asserting None and a constant + // does not bind production behavior. The builder is now covered by route integration tests.) + + // ── HTTP response contract ───────────────────────────────────────────── + + /// The spec requires `{"disconnected": true}` (note the space after `:`). + #[tokio::test] + async fn disconnected_response_is_spec_exact() { + let resp = disconnected_response(); + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(ct, "application/json"); + // Body bytes are verified directly — serde_json compact and the spec + // literal are NOT the same (serde_json omits the space). + let body_bytes = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body_bytes.as_ref(), + b"{\"disconnected\": true}", + "success body must be byte-exact per spec" + ); + } + + /// `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() { + for body in &["evidence rejected\n", "authorization denied\n"] { + let resp = plain_response(StatusCode::FORBIDDEN, body); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "403 must not carry WWW-Authenticate" + ); + } + } + + /// `503` plain responses have the spec-exact body. + #[test] + fn deny_set_full_response_body_is_spec_exact() { + use buzz_auth::CommandError; + let body = CommandError::DenySetFull.response_body(); + assert_eq!( + body, "deny set full\n", + "FI-TRACE-DENY-SET: 503 body must be 'deny set full\\n'" + ); + } +} + +// ── Route integration tests ──────────────────────────────────────────────────── +// +// Exercises `disconnect()` through the full axum router with a warmed +// ProductionJwksSource, a real CommandVerifier, and an AppState wired exactly +// as production does (nip_fi_command_verifier + nip_fi_deny_map both set). +// +// These tests call the route at POST /api/nip-fi/disconnect via oneshot and +// verify every spec response row: 401, 403 (evidence), 403 (authz), 400, 503 +// (capacity), 200 exact bytes. The startup-assembly invariant is also +// verified: the tests GO RED if either state field is absent (503 unavailable). + +#[cfg(test)] +mod route_integration_tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use buzz_auth::{ + CommandIssuerPolicy, CommandVerifier, IssuerCapacity, IssuerRegistry, NipFiDenyMap, + ProductionJwksSource, + }; + use std::sync::Arc; + use tower::ServiceExt; + + // ── Shared test key material ──────────────────────────────────────────── + + // ES256 key pair — same material as command.rs tests, known-good. + const TEST_PRIVATE_KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + + const TEST_ISS: &str = "https://idp.test.example.com"; + const TEST_AUD: &str = "https://relay.test.example.com"; + const TEST_SUB: &str = "admin-svc@test.example.com"; + const TEST_PATH: &str = "/api/nip-fi/disconnect"; + + // Key ID used in both the JWKS and the JWT header. + const TEST_KID: &str = "route-test-key-1"; + + fn test_public_jwk() -> jsonwebtoken::jwk::Jwk { + // Public-key coordinates extracted from TEST_PRIVATE_KEY_PEM (P-256), + // which is the same key pair as command.rs TEST_JWK_X/Y constants. + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA", + "alg": "ES256", + "use": "sig", + "kid": TEST_KID + })) + .expect("valid test JWK") + } + + fn test_jwks() -> jsonwebtoken::jwk::JwkSet { + jsonwebtoken::jwk::JwkSet { + keys: vec![test_public_jwk()], + } + } + + fn test_issuer_policy() -> buzz_auth::IssuerPolicy { + issuer_policy(TEST_ISS) + } + + fn issuer_policy(iss: &str) -> buzz_auth::IssuerPolicy { + use buzz_auth::{FreshnessClass, IssuerPolicy, JwksSourceContract, TokenClass}; + let contract = JwksSourceContract::new(format!("{iss}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + IssuerPolicy::new( + iss.to_owned(), + vec![TEST_AUD.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + contract, + ) + .expect("valid issuer policy") + } + + fn test_jwks_config() -> buzz_auth::IssuerJwksConfig { + jwks_config(TEST_ISS) + } + + fn jwks_config(iss: &str) -> buzz_auth::IssuerJwksConfig { + use buzz_auth::{IssuerJwksConfig, JwksSourceContract}; + let contract = JwksSourceContract::new(format!("{iss}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + IssuerJwksConfig { + issuer: iss.to_owned(), + contract, + } + } + + fn mint_token(target_hex: &str, until_offset_secs: i64, extra: serde_json::Value) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let mut claims = serde_json::json!({ + "iss": TEST_ISS, + "aud": TEST_AUD, + "sub": TEST_SUB, + "iat": now, + "exp": now + 60, + "jti": uuid::Uuid::new_v4().to_string(), + "method": "POST", + "path": TEST_PATH, + "cmd": "disconnect", + "target_pubkey": target_hex, + "until": now + until_offset_secs, + }); + if let Some(obj) = extra.as_object() { + for (k, v) in obj { + claims[k] = v.clone(); + } + } + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi-command+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let key = EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).expect("test EC key"); + encode(&header, &claims, &key).expect("sign test token") + } + + async fn build_test_state(capacity: usize) -> Arc { + Arc::new(build_test_app_state(capacity, crate::config::Config::for_test()).await) + } + + async fn build_test_app_state( + capacity: usize, + config: crate::config::Config, + ) -> crate::state::AppState { + // Build a minimal AppState with NIP-FI S4 components wired. + // Uses lazy/invalid DB+Redis — only nip_fi fields and conn_manager matter. + use crate::state::AppState; + + 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"), + ); + 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 (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + + // Wire NIP-FI S4 components. + let jwks_configs = vec![test_jwks_config()]; + let key_source = Arc::new( + ProductionJwksSource::new(jwks_configs, buzz_auth::HttpJwksFetcher::new()) + .expect("key source"), + ); + // Seed the snapshot without making an HTTP request. + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + + let deny_map = Arc::new(NipFiDenyMap::new( + capacity, + vec![IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity, + }], + )); + let policy = + CommandIssuerPolicy::new(TEST_ISS.to_owned(), 30, vec![TEST_SUB.to_owned()], capacity) + .expect("command policy"); + let verifier = Arc::new(CommandVerifier::new( + registry, + Arc::clone(&key_source), + vec![policy], + (*deny_map).clone(), + )); + + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + state.nip_fi_command_verifier = Some(verifier); + state + } + + fn target_hex() -> String { + nostr::Keys::generate().public_key().to_hex() + } + + async fn do_request( + state: Arc, + method: &str, + headers: Vec<(&'static str, String)>, + body: Option, + ) -> axum::response::Response { + use crate::router::build_router; + let body_bytes = match body { + Some(v) => serde_json::to_vec(&v).unwrap().into(), + None => axum::body::Bytes::new(), + }; + let mut req = Request::builder().method(method).uri(TEST_PATH); + for (k, v) in &headers { + req = req.header(*k, v.as_str()); + } + let req = req.body(Body::from(body_bytes)).unwrap(); + build_router(state).oneshot(req).await.unwrap() + } + + // ── Test: no verifier → 503 (startup-assembly invariant) ───────────────── + + #[tokio::test] + async fn absent_verifier_gives_503_unavailable() { + // If nip_fi_command_verifier is not set, every request gets 503. + // This tests the handler fallback path when the verifier is absent from + // AppState. The production startup assembly is covered separately by + // `production_assembly_build_nip_fi_command_components_wires_both_fields`. + // Build a state without the verifier. + let no_verifier_state = { + let config = crate::config::Config::for_test(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // nip_fi_command_verifier stays None. + Arc::new(state) + }; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + no_verifier_state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + // ── Test: absent header → 401 + WWW-Authenticate ───────────────────────── + + #[tokio::test] + async fn absent_header_route_gives_401_with_www_authenticate() { + let state = build_test_state(1000).await; + let target = target_hex(); + let resp = do_request( + state, + "POST", + vec![("Content-Type", "application/json".into())], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + 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"); + } + + // ── Test: bad signature → 403 evidence rejected ─────────────────────────── + + #[tokio::test] + async fn bad_signature_gives_403_evidence_rejected() { + let state = build_test_state(1000).await; + let target = target_hex(); + // Tamper the token. + let token = mint_token(&target, 300, serde_json::json!({})); + let tampered = format!("{token}X"); + let resp = do_request( + state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {tampered}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } + + // ── Test: capacity exceeded → 503 does NOT burn jti ────────────────────── + + #[tokio::test] + async fn capacity_503_does_not_burn_jti_route_retry_succeeds() { + // capacity=1, two distinct targets. + let state = build_test_state(1).await; + let target_a = target_hex(); + let target_b = target_hex(); + + // First request fills the slot. + let token_a = mint_token(&target_a, 300, serde_json::json!({})); + let resp_a = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token_a}")), + ], + Some(serde_json::json!({"pubkey": target_a})), + ) + .await; + assert_eq!(resp_a.status(), StatusCode::OK); + + // Second request hits capacity → 503. Jti NOT burned. + let jti_b = uuid::Uuid::new_v4().to_string(); + let token_b = mint_token(&target_b, 300, serde_json::json!({"jti": jti_b})); + let resp_b = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token_b}")), + ], + Some(serde_json::json!({"pubkey": target_b})), + ) + .await; + assert_eq!( + resp_b.status(), + StatusCode::SERVICE_UNAVAILABLE, + "capacity exceeded must return 503" + ); + let body = axum::body::to_bytes(resp_b.into_body(), 64).await.unwrap(); + assert_eq!(body.as_ref(), b"deny set full\n"); + // Jti was NOT burned: the same token_b can be reused once the slot frees. + // (Route-level: we verify the 503 body; the jti non-burn is covered by + // command.rs::capacity_503_does_not_burn_jti_retry_succeeds_after_slot_freed) + } + + // ── Test: successful disconnect → 200 spec-exact bytes ─────────────────── + + #[tokio::test] + async fn success_response_is_spec_exact_bytes() { + let state = build_test_state(1000).await; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let ct = resp + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + assert_eq!(ct, "application/json"); + let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body.as_ref(), + b"{\"disconnected\": true}", + "200 body must be byte-exact per spec (note the space after ':')" + ); + } + + // ── Test: count-independence (zero vs many sessions) ───────────────────── + + #[tokio::test] + async fn success_body_identical_regardless_of_sessions_closed() { + // Zero live sessions: response must still be {"disconnected": true}. + let state = build_test_state(1000).await; + let target = target_hex(); + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + state, + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 64).await.unwrap(); + assert_eq!( + body.as_ref(), + b"{\"disconnected\": true}", + "zero-sessions success must be byte-identical to many-sessions success [no count leak]" + ); + } + + // ── Test: deny entry recorded after success ─────────────────────────────── + + #[tokio::test] + async fn success_records_deny_entry_visible_to_is_denied() { + let state = build_test_state(1000).await; + let target = target_hex(); + let target_pubkey = nostr::PublicKey::from_hex(&target).expect("valid hex pubkey"); + + // Before disconnect: not denied. + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + assert!( + !deny_map.is_denied(TEST_ISS, &target_pubkey, chrono::Utc::now()), + "must not be denied before disconnect" + ); + + // Execute disconnect. + let token = mint_token(&target, 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + // After disconnect: denied. + assert!( + deny_map.is_denied(TEST_ISS, &target_pubkey, chrono::Utc::now()), + "must be denied after successful disconnect" + ); + } + + // ── Test: consumer capacity oracle (replacement per NIP-FI.md:306-336) ───── + // + // Drives apply_nip_fi_disconnect with a delivered target that encounters a + // pre-filled capacity-1 map. Asserts Applied(CapacityExceeded); the + // pre-existing map key remains denied only until its TTL; the missed target + // and unrelated key are NOT map-denied; targeted live sessions are closed; + // unrelated live peers remain open. + // + // Mandatory reds: + // (a) consumer stops calling merge_cross_pod_deny → Applied(CapacityExceeded) missed; + // targeted session close assertion fails + // (b) reintroduce issuer-wide blocking → missed-target is_denied assertion fails + // (c) consumer skips close_sessions on CapacityExceeded → targeted cancel assertion fails + + #[tokio::test] + async fn consumer_capacity_miss_closes_targeted_session_without_map_denial() { + use super::apply_nip_fi_disconnect; + use super::NipFiDisconnectApplyResult; + use crate::state::CommunityConnectionControl; + use buzz_auth::CrossPodMergeResult; + use tokio_util::sync::CancellationToken; + + let state = { + let mut config = crate::config::Config::for_test(); + // Wire TEST_ISS into the NIP-FI registry so the consumer seam accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Capacity=1, pre-filled with k_a so the second delivery (k_b) hits capacity. + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 1, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 1, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + let now = chrono::Utc::now(); + let until_unix = (now + chrono::Duration::seconds(300)).timestamp(); + + let k_a = nostr::Keys::generate().public_key(); + let k_b = nostr::Keys::generate().public_key(); + let k_unrelated = nostr::Keys::generate().public_key(); + + // Pre-fill slot with k_a via the consumer seam. + let msg_a = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: k_a.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result_a = apply_nip_fi_disconnect(&state, &msg_a, now); + assert_eq!( + result_a, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "first consumer message must merge" + ); + + // Register live sessions for targeted (k_b) and unrelated (k_unrelated) peers. + let cancel_b = CancellationToken::new(); + let cancel_unrelated = CancellationToken::new(); + let registry = &state.community_connections; + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + let ctrl_b = CommunityConnectionControl::new(cancel_b.clone()); + ctrl_b.set_proven_identity(k_b.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + let _guard_b = registry.register(uuid::Uuid::new_v4(), community, ctrl_b); + + let ctrl_unrelated = CommunityConnectionControl::new(cancel_unrelated.clone()); + ctrl_unrelated + .set_proven_identity(k_unrelated.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + let _guard_unrelated = registry.register(uuid::Uuid::new_v4(), community, ctrl_unrelated); + + // Deliver k_b — capacity exhausted, no map entry added. + let msg_b = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: k_b.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result_b = apply_nip_fi_disconnect(&state, &msg_b, now); + assert_eq!( + result_b, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::CapacityExceeded), + "second consumer message must hit capacity" + ); + + // Targeted session (k_b) must be cancelled despite no map entry. + assert!( + cancel_b.is_cancelled(), + "targeted session must be closed even on CapacityExceeded" + ); + // Unrelated session must NOT be cancelled. + assert!( + !cancel_unrelated.is_cancelled(), + "unrelated session must remain open after capacity miss" + ); + + // Map checks — no issuer-wide denial synthesized. + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + + // k_a is still denied (its entry was not evicted). + assert!( + deny_map.is_denied(TEST_ISS, &k_a, now), + "pre-existing k_a entry must remain denied" + ); + // k_b has no map entry — NOT denied via the map. + assert!( + !deny_map.is_denied(TEST_ISS, &k_b, now), + "missed target k_b must NOT be map-denied after capacity miss" + ); + // Unrelated key is not map-denied. + assert!( + !deny_map.is_denied(TEST_ISS, &k_unrelated, now), + "unrelated key must NOT be denied after capacity miss" + ); + // At exact equality with k_a's TTL, k_a is admitted. + let at_ttl = chrono::DateTime::from_timestamp(until_unix, 0).unwrap(); + assert!( + !deny_map.is_denied(TEST_ISS, &k_a, at_ttl), + "k_a must be admitted at exact equality with its TTL" + ); + } + + // ── Test: Item 5 — dual-transport registration witness ────────────────────────────────────── + // + // Proves that the production audio post-auth registration helper + // (`audio_post_auth_register`) is the seam used to register audio connections + // in the fan-out, and that apply_nip_fi_disconnect drives both connection + // registries (ordinary WS via conn_manager and audio via community_connections). + // + // Four sub-claims verified: + // 1. targeted ordinary WS is cancelled (conn_manager path) + // 2. targeted audio is cancelled with AuthorizationDenied (community_connections path, + // registered via the production audio_post_auth_register helper) + // 3. unrelated ordinary WS and audio peers remain open + // 4. capacity-failure variant: the delivered target still closes both transports + // despite no map entry + // + // Mandatory reds: + // - no-op audio_post_auth_register leaves targeted audio open + // - removing community_connections from the fan-out leaves audio open + // - removing conn_manager from the fan-out leaves ordinary WS open + // - broad/non-key-exact matching would close unrelated peers (asserted absent) + // - skipping close on CapacityExceeded leaves targeted sessions open (capacity variant) + + #[tokio::test] + async fn dual_transport_registration_witness() { + use super::apply_nip_fi_disconnect; + use super::NipFiDisconnectApplyResult; + use crate::audio::handler::audio_post_auth_register; + use crate::state::CommunityConnectionControl; + use buzz_auth::CrossPodMergeResult; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + // ── Helper: register an ordinary WS connection and return (conn_id, cancel). ── + // Mirrors how connection.rs registers after NIP-42 auth: register first, then + // call set_authenticated_pubkey. + let register_ws = + |state: &crate::state::AppState, pubkey_bytes: Vec| -> (Uuid, CancellationToken) { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + let bp = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)); + state.conn_manager.register( + conn_id, + tx, + ctrl_tx, + mpsc::channel(1).0, + None, + cancel.clone(), + community, + bp, + std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), + ); + state.conn_manager.set_authenticated_identity( + conn_id, + pubkey_bytes, + Some(TEST_ISS.to_owned()), + ); + (conn_id, cancel) + }; + + // ── Build state: capacity 2 so the Merged case succeeds for the target. ── + let state = { + let mut config = crate::config::Config::for_test(); + // Wire TEST_ISS into the NIP-FI registry so apply_nip_fi_disconnect accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 2, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 2, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + let target = nostr::Keys::generate().public_key(); + let unrelated = nostr::Keys::generate().public_key(); + let now = chrono::Utc::now(); + let until_unix = (now + chrono::Duration::seconds(300)).timestamp(); + + // Register targeted ordinary WS and audio controls. + let (_target_ws_id, cancel_target_ws) = register_ws(&state, target.to_bytes().to_vec()); + + // Register targeted audio via the production helper. + // Guards must live until after the assertions — declared here, not in a sub-block. + let audio_registry = &state.community_connections; + let cancel_audio_target = CancellationToken::new(); + let _audio_target_guard = { + let ctrl = CommunityConnectionControl::new(cancel_audio_target.clone()); + audio_post_auth_register(&ctrl, target.to_bytes().to_vec(), Some(TEST_ISS.to_owned())); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // Register unrelated ordinary WS and audio controls. + let (_unrelated_ws_id, cancel_unrelated_ws) = + register_ws(&state, unrelated.to_bytes().to_vec()); + let cancel_audio_unrelated = CancellationToken::new(); + let _audio_unrelated_guard = { + let ctrl = CommunityConnectionControl::new(cancel_audio_unrelated.clone()); + audio_post_auth_register( + &ctrl, + unrelated.to_bytes().to_vec(), + Some(TEST_ISS.to_owned()), + ); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // Drive apply_nip_fi_disconnect for the target (Merged case). + let msg = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: target.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result = apply_nip_fi_disconnect(&state, &msg, now); + assert_eq!( + result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "target disconnect must merge" + ); + + // Claim 1: targeted ordinary WS is cancelled. + assert!( + cancel_target_ws.is_cancelled(), + "targeted ordinary WS must be cancelled by disconnect fan-out" + ); + // Claim 2: targeted audio is cancelled (registered via audio_post_auth_register). + assert!( + cancel_audio_target.is_cancelled(), + "targeted audio connection must be cancelled via community_connections fan-out" + ); + // Claim 3a: unrelated ordinary WS remains open. + assert!( + !cancel_unrelated_ws.is_cancelled(), + "unrelated ordinary WS must remain open" + ); + // Claim 3b: unrelated audio remains open. + assert!( + !cancel_audio_unrelated.is_cancelled(), + "unrelated audio connection must remain open" + ); + + // ── Capacity-failure variant ────────────────────────────────────────────── + // Pre-fill the map to capacity with a different key, then deliver target2 + // (capacity exceeded). Assert target2's sessions still close despite no map entry. + let target2 = nostr::Keys::generate().public_key(); + + // Register target2 ordinary WS and audio. + let (_t2_ws_id, cancel_t2_ws) = register_ws(&state, target2.to_bytes().to_vec()); + let cancel_t2_audio = CancellationToken::new(); + let _t2_audio_guard = { + let ctrl = CommunityConnectionControl::new(cancel_t2_audio.clone()); + audio_post_auth_register( + &ctrl, + target2.to_bytes().to_vec(), + Some(TEST_ISS.to_owned()), + ); + audio_registry.register(Uuid::new_v4(), community, ctrl) + }; + + // The map is now at capacity (target is in it from the Merged above, plus we need + // one more to saturate cap=2). Pre-fill the second slot with a filler key. + let filler = nostr::Keys::generate().public_key(); + let msg_fill = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: filler.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let fill_result = apply_nip_fi_disconnect(&state, &msg_fill, now); + assert_eq!( + fill_result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "filler must merge to saturate capacity" + ); + + // Now deliver target2 — capacity exceeded, no map entry. + let msg2 = buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: target2.to_bytes().to_vec(), + until_unix, + until_unix_nanos: 0, + }; + let result2 = apply_nip_fi_disconnect(&state, &msg2, now); + assert_eq!( + result2, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::CapacityExceeded), + "second target must hit capacity" + ); + + // Claim 4a: targeted ordinary WS still closes despite CapacityExceeded. + assert!( + cancel_t2_ws.is_cancelled(), + "target2 ordinary WS must close even on CapacityExceeded" + ); + // Claim 4b: targeted audio still closes despite CapacityExceeded. + assert!( + cancel_t2_audio.is_cancelled(), + "target2 audio must close even on CapacityExceeded" + ); + } + + // ── Test: blocker 3 — fractional deadline survives publisher wire and consumer equality boundary ─ + // + // Exercises the full publisher → encode → decode → apply_nip_fi_disconnect chain with a + // non-zero nanos deadline. Proves denied immediately after T, admitted at exact T + nanos. + // + // Red mutations: + // - publisher writes zero nanos → until reconstructed as T+0 → equality boundary fails + // - encoder omits nanos field → decoder defaults to 0 → same failure + // - decoder defaults a present field to zero → same failure + // - consumer reconstructs with zero nanos → same failure + // - comparison changes from < to <= → admitted before exact boundary + + #[tokio::test] + async fn fractional_deadline_survives_publisher_wire_and_consumer_equality_boundary() { + use super::NipFiDisconnectApplyResult; + use super::{apply_nip_fi_disconnect, nip_fi_disconnect_message}; + use buzz_auth::CrossPodMergeResult; + + // Build a state with TEST_ISS in the registry so apply_nip_fi_disconnect accepts it. + let state = { + let mut config = crate::config::Config::for_test(); + // Wire TEST_ISS into the NIP-FI registry so apply_nip_fi_disconnect accepts it. + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 1000, + vec![buzz_auth::IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity: 1000, + }], + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + Arc::new(state) + }; + + // Build a CommandResult with a fractional deadline: T + 500_000_000 ns. + // We construct the CommandResult directly rather than going through the HTTP + // handler so we can control the exact until timestamp. + let target = nostr::Keys::generate().public_key(); + let t_whole = chrono::DateTime::from_timestamp(1_800_000_000, 0).unwrap(); + let nanos: u32 = 500_000_000; + let t_frac = chrono::DateTime::from_timestamp(1_800_000_000, nanos).unwrap(); + + // Construct a synthetic CommandResult. + let cmd = buzz_auth::CommandResult { + caller_iss: TEST_ISS.to_owned(), + caller_sub: TEST_SUB.to_owned(), + target_pubkey: target, + until: t_frac, + }; + + // Publisher seam → encode → decode. + let msg = nip_fi_disconnect_message(&cmd); + assert_eq!( + msg.until_unix, 1_800_000_000, + "publisher must capture whole-second" + ); + assert_eq!(msg.until_unix_nanos, nanos, "publisher must capture nanos"); + + let encoded = buzz_pubsub::encode_nip_fi_disconnect(&msg).expect("encode must succeed"); + let decoded = buzz_pubsub::decode_nip_fi_disconnect(&encoded).expect("decode must succeed"); + assert_eq!(decoded.until_unix_nanos, nanos, "decoded nanos must match"); + + // Consumer seam: apply with now = t_frac - 1ns (just inside the deadline). + let now_inside = t_frac - chrono::Duration::nanoseconds(1); + let result = apply_nip_fi_disconnect(&state, &decoded, now_inside); + assert_eq!( + result, + NipFiDisconnectApplyResult::Applied(CrossPodMergeResult::Merged), + "apply must succeed with now inside deadline" + ); + + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + + // Denied immediately after T (now = T + 1ns, well inside the deadline T+500ms). + let now_after_t = t_whole + chrono::Duration::nanoseconds(1); + assert!( + deny_map.is_denied(TEST_ISS, &target, now_after_t), + "must be denied at T+1ns (deadline is T+500ms)" + ); + + // Denied at deadline minus 1ns (just before equality boundary). + let now_before_boundary = t_frac - chrono::Duration::nanoseconds(1); + assert!( + deny_map.is_denied(TEST_ISS, &target, now_before_boundary), + "must be denied at deadline - 1ns" + ); + + // Admitted at exact equality (now == until): contract is `now < until`, + // so exact equality means admitted. + assert!( + !deny_map.is_denied(TEST_ISS, &target, t_frac), + "must be admitted at exact equality (now < until fails at now == until)" + ); + + // Also admitted after (now = T + whole second). + assert!( + !deny_map.is_denied(TEST_ISS, &target, t_whole + chrono::Duration::seconds(1)), + "must be admitted past the deadline" + ); + } + + // ── Test: blocker 4b — production_install_populates_both_app_state_fields ───── + // + // Calls install_nip_fi_command_components() directly (the production seam that owns + // both AppState assignments). Proves: + // - command_issuers == 1 + // - both AppState fields are Some + // - a valid signed command through the verifier creates a deny visible via the map + // + // Mandatory red mutations (proven by separate inline verification below): + // 1. delete deny_map assignment → AppState.nip_fi_deny_map is None + // 2. delete verifier assignment → AppState.nip_fi_command_verifier is None + // 3. wire verifier to a different map → verify succeeds but state map denial fails + + #[tokio::test] + async fn production_install_populates_both_app_state_fields() { + use super::install_nip_fi_command_components; + + // Build a minimal AppState — same construction as build_test_state but without + // the S4 fields so we can verify install_nip_fi_command_components populates them. + let config = crate::config::Config::for_test(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + // Both fields start as None — we'll verify install populates them. + assert!(state.nip_fi_deny_map.is_none()); + assert!(state.nip_fi_command_verifier.is_none()); + + let jwks_configs = vec![test_jwks_config()]; + let key_source = Arc::new( + ProductionJwksSource::new(jwks_configs.clone(), buzz_auth::HttpJwksFetcher::new()) + .expect("valid key source"), + ); + // Seed the JWKS snapshot hermetically (no HTTP). + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + + let cmd_configs = vec![( + TEST_ISS.to_owned(), + CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(30), + authorized_principals: Some(vec![TEST_SUB.to_owned()]), + deny_set_capacity: Some(100), + }, + )]; + + let report = install_nip_fi_command_components( + &mut state.nip_fi_deny_map, + &mut state.nip_fi_command_verifier, + buzz_auth::NipFiMode::Enforce, + ®istry, + Arc::clone(&key_source), + &cmd_configs, + ) + .expect("install must succeed for valid config"); + + assert_eq!(report.command_issuers, 1, "command_issuers must equal 1"); + + // Both AppState fields must be populated. + assert!( + state.nip_fi_deny_map.is_some(), + "nip_fi_deny_map must be Some after install" + ); + assert!( + state.nip_fi_command_verifier.is_some(), + "nip_fi_command_verifier must be Some after install" + ); + + // Verify a valid command through the verifier and confirm the deny entry + // is visible through the AppState's deny_map (proves shared map wiring). + let target = nostr::Keys::generate().public_key(); + let token = mint_token(&target.to_hex(), 300, serde_json::json!({})); + let verifier = state.nip_fi_command_verifier.as_ref().unwrap(); + let result = verifier.verify(&token, "POST", TEST_PATH, &target); + assert!( + result.is_ok(), + "verifier must accept a valid command: {result:?}" + ); + let deny_map = state.nip_fi_deny_map.as_deref().unwrap(); + assert!( + deny_map.is_denied(TEST_ISS, &target, chrono::Utc::now()), + "deny entry must be visible via AppState.nip_fi_deny_map after verify" + ); + } + + // ── Single JWKS lifecycle owner witness ─────────────────────────────── + // + // `main.rs` owns the only warm + per-issuer refresh of the shared JWKS + // source. The installer must build and install components without any + // fetch. Reintroducing a warm loop (or refresh spawn) inside the installer + // makes `call_count` non-zero and reds this test. + #[tokio::test(start_paused = true)] + async fn installer_performs_no_jwks_fetch() { + use super::install_nip_fi_command_components; + + let fetcher = buzz_auth::ScriptedJwksFetcher::new([]); + let fetches = Arc::clone(&fetcher.call_count); + let key_source = Arc::new( + ProductionJwksSource::new(vec![test_jwks_config()], fetcher).expect("valid key source"), + ); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let cmd_configs = vec![( + TEST_ISS.to_owned(), + CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(30), + authorized_principals: Some(vec![TEST_SUB.to_owned()]), + deny_set_capacity: Some(100), + }, + )]; + let mut deny_map = None; + let mut command_verifier = None; + + install_nip_fi_command_components( + &mut deny_map, + &mut command_verifier, + buzz_auth::NipFiMode::Enforce, + ®istry, + key_source, + &cmd_configs, + ) + .expect("install must succeed for valid config"); + tokio::time::sleep(std::time::Duration::from_secs(86_400)).await; + + assert!(deny_map.is_some() && command_verifier.is_some()); + assert_eq!( + fetches.load(std::sync::atomic::Ordering::SeqCst), + 0, + "installer must not fetch JWKS; main.rs is the single lifecycle owner" + ); + } + + // ── Startup-oracle: shared JWKS source wiring ────────────────────────── + // + // Proves that the installer, given the state's own JWKS source (as + // `main.rs` passes it), wires the command verifier onto the EXACT Arc that + // `nip_fi_verifier` holds: once that source is warm, both verify. + // + // Construction path mirrors `main.rs` exactly: + // 1. Build AppState with an Enforce config that has jwks_configs populated — + // `build_nip_fi_components` runs and sets `nip_fi_verifier` and + // `nip_fi_jwks_source` on the state. + // 2. Seed the state's own `nip_fi_jwks_source` (no HTTP). + // 3. Call `install_nip_fi_command_components` with + // `state.nip_fi_jwks_source.clone()` — the same Arc the verifier holds. + // 4. Assert both `nip_fi_verifier` and `nip_fi_command_verifier` verify. + // + // Mutation evidence: + // Pass a second, unseeded source to the installer → the command + // verifier's source stays cold → command verify fails. + #[tokio::test] + async fn installer_shares_jwks_source_with_assertion_verifier() { + use super::install_nip_fi_command_components; + use crate::nip_fi_config::NipFiRelayConfig; + use buzz_auth::NipFiMode; + + // Build the config with an Enforce NIP-FI section so build_nip_fi_components + // sets nip_fi_verifier + nip_fi_jwks_source on the AppState. + let mut config = crate::config::Config::for_test(); + let jwks_configs = vec![test_jwks_config()]; + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let cmd_configs = vec![( + TEST_ISS.to_owned(), + CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(30), + authorized_principals: Some(vec![TEST_SUB.to_owned()]), + deny_set_capacity: Some(100), + }, + )]; + config.nip_fi = NipFiRelayConfig { + mode: NipFiMode::Enforce, + registry: registry.clone(), + jwks_configs: jwks_configs.clone(), + command_configs: cmd_configs.clone(), + max_connection_lifetime_secs: 3600, + }; + + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + // build_nip_fi_components runs here because mode == Enforce and jwks_configs + // is non-empty; nip_fi_verifier and nip_fi_jwks_source are set on the state. + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + assert!( + state.nip_fi_verifier.is_some(), + "nip_fi_verifier must be Some for Enforce config" + ); + assert!( + state.nip_fi_jwks_source.is_some(), + "nip_fi_jwks_source must be Some for Enforce config" + ); + + let shared_source = state.nip_fi_jwks_source.clone().unwrap(); + install_nip_fi_command_components( + &mut state.nip_fi_deny_map, + &mut state.nip_fi_command_verifier, + NipFiMode::Enforce, + ®istry, + Arc::clone(&shared_source), + &cmd_configs, + ) + .expect("install must succeed for valid config"); + + // Warm the state's own source after install — the step main.rs's + // single lifecycle owner performs. Both verifiers must see it. + shared_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + + let key = nostr::Keys::generate(); + let token = mint_assertion_token(&key.public_key().to_hex()); + let verifier = state.nip_fi_verifier.as_deref().unwrap(); + let result = verifier.verify_assertion(&token); + assert!( + result.is_ok(), + "assertion verifier must read the warmed shared source; got: {result:?}" + ); + + let target = nostr::Keys::generate().public_key(); + let command = mint_token(&target.to_hex(), 300, serde_json::json!({})); + let command_verifier = state.nip_fi_command_verifier.as_ref().unwrap(); + let result = command_verifier.verify(&command, "POST", TEST_PATH, &target); + assert!( + result.is_ok(), + "command verifier must read the same warmed shared source; got: {result:?}" + ); + } + + /// Mint a valid ES256 `nip-fi+jwt` assertion for `nostr_pubkey = key_hex`, + /// signed by the route-integration-test key pair. + /// Used by the startup-oracle test to verify the assertion verifier against + /// its own warmed JWKS source. + fn mint_assertion_token(key_hex: &str) -> String { + mint_assertion_token_for(TEST_ISS, key_hex) + } + + fn mint_assertion_token_for(iss: &str, key_hex: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": iss, + "aud": TEST_AUD, + "sub": TEST_SUB, + "iat": now, + "exp": now + 600, + "nostr_pubkey": key_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(TEST_KID.to_owned()); + let key = + EncodingKey::from_ec_pem(TEST_PRIVATE_KEY_PEM.as_bytes()).expect("valid test EC key"); + encode(&header, &claims, &key).expect("sign assertion-test token") + } + + // ── Issuer scope: the close scans match (issuer, k) ────────────────────── + // + // A deny entry is keyed by (iss, k). The same key K admitted under a + // second issuer B is not blocked by A's entry, so neither the route nor the + // cross-pod consumer may close B's sessions. [FI-TRACE-DENY-SET] + + const OTHER_ISS: &str = "https://idp-b.test.example.com"; + + /// Live root + audio sessions for one (issuer, key), with the tokens the + /// close path cancels. The guard keeps the audio socket registered. + struct IssuerSessions { + root: tokio_util::sync::CancellationToken, + audio: tokio_util::sync::CancellationToken, + _audio_guard: crate::state::CommunityConnectionGuard, + } + + impl IssuerSessions { + fn register(state: &crate::state::AppState, issuer: &str, key: &nostr::PublicKey) -> Self { + use crate::state::CommunityConnectionControl; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()); + let root = CancellationToken::new(); + let conn_id = uuid::Uuid::new_v4(); + state.conn_manager.register( + conn_id, + mpsc::channel(8).0, + mpsc::channel(8).0, + mpsc::channel(1).0, + None, + root.clone(), + community, + Arc::new(std::sync::atomic::AtomicU8::new(0)), + Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, + CommunityConnectionControl::new(root.clone()), + ); + state.conn_manager.set_authenticated_identity( + conn_id, + key.to_bytes().to_vec(), + Some(issuer.to_owned()), + ); + let audio = CancellationToken::new(); + let ctrl = CommunityConnectionControl::new(audio.clone()); + crate::audio::handler::audio_post_auth_register( + &ctrl, + key.to_bytes().to_vec(), + Some(issuer.to_owned()), + ); + let _audio_guard = + state + .community_connections + .register(uuid::Uuid::new_v4(), community, ctrl); + Self { + root, + audio, + _audio_guard, + } + } + + fn assert_closed(&self, who: &str) { + assert!(self.root.is_cancelled(), "{who}: root session must close"); + assert!(self.audio.is_cancelled(), "{who}: audio session must close"); + } + + fn assert_open(&self, who: &str) { + assert!( + !self.root.is_cancelled(), + "{who}: root session must survive" + ); + assert!( + !self.audio.is_cancelled(), + "{who}: audio session must survive" + ); + } + } + + #[tokio::test] + async fn route_disconnect_closes_only_caller_issuer_sessions() { + let state = build_test_state(1000).await; + let key = nostr::Keys::generate().public_key(); + let under_a = IssuerSessions::register(&state, TEST_ISS, &key); + let under_b = IssuerSessions::register(&state, OTHER_ISS, &key); + + let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + let resp = do_request( + Arc::clone(&state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": key.to_hex()})), + ) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + under_a.assert_closed("issuer A"); + under_b.assert_open("issuer B"); + } + + /// State whose registry and deny map know only issuer A (`TEST_ISS`). + async fn cross_pod_state(capacity: usize) -> Arc { + let mut config = crate::config::Config::for_test(); + config.nip_fi.registry.insert(test_issuer_policy()); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).unwrap(); + 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)) + .unwrap(); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .unwrap(), + ); + 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).unwrap(); + let (mut state, _) = crate::state::AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.nip_fi_deny_map = Some(Arc::new(NipFiDenyMap::new( + capacity, + vec![IssuerCapacity { + issuer: TEST_ISS.to_owned(), + capacity, + }], + ))); + Arc::new(state) + } + + fn cross_pod_message(key: &nostr::PublicKey) -> buzz_pubsub::NipFiDisconnect { + buzz_pubsub::NipFiDisconnect { + issuer: TEST_ISS.to_owned(), + pubkey_bytes: key.to_bytes().to_vec(), + until_unix: (chrono::Utc::now() + chrono::Duration::seconds(300)).timestamp(), + until_unix_nanos: 0, + } + } + + /// Delivers an A-issued cross-pod message for K with A and B sessions + /// live, and asserts the merge outcome and that only A's sessions close. + fn assert_cross_pod_closes_only_issuer_a( + state: &crate::state::AppState, + expected: buzz_auth::CrossPodMergeResult, + ) { + let key = nostr::Keys::generate().public_key(); + let under_a = IssuerSessions::register(state, TEST_ISS, &key); + let under_b = IssuerSessions::register(state, OTHER_ISS, &key); + + let result = apply_nip_fi_disconnect(state, &cross_pod_message(&key), chrono::Utc::now()); + + assert_eq!(result, NipFiDisconnectApplyResult::Applied(expected)); + under_a.assert_closed("issuer A"); + under_b.assert_open("issuer B"); + } + + #[tokio::test] + async fn cross_pod_merged_closes_only_message_issuer_sessions() { + let state = cross_pod_state(10).await; + assert_cross_pod_closes_only_issuer_a(&state, buzz_auth::CrossPodMergeResult::Merged); + } + + #[tokio::test] + async fn cross_pod_capacity_exceeded_closes_only_message_issuer_sessions() { + let state = cross_pod_state(1).await; + let filler = nostr::Keys::generate().public_key(); + assert_eq!( + apply_nip_fi_disconnect(&state, &cross_pod_message(&filler), chrono::Utc::now()), + NipFiDisconnectApplyResult::Applied(buzz_auth::CrossPodMergeResult::Merged), + ); + assert_cross_pod_closes_only_issuer_a( + &state, + buzz_auth::CrossPodMergeResult::CapacityExceeded, + ); + } + + #[tokio::test] + async fn cross_pod_shard_poisoned_closes_only_message_issuer_sessions() { + let state = cross_pod_state(10).await; + state + .nip_fi_deny_map + .as_deref() + .expect("deny map present") + .poison_shard_for_test(TEST_ISS); + assert_cross_pod_closes_only_issuer_a( + &state, + buzz_auth::CrossPodMergeResult::ShardPoisoned, + ); + } + + // ── HTTP admission reads the shared deny map ───────────────────────────── + // + // Deny entries are written only through `POST /api/nip-fi/disconnect`; HTTP + // admission (`admit_nip_fi_http_on_state`) and WS admission must both see + // them through `AppState::nip_fi_deny_map`. [FI-TRACE-DENY-SET] + + /// `build_test_app_state` in `Enforce` mode with an assertion verifier that + /// trusts issuer A (`TEST_ISS`) and issuer B (`OTHER_ISS`), both signed by + /// the test key. The command API and deny map know only issuer A. + async fn http_enforce_state() -> Arc { + let mut config = crate::config::Config::for_test(); + config.require_relay_membership = false; + config.nip_fi.mode = buzz_auth::NipFiMode::Enforce; + config.nip_fi.registry.insert(issuer_policy(TEST_ISS)); + config.nip_fi.registry.insert(issuer_policy(OTHER_ISS)); + let mut state = build_test_app_state(1000, config).await; + + let key_source = Arc::new( + ProductionJwksSource::new( + vec![jwks_config(TEST_ISS), jwks_config(OTHER_ISS)], + buzz_auth::HttpJwksFetcher::new(), + ) + .expect("key source"), + ); + key_source + .seed_snapshot_for_test(TEST_ISS, test_jwks()) + .await; + key_source + .seed_snapshot_for_test(OTHER_ISS, test_jwks()) + .await; + state.nip_fi_verifier = Some(Arc::new(buzz_auth::FederatedAssertionVerifier::new( + state.config.nip_fi.registry.clone(), + key_source, + ))); + Arc::new(state) + } + + /// Deny `key` under issuer A through the real disconnect route. + async fn deny_via_route( + state: &Arc, + key: &nostr::PublicKey, + until_offset_secs: i64, + ) { + let token = mint_token(&key.to_hex(), until_offset_secs, serde_json::json!({})); + let resp = do_request( + Arc::clone(state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": key.to_hex()})), + ) + .await; + assert_eq!( + resp.status(), + StatusCode::OK, + "disconnect route must succeed" + ); + } + + /// HTTP admission for an `(iss, key)` assertion paired with a NIP-98 proof + /// for `key`. `Ok(())` = admitted; `Err` = the denial response. + // Response is intentionally large (axum's design); see admit_nip_fi_http. + #[allow(clippy::result_large_err)] + fn http_admit( + state: &crate::state::AppState, + iss: &str, + key: &nostr::PublicKey, + ) -> Result<(), axum::response::Response> { + let mut headers = HeaderMap::new(); + headers.insert( + CLIENT_ATTACHED_HEADER, + format!("Bearer {}", mint_assertion_token_for(iss, &key.to_hex())) + .parse() + .expect("header value"), + ); + crate::nip_fi_http::admit_nip_fi_http_on_state(state, &headers, || { + Ok(crate::nip_fi_http::Nip98Proof::new(*key, ())) + }) + .map(|_| ()) + } + + async fn assert_fixed_authorization_denied(resp: axum::response::Response, why: &str) { + assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{why}"); + assert_eq!( + resp.headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "{why}" + ); + let body = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!(&body[..], b"authorization denied\n", "{why}"); + } + + #[tokio::test] + async fn http_admission_denies_key_denied_via_route() { + let state = http_enforce_state().await; + let key = nostr::Keys::generate().public_key(); + assert!( + http_admit(&state, TEST_ISS, &key).is_ok(), + "control: (iss-A, k) is admitted before any deny" + ); + + deny_via_route(&state, &key, 300).await; + + let resp = http_admit(&state, TEST_ISS, &key) + .expect_err("(iss-A, k) must be denied at HTTP ingress after the route denies it"); + assert_fixed_authorization_denied(resp, "(iss-A, k) HTTP denial").await; + } + + #[tokio::test] + async fn http_admission_deny_is_issuer_scoped() { + let state = http_enforce_state().await; + let key = nostr::Keys::generate().public_key(); + deny_via_route(&state, &key, 300).await; + + assert!( + http_admit(&state, TEST_ISS, &key).is_err(), + "control: (iss-A, k) is denied" + ); + assert!( + http_admit(&state, OTHER_ISS, &key).is_ok(), + "a deny for (iss-A, k) must not block (iss-B, k)" + ); + } + + #[tokio::test] + async fn http_admission_readmits_after_deny_until_passes() { + let state = http_enforce_state().await; + let key = nostr::Keys::generate().public_key(); + // `until` is whole seconds from `now`: +3 leaves at least 2s of window. + deny_via_route(&state, &key, 3).await; + assert!( + http_admit(&state, TEST_ISS, &key).is_err(), + "(iss-A, k) is denied inside the window" + ); + + tokio::time::sleep(std::time::Duration::from_secs(4)).await; + + assert!( + http_admit(&state, TEST_ISS, &key).is_ok(), + "(iss-A, k) must be admitted again once `until` has passed" + ); + } + + #[tokio::test] + async fn one_route_deny_is_enforced_by_ws_and_http_admission() { + use crate::router::build_router; + let state = http_enforce_state().await; + let key = nostr::Keys::generate().public_key(); + deny_via_route(&state, &key, 300).await; + + let ws_request = Request::get("/") + .header(axum::http::header::HOST, "relay.example") + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + .header( + CLIENT_ATTACHED_HEADER, + format!("Bearer {}", mint_assertion_token(&key.to_hex())), + ) + .body(Body::empty()) + .expect("request"); + let ws_resp = build_router(Arc::clone(&state)) + .oneshot(ws_request) + .await + .expect("router response"); + assert_fixed_authorization_denied(ws_resp, "WS admission sees the route's deny").await; + + let http_resp = + http_admit(&state, TEST_ISS, &key).expect_err("HTTP admission sees the same deny"); + assert_fixed_authorization_denied(http_resp, "HTTP admission sees the route's deny").await; + } + + #[tokio::test] + // Response is intentionally large (axum's design); see admit_nip_fi_http. + #[allow(clippy::result_large_err)] + async fn off_mode_http_admission_ignores_route_deny_entries() { + // Off mode: the command route still records the entry, but HTTP + // admission never consults the deny map — the NIP-98 closure result is + // returned unchanged, byte for byte. + let state = build_test_state(1000).await; + assert!(matches!( + state.config.nip_fi.mode, + buzz_auth::NipFiMode::Off + )); + let key = nostr::Keys::generate().public_key(); + deny_via_route(&state, &key, 300).await; + assert!( + state + .nip_fi_deny_map + .as_deref() + .expect("deny map present") + .is_denied(TEST_ISS, &key, chrono::Utc::now()), + "control: the route recorded the deny entry" + ); + + let admitted = + crate::nip_fi_http::admit_nip_fi_http_on_state(&state, &HeaderMap::new(), || { + Ok(crate::nip_fi_http::Nip98Proof::new(key, 7u8)) + }) + .expect("Off mode admits on NIP-98 success regardless of deny entries"); + assert_eq!(admitted.proven_pubkey(), &key); + assert!(admitted.assertion().is_none()); + assert_eq!(*admitted.extra(), 7); + + let legacy = || { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header("Content-Type", "application/json") + .body(Body::from(r#"{"error":"legacy"}"#)) + .expect("legacy response") + }; + let resp = crate::nip_fi_http::admit_nip_fi_http_on_state::<(), _>( + &state, + &HeaderMap::new(), + || Err(legacy()), + ) + .expect_err("Off mode propagates the NIP-98 failure"); + let expected = legacy(); + assert_eq!(resp.status(), expected.status()); + assert_eq!(resp.headers(), expected.headers()); + let got = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("body"); + let want = axum::body::to_bytes(expected.into_body(), usize::MAX) + .await + .expect("body"); + assert_eq!(got, want, "Off-mode legacy response must be byte-identical"); + } +} diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 7bf30f6291b..5d984a1e3b5 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -381,6 +381,18 @@ where } #[allow(clippy::too_many_arguments)] +/// Records the NIP-42-proven pubkey and its admitting NIP-FI issuer on an audio +/// control after successful auth so the issuer-scoped NIP-FI disconnect scan +/// can reach audio sockets alongside relay peers. Shared by the handler and +/// tests that register audio sockets through the production seam. +pub(crate) fn audio_post_auth_register( + control: &CommunityConnectionControl, + pubkey_bytes: Vec, + nip_fi_issuer: Option, +) { + control.set_proven_identity(pubkey_bytes, nip_fi_issuer); +} + pub(crate) async fn handle_active_audio_connection( socket: WebSocket, state: Arc, @@ -421,7 +433,7 @@ pub(crate) async fn handle_active_audio_connection( ) }); - let (audio_gate, _terminal_ctrl_tx, mut terminal_ctrl_rx, mut _nip_fi_admission_expiry) = + let (audio_gate, terminal_ctrl_tx, mut terminal_ctrl_rx, mut _nip_fi_admission_expiry) = if let Some((gate, tx, rx, expiry)) = pre_built { // Production path: gate already armed pre-bootstrap. (gate, tx, rx, expiry) @@ -443,6 +455,10 @@ pub(crate) async fn handle_active_audio_connection( }); (gate, tx, rx, expiry) }; + // Register the terminal sender before the proven identity becomes + // scan-visible, so a concurrent `disconnect_nip_fi` that finds this socket + // can always enqueue its denial. [FI-TRACE-DENY-SET] + control.set_terminal_frame_sender(terminal_ctrl_tx); // Already-expired fast path: catch a deadline already past at upgrade time // before spending the AUTH_TIMEOUT window. Send the canonical denial frame @@ -635,6 +651,53 @@ pub(crate) async fn handle_active_audio_connection( } } + // Register the proven key with its admitting NIP-FI issuer after pairing, + // then run the deny-set check: a concurrent disconnect either finds this + // socket in its close scan or this check finds its deny entry. + // [FI-TRACE-DENY-SET] + audio_post_auth_register( + &control, + pubkey_bytes.clone(), + nip_fi_assertion + .as_ref() + .map(|a| a.identity().issuer().to_owned()), + ); + #[cfg(test)] + crate::nip_fi_test_hooks::before_deny_set_check(tenant.community()).await; + if let Some(assertion) = &nip_fi_assertion { + if let (Some(asserted_key), Some(deny_map)) = + (assertion.asserted_key(), state.nip_fi_deny_map.as_deref()) + { + if deny_map.is_denied( + assertion.identity().issuer(), + &asserted_key, + chrono::Utc::now(), + ) { + warn!( + channel_id = %channel_id, + pubkey = %pubkey_hex, + "NIP-FI deny-set hit at audio post-registration check — denying" + ); + crate::connection::send_exit_frames_bounded( + &mut ws_send, + [ + crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + buzz_auth::DenialClass::AuthorizationDenied, + ), + crate::state::CommunityDisconnectReason::AuthorizationDenied + .close_message(), + ], + ) + .await; + cancel.cancel(); + return; + } + } + } + #[cfg(test)] + crate::nip_fi_test_hooks::after_deny_set_check_passed(tenant.community()).await; + let relay_refusal = match crate::api::relay_members::check_relay_membership( &state, tenant.community(), @@ -5445,6 +5508,1059 @@ mod tests { // attribute — do not remove the ignore even if a local DB is reachable, // so the discovery contract is not broken. (Lesson S5: test relocation // matters for nextest lane discovery.) + // ── NIP-FI S4 audio deny witnesses ── + /// Build a test AppState with a NipFiDenyMap wired for issuer "test-issuer". + /// If `denied_key` is Some, inserts a live deny entry for that key. + /// Uses a lazy DB (port 1) — sufficient because the deny check fires before + /// any DB read in `handle_active_audio_connection`. + async fn audio_deny_state( + denied_key: Option<&nostr::PublicKey>, + ) -> std::sync::Arc { + use std::sync::Arc; + let mut state = (*audio_test_state().await).clone(); + + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + + if let Some(key) = denied_key { + let until = chrono::Utc::now() + chrono::Duration::seconds(3600); + let result = + deny_map.merge_cross_pod_deny("test-issuer", key, until, chrono::Utc::now()); + assert!( + matches!(result, buzz_auth::CrossPodMergeResult::Merged), + "audio_deny_state: deny entry must be inserted for test setup" + ); + } + + state.nip_fi_deny_map = Some(deny_map); + Arc::new(state) + } + + // ── W_admin_disconnect: registry disconnect_nip_fi delivers payload-then-close ─ + // + // Witnesses that an active audio socket closed via the admin-disconnect path + // (`CommunityConnectionRegistry::disconnect_nip_fi`) delivers the restricted + // JSON payload BEFORE the 1008 POLICY close — the payload-then-close contract. + // + // Before this fix, `CommunityConnectionControl::disconnect_nip_fi` only + // published `AuthorizationDenied` + cancelled; no frame was enqueued on the + // terminal channel. The send loop (or pre-send-loop drain) then emitted only + // the close, with no preceding restricted JSON frame. + // + // Setup: + // - Pre-create and register `CommunityConnectionControl` (so the registry + // scan can find this audio session by pubkey — same pattern as straddle). + // - Key absent from deny map. Assertion carries a 1-hour deadline so the + // expiry task is armed but does NOT fire during the test. + // - `before_first_audio_check_cancel` hook holds the handler AFTER + // `set_terminal_frame_sender` registers the sender on the control (line + // ~421) and BEFORE the first `check_cancel!()`. + // - Test calls `registry.disconnect_nip_fi("test-issuer", &pubkey)` while the hook holds. + // `CommunityConnectionControl::disconnect_nip_fi` enqueues the denial frame + // on `terminal_frame_tx`, publishes `AuthorizationDenied`, then cancels. + // - Hook is released; handler hits `check_cancel!()`, drains the denial + // frame from `terminal_ctrl_rx`, sends `reason.close_message()`. + // - Client asserts: Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // + // Mutation evidence (production seam, not copies): + // A) Remove the `set_terminal_frame_sender` call from `handle_active_audio_connection` + // → `terminal_frame_tx` slot is `None` → `disconnect_nip_fi` enqueues nothing + // → client receives only `1008` with no preceding text frame → Text assertion + // times out → panics. + // B) Remove the `try_send` block from `CommunityConnectionControl::disconnect_nip_fi` + // → same outcome as (A): enqueue suppressed → only close observed → panics. + // C) Delete the `while let Ok(msg) = terminal_ctrl_rx.try_recv()` drain from the + // plain `check_cancel!()` arm → no text frame delivered → panics. + // D) Move `set_terminal_frame_sender` to AFTER `audio_post_auth_register` + // (back to the pass-1 ordering) → when disconnect_nip_fi fires at the + // `before_first_audio_check_cancel` hook (which itself is after the old + // registration point), the sender IS registered → test still PASSES. + // Use W_admin_disconnect_at_deny_check (hook at before_deny_set_check, + // the old gap) to catch this regression instead — that witness is RED + // under the pass-1 ordering. (See W_addc above.) + #[tokio::test] + async fn admin_disconnect_nip_fi_delivers_restricted_json_then_policy_close() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + // 1-hour deadline: expiry task armed but will NOT fire during this test. + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // State with deny map; key is absent (not denied). + let state = audio_deny_state(None).await; + + // Unique community so hook and registry slots don't collide with parallel tests. + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + + // Pre-create and register the control so the pubkey-scan can find it. + // `audio_post_auth_register` writes `proven_pubkey` on this same Arc; + // the registered entry is updated in-place. [same pattern as straddle test] + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = uuid::Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("W_admin_disconnect: bind test listener"); + let addr = listener + .local_addr() + .expect("W_admin_disconnect: test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + None, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("W_admin_disconnect: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("W_admin_disconnect: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("W_admin_disconnect: connect client"); + + // Arm the hook BEFORE sending auth — it fires after `set_terminal_frame_sender` + // registers the sender (now before audio_post_auth_register, line ~343) and + // before the first `check_cancel!()`. + let (hook_arrived_rx, hook_release) = + crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community); + + // NIP-42 challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_admin_disconnect: challenge timeout") + .expect("W_admin_disconnect: challenge item") + .expect("W_admin_disconnect: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("W_admin_disconnect: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("W_admin_disconnect: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("W_admin_disconnect: challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("W_admin_disconnect: send auth"); + + // Wait for the handler to reach before_first_audio_check_cancel. + // At this point `set_terminal_frame_sender` has already been called and + // the terminal sender is registered on the control. + tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) + .await + .expect( + "W_admin_disconnect: handler must reach before_first_audio_check_cancel within 5s", + ) + .expect("W_admin_disconnect: hook arrived channel closed"); + + // Simulate admin-disconnect: call the real registry disconnect scan by pubkey. + // CommunityConnectionControl::disconnect_nip_fi enqueues the denial frame on + // the registered terminal sender, publishes AuthorizationDenied, then cancels. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_admin_disconnect: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + // Release hook — handler resumes, hits check_cancel!(), drains the + // enqueued denial frame, then sends reason.close_message(). + hook_release.notify_one(); + + // Frame 0: restricted JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_admin_disconnect: frame 0 timeout") + .expect("W_admin_disconnect: frame 0 item") + .expect("W_admin_disconnect: frame 0 ws message"); + let expected_json = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_json.as_str(), + "W_admin_disconnect: frame 0 must be exact restricted JSON payload" + ); + } + other => { + panic!("W_admin_disconnect: frame 0 must be Text(restricted JSON); got {other:?}") + } + } + + // Pre-writer exit: main's S3 drains only the terminal channel, then + // drops the socket. Nothing may follow the denial payload. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_admin_disconnect: frame 1 timeout"); + assert!( + !matches!( + frame1, + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) + | Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(_))) + ), + "W_admin_disconnect: socket must terminate after the denial payload; got {frame1:?}" + ); + + assert!( + cancel_for_assert.is_cancelled(), + "W_admin_disconnect: conn_cancel must be cancelled after admin disconnect" + ); + + server.abort(); + let _ = server.await; + } + + // ── W_admin_disconnect_at_deny_check: pre-registration-window is now closed ── + // + // Witnesses that a disconnect_nip_fi call that fires at the `before_deny_set_check` + // hook window — AFTER audio_post_auth_register (pubkey scan-visible) but BEFORE + // the deny-set check — still delivers the restricted JSON payload before the 1008 + // close. This is the exact window Thufir identified as the pre-registration gap + // in pass 2: the old code registered the terminal sender AFTER this point, so + // `disconnect_nip_fi` found `terminal_frame_tx = None` and queued nothing. The + // fix moves sender registration to BEFORE `audio_post_auth_register`, closing + // the window. + // + // Setup: + // - Pre-create and register control (same pattern as straddle/admin_disconnect). + // - Key absent from deny map. 1-hour deadline — expiry does not fire. + // - Arm `before_deny_set_check` hook. This hook fires AFTER both + // `set_terminal_frame_sender` and `audio_post_auth_register`. + // - While handler is held at the hook, call `registry.disconnect_nip_fi`. + // - Release; handler hits check_cancel!(), drains denial frame, emits 1008. + // - Client asserts Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // + // Mutation evidence: + // A) Move `set_terminal_frame_sender` to AFTER the hook window (into the B1 + // block, after the deny-set check, where it was in the original pass-1 code) + // → when disconnect_nip_fi fires at the before_deny_set_check window, the + // slot is still `None` → nothing enqueued → check_cancel!() drains nothing + // → client receives only 1008 with no preceding Text frame → frame-0 Text + // assertion panics. + // B) Remove `set_terminal_frame_sender` entirely → same outcome as (A). + #[tokio::test] + async fn w_admin_disconnect_at_deny_check_delivers_payload_then_close() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // State with deny map; key is absent (not denied) — the check must pass. + let state = audio_deny_state(None).await; + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + + // Pre-create and register the control so the pubkey-scan can find it. + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id = uuid::Uuid::new_v4(); + let _conn_guard = + state + .community_connections + .register(conn_id, community, conn_control.clone()); + let conn_control_for_server = conn_control.clone(); + + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("W_addc: bind test listener"); + let addr = listener.local_addr().expect("W_addc: test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let control_inner = control_outer.clone(); + let conn_time = chrono::Utc::now(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + None, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app) + .await + .expect("W_addc: test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("W_addc: server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("W_addc: connect client"); + + // Arm before_deny_set_check — fires AFTER set_terminal_frame_sender AND + // audio_post_auth_register (pubkey scan-visible). This is the exact window + // where the old code had terminal_frame_tx = None. + let (hook_arrived_rx, hook_release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // NIP-42 challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("W_addc: challenge timeout") + .expect("W_addc: challenge item") + .expect("W_addc: challenge message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("W_addc: expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("W_addc: challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("W_addc: challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("W_addc: send auth"); + + // Wait for the handler to reach before_deny_set_check. + // At this point BOTH set_terminal_frame_sender and audio_post_auth_register + // have already executed — the terminal sender is registered and the pubkey + // is scan-visible. (In the old code this was the gap window.) + tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) + .await + .expect("W_addc: handler must reach before_deny_set_check within 5s") + .expect("W_addc: hook arrived channel closed"); + + // Simulate admin-disconnect at the exact old-gap position. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_addc: registry scan must find exactly 1 audio session \ + (proves audio_post_auth_register ran before the hook)" + ); + + // Release — handler resumes, hits check_cancel!(), drains the enqueued + // denial frame, sends reason.close_message(). + hook_release.notify_one(); + + // Frame 0: restricted JSON payload. + let frame0 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_addc: frame 0 timeout") + .expect("W_addc: frame 0 item") + .expect("W_addc: frame 0 ws message"); + let expected_json = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + match frame0 { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_json.as_str(), + "W_addc: frame 0 must be exact restricted JSON (terminal sender was \ + registered before scan-visibility, so the old gap is closed)" + ); + } + other => { + panic!("W_addc: frame 0 must be Text(restricted JSON); got {other:?}") + } + } + + // Pre-writer exit: main's S3 drains only the terminal channel, then + // drops the socket. Nothing may follow the denial payload. + let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_addc: frame 1 timeout"); + assert!( + !matches!( + frame1, + Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) + | Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(_))) + ), + "W_addc: socket must terminate after the denial payload; got {frame1:?}" + ); + + assert!( + cancel_for_assert.is_cancelled(), + "W_addc: conn_cancel must be cancelled after admin disconnect" + ); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn w_audio_deny_absent_key_passes_deny_check_reaches_membership_gate() { + // A key NOT in the deny map must pass the deny-set check and reach the + // post-check / membership-entry gate without denial or cancellation. + // + // Two hooks bracket the deny-set check block: + // 1. `before_deny_set_check` (pre-check): proves the handler reached + // the deny-check seam after pairing + registration; connection is + // NOT cancelled here. + // 2. `after_deny_set_check_passed` (post-check): fires only when the + // key was NOT denied — proves the handler continued past the check + // without a denial or cancel. An unconditional denial immediately + // after the pre-check hook would prevent this hook from firing. + // + // Mutation evidence: + // A) Invert `is_denied` → absent key is denied after pre-check hook + // releases → handler returns early → post-check hook NEVER fires → + // `post_arrived_rx` times out → test panics. + // B) Delete the `before_deny_set_check` hook → pre-check `arrived_rx` + // times out → test panics (seam unreachable). + // C) Delete the `after_deny_set_check_passed` hook → post-check + // `post_arrived_rx` times out → test panics (pass-through unproven). + // D) Remove `nip_fi_deny_map` from state → map is None → guard + // short-circuits → both hooks still fire (map guard is after both + // hooks are in the control path) — off-mode passes through cleanly. + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + // Different key is denied; `key` is absent from the map. + let other_key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let state = audio_deny_state(Some(&other_key.public_key())).await; + + // Use a unique UUID so this test's hook slot doesn't collide with + // other concurrent tests (active test uses Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + None, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Arm BOTH hooks before sending the auth message. + // Hook 1: pre-check barrier — fires when handler reaches before_deny_set_check. + let (pre_arrived_rx, pre_release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + // Hook 2: post-check barrier — fires when handler passes deny check (key absent). + let (post_arrived_rx, post_release) = + crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // === Pre-check seam === + // Wait for handler to reach before_deny_set_check. + // Proves: pairing passed, registration happened, deny check reached. + tokio::time::timeout(std::time::Duration::from_secs(5), pre_arrived_rx) + .await + .expect("W_audio_deny_absent: handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Connection is NOT cancelled at the pre-check seam. + assert!( + !cancel_for_assert.is_cancelled(), + "W_audio_deny_absent: connection must NOT be cancelled at the pre-check seam" + ); + + // Release pre-check hook — handler proceeds to run the deny check. + pre_release.notify_one(); + + // === Post-check seam === + // Wait for handler to reach after_deny_set_check_passed. + // This hook ONLY fires if the key was NOT denied. An inverted `is_denied` + // would deny the absent key and return early, never reaching this hook. + tokio::time::timeout(std::time::Duration::from_secs(5), post_arrived_rx) + .await + .expect( + "W_audio_deny_absent: handler must reach after_deny_set_check_passed within 5s \ + (absent key must pass the deny check without denial)", + ) + .expect("post-check arrived channel closed"); + + // Connection is STILL not cancelled — the absent key passed clean. + assert!( + !cancel_for_assert.is_cancelled(), + "W_audio_deny_absent: connection must NOT be cancelled after the deny check \ + (absent key must pass clean)" + ); + + // Release post-check hook — handler proceeds to membership check (lazy DB). + post_release.notify_one(); + + // Allow the handler to proceed briefly (lazy-DB membership error is expected; + // that path is out of scope for this witness). + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn w_audio_deny_active_key_refused_at_post_registration_check() { + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + // Assertion with "test-issuer"; the key IS in the deny map. + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let state = audio_deny_state(Some(&key.public_key())).await; + + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), + "test.local".to_string(), + ); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let cancel_i = conn_cancel.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + let control_inner = + crate::state::CommunityConnectionControl::new(cancel_i.clone()); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + None, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + // Sign with the SAME key as the assertion — pairing passes. + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Receive the denial frame. + let frame = tokio::time::timeout(std::time::Duration::from_secs(3), client.next()) + .await + .expect("W_audio_deny_active: denial frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_denied = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_denied.as_str(), + "W_audio_deny_active: active deny entry must produce exact \ + authorization_denied frame at post-registration check" + ); + } + other => panic!("W_audio_deny_active: expected Text(restricted JSON); got {other:?}"), + } + + // Connection must close after denial. + let close = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()).await; + assert!( + matches!( + close, + Ok(Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_)))) + | Ok(Some(Err(_))) + | Ok(None) + ), + "W_audio_deny_active: connection must close after denial; got {close:?}" + ); + + assert!( + cancel_for_assert.is_cancelled(), + "W_audio_deny_active: conn_cancel must be cancelled after denial" + ); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn w_audio_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { + // Arms `before_deny_set_check` — fires AFTER audio_post_auth_register and + // BEFORE the is_denied call. Entry starts absent; inserted during the window. + // The deny check finds it and closes the connection. + use buzz_auth::VerifiedAssertion; + use chrono::{Duration, Utc}; + use std::sync::Arc; + + let key = nostr::Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + // Build state with empty deny map (key not denied yet). + let deny_map = Arc::new(buzz_auth::NipFiDenyMap::new( + 16, + vec![buzz_auth::IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + let deny_map_for_insert = Arc::clone(&deny_map); + + let mut base_state = (*audio_test_state().await).clone(); + base_state.nip_fi_deny_map = Some(deny_map); + let state = Arc::new(base_state); + + // Use a unique UUID so this test's hook slot doesn't collide with + // other concurrent tests (absent/active tests use Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let tenant = + buzz_core::tenant::TenantContext::resolved(community, "test.local".to_string()); + + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); + let conn_cancel = CancellationToken::new(); + let cancel_for_assert = conn_cancel.clone(); + let state_c = Arc::clone(&state); + let tenant_c = tenant.clone(); + let assertion_c = assertion.clone(); + + // Pre-create and register the CommunityConnectionControl before the server + // runs. audio_post_auth_register writes proven_pubkey on the control; since + // Clone shares the same proven_pubkey Arc, the registered entry is updated + // in-place and disconnect_nip_fi can find it at the close-scan assertion. + // The guard keeps the entry live through that assertion. + let conn_control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let conn_id_for_registration = uuid::Uuid::new_v4(); + let _conn_guard = state.community_connections.register( + conn_id_for_registration, + community, + conn_control.clone(), + ); + let conn_control_for_server = conn_control.clone(); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let addr = listener.local_addr().expect("test listener addr"); + + let server = tokio::spawn(async move { + let app = Router::new().route( + "/", + get({ + let state_i = Arc::clone(&state_c); + let tenant_i = tenant_c.clone(); + let assertion_i = assertion_c.clone(); + let control_outer = conn_control_for_server.clone(); + move |ws: WebSocketUpgrade| { + let state_i = Arc::clone(&state_i); + let tenant_i = tenant_i.clone(); + let assertion_i = assertion_i.clone(); + let conn_time = chrono::Utc::now(); + // Use the pre-registered control so audio_post_auth_register + // writes to the registered entry (shared proven_pubkey Arc). + let control_inner = control_outer.clone(); + async move { + ws.on_upgrade(move |socket| async move { + handle_active_audio_connection( + socket, + state_i, + tenant_i, + uuid::Uuid::new_v4(), + control_inner, + Some(assertion_i), + conn_time, + None, + ) + .await + }) + } + } + }), + ); + let _ = ready_tx.send(()); + axum::serve(listener, app).await.expect("test server"); + }); + + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ready_rx) + .await + .expect("server ready"); + + let (mut client, _) = connect_async(format!("ws://{addr}/")) + .await + .expect("connect client"); + + // Arm the barrier BEFORE sending auth (handler stalls when it reaches the hook). + let (arrived_rx, release) = crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // Receive challenge. + let challenge_msg = tokio::time::timeout(std::time::Duration::from_secs(2), client.next()) + .await + .expect("challenge timeout") + .expect("challenge message") + .expect("challenge ws message"); + let challenge_text = match challenge_msg { + tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), + other => panic!("expected text challenge; got {other:?}"), + }; + let challenge_json: serde_json::Value = + serde_json::from_str(&challenge_text).expect("challenge JSON"); + let challenge = challenge_json["challenge"] + .as_str() + .expect("challenge field") + .to_string(); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + let auth_msg = serde_json::json!({"type": "auth", "event": auth_event}).to_string(); + client + .send(tokio_tungstenite::tungstenite::Message::Text( + auth_msg.into(), + )) + .await + .expect("send auth msg"); + + // Wait for the handler to reach before_deny_set_check (after registration). + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W_audio_deny_straddle: handler must reach hook within 5s") + .expect("arrived channel closed"); + + // Insert the deny entry — handler is between registration and check. + let until = Utc::now() + Duration::seconds(3600); + let merge = deny_map_for_insert.merge_cross_pod_deny( + "test-issuer", + &key.public_key(), + until, + Utc::now(), + ); + assert!( + matches!(merge, buzz_auth::CrossPodMergeResult::Merged), + "W_audio_deny_straddle: deny entry must be inserted during hook window" + ); + + // Close-scan side: run the real CommunityConnectionRegistry::disconnect_nip_fi + // now that the audio connection is registered (audio_post_auth_register fired + // before the hook). This proves registration is visible to the concurrent close + // scan — the normative invariant [FI-TRACE-DENY-SET] for the audio path. + // With the deny entry live, the scan finds exactly one session matching this + // pubkey and closes it. + // + // Mutation evidence (Mut-C: move hook before audio_post_auth_register): + // disconnect_nip_fi returns 0 (not yet registered) → assertion panics. + // Causally falsifies the registration-before-check invariant. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .community_connections + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_audio_deny_straddle: close scan must find exactly 1 registered audio session \ + (proves audio_post_auth_register is visible between the hook and the check)" + ); + + // Release — handler resumes and calls is_denied(). + release.notify_one(); + + // Receive the denial frame from the server. + let frame = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("W_audio_deny_straddle: denial frame timeout") + .expect("frame") + .expect("ws frame"); + + let expected_denied = serde_json::json!({ + "type": "restricted", + "message": buzz_auth::DenialClass::AuthorizationDenied.nostr_text() + }) + .to_string(); + + match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => { + assert_eq!( + t.as_str(), + expected_denied.as_str(), + "W_audio_deny_straddle: deny entry inserted between registration \ + and check must produce exact authorization_denied frame" + ); + } + other => panic!("W_audio_deny_straddle: expected Text(restricted JSON); got {other:?}"), + } + + assert!( + cancel_for_assert.is_cancelled(), + "W_audio_deny_straddle: conn_cancel must be cancelled after straddle denial" + ); + + server.abort(); + let _ = server.await; + } + mod postgres_tests { use super::*; diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index c94ccab85fd..cceee16b070 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -112,6 +112,10 @@ pub struct ConnectionState { pub backpressure_count: Arc, /// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`). pub grace_limit: u8, + /// Lifecycle control shared with the community registry; the auth + /// handler's post-registration deny check routes its denial through it. + /// [FI-TRACE-DENY-SET] + pub(crate) community_control: crate::state::CommunityConnectionControl, /// The NIP-FI assertion presented at upgrade, when enforcement is enabled. /// @@ -595,6 +599,7 @@ async fn handle_active_connection( nip_fi_assertion, session_deadline, nip_fi_gate: nip_fi_gate.clone(), + community_control: control.clone(), }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); @@ -625,12 +630,14 @@ async fn handle_active_connection( conn_id, tx.clone(), ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), Some(restart_tx), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), subscriptions, state.config.slow_client_grace_limit, + control.clone(), ); let (ws_send, ws_recv) = socket.split(); @@ -1324,6 +1331,7 @@ pub(crate) mod tests { nip_fi_assertion: None, 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) } @@ -2641,6 +2649,7 @@ pub(crate) mod tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = crate::state::tests::test_state().await; diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 5d428efe190..2fd584f0507 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -465,11 +465,51 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if !conn.authenticate(auth_ctx) { return; } - // The permit is held through set_authenticated_pubkey and the OK send - // so the entire auth commit is atomic with respect to expiry. - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.to_bytes().to_vec()); + // The permit is held through identity registration, the deny-set + // check, and the OK send so the auth commit is atomic with respect + // to expiry. + // + // Register the proven key with its admitting NIP-FI issuer BEFORE + // the deny-set check: a concurrent disconnect either finds this + // session in its close scan or this check finds its deny entry. + state.conn_manager.set_authenticated_identity( + conn_id, + pubkey.to_bytes().to_vec(), + conn.nip_fi_assertion + .as_ref() + .map(|a| a.identity().issuer().to_owned()), + ); + #[cfg(test)] + crate::nip_fi_test_hooks::before_deny_set_check(conn.tenant.community()).await; + // [FI-TRACE-DENY-SET] + if let Some(assertion) = &conn.nip_fi_assertion { + if let (Some(asserted_key), Some(deny_map)) = + (assertion.asserted_key(), state.nip_fi_deny_map.as_deref()) + { + if deny_map.is_denied( + assertion.identity().issuer(), + &asserted_key, + chrono::Utc::now(), + ) { + warn!( + conn_id = %conn_id, + pubkey = %pubkey.to_hex(), + "NIP-FI deny-set hit at post-registration check — denying" + ); + metrics::counter!( + "buzz_nip_fi_admission_denied_total", + "reason" => "deny_set_post_registration" + ) + .increment(1); + conn.community_control.auth_deny_terminal( + &conn.terminal_ctrl_tx, + crate::nip_fi_session::NipFiWsRoute::Root, + ); + conn.cancel.cancel(); + return; + } + } + } conn.send(RelayMessage::ok(&event_id_hex, true, "")); // _auth_permit drops here — expiry's write guard may proceed. } @@ -698,6 +738,7 @@ mod tests { nip_fi_assertion: Some(assertion), session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = auth_test_state().await; @@ -820,6 +861,7 @@ mod tests { nip_fi_assertion: Some(assertion), session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = auth_test_state().await; @@ -891,7 +933,8 @@ mod tests { grace_limit: 3, nip_fi_assertion: assertion, session_deadline: None, - nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel), + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel), }); Self { conn, @@ -1108,6 +1151,396 @@ mod tests { Arc::new(state) } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught() { + w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body().await; + } + + // ── W_deny_straddle: deny entry inserted in window between registration and check + // + // Arms `before_deny_set_check` — the hook immediately AFTER + // `set_authenticated_pubkey` (registration) and BEFORE the `is_denied` call. + // The key starts absent from the deny map. Once registration occurs the + // handler stalls at the hook. At the hook, the test: + // 1. Inserts the deny entry into the live map. + // 2. Executes the real ConnectionManager::disconnect_nip_fi (close-scan side): + // asserts it finds exactly 1 registered session — proving registration is + // visible to a concurrent disconnect in this exact window. + // 3. Releases the hook — the handler resumes and calls is_denied() (check side). + // Both sides are exercised; neither can miss. The connection is cancelled and + // the exact `authorization_denied` NOTICE frame is asserted; no OK(true) sent. + // + // Mutation evidence (executed on green baseline): + // A) Delete `#[cfg(test)] before_deny_set_check(...)` from auth.rs → + // handler never stalls → deny entry inserted AFTER check runs and + // missed → close_scan returns 0 (session deregistered) → assertion panics. + // B) Remove the `is_denied` check entirely → same outcome as (A). + // C) Move hook to before `set_authenticated_pubkey` (registration) → + // handler stalls before registration → close-scan `disconnect_nip_fi` + // returns 0 (not yet registered) → "exactly 1 session" assertion panics. + // Causally falsifies the registration-before-check invariant. + // + // Runs in PG lane on wrapper DB (same constraint as W1: ban-check is fail-closed). + async fn w_deny_straddle_entry_inserted_between_registration_and_check_is_caught_body() { + use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + // Same key for assertion and NIP-42 event — pairing passes. + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + // `for_test` produces issuer = "test-issuer". + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let challenge = "w-deny-straddle-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + + // Use a unique community UUID so this test's deny_set_check_hook slot + // does not collide with other concurrent tests (audio-active uses Uuid::nil()). + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + let deny_straddle_control = + crate::state::CommunityConnectionControl::new(cancel.clone()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + community, + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: deny_straddle_control, + }); + + // Real DB required (ban-check is fail-closed; lazy pool denies before hook). + let mut state = Arc::try_unwrap(auth_test_state_real_db_expect().await) + .unwrap_or_else(|arc| (*arc).clone()); + + // Wire an empty deny map for issuer "test-issuer" (the issuer used by + // VerifiedAssertion::for_test). No entries yet — the key is clean. + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + vec![IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + // Retain a handle so we can insert the entry during the hook window. + let deny_map_for_insert = Arc::clone(&deny_map); + state.nip_fi_deny_map = Some(deny_map); + let state = Arc::new(state); + + // Register the connection with conn_manager so set_authenticated_pubkey + // (called by handle_auth after NIP-42 succeeds) stores the pubkey — the + // close-scan side calls disconnect_nip_fi which iterates over registered + // connections. Without this registration, set_authenticated_pubkey is a + // no-op and disconnect_nip_fi always returns 0. + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, // no restart_tx for this unit-test fixture + cancel.clone(), + community, + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + // Arm the barrier: fires when handle_auth reaches before_deny_set_check. + let (arrived_rx, release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + + // Spawn handle_auth — it will stall at the hook after registration. + let conn2 = Arc::clone(&conn); + let state2 = Arc::clone(&state); + let handle = tokio::spawn(async move { handle_auth(auth_event, conn2, state2).await }); + + // Wait for the handler to reach the deny-check seam. + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("W_deny_straddle: handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Handler is now AFTER set_authenticated_pubkey (registered) and BEFORE + // the deny check. Insert the deny entry into the live map. + let until = Utc::now() + Duration::seconds(3600); + let merge = deny_map_for_insert.merge_cross_pod_deny( + "test-issuer", + &key.public_key(), + until, + Utc::now(), + ); + assert!( + matches!(merge, buzz_auth::CrossPodMergeResult::Merged), + "W_deny_straddle: deny entry must be inserted during the hook window" + ); + + // Close-scan side: run the real ConnectionManager::disconnect_nip_fi now + // that the connection is registered. This proves the registration is visible + // to the concurrent close scan — the normative invariant [FI-TRACE-DENY-SET]. + // With the deny entry live, the scan finds exactly one session matching this + // pubkey and closes it. + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .conn_manager + .disconnect_nip_fi("test-issuer", &pubkey_bytes); + assert_eq!( + closed, 1, + "W_deny_straddle: close scan must find exactly 1 registered session \ + (proves registration is visible between the hook and the check)" + ); + + // Release the hook — handler resumes and calls is_denied(). + release.notify_one(); + + // Wait for handle_auth to return. + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("W_deny_straddle: handle_auth must return within 5s after hook release") + .expect("handle_auth task must not panic"); + + // The connection must be cancelled — the deny check closed it. + assert!( + cancel.is_cancelled(), + "W_deny_straddle: connection must be cancelled after deny-set hit \ + (entry inserted between registration and check)" + ); + + // The denial frame must be on the terminal channel (authorization_denied). + // Both the close-scan side (manager_disconnect_nip_fi) and the check side + // (auth_deny_terminal) enqueue on terminal_ctrl_tx, which has capacity-1 + // and first-writer-wins semantics — exactly one frame lands there. + let terminal_frame = terminal_ctrl_rx + .try_recv() + .expect("W_deny_straddle: terminal channel must contain the denial frame"); + if let WsMessage::Text(t) = &terminal_frame { + let expected = crate::protocol::RelayMessage::notice( + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + ); + assert_eq!( + t.as_str(), + expected.as_str(), + "W_deny_straddle: terminal frame must be exact authorization_denied NOTICE; got: {t}" + ); + } else { + panic!( + "W_deny_straddle: terminal frame must be Text(NOTICE); got {terminal_frame:?}" + ); + } + // ctrl channel must be empty — denial goes to terminal only. + assert!( + ctrl_rx.try_recv().is_err(), + "W_deny_straddle: ctrl channel must be empty (denial goes to terminal channel)" + ); + + // No OK(true) on the data channel. + while let Ok(frame) = send_rx.try_recv() { + if let WsMessage::Text(t) = &frame { + assert!( + !t.contains("\"true\"") && !t.contains(r#"[true"#), + "W_deny_straddle: no OK(true) must be sent when deny-set catches \ + the entry inserted between registration and check; got: {t}" + ); + } + } + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_other_issuer_straddle_leaves_session_admitted() { + w_deny_other_issuer_straddle_leaves_session_admitted_body().await; + } + + // ── W_deny_other_issuer_straddle: a deny for (A, K) in the registration→check + // window neither closes nor refuses a session admitted under B with key K. + // + // Same seam as W_deny_straddle. The session's assertion is issued by B + // ("test-issuer"); at the hook the test inserts a deny entry for issuer A + // and runs A's close scan (root + audio), which must match nothing. After + // release the B session completes admission: OK(true), not cancelled. + // Mutation: a pubkey-only scan closes the B session → `closed == 0` fails. + // [FI-TRACE-DENY-SET] + // + // Runs in PG lane on wrapper DB (ban-check is fail-closed). + async fn w_deny_other_issuer_straddle_leaves_session_admitted_body() { + use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + const ISSUER_A: &str = "https://issuer-a.example"; + const ISSUER_B: &str = "test-issuer"; // VerifiedAssertion::for_test + + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + assert_eq!(assertion.identity().issuer(), ISSUER_B); + + let challenge = "w-deny-other-issuer-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + community, + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let mut state = Arc::try_unwrap(auth_test_state_real_db_expect().await) + .unwrap_or_else(|arc| (*arc).clone()); + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + [ISSUER_A, ISSUER_B] + .into_iter() + .map(|issuer| IssuerCapacity { + issuer: issuer.to_owned(), + capacity: 16, + }) + .collect(), + )); + state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); + let state = Arc::new(state); + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, + cancel.clone(), + community, + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + + let (arrived_rx, release) = + crate::nip_fi_test_hooks::deny_set_check_hook::arm(community); + let handle = tokio::spawn(handle_auth( + auth_event, + Arc::clone(&conn), + Arc::clone(&state), + )); + tokio::time::timeout(std::time::Duration::from_secs(5), arrived_rx) + .await + .expect("handler must reach before_deny_set_check within 5s") + .expect("arrived channel closed"); + + // Issuer A's command lands in the window: entry + both close scans. + assert!(matches!( + deny_map.merge_cross_pod_deny( + ISSUER_A, + &key.public_key(), + Utc::now() + Duration::seconds(3600), + Utc::now(), + ), + buzz_auth::CrossPodMergeResult::Merged + )); + let pubkey_bytes = key.public_key().to_bytes().to_vec(); + let closed = state + .conn_manager + .disconnect_nip_fi(ISSUER_A, &pubkey_bytes) + + state + .community_connections + .disconnect_nip_fi(ISSUER_A, &pubkey_bytes); + assert_eq!( + closed, 0, + "issuer A's close scan must not match a session admitted under B" + ); + + release.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), handle) + .await + .expect("handle_auth must return within 5s after hook release") + .expect("handle_auth task must not panic"); + + assert!( + !cancel.is_cancelled(), + "B session must not be cancelled by A's deny" + ); + assert!( + terminal_ctrl_rx.try_recv().is_err(), + "B session must get no denial frame" + ); + assert!( + matches!( + *conn.auth_state.lock().unwrap(), + AuthState::Authenticated(_) + ), + "B session must complete admission" + ); + let mut ok_true = false; + while let Ok(WsMessage::Text(t)) = send_rx.try_recv() { + ok_true |= t.contains("\"OK\"") && t.contains("true"); + } + assert!(ok_true, "B session must receive OK(true)"); + } + /// W1 (auth barrier): expiry fired mid-flight blocks AUTH commit. /// /// Arms `before_auth_commit` — the hook immediately before `acquire_effect()` @@ -1187,6 +1620,7 @@ mod tests { nip_fi_assertion: Some(assertion), session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = auth_test_state_real_db_expect().await; @@ -1307,6 +1741,7 @@ mod tests { deadline, cancel.clone(), ), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = auth_test_state_real_db_expect().await; @@ -1458,6 +1893,7 @@ mod tests { nip_fi_assertion: Some(assertion), session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let relay_url = "ws://test.local"; diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 30243451ab3..4dca0beb7f6 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -418,6 +418,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = crate::state::tests::test_state().await; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 11e24fe2a98..0bb344c4cbe 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1534,6 +1534,9 @@ mod tests { nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( CancellationToken::new(), ), + community_control: crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), }); super::handle_agent_observer_event( @@ -1621,6 +1624,9 @@ mod tests { nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( CancellationToken::new(), ), + community_control: crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), }); let watcher = Uuid::new_v4(); let (tx, mut rx) = mpsc::channel(10); @@ -1629,12 +1635,16 @@ mod tests { watcher, tx, ctrl, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), tenant.community(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state.sub_registry.register_scoped( tenant.community(), @@ -1775,6 +1785,9 @@ mod tests { nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode( CancellationToken::new(), ), + community_control: crate::state::CommunityConnectionControl::new( + CancellationToken::new(), + ), }); // Same watcher registration as the ACK/fan-out cases, proving // the storage failure still reaches no subscriber while its @@ -1786,12 +1799,16 @@ mod tests { watcher, tx, ctrl, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), tenant.community(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); state.sub_registry.register_scoped( tenant.community(), @@ -1925,12 +1942,16 @@ mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pubkey) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pubkey); @@ -2565,12 +2586,16 @@ mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); @@ -2891,12 +2916,16 @@ mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); @@ -3046,6 +3075,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); // Kind:1 TextNote with no #h tag — no DB calls before before_event_ingest. @@ -3236,6 +3266,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); // Build a valid KIND_AGENT_OBSERVER_FRAME telemetry event: diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 7245e404d5b..c2296ee717d 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1773,6 +1773,7 @@ mod tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }; (conn, send_rx) } @@ -2027,12 +2028,14 @@ mod tests { conn.conn_id, conn.send_tx.clone(), conn.ctrl_tx.clone(), + tokio::sync::mpsc::channel(1).0, None, conn.cancel.clone(), conn.tenant.community(), Arc::clone(&conn.backpressure_count), Arc::clone(&conn.subscriptions), 3, + crate::state::CommunityConnectionControl::new(conn.cancel.clone()), ); let (a, b) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4()); claim_live_subscription("x", &text_filters(), Some(&[a]), &conn, &state) @@ -2145,6 +2148,7 @@ mod tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); // Claim, then fill the send buffer so the next `send` returns false. @@ -2204,6 +2208,7 @@ mod tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); // Register in conn_manager so evict_conn_channel_subscriptions can // find the connection for cancel_conn. @@ -2211,12 +2216,14 @@ mod tests { conn.conn_id, send_tx, conn.ctrl_tx.clone(), + tokio::sync::mpsc::channel(1).0, None, conn.cancel.clone(), conn.tenant.community(), Arc::clone(&conn.backpressure_count), Arc::clone(&conn.subscriptions), 3, + crate::state::CommunityConnectionControl::new(conn.cancel.clone()), ); let channel = uuid::Uuid::new_v4(); @@ -3330,6 +3337,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = crate::state::tests::test_state().await; @@ -3458,6 +3466,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = crate::state::tests::test_state().await; @@ -3594,6 +3603,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: Arc::clone(&gate), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let state = crate::state::tests::test_state().await; state @@ -3736,6 +3746,7 @@ mod tests { nip_fi_assertion: None, session_deadline: Some(deadline), nip_fi_gate: Arc::clone(&gate), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }); let channels = [Uuid::new_v4(), Uuid::new_v4()]; state.accessible_channels_cache.insert( diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 2a0fe8c42d6..de8791895f1 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -492,6 +492,12 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { let pubsub_for_conn_ctrl = Arc::clone(&pubsub); tokio::spawn(async move { pubsub_for_conn_ctrl.run_conn_control_subscriber().await }); + // Spawn Redis pub/sub subscriber for NIP-FI cross-pod disconnect commands. + // Remote pods publish to this global channel after accepting a disconnect + // command; every pod merges the deny entry and closes matching sessions. + let pubsub_for_nip_fi = Arc::clone(&pubsub); + tokio::spawn(async move { pubsub_for_nip_fi.run_nip_fi_disconnect_subscriber().await }); + let auth = AuthService::new(config.auth.clone()); // Postgres FTS: the searchable row IS the persisted event row (its @@ -525,7 +531,7 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { .map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?; info!("Media storage connected"); - let (app_state, audit_shutdown) = AppState::new( + let (mut app_state, audit_shutdown) = AppState::new( config.clone(), db, redis_health_pool, @@ -537,6 +543,29 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { relay_keypair, media_storage, ); + // NIP-FI S4: construct deny map + command verifier from startup config, + // before Arc::new so we can mutate app_state directly. The installer does + // no JWKS I/O; the warm + refresh block below is the single key lifecycle + // owner for the shared source both verifiers read. + { + let nip_fi = &config.nip_fi; + if let Some(key_source) = app_state.nip_fi_jwks_source.clone() { + buzz_relay::api::nip_fi::install_nip_fi_command_components( + &mut app_state.nip_fi_deny_map, + &mut app_state.nip_fi_command_verifier, + nip_fi.mode, + &nip_fi.registry, + key_source, + &nip_fi.command_configs, + ) + .map_err(|e| anyhow::anyhow!("NIP-FI startup failed: {e}"))?; + } else if nip_fi.is_enforce() { + return Err(anyhow::anyhow!( + "NIP-FI: failed to construct JWKS key source \ + (empty or duplicate issuer config)" + )); + } + } let state = Arc::new(app_state); // NIP-FI JWKS warm + background refresh. @@ -1169,6 +1198,42 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { }); } + // Cross-pod NIP-FI disconnect consumer: receive deny entries from remote + // pods, merge them into the local deny map (same max(until) rule), and + // close any matching sessions. Every pod subscribes; the publishing pod + // also receives its own message and applies it — this is idempotent because + // the deny entry was already inserted locally before the publish. + // + // The consumer delegates to `apply_nip_fi_disconnect` which owns all + // validation, merge, and session-close logic. This keeps the loop body + // minimal and makes the exact production path testable end-to-end. + { + let state_for_nip_fi = Arc::clone(&state); + let mut rx = state_for_nip_fi.pubsub.subscribe_nip_fi_disconnect(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(msg) => { + let now = chrono::Utc::now(); + buzz_relay::api::nip_fi::apply_nip_fi_disconnect( + &state_for_nip_fi, + &msg, + now, + ); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + metrics::counter!("buzz_nip_fi_disconnect_lag_total").increment(n); + tracing::warn!("NIP-FI disconnect consumer lagged by {n} messages"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::error!("NIP-FI disconnect broadcast channel closed"); + break; + } + } + } + }); + } + let router = build_router(Arc::clone(&state)); let health_router = build_health_router(Arc::clone(&state)); diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 9741edcc98f..876badfae97 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -76,6 +76,20 @@ pub(super) struct IssuerEnvConfig { pub jwks_refresh_interval_seconds: u64, /// Hard deadline for accepting a JWKS snapshot in seconds. pub jwks_hard_deadline_seconds: u64, + + // ── S4 command-API fields (all optional) ────────────────────────────── + /// Maximum command JWT age in seconds; `0 < x ≤ 60`. Required to enable + /// the disconnect API for this issuer. + #[serde(default)] + pub maximum_command_age_seconds: Option, + /// Non-empty list of authorized `sub` values. Required when + /// `maximum_command_age_seconds` is set. + #[serde(default)] + pub authorized_principals: Option>, + /// Hard ceiling on live deny entries for this issuer. Defaults to + /// [`crate::api::nip_fi::DEFAULT_DENY_SET_CAPACITY`] when absent. + #[serde(default)] + pub deny_set_capacity: Option, } /// Token-class discriminant in the issuer config JSON. @@ -107,6 +121,9 @@ pub struct NipFiRelayConfig { /// 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, + /// Per-issuer S4 command entries: `(issuer_uri, CommandIssuerEnvConfig)`. + /// Empty when mode is Off/DenyProtected. + pub command_configs: Vec<(String, crate::api::nip_fi::CommandIssuerEnvConfig)>, } impl NipFiRelayConfig { @@ -123,6 +140,7 @@ impl NipFiRelayConfig { registry: IssuerRegistry::new(), jwks_configs: Vec::new(), max_connection_lifetime_secs: 0, + command_configs: Vec::new(), }); } @@ -181,6 +199,7 @@ impl NipFiRelayConfig { let mut registry = IssuerRegistry::new(); let mut jwks_configs = Vec::with_capacity(issuer_entries.len()); + let mut command_configs = Vec::new(); for (issuer_idx, entry) in issuer_entries.iter().enumerate() { let (policy, jwks_config) = build_issuer(entry).map_err(|e| { @@ -192,6 +211,83 @@ impl NipFiRelayConfig { })?; registry.insert(policy); jwks_configs.push(jwks_config); + + // Extract S4 command fields if present. + if let Some(cmd_age) = entry.maximum_command_age_seconds { + let principals = entry.authorized_principals.clone().unwrap_or_default(); + // Malformed S4 fields in enforce mode must reject startup. + if principals.is_empty() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + maximum_command_age_seconds is set but authorized_principals is \ + absent or empty — command API requires at least one authorized principal" + ))); + } + if cmd_age == 0 || cmd_age > 60 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + maximum_command_age_seconds must be in [1, 60]; got {cmd_age}" + ))); + } + let capacity = entry + .deny_set_capacity + .unwrap_or(crate::api::nip_fi::DEFAULT_DENY_SET_CAPACITY); + if capacity == 0 { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + deny_set_capacity must be positive (non-zero)" + ))); + } + // Validate that CommandIssuerPolicy can be constructed — this is the + // same gate the builder uses, so a startup rejection here is tight. + crate::api::nip_fi::validate_command_issuer_config( + issuer_idx, + cmd_age, + &principals, + capacity, + ) + .map_err(ConfigError::InvalidValue)?; + command_configs.push(( + entry.issuer.clone(), + crate::api::nip_fi::CommandIssuerEnvConfig { + maximum_command_age_seconds: Some(cmd_age), + authorized_principals: Some(principals), + deny_set_capacity: entry.deny_set_capacity, + }, + )); + } else { + // No maximum_command_age_seconds: in enforce mode every issuer MUST be + // command-capable (NIP-FI.md:405-409 requires maximum_command_age per + // authorized issuer). An enforce issuer without command fields would + // silently produce an empty command_configs and a permanently-503 + // endpoint — reject it at startup. + // + // Orphan S4 fields are detected first to give the operator precise + // error feedback before the all-or-nothing rejection fires. + if entry.authorized_principals.is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + authorized_principals is set but maximum_command_age_seconds is absent — \ + S4 command API requires maximum_command_age_seconds" + ))); + } + if entry.deny_set_capacity.is_some() { + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + deny_set_capacity is set but maximum_command_age_seconds is absent — \ + S4 command API requires maximum_command_age_seconds" + ))); + } + // No orphan fields: reject because enforce mode requires every issuer + // to be command-capable (NIP-FI.md:405-409). + return Err(ConfigError::InvalidValue(format!( + "BUZZ_NIP_FI_ISSUERS: issuer [index {issuer_idx}]: \ + maximum_command_age_seconds is required in enforce mode — \ + every configured issuer must be command-capable. \ + Add maximum_command_age_seconds and authorized_principals, \ + or remove this issuer from BUZZ_NIP_FI_ISSUERS" + ))); + } } // Delegate final validation to buzz-auth startup gate. @@ -204,6 +300,7 @@ impl NipFiRelayConfig { registry, jwks_configs, max_connection_lifetime_secs, + command_configs, }) } } @@ -477,7 +574,9 @@ mod tests { "maximum_assertion_age_seconds": 3600, "jwks_uri": "https://issuer.test/.well-known/jwks.json", "jwks_refresh_interval_seconds": 300, - "jwks_hard_deadline_seconds": 3600 + "jwks_hard_deadline_seconds": 3600, + "maximum_command_age_seconds": 30, + "authorized_principals": ["admin@issuer.test"] }) } @@ -796,4 +895,196 @@ mod tests { "a deadline in the future must not be expired" ); } + + // ── NIP-FI S4 deny witnesses ── + #[test] + fn config_error_does_not_expose_sensitive_principal_value() { + let _guard = super::NIP_FI_ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::new(NIP_FI_VARS); + + // A syntactically broken JSON object that contains a sensitive sentinel + // where authorized_principals would be. The `INVALID_TYPE_HERE` string + // is not valid JSON for the Vec field — serde will produce a + // type error that in a naive `{e}` interpolation would include the raw + // string, potentially exposing the surrounding value. + const SENTINEL: &str = "admin+private-sentinel@example.invalid"; + + std::env::set_var("BUZZ_NIP_FI_MODE", "enforce"); + std::env::set_var("BUZZ_NIP_FI_MAX_CONNECTION_LIFETIME_SECS", "3600"); + // The authorized_principals field is a string instead of an array, + // which causes serde to emit a type-error that typically includes the + // supplied value when formatted with `{e}` (the bug we are guarding). + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + format!( + r#"[{{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30, + "authorized_principals": "{SENTINEL}" + }}]"# + ), + ); + + let err = NipFiRelayConfig::from_env().expect_err("malformed issuers must fail"); + let display_msg = err.to_string(); + let debug_msg = format!("{err:?}"); + + // Safe category must be present. + assert!( + display_msg.contains("BUZZ_NIP_FI_ISSUERS is not valid JSON"), + "Display message must contain the safe category string: {display_msg}" + ); + // Sentinel must NOT appear in any user-facing output path. + assert!( + !display_msg.contains(SENTINEL), + "Display message must NOT contain the sensitive sentinel: {display_msg}" + ); + assert!( + !debug_msg.contains(SENTINEL), + "Debug output must NOT contain the sensitive sentinel: {debug_msg}" + ); + } + + #[test] + fn enforce_command_age_without_principals_fails_closed() { + let _guard = super::NIP_FI_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"); + // issuer has command age but no principals + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "maximum_command_age_seconds": 30 + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("command age without principals must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("authorized_principals"), + "error names the missing field: {msg}" + ); + } + + #[test] + fn enforce_issuer_without_command_fields_is_rejected() { + // An enforce-mode issuer entry with ALL THREE S4 fields absent must + // fail startup. This is the blocker-4a case: the issuer is a valid + // JWKS/assertion issuer but carries no command config. Without this + // rejection from_env() would succeed with an empty command_configs, + // the endpoint would permanently return 503, and startup would log nothing. + let _guard = super::NIP_FI_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"); + // All three S4 command fields absent — pure assertion/JWKS issuer. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400 + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("enforce issuer without command fields must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("maximum_command_age_seconds"), + "error must name the missing field: {msg}" + ); + } + + #[test] + fn orphan_authorized_principals_without_command_age_fails_closed() { + let _guard = super::NIP_FI_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"); + // authorized_principals without maximum_command_age_seconds — orphan field. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "authorized_principals": ["admin@idp.example.com"] + }]"#, + ); + let err = NipFiRelayConfig::from_env() + .expect_err("orphan authorized_principals must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("authorized_principals"), + "error names the orphan field: {msg}" + ); + assert!( + msg.contains("maximum_command_age_seconds"), + "error names the missing dependency: {msg}" + ); + } + + #[test] + fn orphan_deny_set_capacity_without_command_age_fails_closed() { + let _guard = super::NIP_FI_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"); + // deny_set_capacity without maximum_command_age_seconds — orphan field. + std::env::set_var( + "BUZZ_NIP_FI_ISSUERS", + r#"[{ + "issuer": "https://idp.example.com", + "audiences": ["https://relay.example.com"], + "token_class": "nip-fi+jwt", + "algorithms": ["ES256"], + "maximum_assertion_age_seconds": 3600, + "jwks_uri": "https://idp.example.com/.well-known/jwks.json", + "jwks_refresh_interval_seconds": 300, + "jwks_hard_deadline_seconds": 86400, + "deny_set_capacity": 1000 + }]"#, + ); + let err = + NipFiRelayConfig::from_env().expect_err("orphan deny_set_capacity must fail closed"); + let msg = err.to_string(); + assert!( + msg.contains("deny_set_capacity"), + "error names the orphan field: {msg}" + ); + assert!( + msg.contains("maximum_command_age_seconds"), + "error names the missing dependency: {msg}" + ); + } } diff --git a/crates/buzz-relay/src/nip_fi_http.rs b/crates/buzz-relay/src/nip_fi_http.rs index cd74a2d28c2..75b301480fe 100644 --- a/crates/buzz-relay/src/nip_fi_http.rs +++ b/crates/buzz-relay/src/nip_fi_http.rs @@ -35,10 +35,10 @@ //! //! ## 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. +//! HTTP admission reads the relay's single shared [`buzz_auth::NipFiDenyMap`] +//! (`AppState::nip_fi_deny_map`) — the same `Arc` the admin disconnect endpoint +//! writes and WebSocket admission reads — so one deny is enforced on every +//! ingress. //! //! ## Off-mode regression //! @@ -63,14 +63,10 @@ use chrono::{DateTime, Utc}; use nostr::PublicKey; use std::fmt; -// ── Deny-map seam (S4 stub) ─────────────────────────────────────────────────── +// ── Deny-map seam ───────────────────────────────────────────────────────────── -/// 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. +/// Narrow interface consumed by HTTP enforcement. Production implements it +/// for the shared [`buzz_auth::NipFiDenyMap`]; tests may substitute fixtures. /// /// `(issuer, pubkey, now)` are required because the deny set is issuer- /// scoped per `NIP-FI.md:624-627`. Passing only pubkey would collide @@ -79,11 +75,8 @@ use std::fmt; /// 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. + /// `now` (`now < until`). Implementations fail closed: a poisoned or + /// unavailable backing store returns `true` (deny). fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool; } @@ -91,16 +84,29 @@ pub(crate) mod sealed { pub(crate) trait Sealed {} } -/// Stub deny map that always admits. Used until S4 provides the real map. +impl sealed::Sealed for buzz_auth::NipFiDenyMap {} +impl HttpDenyMap for buzz_auth::NipFiDenyMap { + /// Delegates to [`buzz_auth::NipFiDenyMap::is_denied`], which is issuer- + /// scoped and returns `true` on a poisoned shard. + fn is_denied(&self, issuer: &str, pubkey: &PublicKey, now: DateTime) -> bool { + buzz_auth::NipFiDenyMap::is_denied(self, issuer, pubkey, now) + } +} + +/// The deny map for a relay that has no `nip_fi_deny_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). +/// A serving `Enforce` relay always has the map: validated config requires a +/// command-capable issuer for every enforce issuer, and startup installs the +/// map (with the command verifier) before the router is built, aborting on +/// failure. `Off` bypasses the map and `DenyProtected` rejects before it is +/// consulted, so `None` is reached only in `Off`, before install, or in +/// hand-built test states. Deny entries are written solely into that map, by +/// the admin disconnect endpoint and the cross-pod consumer; startup installs +/// the map and the command verifier together, so no writer exists without it. +/// No entry can exist, and admitting is exact, not fail-open. +struct NoDenyMapConfigured; +impl sealed::Sealed for NoDenyMapConfigured {} +impl HttpDenyMap for NoDenyMapConfigured { fn is_denied(&self, _issuer: &str, _pubkey: &PublicKey, _now: DateTime) -> bool { false } @@ -456,13 +462,12 @@ pub(crate) fn http_denial(class: DenialClass) -> Response { // ── State-convenience wrapper ───────────────────────────────────────────────── -/// Convenience wrapper: pull mode + verifier from `AppState` and call -/// [`admit_nip_fi_http`]. +/// Convenience wrapper: pull mode, verifier, and the shared deny map 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. +/// returns `(proven_pubkey, X)`. The deny map is `state.nip_fi_deny_map` — +/// the same `Arc` the disconnect endpoint writes and WS admission reads. /// /// This is the single entry-point every NIP-FI-protected surface calls. It /// delegates to [`admit_nip_fi_http`], which alone constructs a @@ -481,13 +486,10 @@ where { 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, - ) + match state.nip_fi_deny_map.as_deref() { + Some(deny_map) => admit_nip_fi_http(headers, extract_nip98, verifier, mode, deny_map), + None => admit_nip_fi_http(headers, extract_nip98, verifier, mode, &NoDenyMapConfigured), + } } // ── Tests ───────────────────────────────────────────────────────────────────── @@ -498,6 +500,15 @@ mod tests { // throughout this module — it IS the HTTP response returned from tests. #![allow(clippy::result_large_err)] use super::*; + + /// Test fixture: a deny map with no entries. + struct AlwaysAdmitStubDenyMap; + impl sealed::Sealed for AlwaysAdmitStubDenyMap {} + impl HttpDenyMap for AlwaysAdmitStubDenyMap { + fn is_denied(&self, _issuer: &str, _pubkey: &PublicKey, _now: DateTime) -> bool { + false + } + } use axum::http::HeaderValue; use buzz_auth::{NipFiMode, VerifyAssertion}; use chrono::Utc; @@ -1134,23 +1145,6 @@ mod tests { } } - // ── 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 diff --git a/crates/buzz-relay/src/nip_fi_session.rs b/crates/buzz-relay/src/nip_fi_session.rs index b89e348470c..64a2ab5904c 100644 --- a/crates/buzz-relay/src/nip_fi_session.rs +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -298,6 +298,9 @@ mod tests { 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. diff --git a/crates/buzz-relay/src/nip_fi_test_hooks.rs b/crates/buzz-relay/src/nip_fi_test_hooks.rs index fd09a85dfd1..67296405926 100644 --- a/crates/buzz-relay/src/nip_fi_test_hooks.rs +++ b/crates/buzz-relay/src/nip_fi_test_hooks.rs @@ -219,6 +219,19 @@ make_hook!(directory_acquire_hook, after_directory_acquire); // serialized against the join commit. make_hook!(audio_archive_recheck_hook, before_archive_recheck); +// ── Deny-set admission hooks ─────────────────────────────────────────────── +// `before_deny_set_check`: fires in both the root WS auth handler and the audio +// handler after the proven identity is registered and before `is_denied`, so a +// straddle witness can insert a deny entry in that window. [FI-TRACE-DENY-SET] +make_hook!(deny_set_check_hook, before_deny_set_check); + +// `after_deny_set_check_passed`: fires in the audio handler after the deny-set +// check completes without denying. Used by `w_audio_deny_absent`. +make_hook!( + audio_after_deny_check_passed_hook, + after_deny_set_check_passed +); + // ── Publication-attempt counter ──────────────────────────────────────────── // `before_event_publish`: fires immediately before `state.pubsub.publish_event` // in `dispatch_persistent_event_inner`. Used by W2: after handle_event returns diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index a4a812fa491..f0707354862 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -130,6 +130,12 @@ const NIP_FI_EXEMPT_PREFIXES: &[&str] = &[ "/operator/", // Admin SPA backend — operator-credential gated; subtree "/api/admin/", + // NIP-FI admin disconnect — authenticated by its own command JWT + // (`nip-fi-command+jwt` in the same header), which the assertion verifier + // would reject. No trailing slash, so the matcher exempts the exact path + // and its subtree (not `/api/nip-fi/disconnect-extra`); sub-paths are + // harmless since no routes exist beneath it. + "/api/nip-fi/disconnect", // Static assets served by the SPA fallback; subtree "/assets/", "/favicon.svg", @@ -360,6 +366,9 @@ pub fn build_router(state: Arc) -> Router { post(api::invites::accept_policy), ) .route("/api/invites/claim", post(api::invites::claim_invite)) + // NIP-FI admin command API — authenticated by signed command JWT, + // NOT by NIP-98. Self-contained auth inside the handler. + .route("/api/nip-fi/disconnect", post(api::nip_fi::disconnect)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) @@ -641,6 +650,33 @@ async fn nip11_or_ws_handler( } }; + // S4 deny-map early-bounce check: runs after assertion validation, before bind_community. + // + // This is an OPTIMIZATION (early HTTP bounce), not the correctness mechanism. + // Correctness is enforced in step 6 of the spec (NIP-FI.md:217-233): + // NIP-42 proof → key equality → register proven k → deny check → admit. + // That normative sequence runs in handlers/auth.rs after set_authenticated_pubkey. + // + // This pre-upgrade check provides a cheap bounce for keys already in the deny + // map before the connection is upgraded — pays zero DB cost and rejects before + // tungstenite hands the socket to the application. It is NOT race-free against + // a concurrent disconnect (the session isn't registered yet), which is why the + // normative post-registration check in auth.rs is the correctness gate. + // + // Off-mode: `nip_fi_deny_map` is `None` → the entire block is a no-op; + // `asserted_key` is `None` → no key to check → pass through. + // [FI-TRACE-DENY-SET] + if let Some(assertion) = &nip_fi_assertion { + if let Some(key) = assertion.asserted_key() { + if let Some(deny_map) = state.nip_fi_deny_map.as_deref() { + if deny_map.is_denied(assertion.identity().issuer(), &key, chrono::Utc::now()) { + return http_denial(buzz_auth::DenialClass::AuthorizationDenied) + .into_response(); + } + } + } + } + // Row zero: bind the connection to its community from the request host // BEFORE the WebSocket upgrade, so no frame is ever read on an unbound // connection. The host is the authoritative selector; an unmapped host or a @@ -1995,6 +2031,7 @@ mod tests { registry: IssuerRegistry::new(), jwks_configs: vec![], max_connection_lifetime_secs: 3600, + command_configs: Vec::new(), }; // Unreachable database: port 1 refuses every connection, so each @@ -3148,4 +3185,288 @@ mod tests { "/internal/other must NOT be exempt (no /internal/ subtree entry)" ); } + + // ── NIP-FI S4 deny witnesses ── + // ES256 key pair — same as command.rs / api/nip_fi.rs test material. + const DENY_TEST_PRIVATE_KEY_PEM: &str = + "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgcnxDM4EiirH9dHUE\nWZc759TX4s5PAn8kO5ovXSnGxCWhRANCAARFb6ZnsfkqOOXyEhj3KBQphGKF4vTa\nzhebbavbZ1ZoklqkF1cGg+jTO7rONAVEzXvXUWtV6CdDV+rybiVmFP2w\n-----END PRIVATE KEY-----\n"; + + const DENY_TEST_ISS: &str = "https://nip-fi-deny-test.example.com"; + const DENY_TEST_AUD: &str = "https://relay.example"; + const DENY_TEST_KID: &str = "deny-test-key-1"; + + fn deny_test_public_jwk() -> jsonwebtoken::jwk::Jwk { + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": "RW-mZ7H5Kjjl8hIY9ygUKYRiheL02s4Xm22r22dWaJI", + "y": "WqQXVwaD6NM7us40BUTNe9dRa1XoJ0NX6vJuJWYU_bA", + "alg": "ES256", + "use": "sig", + "kid": DENY_TEST_KID + })) + .expect("valid deny-test JWK") + } + + /// Mint a valid ES256 `nip-fi+jwt` assertion for `nostr_pubkey = key_hex`, + /// signed by the deny-test key pair. + fn mint_deny_test_token(key_hex: &str) -> String { + use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + let now = chrono::Utc::now().timestamp(); + let claims = serde_json::json!({ + "iss": DENY_TEST_ISS, + "aud": DENY_TEST_AUD, + "sub": "test-subject", + "iat": now, + "exp": now + 600, + "nostr_pubkey": key_hex, + }); + let mut header = Header::new(Algorithm::ES256); + header.typ = Some("nip-fi+jwt".to_owned()); + header.kid = Some(DENY_TEST_KID.to_owned()); + let key = EncodingKey::from_ec_pem(DENY_TEST_PRIVATE_KEY_PEM.as_bytes()) + .expect("valid test EC key"); + encode(&header, &claims, &key).expect("sign deny-test token") + } + + /// Build an AppState with a seeded NIP-FI assertion verifier (`Enforce` + /// mode, test issuer) and a populated deny map containing `denied_key`. + async fn nip_fi_deny_state(denied_key: &nostr::PublicKey) -> Arc { + use crate::nip_fi_config::NipFiRelayConfig; + use buzz_auth::{ + FederatedAssertionVerifier, FreshnessClass, HttpJwksFetcher, IssuerCapacity, + IssuerRegistry, JwksSourceContract, NipFiDenyMap, NipFiMode, ProductionJwksSource, + TokenClass, + }; + + // Build config in enforce mode. + let mut config = crate::config::Config::for_test(); + config.require_relay_membership = false; + + let jwks_contract = + JwksSourceContract::new(format!("{DENY_TEST_ISS}/.well-known/jwks.json"), 300, 86400) + .expect("valid JWKS contract"); + let issuer_policy = buzz_auth::IssuerPolicy::new( + DENY_TEST_ISS.to_owned(), + vec![DENY_TEST_AUD.to_owned()], + TokenClass::DedicatedNipFi, + FreshnessClass::OfflineJwt, + vec![jsonwebtoken::Algorithm::ES256], + 30, + 3600, + None, + jwks_contract.clone(), + ) + .expect("valid test issuer policy"); + + let jwks_config = buzz_auth::IssuerJwksConfig { + issuer: DENY_TEST_ISS.to_owned(), + contract: jwks_contract, + }; + + config.nip_fi = NipFiRelayConfig { + mode: NipFiMode::Enforce, + registry: { + let mut r = IssuerRegistry::new(); + r.insert(issuer_policy); + r + }, + jwks_configs: vec![jwks_config], + command_configs: vec![], + max_connection_lifetime_secs: 3600, + }; + + // 100ms acquire timeout: the port-1 stub must fail fast instead of + // waiting out sqlx's 30s default, keeping the unit lane quick. + let pool = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(100)) + .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 (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + + // Wire the NIP-FI assertion verifier with a seeded JWKS snapshot so the + // full JWT pipeline runs without any HTTP call. The seeded JWKS contains + // the test public key that signs tokens in `mint_deny_test_token`. + let jwks = jsonwebtoken::jwk::JwkSet { + keys: vec![deny_test_public_jwk()], + }; + let key_source = Arc::new( + ProductionJwksSource::new( + vec![buzz_auth::IssuerJwksConfig { + issuer: DENY_TEST_ISS.to_owned(), + contract: buzz_auth::JwksSourceContract::new( + format!("{DENY_TEST_ISS}/.well-known/jwks.json"), + 300, + 86400, + ) + .expect("valid contract"), + }], + HttpJwksFetcher::new(), + ) + .expect("key source"), + ); + key_source.seed_snapshot_for_test(DENY_TEST_ISS, jwks).await; + let verifier = Arc::new(FederatedAssertionVerifier::new( + state.config.nip_fi.registry.clone(), + Arc::clone(&key_source), + )); + state.nip_fi_verifier = Some(verifier); + state.nip_fi_jwks_source = Some(Arc::clone(&key_source)); + + // Populate the deny map with a live entry for the denied key. + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + vec![IssuerCapacity { + issuer: DENY_TEST_ISS.to_owned(), + capacity: 16, + }], + )); + let until = chrono::Utc::now() + chrono::Duration::seconds(3600); + let merge_result = + deny_map.merge_cross_pod_deny(DENY_TEST_ISS, denied_key, until, chrono::Utc::now()); + assert!( + matches!(merge_result, buzz_auth::CrossPodMergeResult::Merged), + "deny entry must be inserted for test setup" + ); + state.nip_fi_deny_map = Some(deny_map); + + Arc::new(state) + } + + /// Drive a request through the real built router. Returns the full response. + async fn nip_fi_gate_response( + state: Arc, + path: &str, + extra_header_name: Option<&str>, + extra_header_value: Option<&str>, + ) -> axum::response::Response { + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + let mut builder = Request::get(path) + .header(axum::http::header::HOST, "relay.example") + // WebSocket upgrade headers so axum's WebSocketUpgrade extractor + // doesn't reject with 400/426 before the handler body runs. + .header("Upgrade", "websocket") + .header("Connection", "Upgrade") + .header("Sec-WebSocket-Version", "13") + .header("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="); + if let (Some(name), Some(value)) = (extra_header_name, extra_header_value) { + builder = builder.header(name, value); + } + let req = builder.body(Body::empty()).expect("request"); + build_router(state) + .oneshot(req) + .await + .expect("router response") + } + + #[tokio::test] + async fn deny_map_admits_key_not_in_map() { + // A key NOT in the deny map passes the check. This proves the Off-path (no deny entry → pass through) and guards + // against an inverted condition. + let clean_key = nostr::Keys::generate().public_key(); + // Build state with a DIFFERENT denied key so clean_key is not in the map. + let other_key = nostr::Keys::generate().public_key(); + let state = nip_fi_deny_state(&other_key).await; + let token = mint_deny_test_token(&clean_key.to_hex()); + let bearer = format!("Bearer {token}"); + + let status = + nip_fi_gate_status(state, "/", Some("Nostr-Federated-Identity"), Some(&bearer)).await; + + // The key is not denied: the pre-101 gate admits it. What happens past + // the gate depends on host routing, so assert only that no NIP-FI + // refusal status came back. A 403 means the deny check fired for a + // non-denied key. + assert!( + !matches!( + status, + axum::http::StatusCode::UNAUTHORIZED + | axum::http::StatusCode::FORBIDDEN + | axum::http::StatusCode::SERVICE_UNAVAILABLE + ), + "WS admission for a key NOT in the deny map must pass the NIP-FI gate; got {status}" + ); + } + + #[tokio::test] + async fn deny_map_blocks_ws_admission_for_live_entry() { + // A key with a live deny entry is refused 403 `authorization_denied` + // at WS admission even when the bearer JWT is otherwise valid. + // + // Full wire contract assertion: status 403, Content-Type text/plain, + // exact body "authorization denied\n", no WWW-Authenticate header. + // This distinguishes AuthorizationDenied from EvidenceRejected (also 403) + // and from AuthorizationUnavailable (503). [FI-TRACE-DENIAL-ORACLE] + // + // Mutation evidence (A–C in build comments above): + // A) Delete the deny-map check → 404 not 403 → status assert panics. + // B) Use DenialClass::EvidenceRejected → body is "evidence rejected\n" + // → body assert panics. + // C) Remove nip_fi_deny_map from state → map None → 404 → status panics. + let denied_key = nostr::Keys::generate().public_key(); + let state = nip_fi_deny_state(&denied_key).await; + let token = mint_deny_test_token(&denied_key.to_hex()); + let bearer = format!("Bearer {token}"); + + let resp = + nip_fi_gate_response(state, "/", Some("Nostr-Federated-Identity"), Some(&bearer)).await; + + assert_eq!( + resp.status(), + axum::http::StatusCode::FORBIDDEN, + "WS admission for a key with a live deny entry must be refused 403 \ + authorization_denied [FI-TRACE-DENY-SET]" + ); + assert_eq!( + resp.headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "authorization_denied response must carry text/plain; charset=utf-8" + ); + assert!( + resp.headers().get("WWW-Authenticate").is_none(), + "authorization_denied must NOT carry WWW-Authenticate (that is MissingEvidence only)" + ); + let body = axum::body::to_bytes(resp.into_body(), 64) + .await + .expect("body bytes"); + assert_eq!( + body.as_ref(), + b"authorization denied\n", + "authorization_denied wire body must be exactly 'authorization denied\\n' \ + [FI-TRACE-DENIAL-ORACLE]" + ); + } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 66a2e37b018..009f9d706e8 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -44,6 +44,8 @@ pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CommunityDisconnectReason { CommunityDeleted, + /// NIP-FI: the connection's proven pubkey was added to the deny set. + AuthorizationDenied, } impl CommunityDisconnectReason { @@ -53,21 +55,49 @@ impl CommunityDisconnectReason { code: axum::extract::ws::close_code::POLICY, reason: WsUtf8Bytes::from_static("community deleted"), })), + Self::AuthorizationDenied => WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::POLICY, + reason: WsUtf8Bytes::from_static("authorization denied"), + })), } } } +/// NIP-42-proven key and the NIP-FI issuer a socket was admitted under. +#[derive(Clone)] +struct ProvenIdentity { + pubkey: Vec, + nip_fi_issuer: Option, +} + +impl ProvenIdentity { + fn matches(&self, issuer: &str, pubkey: &[u8]) -> bool { + self.pubkey == pubkey && self.nip_fi_issuer.as_deref() == Some(issuer) + } +} + /// Per-socket lifecycle controls shared by the registry and the writer. #[derive(Clone)] pub(crate) struct CommunityConnectionControl { cancel: CancellationToken, reason_tx: watch::Sender>, + /// NIP-42-proven pubkey plus the NIP-FI issuer the session was admitted + /// under; matched by the registry's `disconnect_nip_fi` scan. [FI-TRACE-DENY-SET] + proven_identity: Arc>>, + /// Serializes NIP-FI denial writers across reason-win + terminal enqueue, + /// and holds the audio terminal-frame sender (root connections leave it `None`). + terminal_frame_tx: Arc>>>, } impl CommunityConnectionControl { pub(crate) fn new(cancel: CancellationToken) -> Self { let (reason_tx, _reason_rx) = watch::channel(None); - Self { cancel, reason_tx } + Self { + cancel, + reason_tx, + proven_identity: Arc::new(std::sync::RwLock::new(None)), + terminal_frame_tx: Arc::new(std::sync::Mutex::new(None)), + } } pub(crate) fn cancellation_token(&self) -> CancellationToken { @@ -78,6 +108,111 @@ impl CommunityConnectionControl { self.reason_tx.subscribe() } + /// Records the NIP-42-proven pubkey and admitting NIP-FI issuer for this + /// connection so the registry can close it via `disconnect_nip_fi`. + pub(crate) fn set_proven_identity(&self, pubkey: Vec, nip_fi_issuer: Option) { + if let Ok(mut slot) = self.proven_identity.write() { + *slot = Some(ProvenIdentity { + pubkey, + nip_fi_issuer, + }); + } + } + + /// Registers the audio terminal-frame sender so `disconnect_nip_fi` can + /// enqueue the denial payload before cancelling. + /// + /// Called by `handle_active_audio_connection` immediately after the terminal + /// channel is created (before any `check_cancel!` or `send_loop`). The + /// sender is optional — root relay connections leave this unset and rely on + /// the separate `ctrl_tx` path in `ConnectionManager::disconnect_nip_fi`. + pub(crate) fn set_terminal_frame_sender(&self, tx: mpsc::Sender) { + let mut slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *slot = Some(tx); + } + + /// Terminal transition for the post-registration deny-set check in the + /// auth handler: under the transition lock, publishes `AuthorizationDenied` + /// first-writer-wins and enqueues the denial frame only if it won. Does + /// not cancel; the caller cancels after this returns. [FI-TRACE-DENY-SET] + pub(crate) fn auth_deny_terminal( + &self, + frame_tx: &mpsc::Sender, + route: crate::nip_fi_session::NipFiWsRoute, + ) { + let _lock = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::denial_frame( + route, + buzz_auth::DenialClass::AuthorizationDenied, + )); + } + } + + /// Denial transition for `ConnectionManager::disconnect_nip_fi`: same + /// winner-only enqueue as `auth_deny_terminal`, on the root connection's + /// `terminal_ctrl_tx` (drained first by `send_loop` on cancel), then cancels. + pub(crate) fn manager_disconnect_nip_fi(&self, frame_tx: &mpsc::Sender) { + let slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + if won { + let _ = frame_tx.try_send(crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + buzz_auth::DenialClass::AuthorizationDenied, + )); + } + drop(slot); + self.cancel.cancel(); + } + + /// Denial transition for registry sockets (audio): winner-only enqueue on + /// the sender registered by `set_terminal_frame_sender`, then cancel. + fn disconnect_nip_fi(&self) { + let slot = self + .terminal_frame_tx + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let won = self.reason_tx.send_if_modified(|current| match current { + None => { + *current = Some(CommunityDisconnectReason::AuthorizationDenied); + true + } + Some(_) => false, + }); + if won { + if let Some(ref tx) = *slot { + let _ = tx.try_send(crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + buzz_auth::DenialClass::AuthorizationDenied, + )); + } + } + drop(slot); + self.cancel.cancel(); + } + fn disconnect_community(&self) { self.reason_tx .send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); @@ -97,6 +232,9 @@ struct ConnEntry { /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). ctrl_tx: mpsc::Sender, + /// Dedicated one-slot sender for the terminal NIP-FI denial frame, drained + /// first by `send_loop` on cancel. + terminal_ctrl_tx: mpsc::Sender, restart_tx: Option>, cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the @@ -107,7 +245,13 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + /// NIP-FI issuer the session was admitted under. Written while holding + /// the `authenticated_pubkey` write lock and read under its read lock, so + /// `disconnect_nip_fi` never sees the pubkey without its issuer. + nip_fi_issuer: std::sync::RwLock>, grace_limit: u8, + /// Lifecycle control used by `disconnect_nip_fi` for the denial transition. + community_control: CommunityConnectionControl, } /// Community-scoped lifecycle registry shared by every long-lived socket type. @@ -161,6 +305,39 @@ impl CommunityConnectionRegistry { closed } + /// Disconnects every registered socket admitted under NIP-FI `issuer` whose + /// proven pubkey matches `pubkey`, across all communities. A same-key + /// socket admitted under a different issuer (or with no assertion) is not + /// matched: this issuer's deny entry cannot block it. [FI-TRACE-DENY-SET] + /// + /// Called by the admin disconnect route (`api/nip_fi.rs`) and the cross-pod + /// apply closure (`apply_nip_fi_disconnect`), each alongside + /// `ConnectionManager::disconnect_nip_fi` for Nostr relay connections. + /// A match fires `AuthorizationDenied`, which the send loop turns into a 1008 + /// close frame before the socket shuts down. Sockets not yet registered with + /// a proven identity are not matched; admission registers the identity first + /// and then runs the deny-set check, so such a socket is rejected there. + /// + /// Returns the number of connections closed. + pub fn disconnect_nip_fi(&self, issuer: &str, pubkey: &[u8]) -> usize { + let mut closed = 0; + for entry in self.connections.iter() { + let matches = entry + .value() + .1 + .proven_identity + .read() + .ok() + .and_then(|v| v.as_ref().map(|id| id.matches(issuer, pubkey))) + .unwrap_or(false); + if matches { + entry.value().1.disconnect_nip_fi(); + closed += 1; + } + } + closed + } + /// Returns the distinct communities with live sockets on this pod. pub fn bound_communities(&self) -> HashSet { self.connections @@ -411,12 +588,14 @@ impl ConnectionManager { conn_id: Uuid, tx: mpsc::Sender, ctrl_tx: mpsc::Sender, + terminal_ctrl_tx: mpsc::Sender, restart_tx: Option>, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, subscriptions: ConnectionSubscriptions, grace_limit: u8, + community_control: CommunityConnectionControl, ) { let drain_ctrl_tx = ctrl_tx.clone(); let drain_cancel = cancel.clone(); @@ -425,13 +604,16 @@ impl ConnectionManager { ConnEntry { tx, ctrl_tx, + terminal_ctrl_tx, restart_tx, cancel, community_id, backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + nip_fi_issuer: std::sync::RwLock::new(None), grace_limit, + community_control, }, ); // Insert-then-check pairs with drain_all's store-then-iterate: either @@ -455,8 +637,24 @@ impl ConnectionManager { /// Record the authenticated pubkey for a connection after NIP-42 succeeds. pub fn set_authenticated_pubkey(&self, conn_id: Uuid, pubkey_bytes: Vec) { + self.set_authenticated_identity(conn_id, pubkey_bytes, None); + } + + /// Record the authenticated pubkey and the NIP-FI issuer it was admitted + /// under as one unit: the issuer is written while the pubkey write lock is + /// held, so a concurrent `disconnect_nip_fi` scan that sees the pubkey also + /// sees its issuer. [FI-TRACE-DENY-SET] + pub(crate) fn set_authenticated_identity( + &self, + conn_id: Uuid, + pubkey_bytes: Vec, + nip_fi_issuer: Option, + ) { if let Some(entry) = self.connections.get(&conn_id) { if let Ok(mut slot) = entry.authenticated_pubkey.write() { + if let Ok(mut issuer_slot) = entry.nip_fi_issuer.write() { + *issuer_slot = nip_fi_issuer; + } *slot = Some(pubkey_bytes); } } @@ -551,6 +749,47 @@ impl ConnectionManager { closed } + /// Close all live connections admitted under NIP-FI `issuer` whose proven + /// pubkey equals `pubkey`, **across all communities**. + /// + /// Used by the NIP-FI admin disconnect API: the deny entry is keyed by + /// `(issuer, pubkey)` and spans every community this relay serves under + /// that issuer, so the scan is issuer-scoped but not community-fenced. A + /// same-key connection admitted under a different issuer (or with no + /// assertion) is never matched. [FI-TRACE-DENY-SET] + /// + /// Enqueues the `authorization_denied` NOTICE on the dedicated + /// `terminal_ctrl_tx` channel before cancelling; the send loop drains that + /// channel first on cancel, so the denial is delivered even when the + /// ordinary control buffer is full. + /// + /// Returns the number of connections closed. + pub fn disconnect_nip_fi(&self, issuer: &str, pubkey: &[u8]) -> usize { + let mut closed = 0usize; + for entry in self.connections.iter() { + let matches = entry + .authenticated_pubkey + .read() + .ok() + .map(|stored| { + stored.as_deref() == Some(pubkey) + && entry + .nip_fi_issuer + .read() + .is_ok_and(|iss| iss.as_deref() == Some(issuer)) + }) + .unwrap_or(false); + if matches { + // Winner-only enqueue on the terminal channel, then cancel. + entry + .community_control + .manager_disconnect_nip_fi(&entry.terminal_ctrl_tx); + closed += 1; + } + } + closed + } + /// Closes every live connection with a `1012 Service Restart` close frame. /// /// This is the original, all-at-once drain, retained as the default path @@ -962,6 +1201,21 @@ pub struct AppState { /// can warm it at startup and drive the background refresh loop. /// `None` iff `nip_fi_verifier` is `None`. pub nip_fi_jwks_source: Option>, + + // ── NIP-FI command API (S4) ──────────────────────────────────────────── + /// Shared in-memory deny set for NIP-FI. Absent when mode is `Off`. + /// + /// Written by the admin disconnect endpoint; read at WS admission (S4 item + /// 4) and HTTP admission (`nip_fi_http`): one `Arc`, never a second map. + pub nip_fi_deny_map: Option>, + + /// Command JWT verifier for the NIP-FI admin disconnect endpoint. + /// + /// `None` when mode is `Off` (no command API is reachable). When + /// `Some`, the verifier owns a reference to `nip_fi_deny_map` so the + /// atomic jti-reservation + deny-entry insertion happens inside `verify()`. + pub nip_fi_command_verifier: + Option>>>, } impl AppState { @@ -1145,6 +1399,12 @@ impl AppState { mesh: Arc::new(std::sync::OnceLock::new()), nip_fi_verifier, nip_fi_jwks_source, + // NIP-FI deny map and command verifier are initialized lazily by + // `build_nip_fi_command_components` in `api::nip_fi`, called from + // `main.rs` after startup validation. `None` is safe before that + // call: the endpoint returns 503 when the verifier is absent. + nip_fi_deny_map: None, + nip_fi_command_verifier: None, }; ( state, @@ -1677,16 +1937,393 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); (mgr, conn_id, rx, ctrl_rx, cancel, bp) } + // ── NIP-FI S4 disconnect/deny witnesses ── + #[test] + fn conn_manager_disconnect_nip_fi_ignores_unproven_connection() { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let pubkey = vec![0xabu8; 32]; + + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + mgr.register( + conn_id, + tx, + ctrl_tx, + mpsc::channel(1).0, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + control, + ); + // No set_authenticated_pubkey — simulates pre-NIP-42 state. + + let closed = mgr.disconnect_nip_fi("test-issuer", &pubkey); + + assert_eq!(closed, 0, "unproven connection must not be closed"); + assert!(!cancel.is_cancelled(), "unproven connection must stay live"); + assert_eq!( + *reason_rx.borrow(), + None, + "reason must remain None for untouched connection", + ); + } + + // ── Issuer scope: a deny entry keyed (A, K) closes only sessions admitted + // under A. A same-key session admitted under B (or with no assertion) is + // untouched — no cancel, no frame. [FI-TRACE-DENY-SET] + #[test] + fn conn_manager_disconnect_nip_fi_is_issuer_scoped() { + let mgr = ConnectionManager::new(); + let key = vec![0xabu8; 32]; + let register = |issuer: Option<&str>| { + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + terminal_ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + CommunityConnectionControl::new(cancel.clone()), + ); + mgr.set_authenticated_identity(conn_id, key.clone(), issuer.map(str::to_owned)); + (cancel, terminal_ctrl_rx) + }; + let (cancel_a, mut frames_a) = register(Some("https://issuer-a.example")); + let (cancel_b, mut frames_b) = register(Some("https://issuer-b.example")); + let (cancel_none, mut frames_none) = register(None); + + assert_eq!(mgr.disconnect_nip_fi("https://issuer-a.example", &key), 1); + + assert!(cancel_a.is_cancelled(), "A session must be closed"); + assert!( + frames_a.try_recv().is_ok(), + "A session must receive the denial frame" + ); + assert!(!cancel_b.is_cancelled(), "B session must survive an A deny"); + assert!( + frames_b.try_recv().is_err(), + "B session must not get a frame" + ); + assert!( + !cancel_none.is_cancelled(), + "no-assertion session must survive" + ); + assert!( + frames_none.try_recv().is_err(), + "no-assertion session must not get a frame" + ); + } + + // ── F10: ConnectionManager::disconnect_nip_fi sets AuthorizationDenied ──── + // + // When the deny-API closes an active root-WS connection via + // `disconnect_nip_fi`, the `nip_fi_reason_tx` inside `CommunityConnectionControl` + // must be set to `AuthorizationDenied` before the cancellation fires. + // The send loop reads this reason via `disconnect_reason.borrow()` and + // emits a 1008 POLICY close frame instead of a bare Close(None). + // [FI-TRACE-CLOSE-CODE] + #[test] + fn conn_manager_disconnect_nip_fi_sets_authorization_denied_reason() { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let pubkey = vec![0xabu8; 32]; + + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + mgr.register( + conn_id, + tx, + ctrl_tx, + terminal_ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + control, + ); + mgr.set_authenticated_identity(conn_id, pubkey.clone(), Some("test-issuer".to_owned())); + + let closed = mgr.disconnect_nip_fi("test-issuer", &pubkey); + + assert_eq!(closed, 1, "one matching connection must be closed"); + assert!(cancel.is_cancelled(), "connection token must be cancelled"); + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "reason must be AuthorizationDenied so the send loop emits 1008 POLICY", + ); + // Winner-only enqueue: the denial frame is enqueued on terminal_ctrl_tx. + let frame = terminal_ctrl_rx + .try_recv() + .expect("denial frame must be enqueued"); + let WsMessage::Text(text) = frame else { + panic!("expected Text frame, got {:?}", frame); + }; + assert!( + text.contains("authorization denied"), + "denial frame must contain 'authorization denied'; got: {text}" + ); + } + + #[test] + fn nip_fi_disconnect_audio_is_issuer_scoped() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xce)); + let key = vec![0x42u8; 32]; + let register = |issuer: Option<&str>| { + let cancel = CancellationToken::new(); + let (terminal_tx, terminal_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(key.clone(), issuer.map(str::to_owned)); + control.set_terminal_frame_sender(terminal_tx); + let guard = registry.register(Uuid::new_v4(), community, control); + (cancel, terminal_rx, guard) + }; + let (cancel_a, mut frames_a, _ga) = register(Some("https://issuer-a.example")); + let (cancel_b, mut frames_b, _gb) = register(Some("https://issuer-b.example")); + let (cancel_none, mut frames_none, _gn) = register(None); + + assert_eq!( + registry.disconnect_nip_fi("https://issuer-a.example", &key), + 1 + ); + + assert!(cancel_a.is_cancelled(), "A audio session must be closed"); + assert!( + frames_a.try_recv().is_ok(), + "A audio session must receive the denial frame" + ); + assert!( + !cancel_b.is_cancelled(), + "B audio session must survive an A deny" + ); + assert!( + frames_b.try_recv().is_err(), + "B audio session must not get a frame" + ); + assert!( + !cancel_none.is_cancelled(), + "no-assertion audio session must survive" + ); + assert!( + frames_none.try_recv().is_err(), + "no-assertion audio session must not get a frame" + ); + } + + #[test] + fn nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason() { + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xca)); + let target_pubkey = vec![0x42u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); + assert!(cancel.is_cancelled(), "audio socket must be cancelled"); + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "close reason must be AuthorizationDenied so send_loop sends 1008" + ); + } + + #[test] + fn nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer() { + // Two audio sockets in the same community: only the target's is closed. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcd)); + let target_pubkey = vec![0x42u8; 32]; + let peer_pubkey = vec![0x55u8; 32]; + + let target_cancel = CancellationToken::new(); + let target_control = CommunityConnectionControl::new(target_cancel.clone()); + target_control.set_proven_identity(target_pubkey.clone(), Some("test-issuer".to_owned())); + let _target_guard = registry.register(Uuid::new_v4(), community, target_control); + + let peer_cancel = CancellationToken::new(); + let peer_control = CommunityConnectionControl::new(peer_cancel.clone()); + peer_control.set_proven_identity(peer_pubkey, Some("test-issuer".to_owned())); + let _peer_guard = registry.register(Uuid::new_v4(), community, peer_control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 1); + assert!( + target_cancel.is_cancelled(), + "target audio socket must be cancelled" + ); + assert!( + !peer_cancel.is_cancelled(), + "collocated peer must remain connected" + ); + } + + #[test] + fn nip_fi_disconnect_does_not_close_different_pubkey_audio_socket() { + // A socket whose proven pubkey is different from the target must not + // be closed — the scan must be key-exact. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcc)); + let target_pubkey = vec![0x42u8; 32]; + let other_pubkey = vec![0x99u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_proven_identity(other_pubkey, Some("test-issuer".to_owned())); + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 0); + assert!( + !cancel.is_cancelled(), + "different-key socket must not be touched" + ); + } + + #[test] + fn nip_fi_disconnect_does_not_close_unproven_audio_socket() { + // A socket that registered but has not yet completed NIP-42 auth (no + // proven pubkey) must not be touched by a targeted disconnect. + let registry = CommunityConnectionRegistry::new(); + let community = CommunityId::from_uuid(Uuid::from_u128(0xcb)); + let target_pubkey = vec![0x42u8; 32]; + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + // Intentionally skip set_proven_pubkey — simulates pre-auth state. + let _guard = registry.register(Uuid::new_v4(), community, control); + + assert_eq!(registry.disconnect_nip_fi("test-issuer", &target_pubkey), 0); + assert!( + !cancel.is_cancelled(), + "pre-auth socket must not be touched" + ); + } + + #[test] + fn community_disconnect_then_nip_fi_keeps_community_deleted_reason() { + // CommunityDeleted fires first, AuthorizationDenied arrives second. + // The slot must retain CommunityDeleted. + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + let reason_rx = control.disconnect_reason(); + + // First writer: CommunityDeleted (via disconnect_community). + control.disconnect_community(); + // Second writer: AuthorizationDenied — must be ignored (via disconnect_nip_fi). + control.disconnect_nip_fi(); + + assert_eq!( + *reason_rx.borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted (first writer) must not be clobbered by AuthorizationDenied" + ); + } + + #[test] + fn disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame() { + // disconnect_community fires first → wins reason → no payload (community-deleted + // path is intentionally payload-less). + // disconnect_nip_fi fires second → loses reason → must NOT enqueue a denial + // frame against the CommunityDeleted close. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + control.set_terminal_frame_sender(terminal_tx); + + // First writer: disconnect_community. + control.disconnect_community(); + // Second writer: disconnect_nip_fi — loses reason slot. + control.disconnect_nip_fi(); + + // Reason slot retains CommunityDeleted. + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::CommunityDeleted), + "CommunityDeleted must be retained when community wins reason" + ); + + // No frame queued — losing deny must not send an authorization_denied payload + // against a community-deleted close. + assert!( + terminal_rx.try_recv().is_err(), + "losing disconnect_nip_fi must not enqueue a denial frame when community wins reason" + ); + } + + #[test] + fn manager_wins_reason_enqueues_frame_then_losing_delete_does_not() { + // manager_disconnect_nip_fi fires first → wins AuthorizationDenied. + // disconnect_community fires second → loses, queues nothing. + let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); + let cancel = CancellationToken::new(); + let control = CommunityConnectionControl::new(cancel.clone()); + + control.manager_disconnect_nip_fi(&terminal_tx); + // disconnect_community is a no-op on reason (slot already set). + // Cannot call it here because manager_disconnect_nip_fi already cancelled; + // test the frame delivery instead. + assert_eq!( + *control.disconnect_reason().borrow(), + Some(CommunityDisconnectReason::AuthorizationDenied), + "AuthorizationDenied must be retained when manager wins reason" + ); + let frame = terminal_rx + .try_recv() + .expect("manager_disconnect_nip_fi must enqueue a denial frame when it wins"); + let expected = crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + buzz_auth::DenialClass::AuthorizationDenied, + ); + assert_eq!( + frame, expected, + "enqueued frame must be the Root denial frame" + ); + assert!( + cancel.is_cancelled(), + "manager_disconnect_nip_fi must cancel the token" + ); + } + /// A relay state whose Redis is deliberately unreachable, so admission /// checks resolve to `AdmissionError::Unavailable` without any live /// infrastructure. Shared with `crate::rejection`'s tests. @@ -1969,6 +2606,7 @@ pub(crate) mod tests { nip_fi_assertion: None, session_deadline: None, nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), }; let mgr = ConnectionManager::new(); @@ -1976,12 +2614,14 @@ pub(crate) mod tests { conn_id, tx, conn.ctrl_tx.clone(), + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), Arc::clone(&conn.subscriptions), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); // Fill the buffer via direct send. @@ -2023,23 +2663,31 @@ pub(crate) mod tests { conn_a, tx_a, ctrl_tx_a, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), community_a, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); mgr.register( conn_b, tx_b, ctrl_tx_b, + tokio::sync::mpsc::channel(1).0, None, CancellationToken::new(), community_b, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new( + tokio_util::sync::CancellationToken::new(), + ), ); let pubkey = vec![7u8; 32]; @@ -2071,12 +2719,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, - cancel, + cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, subscriptions, 3, + crate::state::CommunityConnectionControl::new(cancel), ); assert_eq!(mgr.pubkey_for_conn(conn_id), None); @@ -2665,12 +3315,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); cancel @@ -2706,12 +3358,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, Some(restart_tx), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); let drain_mgr = Arc::clone(&mgr); @@ -2750,12 +3404,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, Some(restart_tx), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); assert_eq!(mgr.drain_all_jittered(1).await, 1); @@ -2781,12 +3437,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, Some(restart_tx), cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); let drain_mgr = Arc::clone(&mgr); @@ -2821,12 +3479,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); (ctrl_rx, cancel) }; @@ -2873,12 +3533,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx.clone(), + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); // Wedge the 1-slot control channel. ctrl_tx @@ -2921,12 +3583,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); assert!( @@ -2959,12 +3623,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); let closed = mgr.drain_all(); @@ -2996,12 +3662,14 @@ pub(crate) mod tests { conn_id, tx, ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(cancel.clone()), ); let jitter_ms = 20_000u64; @@ -3034,12 +3702,14 @@ pub(crate) mod tests { late_id, late_tx, late_ctrl_tx, + tokio::sync::mpsc::channel(1).0, None, late_cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), 3, + crate::state::CommunityConnectionControl::new(late_cancel.clone()), ); assert!( late_cancel.is_cancelled(), diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index b59d30ce853..06a2fc57aed 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -226,6 +226,9 @@ run_unit_tests() { run_test_step "buzz-relay NIP-FI upgrade tests" \ cargo test -p buzz-relay --lib nip_fi_upgrade:: -- --nocapture + run_test_step "buzz-relay NIP-FI admin API tests" \ + cargo test -p buzz-relay --lib api::nip_fi:: -- --nocapture + run_test_step "buzz-relay auth metrics contract tests" \ cargo test -p buzz-relay --lib metrics::contract_tests:: -- --nocapture @@ -256,6 +259,17 @@ run_unit_tests() { handlers::req::tests::p1a_huddle_liveness_req_barrier_expiry_blocks_query_and_emission state::tests::f3_cancellation_during_check_terminates_socket_without_waiting_for_check state::tests::on_not_run_runs_once_on_each_deny_arm_and_never_on_admit + state::tests::conn_manager_disconnect_nip_fi_ignores_unproven_connection + state::tests::conn_manager_disconnect_nip_fi_is_issuer_scoped + state::tests::conn_manager_disconnect_nip_fi_sets_authorization_denied_reason + state::tests::nip_fi_disconnect_audio_is_issuer_scoped + state::tests::nip_fi_disconnect_closes_proven_audio_socket_and_sends_policy_close_reason + state::tests::nip_fi_disconnect_closes_target_audio_only_and_preserves_collocated_peer + state::tests::nip_fi_disconnect_does_not_close_different_pubkey_audio_socket + state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket + state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason + state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame + state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not ) local name for name in "${nip_fi_exact_tests[@]}"; do From 2e5e1bc6176a704c5b1276527e7ffa5e5cab6ca6 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 29 Sep 2026 16:13:00 -0400 Subject: [PATCH 02/14] test(nip-fi): tighten S4 deny witnesses and wire pubsub tests into CI The root-AUTH witness could pass with the handler's deny check removed; add a pre-registration case the close scan misses and parse OK frames. Restore the clippy allowance to the eight-argument audio handler. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- Justfile | 4 +- crates/buzz-relay/src/audio/handler.rs | 132 +++++++++--------- crates/buzz-relay/src/handlers/auth.rs | 184 +++++++++++++++++++++++-- crates/buzz-relay/src/router.rs | 32 +++-- crates/buzz-relay/src/state.rs | 11 +- scripts/run-tests.sh | 7 +- 6 files changed, 274 insertions(+), 96 deletions(-) diff --git a/Justfile b/Justfile index 53284f9b0d0..390d8e30f8a 100644 --- a/Justfile +++ b/Justfile @@ -366,6 +366,8 @@ test-unit: if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-audit --lib + # S4 cross-pod NIP-FI disconnect payload tests (infra-free). + cargo nextest run -p buzz-pubsub --lib -E 'test(/^conn_control::tests::nip_fi_disconnect_/)' # buzz-auth NIP-FI verifier doctests. The sealed-authority # `compile_fail` doctests prove the default-feature public API alone # cannot forge the issuer→JWKS authority; nextest does not run @@ -529,7 +531,7 @@ test-unit: + test(=state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) - + test(=state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not) + + test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels) + test(/^api::nip_fi::/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 5d984a1e3b5..6376735d789 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -380,7 +380,6 @@ where } } -#[allow(clippy::too_many_arguments)] /// Records the NIP-42-proven pubkey and its admitting NIP-FI issuer on an audio /// control after successful auth so the issuer-scoped NIP-FI disconnect scan /// can reach audio sockets alongside relay peers. Shared by the handler and @@ -393,6 +392,7 @@ pub(crate) fn audio_post_auth_register( control.set_proven_identity(pubkey_bytes, nip_fi_issuer); } +#[allow(clippy::too_many_arguments)] pub(crate) async fn handle_active_audio_connection( socket: WebSocket, state: Arc, @@ -5541,50 +5541,77 @@ mod tests { Arc::new(state) } - // ── W_admin_disconnect: registry disconnect_nip_fi delivers payload-then-close ─ + /// Consumes Ping/Pong until the socket terminates (Close, EOF or error) + /// within 5s; panics on any data frame or if termination never arrives. + async fn assert_terminates_without_data(client: &mut S, tag: &str) + where + S: futures_util::Stream< + Item = Result< + tokio_tungstenite::tungstenite::Message, + tokio_tungstenite::tungstenite::Error, + >, + > + Unpin, + { + use tokio_tungstenite::tungstenite::Message; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + match client.next().await { + Some(Ok(Message::Ping(_) | Message::Pong(_))) => continue, + None | Some(Err(_)) | Some(Ok(Message::Close(_))) => return, + Some(Ok(other)) => { + panic!( + "{tag}: socket must terminate after the denial payload; got {other:?}" + ) + } + } + } + }) + .await + .unwrap_or_else(|_| panic!("{tag}: socket did not terminate within 5s")); + } + + // ── W_admin_disconnect: registry disconnect_nip_fi delivers payload-then-terminate ─ // // Witnesses that an active audio socket closed via the admin-disconnect path // (`CommunityConnectionRegistry::disconnect_nip_fi`) delivers the restricted - // JSON payload BEFORE the 1008 POLICY close — the payload-then-close contract. - // - // Before this fix, `CommunityConnectionControl::disconnect_nip_fi` only - // published `AuthorizationDenied` + cancelled; no frame was enqueued on the - // terminal channel. The send loop (or pre-send-loop drain) then emitted only - // the close, with no preceding restricted JSON frame. + // JSON payload and then terminates with no further data frame. On main's S3 + // pre-writer exit the terminal channel is drained and the socket dropped; no + // specific close code is asserted. // // Setup: // - Pre-create and register `CommunityConnectionControl` (so the registry // scan can find this audio session by pubkey — same pattern as straddle). // - Key absent from deny map. Assertion carries a 1-hour deadline so the // expiry task is armed but does NOT fire during the test. - // - `before_first_audio_check_cancel` hook holds the handler AFTER - // `set_terminal_frame_sender` registers the sender on the control (line - // ~421) and BEFORE the first `check_cancel!()`. + // - `after_deny_set_check_passed` hook holds the handler AFTER the terminal + // sender is registered and the deny-set check has passed (so the direct + // map-denial branch is not what produces the frame). // - Test calls `registry.disconnect_nip_fi("test-issuer", &pubkey)` while the hook holds. // `CommunityConnectionControl::disconnect_nip_fi` enqueues the denial frame // on `terminal_frame_tx`, publishes `AuthorizationDenied`, then cancels. - // - Hook is released; handler hits `check_cancel!()`, drains the denial - // frame from `terminal_ctrl_rx`, sends `reason.close_message()`. - // - Client asserts: Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // - Hook is released; handler observes cancellation, drains the denial + // frame from `terminal_ctrl_rx`, and drops the socket. + // - Client asserts: Text(restricted JSON), then Close/EOF/error (control + // frames skipped) with no further data frame. // // Mutation evidence (production seam, not copies): // A) Remove the `set_terminal_frame_sender` call from `handle_active_audio_connection` // → `terminal_frame_tx` slot is `None` → `disconnect_nip_fi` enqueues nothing - // → client receives only `1008` with no preceding text frame → Text assertion - // times out → panics. + // → client sees termination with no preceding text frame → frame-0 + // assertion panics. // B) Remove the `try_send` block from `CommunityConnectionControl::disconnect_nip_fi` // → same outcome as (A): enqueue suppressed → only close observed → panics. - // C) Delete the `while let Ok(msg) = terminal_ctrl_rx.try_recv()` drain from the - // plain `check_cancel!()` arm → no text frame delivered → panics. + // C) Delete the terminal-channel drain on the cancellation exit → no text + // frame delivered → panics. // D) Move `set_terminal_frame_sender` to AFTER `audio_post_auth_register` - // (back to the pass-1 ordering) → when disconnect_nip_fi fires at the - // `before_first_audio_check_cancel` hook (which itself is after the old - // registration point), the sender IS registered → test still PASSES. + // → when disconnect_nip_fi fires at the `after_deny_set_check_passed` + // hook (after the registration point), the sender IS registered → test + // still PASSES. // Use W_admin_disconnect_at_deny_check (hook at before_deny_set_check, // the old gap) to catch this regression instead — that witness is RED // under the pass-1 ordering. (See W_addc above.) #[tokio::test] - async fn admin_disconnect_nip_fi_delivers_restricted_json_then_policy_close() { + async fn admin_disconnect_nip_fi_delivers_restricted_json_then_terminates() { use buzz_auth::VerifiedAssertion; use chrono::{Duration, Utc}; use std::sync::Arc; @@ -5675,8 +5702,7 @@ mod tests { .expect("W_admin_disconnect: connect client"); // Arm the hook BEFORE sending auth — it fires after `set_terminal_frame_sender` - // registers the sender (now before audio_post_auth_register, line ~343) and - // before the first `check_cancel!()`. + // registers the sender and after the deny-set check has passed. let (hook_arrived_rx, hook_release) = crate::nip_fi_test_hooks::audio_after_deny_check_passed_hook::arm(community); @@ -5711,14 +5737,12 @@ mod tests { .await .expect("W_admin_disconnect: send auth"); - // Wait for the handler to reach before_first_audio_check_cancel. + // Wait for the handler to reach after_deny_set_check_passed. // At this point `set_terminal_frame_sender` has already been called and // the terminal sender is registered on the control. tokio::time::timeout(std::time::Duration::from_secs(5), hook_arrived_rx) .await - .expect( - "W_admin_disconnect: handler must reach before_first_audio_check_cancel within 5s", - ) + .expect("W_admin_disconnect: handler must reach after_deny_set_check_passed within 5s") .expect("W_admin_disconnect: hook arrived channel closed"); // Simulate admin-disconnect: call the real registry disconnect scan by pubkey. @@ -5734,8 +5758,8 @@ mod tests { (proves audio_post_auth_register ran before the hook)" ); - // Release hook — handler resumes, hits check_cancel!(), drains the - // enqueued denial frame, then sends reason.close_message(). + // Release hook — handler resumes, observes cancellation, drains the + // enqueued denial frame, then drops the socket. hook_release.notify_one(); // Frame 0: restricted JSON payload. @@ -5764,17 +5788,7 @@ mod tests { // Pre-writer exit: main's S3 drains only the terminal channel, then // drops the socket. Nothing may follow the denial payload. - let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) - .await - .expect("W_admin_disconnect: frame 1 timeout"); - assert!( - !matches!( - frame1, - Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) - | Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(_))) - ), - "W_admin_disconnect: socket must terminate after the denial payload; got {frame1:?}" - ); + assert_terminates_without_data(&mut client, "W_admin_disconnect").await; assert!( cancel_for_assert.is_cancelled(), @@ -5789,12 +5803,10 @@ mod tests { // // Witnesses that a disconnect_nip_fi call that fires at the `before_deny_set_check` // hook window — AFTER audio_post_auth_register (pubkey scan-visible) but BEFORE - // the deny-set check — still delivers the restricted JSON payload before the 1008 - // close. This is the exact window Thufir identified as the pre-registration gap - // in pass 2: the old code registered the terminal sender AFTER this point, so - // `disconnect_nip_fi` found `terminal_frame_tx = None` and queued nothing. The - // fix moves sender registration to BEFORE `audio_post_auth_register`, closing - // the window. + // the deny-set check — still delivers the restricted JSON payload before the + // socket terminates. If the terminal sender were registered after this point, + // `disconnect_nip_fi` would find `terminal_frame_tx = None` and queue nothing; + // registering it before `audio_post_auth_register` closes that window. // // Setup: // - Pre-create and register control (same pattern as straddle/admin_disconnect). @@ -5802,19 +5814,19 @@ mod tests { // - Arm `before_deny_set_check` hook. This hook fires AFTER both // `set_terminal_frame_sender` and `audio_post_auth_register`. // - While handler is held at the hook, call `registry.disconnect_nip_fi`. - // - Release; handler hits check_cancel!(), drains denial frame, emits 1008. - // - Client asserts Text(restricted JSON) → Close(1008 POLICY, "authorization denied"). + // - Release; handler observes cancellation, drains the denial frame, drops the socket. + // - Client asserts Text(restricted JSON), then Close/EOF/error with no further data. // // Mutation evidence: // A) Move `set_terminal_frame_sender` to AFTER the hook window (into the B1 - // block, after the deny-set check, where it was in the original pass-1 code) + // block, after the deny-set check) // → when disconnect_nip_fi fires at the before_deny_set_check window, the // slot is still `None` → nothing enqueued → check_cancel!() drains nothing - // → client receives only 1008 with no preceding Text frame → frame-0 Text + // → client sees termination with no preceding Text frame → frame-0 // assertion panics. // B) Remove `set_terminal_frame_sender` entirely → same outcome as (A). #[tokio::test] - async fn w_admin_disconnect_at_deny_check_delivers_payload_then_close() { + async fn w_admin_disconnect_at_deny_check_delivers_payload_then_terminates() { use buzz_auth::VerifiedAssertion; use chrono::{Duration, Utc}; use std::sync::Arc; @@ -5955,8 +5967,8 @@ mod tests { (proves audio_post_auth_register ran before the hook)" ); - // Release — handler resumes, hits check_cancel!(), drains the enqueued - // denial frame, sends reason.close_message(). + // Release — handler resumes, observes cancellation, drains the enqueued + // denial frame, then drops the socket. hook_release.notify_one(); // Frame 0: restricted JSON payload. @@ -5986,17 +5998,7 @@ mod tests { // Pre-writer exit: main's S3 drains only the terminal channel, then // drops the socket. Nothing may follow the denial payload. - let frame1 = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) - .await - .expect("W_addc: frame 1 timeout"); - assert!( - !matches!( - frame1, - Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) - | Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(_))) - ), - "W_addc: socket must terminate after the denial payload; got {frame1:?}" - ); + assert_terminates_without_data(&mut client, "W_addc").await; assert!( cancel_for_assert.is_cancelled(), diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 2fd584f0507..e8e34b4a975 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -1367,14 +1367,12 @@ mod tests { ); // No OK(true) on the data channel. - while let Ok(frame) = send_rx.try_recv() { - if let WsMessage::Text(t) = &frame { - assert!( - !t.contains("\"true\"") && !t.contains(r#"[true"#), - "W_deny_straddle: no OK(true) must be sent when deny-set catches \ + while let Ok(WsMessage::Text(t)) = send_rx.try_recv() { + assert!( + !is_ok_true(t.as_str()), + "W_deny_straddle: no OK(true) must be sent when deny-set catches \ the entry inserted between registration and check; got: {t}" - ); - } + ); } } @@ -1536,11 +1534,181 @@ mod tests { ); let mut ok_true = false; while let Ok(WsMessage::Text(t)) = send_rx.try_recv() { - ok_true |= t.contains("\"OK\"") && t.contains("true"); + ok_true |= is_ok_true(t.as_str()); } assert!(ok_true, "B session must receive OK(true)"); } + /// True iff `frame` is a NIP-01 `["OK", , true, ...]` acceptance. + fn is_ok_true(frame: &str) -> bool { + serde_json::from_str::(frame) + .ok() + .and_then(|v| v.as_array().cloned()) + .is_some_and(|a| { + a.first().and_then(|v| v.as_str()) == Some("OK") + && a.get(2) == Some(&serde_json::Value::Bool(true)) + }) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_pre_registration_denied_by_handler_check() { + w_deny_pre_registration_body(true).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn w_deny_pre_registration_clean_key_admitted() { + w_deny_pre_registration_body(false).await; + } + + // ── W_deny_pre_registration: the deny entry exists BEFORE the connection + // proves its identity, so an issuer-scoped close scan run at that point + // misses the unproven connection (returns 0). Only the handler's own + // post-registration deny check can refuse it. With `deny_self == false` + // the entry targets a different key (positive control): the session must + // be admitted with OK(true). + // + // Mutation: bypass the `[FI-TRACE-DENY-SET]` check in `handle_auth` → + // the denied case sends OK(true), stays uncancelled, enqueues no frame. + async fn w_deny_pre_registration_body(deny_self: bool) { + use buzz_auth::{IssuerCapacity, NipFiDenyMap, VerifiedAssertion}; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + let key = Keys::generate(); + let deadline = Utc::now() + Duration::hours(1); + // `for_test` produces issuer = "test-issuer". + let assertion = VerifiedAssertion::for_test(Some(key.public_key()), vec![deadline]); + + let challenge = "w-deny-pre-registration-challenge".to_string(); + let (send_tx, mut send_rx) = mpsc::channel::(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel::(8); + let (terminal_ctrl_tx, mut terminal_ctrl_rx) = mpsc::channel::(1); + let cancel = CancellationToken::new(); + let gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, cancel.clone()); + let community = buzz_core::tenant::CommunityId::from_uuid(Uuid::new_v4()); + + let conn = Arc::new(crate::connection::ConnectionState { + conn_id: Uuid::new_v4(), + tenant: buzz_core::tenant::TenantContext::resolved( + community, + "test.local".to_string(), + ), + remote_addr: "127.0.0.1:1234".parse().unwrap(), + auth_state: std::sync::Mutex::new(AuthState::Pending { + challenge: challenge.clone(), + started_at: std::time::Instant::now(), + }), + subscriptions: Arc::new(tokio::sync::Mutex::new(HashMap::new())), + send_tx, + ctrl_tx, + terminal_ctrl_tx, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: Some(assertion), + session_deadline: Some(deadline), + nip_fi_gate: gate, + community_control: crate::state::CommunityConnectionControl::new(cancel.clone()), + }); + + let mut state = Arc::try_unwrap(auth_test_state_real_db_expect().await) + .unwrap_or_else(|arc| (*arc).clone()); + let deny_map = Arc::new(NipFiDenyMap::new( + 16, + vec![IssuerCapacity { + issuer: "test-issuer".to_owned(), + capacity: 16, + }], + )); + let denied_key = if deny_self { + key.public_key() + } else { + Keys::generate().public_key() + }; + let merge = deny_map.merge_cross_pod_deny( + "test-issuer", + &denied_key, + Utc::now() + Duration::hours(1), + Utc::now(), + ); + assert!(matches!(merge, buzz_auth::CrossPodMergeResult::Merged)); + state.nip_fi_deny_map = Some(deny_map); + let state = Arc::new(state); + + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, + cancel.clone(), + community, + Arc::clone(&conn.backpressure_count), + Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + + // The close scan runs while the connection is still unproven: it + // must miss it, leaving the handler's check as the only defence. + let closed = state + .conn_manager + .disconnect_nip_fi("test-issuer", &key.public_key().to_bytes()); + assert_eq!(closed, 0, "close scan must miss the unproven connection"); + assert!(!cancel.is_cancelled()); + + let relay_url = "ws://test.local"; + let auth_event = nostr::EventBuilder::new(nostr::Kind::Authentication, "") + .tag(nostr::Tag::parse(["relay", relay_url]).unwrap()) + .tag(nostr::Tag::parse(["challenge", &challenge]).unwrap()) + .sign_with_keys(&key) + .unwrap(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + handle_auth(auth_event, Arc::clone(&conn), Arc::clone(&state)), + ) + .await + .expect("handle_auth must return within 5s"); + + let mut ok_true = false; + while let Ok(WsMessage::Text(t)) = send_rx.try_recv() { + ok_true |= is_ok_true(t.as_str()); + } + assert!(ctrl_rx.try_recv().is_err(), "ctrl channel must stay empty"); + + if deny_self { + assert!(!ok_true, "denied key must not receive OK(true)"); + assert!( + cancel.is_cancelled(), + "handler check must cancel the session" + ); + let expected = crate::protocol::RelayMessage::notice( + buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + ); + match terminal_ctrl_rx.try_recv() { + Ok(WsMessage::Text(t)) => assert_eq!(t.as_str(), expected.as_str()), + other => panic!("expected authorization_denied NOTICE; got {other:?}"), + } + } else { + assert!(ok_true, "clean key must receive OK(true)"); + assert!(!cancel.is_cancelled(), "clean key must not be cancelled"); + assert!( + terminal_ctrl_rx.try_recv().is_err(), + "clean key gets no frame" + ); + assert!(matches!( + *conn.auth_state.lock().unwrap(), + AuthState::Authenticated(_) + )); + } + } + /// W1 (auth barrier): expiry fired mid-flight blocks AUTH commit. /// /// Arms `before_auth_commit` — the hook immediately before `acquire_effect()` diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index f0707354862..966dc5f576b 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -3392,30 +3392,34 @@ mod tests { #[tokio::test] async fn deny_map_admits_key_not_in_map() { - // A key NOT in the deny map passes the check. This proves the Off-path (no deny entry → pass through) and guards - // against an inverted condition. + // A key NOT in the deny map passes the check and gets exactly the + // downstream outcome of the same request with no deny map installed. + // What lies past the gate (host binding, extractor) depends on the + // local DB, so the no-map baseline is the known outcome; a 403 means + // the deny check fired for a non-denied key (inverted condition). let clean_key = nostr::Keys::generate().public_key(); // Build state with a DIFFERENT denied key so clean_key is not in the map. let other_key = nostr::Keys::generate().public_key(); let state = nip_fi_deny_state(&other_key).await; + let mut baseline = (*state).clone(); + baseline.nip_fi_deny_map = None; let token = mint_deny_test_token(&clean_key.to_hex()); let bearer = format!("Bearer {token}"); let status = nip_fi_gate_status(state, "/", Some("Nostr-Federated-Identity"), Some(&bearer)).await; + let expected = nip_fi_gate_status( + Arc::new(baseline), + "/", + Some("Nostr-Federated-Identity"), + Some(&bearer), + ) + .await; - // The key is not denied: the pre-101 gate admits it. What happens past - // the gate depends on host routing, so assert only that no NIP-FI - // refusal status came back. A 403 means the deny check fired for a - // non-denied key. - assert!( - !matches!( - status, - axum::http::StatusCode::UNAUTHORIZED - | axum::http::StatusCode::FORBIDDEN - | axum::http::StatusCode::SERVICE_UNAVAILABLE - ), - "WS admission for a key NOT in the deny map must pass the NIP-FI gate; got {status}" + assert_ne!(expected, axum::http::StatusCode::FORBIDDEN); + assert_eq!( + status, expected, + "WS admission for a key NOT in the deny map must reach the no-map downstream outcome" ); } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 009f9d706e8..c42fe26e23f 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -2291,21 +2291,18 @@ pub(crate) mod tests { } #[test] - fn manager_wins_reason_enqueues_frame_then_losing_delete_does_not() { - // manager_disconnect_nip_fi fires first → wins AuthorizationDenied. - // disconnect_community fires second → loses, queues nothing. + fn manager_disconnect_sets_reason_enqueues_frame_then_cancels() { + // manager_disconnect_nip_fi on a fresh control sets AuthorizationDenied, + // enqueues the Root denial frame, and cancels the token. let (terminal_tx, mut terminal_rx) = tokio::sync::mpsc::channel(1); let cancel = CancellationToken::new(); let control = CommunityConnectionControl::new(cancel.clone()); control.manager_disconnect_nip_fi(&terminal_tx); - // disconnect_community is a no-op on reason (slot already set). - // Cannot call it here because manager_disconnect_nip_fi already cancelled; - // test the frame delivery instead. assert_eq!( *control.disconnect_reason().borrow(), Some(CommunityDisconnectReason::AuthorizationDenied), - "AuthorizationDenied must be retained when manager wins reason" + "manager_disconnect_nip_fi must set AuthorizationDenied" ); let frame = terminal_rx .try_recv() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 06a2fc57aed..08c2818dced 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -87,6 +87,11 @@ run_unit_tests() { run_test_step "buzz-auth unit tests" \ cargo test -p buzz-auth --lib -- --nocapture + # S4 cross-pod NIP-FI disconnect payload tests (infra-free). Mirrors + # `just test-unit`. + run_test_step "buzz-pubsub conn_control NIP-FI tests" \ + cargo test -p buzz-pubsub --lib conn_control::tests::nip_fi_disconnect_ -- --nocapture + run_test_step "buzz-voice tests" \ cargo test -p buzz-voice --lib -- --nocapture @@ -269,7 +274,7 @@ run_unit_tests() { state::tests::nip_fi_disconnect_does_not_close_unproven_audio_socket state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame - state::tests::manager_wins_reason_enqueues_frame_then_losing_delete_does_not + state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels ) local name for name in "${nip_fi_exact_tests[@]}"; do From c57fe3dd8d57b282447e77aa6d0b2ae65a812907 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 29 Sep 2026 16:35:47 -0400 Subject: [PATCH 03/14] test(relay): pin deny-map clean-key baseline and fix stale audio comments Constrain the no-map baseline to 200/404 so identical upstream failures cannot satisfy the equality check. Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/audio/handler.rs | 7 ++++--- crates/buzz-relay/src/router.rs | 10 +++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 6376735d789..3a50f65fd2a 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -5609,7 +5609,8 @@ mod tests { // still PASSES. // Use W_admin_disconnect_at_deny_check (hook at before_deny_set_check, // the old gap) to catch this regression instead — that witness is RED - // under the pass-1 ordering. (See W_addc above.) + // when the sender is registered after that window. (See + // w_admin_disconnect_at_deny_check_delivers_payload_then_terminates below.) #[tokio::test] async fn admin_disconnect_nip_fi_delivers_restricted_json_then_terminates() { use buzz_auth::VerifiedAssertion; @@ -5821,8 +5822,8 @@ mod tests { // A) Move `set_terminal_frame_sender` to AFTER the hook window (into the B1 // block, after the deny-set check) // → when disconnect_nip_fi fires at the before_deny_set_check window, the - // slot is still `None` → nothing enqueued → check_cancel!() drains nothing - // → client sees termination with no preceding Text frame → frame-0 + // slot is still `None` → nothing enqueued → the S3 drain-and-drop exit + // has no frame to send → client sees termination with no preceding Text frame → frame-0 // assertion panics. // B) Remove `set_terminal_frame_sender` entirely → same outcome as (A). #[tokio::test] diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 966dc5f576b..b1a9a4042a4 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -3416,7 +3416,15 @@ mod tests { ) .await; - assert_ne!(expected, axum::http::StatusCode::FORBIDDEN); + // Unbound host → 404; bound host → NIP-11 fallback → 200. Anything + // else means an upstream failure that could mask the comparison. + assert!( + matches!( + expected, + axum::http::StatusCode::OK | axum::http::StatusCode::NOT_FOUND + ), + "no-map baseline must be 200 or 404, got {expected}" + ); assert_eq!( status, expected, "WS admission for a key NOT in the deny map must reach the no-map downstream outcome" From ca579b7c724e98e489763b56578095d5e964c3c8 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 29 Sep 2026 18:38:07 -0400 Subject: [PATCH 04/14] perf(nip-fi): sweep deny map only at capacity and bound jti length The full expiry sweep ran on every write while holding the shard mutex that every admission's is_denied also takes. It now runs only when a write would otherwise hit capacity; reads already compare against now, and replay checks treat a lingering expired jti as absent. Cross-pod merges share the local merge path, removing an unreachable! that could kill the consumer task. jti is capped at 512 bytes so the 2x-capacity reservation budget bounds memory. Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/command.rs | 37 +++- crates/buzz-auth/src/nip_fi/config.rs | 6 + crates/buzz-auth/src/nip_fi/deny_map.rs | 251 +++++++++++++++++------- 3 files changed, 223 insertions(+), 71 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index 3e4627f035c..739f96d9151 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -22,7 +22,9 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use nostr::PublicKey; use serde_json::{Map, Value}; -use super::config::{IssuerPolicy, IssuerRegistry, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES}; +use super::config::{ + IssuerPolicy, IssuerRegistry, MAX_JTI_BYTES, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, +}; use super::deny_map::{NipFiDenyMap, ReserveError}; use super::verifier::{ enforce_compact_structure, enforce_signature_shape, parse_header, parse_numeric_date, @@ -397,8 +399,11 @@ impl CommandVerifier { return Err(CommandError::UntilExceedsCeiling); } - // jti: must be present and non-empty. + // jti: must be present, non-empty, and within MAX_JTI_BYTES. let jti = claim_str(&claims, "jti").ok_or(CommandError::EvidenceRejected)?; + if jti.len() > MAX_JTI_BYTES { + return Err(CommandError::EvidenceRejected); + } // ── Step 4: principal authorization ─────────────────────────────────── // AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub). @@ -1120,6 +1125,34 @@ mod tests { ); } + #[test] + fn jti_longer_than_max_jti_bytes_rejects() { + // Mutation anchor: removing the MAX_JTI_BYTES check admits the oversized + // jti into the deny shard's reservation map. + let cv = test_command_verifier(); + let target = target_key(); + let now = Utc::now(); + let oversized = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"jti": "j".repeat(MAX_JTI_BYTES + 1)}), + ); + assert_eq!( + cv.verify_at(&oversized, METHOD, PATH, &target, now), + Err(CommandError::EvidenceRejected), + "jti over MAX_JTI_BYTES must be EvidenceRejected" + ); + let at_limit = mint_cmd_jwt( + &target, + 300, + serde_json::json!({"jti": "j".repeat(MAX_JTI_BYTES)}), + ); + assert!( + cv.verify_at(&at_limit, METHOD, PATH, &target, now).is_ok(), + "jti of exactly MAX_JTI_BYTES must be accepted" + ); + } + // ── 503 does NOT burn the jti ───────────────────────────────────────────── #[test] diff --git a/crates/buzz-auth/src/nip_fi/config.rs b/crates/buzz-auth/src/nip_fi/config.rs index 99910598426..17f7d7c2e4e 100644 --- a/crates/buzz-auth/src/nip_fi/config.rs +++ b/crates/buzz-auth/src/nip_fi/config.rs @@ -60,6 +60,12 @@ pub(crate) const MAX_CLIENT_ID_BYTES: usize = 2_048; /// attacker-driven O(keys) scan. pub(crate) const MAX_JWKS_KEYS: usize = 64; +/// Maximum accepted command-JWT `jti` length, in bytes. Each reservation +/// stores the jti in the per-issuer deny shard, so this bounds its memory. +/// A command-JWT rule, not an assertion bound, so it is not folded into +/// `assertion_policy_id`. +pub(crate) const MAX_JTI_BYTES: usize = 512; + /// The compiled-verifier-behavior fingerprint folded into every /// [`AssertionPolicyId`]. It stands in for the normative semantic inputs that /// are not otherwise field-encoded: duplicate-member rejection, exact-byte diff --git a/crates/buzz-auth/src/nip_fi/deny_map.rs b/crates/buzz-auth/src/nip_fi/deny_map.rs index 3fa304293e3..9b158932ccf 100644 --- a/crates/buzz-auth/src/nip_fi/deny_map.rs +++ b/crates/buzz-auth/src/nip_fi/deny_map.rs @@ -27,8 +27,10 @@ //! in one lock scope (both or neither). [VerifyCommandJwt step 7] //! * **Issuer-global scope**: the deny applies across all communities served //! under that issuer. [FI-TRACE-DENY-SET] -//! * **Self-eviction**: expired entries are pruned lazily on each mutation and -//! on read (deny check), so the map does not grow without bound. +//! * **Self-eviction**: expired entries and jtis are pruned on write, only when +//! the shard is at capacity, so the map stays bounded without putting an +//! O(n) sweep on every write. Reads compare against `now`, so a lingering +//! expired entry or jti is never treated as active. use chrono::{DateTime, Utc}; use dashmap::DashMap; @@ -57,14 +59,15 @@ struct IssuerShard { /// Active deny entries: hex-encoded pubkey → until. entries: HashMap>, /// Reserved jtis: jti string → effective_expiry. Expired jtis are evicted - /// lazily on each write so the map never grows to replay-corpus size. + /// when the jti budget is full so the map never grows to replay-corpus size. jtis: HashMap>, - /// Maximum number of live deny entries for this issuer. + /// Maximum number of deny entries for this issuer. capacity: usize, - /// Maximum number of live jti reservations for this issuer. + /// Maximum number of jti reservations for this issuer. /// Bounded separately so an issuer cannot exhaust memory by replaying /// distinct jtis faster than they expire, even for already-denied keys. - /// Set to `capacity * 2` at construction for O(capacity) memory with + /// Set to `capacity * 2` at construction; with each jti capped at + /// `MAX_JTI_BYTES` this keeps jti memory O(capacity × MAX_JTI_BYTES), with /// headroom for in-flight update commands on already-denied keys. max_jti_count: usize, } @@ -83,12 +86,24 @@ impl IssuerShard { } } - /// Evict expired entries and jtis. Called inside the lock on every write. + /// Evict expired entries and jtis. O(n); called only from + /// [`Self::full_after_eviction`] so the common write path never stalls + /// `is_denied` readers behind a sweep. fn evict_expired(&mut self, now: DateTime) { self.entries.retain(|_, until| *until > now); self.jtis.retain(|_, exp| *exp > now); } + /// True if `full` holds even after evicting expired state. The sweep runs + /// only when `full` already holds. + fn full_after_eviction(&mut self, now: DateTime, full: impl Fn(&Self) -> bool) -> bool { + if !full(self) { + return false; + } + self.evict_expired(now); + full(self) + } + /// True if `(iss, pubkey_hex)` has an active deny entry (`now < until`). fn is_denied(&self, pubkey_hex: &str, now: DateTime) -> bool { self.entries @@ -99,12 +114,10 @@ impl IssuerShard { /// Attempt the atomic jti-reservation + deny-entry insertion. /// - /// **Atomicity**: both HashMap inserts are precomputed before any write. - /// Eviction is done first (pure mutation of existing map, always safe), - /// then all fallible pre-conditions are checked, then both inserts happen - /// under the same lock scope. An unwind before the inserts leaves the - /// shard unchanged; an unwind mid-insert is not possible because HashMap - /// insert is infallible after capacity reservation. + /// **Atomicity**: every fallible check runs before the first insert. The + /// deny-entry merge is the only fallible write and fails before mutating + /// anything; the jti insert after it is infallible. Eviction may run + /// first, but it only drops already-expired state, which is always safe. fn atomic_reserve_and_insert( &mut self, jti: &str, @@ -113,72 +126,47 @@ impl IssuerShard { until: DateTime, now: DateTime, ) -> Result<(), ReserveError> { - self.evict_expired(now); - - // Replay check: jti already in set → AuthorizationDenied. - if self.jtis.contains_key(jti) { + // Replay check: live jti already in set → AuthorizationDenied. An + // expired, not-yet-evicted reservation is treated as absent. + if self.jtis.get(jti).is_some_and(|exp| *exp > now) { return Err(ReserveError::JtiAlreadyReserved); } - // JTI resource bound: cap live jti reservations at max_jti_count so an + // JTI resource bound: cap jti reservations at max_jti_count so an // issuer cannot exhaust memory by sending distinct jtis for already-denied // keys faster than they expire. Uses CapacityExceeded so the caller // responds 503 and the command remains replayable (jti not burned). - if self.jtis.len() >= self.max_jti_count { - return Err(ReserveError::CapacityExceeded); - } - - // Deny-entry capacity check: only count as new if no active entry exists. - // The merge rule never increases live entry count. - let is_update = self - .entries - .get(pubkey_hex) - .map(|existing| now < *existing) - .unwrap_or(false); - if !is_update && self.entries.len() >= self.capacity { + // Overwriting an expired reservation of the same jti does not grow the map. + if !self.jtis.contains_key(jti) + && self.full_after_eviction(now, |s| s.jtis.len() >= s.max_jti_count) + { return Err(ReserveError::CapacityExceeded); } - // Prebuild both values before writing anything. - let jti_key = jti.to_owned(); - let entry_key = pubkey_hex.to_owned(); - let effective_until = match self.entries.get(pubkey_hex) { - Some(&existing) => existing.max(until), - None => until, - }; - - // Both mutations are infallible HashMap inserts; executed together - // so no intermediate observable state exists. - self.jtis.insert(jti_key, jti_effective_expiry); - self.entries.insert(entry_key, effective_until); - + self.merge_entry(pubkey_hex, until, now)?; + self.jtis.insert(jti.to_owned(), jti_effective_expiry); Ok(()) } - /// Merge a remote deny entry without consuming a jti. + /// Insert or `max(existing_until, until)`-merge a deny entry. /// - /// Used for cross-pod propagation where replay idempotency is achieved by - /// the max(until) merge rule alone — no jti tracking needed. - /// Returns `Err(CapacityExceeded)` if the entry is new and the shard is full. - fn remote_merge( + /// Used directly for cross-pod propagation, where replay idempotency is + /// achieved by the merge rule alone — no jti tracking needed. + /// Returns `Err(CapacityExceeded)` without mutating the entry map if the + /// key is new and the shard is full even after eviction. + fn merge_entry( &mut self, pubkey_hex: &str, until: DateTime, now: DateTime, - ) -> Result<(), ReserveError> { - self.evict_expired(now); - - // Capacity check: only count as new if there is no active entry. - let is_update = self - .entries - .get(pubkey_hex) - .map(|existing| now < *existing) - .unwrap_or(false); - if !is_update && self.entries.len() >= self.capacity { - return Err(ReserveError::CapacityExceeded); + ) -> Result<(), CapacityExceeded> { + // Overwriting an existing (active or expired) entry never grows the map. + if !self.entries.contains_key(pubkey_hex) + && self.full_after_eviction(now, |s| s.entries.len() >= s.capacity) + { + return Err(CapacityExceeded); } - // max(existing_until, until) merge. let effective_until = match self.entries.get(pubkey_hex) { Some(&existing) => existing.max(until), None => until, @@ -188,6 +176,15 @@ impl IssuerShard { } } +/// The shard has no room for a new deny entry, even after eviction. +struct CapacityExceeded; + +impl From for ReserveError { + fn from(_: CapacityExceeded) -> Self { + Self::CapacityExceeded + } +} + /// Reasons an atomic reserve can fail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ReserveError { @@ -342,9 +339,9 @@ impl NipFiDenyMap { // fail-closed without any explicit write. CrossPodMergeResult::ShardPoisoned } - Ok(mut guard) => match guard.remote_merge(&pubkey_hex, until, now) { + Ok(mut guard) => match guard.merge_entry(&pubkey_hex, until, now) { Ok(()) => CrossPodMergeResult::Merged, - Err(ReserveError::CapacityExceeded) => { + Err(CapacityExceeded) => { // Cannot record the deny entry — return the outcome so // the caller can close the delivered target's sessions // and report/metric the capacity miss. No issuer-wide @@ -352,10 +349,6 @@ impl NipFiDenyMap { // [NIP-FI.md:306-336] CrossPodMergeResult::CapacityExceeded } - Err(ReserveError::JtiAlreadyReserved) => { - // remote_merge never touches jtis; this arm is unreachable. - unreachable!("remote_merge does not use jti tracking") - } }, }, } @@ -748,7 +741,7 @@ mod tests { #[test] fn remote_merge_shorter_after_longer_does_not_shorten() { - // Map with iss() pre-registered so remote_merge can operate on it. + // Map with iss() pre-registered so merge_cross_pod_deny can operate on it. let m = NipFiDenyMap::new( 100, vec![IssuerCapacity { @@ -780,7 +773,7 @@ mod tests { #[test] fn remote_merge_replay_is_idempotent() { - // Map with iss() pre-registered so remote_merge can operate on it. + // Map with iss() pre-registered so merge_cross_pod_deny can operate on it. let m = NipFiDenyMap::new( 100, vec![IssuerCapacity { @@ -922,7 +915,7 @@ mod tests { let map_c = make_map(&k_a); // pre-filled with k_a active let result_c = map_c.merge_cross_pod_deny(iss(), &k_b, expired_until, now); // Expired remote entry is treated as a new (already-expired) entry; since - // the shard is at capacity the remote_merge returns CapacityExceeded. + // the shard is at capacity merge_cross_pod_deny returns CapacityExceeded. // The live k_a entry must remain; k_b and k_unrelated must not be denied. assert!( map_c.is_denied(iss(), &k_a, now), @@ -1164,4 +1157,124 @@ mod tests { "key must be denied at t0+15s after successful retry (deadline now t0+30s)" ); } + + // ── Eviction only at capacity ───────────────────────────────────────────── + + #[test] + fn insert_into_shard_full_of_expired_entries_succeeds() { + use chrono::TimeZone; + + // Mutation anchor: dropping the evict-and-recheck on the entry-capacity + // path returns a false CapacityExceeded here. + let m = NipFiDenyMap::new(2, vec![]); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let until = t0 + Duration::seconds(10); + let (k1, k2, k3) = (key(), key(), key()); + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k1, until, t0) + .expect("k1"); + m.atomic_reserve_and_insert(iss(), "jti-2", until, &k2, until, t0) + .expect("k2"); + + let later = t0 + Duration::seconds(20); + let later_until = later + Duration::seconds(10); + m.atomic_reserve_and_insert(iss(), "jti-3", later_until, &k3, later_until, later) + .expect("expired entries must not hold capacity against a new insert"); + assert!(m.is_denied(iss(), &k3, later), "k3 must be denied"); + assert!(!m.is_denied(iss(), &k1, later), "expired k1 stays admitted"); + } + + #[test] + fn write_below_capacity_does_not_sweep_expired_state() { + use chrono::TimeZone; + + // Mutation anchor: restoring an unconditional sweep on every write + // evicts k1's expired entry and jti here. + let m = map(); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let until = t0 + Duration::seconds(10); + let (k1, k2) = (key(), key()); + m.atomic_reserve_and_insert(iss(), "jti-1", until, &k1, until, t0) + .expect("k1"); + + let later = t0 + Duration::seconds(20); + let later_until = later + Duration::seconds(10); + m.atomic_reserve_and_insert(iss(), "jti-2", later_until, &k2, later_until, later) + .expect("k2"); + + let shard = m.shards.get(iss()).unwrap(); + let guard = shard.lock().unwrap(); + assert_eq!(guard.entries.len(), 2, "below capacity: no entry sweep"); + assert_eq!(guard.jtis.len(), 2, "below capacity: no jti sweep"); + } + + #[test] + fn expired_unevicted_jti_is_not_a_replay() { + use chrono::TimeZone; + + // Mutation anchor: reverting the replay check to `contains_key` rejects + // the post-expiry reuse; not overwriting the stored expiry admits the + // final replay. + let m = map(); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let k = key(); + let until = t0 + Duration::seconds(60); + m.atomic_reserve_and_insert(iss(), "jti-A", t0 + Duration::seconds(2), &k, until, t0) + .expect("first reservation"); + + // jti-A expired at t0+2 but lingers (shard is far below capacity). + let t1 = t0 + Duration::seconds(3); + m.atomic_reserve_and_insert(iss(), "jti-A", t0 + Duration::seconds(30), &k, until, t1) + .expect("expired reservation must not count as a replay"); + + // The re-reservation overwrote the expiry, so jti-A is live again. + assert_eq!( + m.atomic_reserve_and_insert( + iss(), + "jti-A", + t0 + Duration::seconds(30), + &k, + until, + t0 + Duration::seconds(5) + ), + Err(ReserveError::JtiAlreadyReserved), + "re-reserved jti must be live until its new expiry" + ); + } + + #[test] + fn cross_pod_merge_into_full_shard_evicts_only_expired_entries() { + use chrono::TimeZone; + + // Mutation anchor: dropping the evict-and-recheck makes the second + // merge return CapacityExceeded; evicting live entries makes the first + // merge succeed. + let m = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 1, + }], + ); + let t0 = Utc.with_ymd_and_hms(2030, 1, 1, 0, 0, 0).unwrap(); + let until = t0 + Duration::seconds(10); + let (k_a, k_b) = (key(), key()); + m.atomic_reserve_and_insert(iss(), "jti-a", until, &k_a, until, t0) + .expect("fill the single slot"); + + let t_live = t0 + Duration::seconds(5); + assert_eq!( + m.merge_cross_pod_deny(iss(), &k_b, t_live + Duration::seconds(60), t_live), + CrossPodMergeResult::CapacityExceeded, + "full shard with a live entry must still reject a new key" + ); + assert!(m.is_denied(iss(), &k_a, t_live), "live k_a is preserved"); + + let t_expired = t0 + Duration::seconds(20); + assert_eq!( + m.merge_cross_pod_deny(iss(), &k_b, t_expired + Duration::seconds(60), t_expired), + CrossPodMergeResult::Merged, + "an expired entry must not hold the slot" + ); + assert!(m.is_denied(iss(), &k_b, t_expired), "k_b must be denied"); + } } From ff3bb85ad5228323ed4342a0191864b85c4ba601 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 29 Sep 2026 18:42:44 -0400 Subject: [PATCH 05/14] fix(nip-fi): subscribe to cross-pod disconnects before the Redis subscriber starts The broadcast receiver was created only after AppState construction and JWKS warm, so disconnects relayed during boot hit a receiverless channel and were dropped. Subscribing first lets the receiver buffer them until the consumer runs. Signed-off-by: Will Pfleger --- crates/buzz-relay/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index de8791895f1..886085de745 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -495,6 +495,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // Spawn Redis pub/sub subscriber for NIP-FI cross-pod disconnect commands. // Remote pods publish to this global channel after accepting a disconnect // command; every pod merges the deny entry and closes matching sessions. + // Subscribe before the Redis subscriber starts so messages buffer (up to the + // channel capacity) instead of being dropped until the consumer below runs. + let mut nip_fi_disconnect_rx = pubsub.subscribe_nip_fi_disconnect(); let pubsub_for_nip_fi = Arc::clone(&pubsub); tokio::spawn(async move { pubsub_for_nip_fi.run_nip_fi_disconnect_subscriber().await }); @@ -1209,10 +1212,9 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { // minimal and makes the exact production path testable end-to-end. { let state_for_nip_fi = Arc::clone(&state); - let mut rx = state_for_nip_fi.pubsub.subscribe_nip_fi_disconnect(); tokio::spawn(async move { loop { - match rx.recv().await { + match nip_fi_disconnect_rx.recv().await { Ok(msg) => { let now = chrono::Utc::now(); buzz_relay::api::nip_fi::apply_nip_fi_disconnect( From d974df5e9989b97a5ff263b2131817b47b6775f7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 29 Sep 2026 18:42:48 -0400 Subject: [PATCH 06/14] fix(nip-fi): clamp cross-pod until to the local ceiling instead of rejecting A pod whose clock trailed the origin rejected at-ceiling commands the origin had already accepted with 200, leaving the target's sessions open on that pod. The consumer now clamps until to its own ceiling and still merges and closes; malformed pubkey, unknown issuer, and unrepresentable timestamps stay rejected. Also documents that enforce-mode issuers must carry maximum_command_age_seconds and authorized_principals (startup already required them), and drops a 4s wall-clock admission test whose expiry behavior is pinned with an injected clock. Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/nip_fi.rs | 219 ++++++++++++++++++------- crates/buzz-relay/src/nip_fi_config.rs | 23 ++- 2 files changed, 173 insertions(+), 69 deletions(-) diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 24b55a0de21..97592cf467b 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -19,9 +19,9 @@ //! //! ## Environment variables //! -//! The command API is enabled when `BUZZ_NIP_FI_MODE=enforce` and the issuer -//! JSON entries include the S4 fields. S4 fields are read from the same -//! `BUZZ_NIP_FI_ISSUERS` JSON array; each issuer entry optionally carries: +//! The command API is enabled when `BUZZ_NIP_FI_MODE=enforce`. S4 fields are +//! read from the same `BUZZ_NIP_FI_ISSUERS` JSON array as the assertion +//! policy; in enforce mode every issuer entry must carry them: //! //! ```json //! { @@ -31,9 +31,10 @@ //! } //! ``` //! -//! `maximum_command_age_seconds` and `authorized_principals` are required in -//! enforce mode if any issuer is command-capable. `deny_set_capacity` defaults -//! to [`DEFAULT_DENY_SET_CAPACITY`] when absent. +//! `maximum_command_age_seconds` (1–60) and a non-empty +//! `authorized_principals` are required on every issuer; startup fails if any +//! issuer lacks them. `deny_set_capacity` is optional and defaults to +//! [`DEFAULT_DENY_SET_CAPACITY`]. use std::sync::Arc; @@ -194,15 +195,18 @@ pub async fn disconnect( /// Per-issuer command configuration parsed from the `BUZZ_NIP_FI_ISSUERS` JSON. /// -/// Added to each entry in S4. All three fields are optional (absent = -/// command API disabled for that issuer / default capacity used). +/// In enforce mode `maximum_command_age_seconds` and `authorized_principals` +/// are required on every issuer and startup validation rejects any entry +/// without them. The fields remain `Option` for the off and `deny_protected` +/// modes, which do not require them. #[derive(Debug, Default, Clone, serde::Deserialize)] pub struct CommandIssuerEnvConfig { - /// Positive seconds, ≤ 60. Required for the command API to be enabled. + /// Maximum command JWT age in seconds, in `[1, 60]`. Required in enforce mode. pub maximum_command_age_seconds: Option, - /// Non-empty list of authorized `sub` values. Required if command age is set. + /// Non-empty list of authorized `sub` values, matched exactly + /// (case-sensitive). Required in enforce mode. pub authorized_principals: Option>, - /// Hard ceiling on live deny entries for this issuer. + /// Hard ceiling on live deny entries for this issuer; must be positive. /// Defaults to [`DEFAULT_DENY_SET_CAPACITY`] when absent. pub deny_set_capacity: Option, } @@ -224,7 +228,8 @@ pub enum NipFiDisconnectApplyResult { /// Command API is not enabled on this pod (deny map absent); message ignored. Disabled, /// Message was rejected before reaching the map (invalid pubkey, unknown - /// issuer, unrepresentable timestamp, or ceiling exceeded). + /// issuer, or unrepresentable timestamp). An `until` beyond this pod's + /// ceiling is not a rejection: it is clamped to the ceiling and applied. Rejected, /// Message was applied; carries the map's merge result. Applied(buzz_auth::CrossPodMergeResult), @@ -253,6 +258,10 @@ pub fn nip_fi_disconnect_message(cmd: &buzz_auth::CommandResult) -> buzz_pubsub: /// the logic here allows tests to call the exact production path end-to-end /// without driving a live Redis subscriber. /// +/// `until` is clamped to `now + skew_seconds + maximum_assertion_age_seconds` +/// for the message's issuer, so clock drift between pods cannot drop a command +/// the origin pod already accepted. +/// /// `now` is passed explicitly so tests can supply controlled timestamps. pub fn apply_nip_fi_disconnect( state: &crate::state::AppState, @@ -277,48 +286,40 @@ pub fn apply_nip_fi_disconnect( }; // Validate that the issuer is locally configured. - if state + let Some(policy) = state .config .nip_fi .registry .policy_for_issuer(&message.issuer) - .is_none() - { + else { tracing::warn!("nip-fi cross-pod: unknown issuer (not locally configured) — rejected"); return NipFiDisconnectApplyResult::Rejected; - } - - // Validate timestamp representability. - let until = match chrono::DateTime::from_timestamp(message.until_unix, message.until_unix_nanos) - { - Some(t) => t, - None => { - tracing::warn!( - until_unix = message.until_unix, - "nip-fi cross-pod: unrepresentable until timestamp — rejected" - ); - return NipFiDisconnectApplyResult::Rejected; - } }; - // Validate that `until` does not exceed the issuer's ceiling. - if let Some(policy) = state - .config - .nip_fi - .registry - .policy_for_issuer(&message.issuer) - { - let skew = chrono::Duration::seconds(policy.skew_seconds() as i64); - let max_age = chrono::Duration::seconds(policy.maximum_assertion_age_seconds() as i64); - if let Some(ceiling) = now - .checked_add_signed(skew) - .and_then(|t| t.checked_add_signed(max_age)) - { - if until > ceiling { - tracing::warn!("nip-fi cross-pod: until exceeds issuer ceiling — rejected"); + // Validate timestamp representability. + let mut until = + match chrono::DateTime::from_timestamp(message.until_unix, message.until_unix_nanos) { + Some(t) => t, + None => { + tracing::warn!( + until_unix = message.until_unix, + "nip-fi cross-pod: unrepresentable until timestamp — rejected" + ); return NipFiDisconnectApplyResult::Rejected; } - } + }; + + // Clamp `until` to this pod's ceiling rather than rejecting it. The origin + // pod already verified `until` against its own clock and returned 200; a + // pod whose clock trails the origin would otherwise drop a legitimate + // at-ceiling command and leave the target's sessions open. + let skew = chrono::Duration::seconds(policy.skew_seconds() as i64); + let max_age = chrono::Duration::seconds(policy.maximum_assertion_age_seconds() as i64); + if let Some(ceiling) = now + .checked_add_signed(skew) + .and_then(|t| t.checked_add_signed(max_age)) + { + until = until.min(ceiling); } // Merge the deny entry. @@ -2248,6 +2249,117 @@ mod route_integration_tests { ); } + // ── Cross-pod consumer: rejection and ceiling clamp ────────────────────── + // + // Only malformed messages are rejected. An `until` beyond the receiving + // pod's ceiling is clamped, so clock drift between pods cannot drop a + // command the origin pod already accepted. + + /// Fixed consumer clock: the tests below inject time, never sleep. + fn cross_pod_now() -> chrono::DateTime { + chrono::DateTime::from_timestamp(1_900_000_000, 0).expect("representable") + } + + /// Delivers `message` for `key` with an issuer-A session live and asserts + /// the consumer rejects it without recording a deny or closing the session. + fn assert_cross_pod_rejected( + state: &crate::state::AppState, + key: &nostr::PublicKey, + message: &buzz_pubsub::NipFiDisconnect, + ) { + let sessions = IssuerSessions::register(state, TEST_ISS, key); + let now = cross_pod_now(); + + let result = apply_nip_fi_disconnect(state, message, now); + + assert_eq!(result, NipFiDisconnectApplyResult::Rejected); + sessions.assert_open("rejected message"); + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + assert!(!deny_map.is_denied(TEST_ISS, key, now)); + } + + #[tokio::test] + async fn cross_pod_malformed_pubkey_bytes_rejected() { + let state = cross_pod_state(10).await; + let key = nostr::Keys::generate().public_key(); + let message = buzz_pubsub::NipFiDisconnect { + pubkey_bytes: key.to_bytes()[..31].to_vec(), + ..cross_pod_message(&key) + }; + assert_cross_pod_rejected(&state, &key, &message); + } + + #[tokio::test] + async fn cross_pod_unknown_issuer_rejected() { + let state = cross_pod_state(10).await; + let key = nostr::Keys::generate().public_key(); + let message = buzz_pubsub::NipFiDisconnect { + issuer: OTHER_ISS.to_owned(), + ..cross_pod_message(&key) + }; + assert_cross_pod_rejected(&state, &key, &message); + } + + #[tokio::test] + async fn cross_pod_unrepresentable_until_rejected() { + let state = cross_pod_state(10).await; + let key = nostr::Keys::generate().public_key(); + let message = buzz_pubsub::NipFiDisconnect { + until_unix: i64::MAX, + ..cross_pod_message(&key) + }; + assert_cross_pod_rejected(&state, &key, &message); + } + + #[tokio::test] + async fn cross_pod_without_deny_map_is_disabled() { + let mut config = crate::config::Config::for_test(); + config.nip_fi.registry.insert(test_issuer_policy()); + let mut state = build_test_app_state(10, config).await; + state.nip_fi_deny_map = None; + let key = nostr::Keys::generate().public_key(); + let sessions = IssuerSessions::register(&state, TEST_ISS, &key); + + let result = apply_nip_fi_disconnect(&state, &cross_pod_message(&key), cross_pod_now()); + + assert_eq!(result, NipFiDisconnectApplyResult::Disabled); + sessions.assert_open("disabled consumer"); + } + + #[tokio::test] + async fn cross_pod_until_beyond_ceiling_is_clamped_and_applied() { + let state = cross_pod_state(10).await; + let key = nostr::Keys::generate().public_key(); + let sessions = IssuerSessions::register(&state, TEST_ISS, &key); + let now = cross_pod_now(); + let policy = test_issuer_policy(); + let ceiling = now + + chrono::Duration::seconds(policy.skew_seconds() as i64) + + chrono::Duration::seconds(policy.maximum_assertion_age_seconds() as i64); + let message = buzz_pubsub::NipFiDisconnect { + until_unix: (ceiling + chrono::Duration::days(365)).timestamp(), + ..cross_pod_message(&key) + }; + + let result = apply_nip_fi_disconnect(&state, &message, now); + + assert_eq!( + result, + NipFiDisconnectApplyResult::Applied(buzz_auth::CrossPodMergeResult::Merged) + ); + sessions.assert_closed("clamped message"); + let deny_map = state.nip_fi_deny_map.as_deref().expect("deny map present"); + assert!(deny_map.is_denied(TEST_ISS, &key, now), "denied at now"); + assert!( + deny_map.is_denied(TEST_ISS, &key, ceiling - chrono::Duration::nanoseconds(1)), + "denied up to the local ceiling" + ); + assert!( + !deny_map.is_denied(TEST_ISS, &key, ceiling), + "admitted once the clamped ceiling is reached" + ); + } + // ── HTTP admission reads the shared deny map ───────────────────────────── // // Deny entries are written only through `POST /api/nip-fi/disconnect`; HTTP @@ -2378,25 +2490,6 @@ mod route_integration_tests { ); } - #[tokio::test] - async fn http_admission_readmits_after_deny_until_passes() { - let state = http_enforce_state().await; - let key = nostr::Keys::generate().public_key(); - // `until` is whole seconds from `now`: +3 leaves at least 2s of window. - deny_via_route(&state, &key, 3).await; - assert!( - http_admit(&state, TEST_ISS, &key).is_err(), - "(iss-A, k) is denied inside the window" - ); - - tokio::time::sleep(std::time::Duration::from_secs(4)).await; - - assert!( - http_admit(&state, TEST_ISS, &key).is_ok(), - "(iss-A, k) must be admitted again once `until` has passed" - ); - } - #[tokio::test] async fn one_route_deny_is_enforced_by_ws_and_http_admission() { use crate::router::build_router; diff --git a/crates/buzz-relay/src/nip_fi_config.rs b/crates/buzz-relay/src/nip_fi_config.rs index 876badfae97..992fe6a64c3 100644 --- a/crates/buzz-relay/src/nip_fi_config.rs +++ b/crates/buzz-relay/src/nip_fi_config.rs @@ -13,6 +13,15 @@ //! | `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. | //! +//! Each `BUZZ_NIP_FI_ISSUERS` entry also carries the S4 command-API fields. +//! Enforce-mode startup fails if any issuer lacks the required ones: +//! +//! | Field | Required | Constraint | +//! |---|---|---| +//! | `maximum_command_age_seconds` | Every issuer, in enforce | Integer in `[1, 60]`. | +//! | `authorized_principals` | Every issuer, in enforce | Non-empty array of `sub` values, each 1–2048 bytes; matched by exact, case-sensitive byte comparison. | +//! | `deny_set_capacity` | No | Integer > 0; defaults to [`crate::api::nip_fi::DEFAULT_DENY_SET_CAPACITY`] (50000). | +//! //! `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. @@ -49,7 +58,9 @@ const MAX_CONNECTION_LIFETIME_SECS: u64 = 30 * 24 * 3600; /// "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 +/// "jwks_hard_deadline_seconds": 86400, +/// "maximum_command_age_seconds": 30, +/// "authorized_principals": ["admin-svc@login.example.com"] /// } /// ] /// ``` @@ -77,13 +88,13 @@ pub(super) struct IssuerEnvConfig { /// Hard deadline for accepting a JWKS snapshot in seconds. pub jwks_hard_deadline_seconds: u64, - // ── S4 command-API fields (all optional) ────────────────────────────── - /// Maximum command JWT age in seconds; `0 < x ≤ 60`. Required to enable - /// the disconnect API for this issuer. + // ── S4 command-API fields (required in enforce mode) ────────────────── + /// Maximum command JWT age in seconds; `0 < x ≤ 60`. Required on every + /// issuer in enforce mode. #[serde(default)] pub maximum_command_age_seconds: Option, - /// Non-empty list of authorized `sub` values. Required when - /// `maximum_command_age_seconds` is set. + /// Non-empty list of authorized `sub` values. Required on every issuer + /// in enforce mode. #[serde(default)] pub authorized_principals: Option>, /// Hard ceiling on live deny entries for this issuer. Defaults to From b629422d094a6e7364b4587c355123c62de08418 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 29 Sep 2026 19:17:41 -0400 Subject: [PATCH 07/14] fix(nip-fi): send one authorization_denied close frame on every WS path FI-TRACE-DENIAL-ORACLE requires every authorization_denied row to be byte-identical, but S3 key mismatch and lease expiry closed with Close(None) while S4 deny-set hits and admin disconnects closed 1008. Every root and audio authorization_denied now goes through one first-writer-wins transition that enqueues the denial frame only for the winner and closes 1008, so a concurrent registry scan can no longer queue a second audio denial. OK(true) is also skipped when a concurrent disconnect already cancelled the session. Signed-off-by: Will Pfleger --- Justfile | 1 + crates/buzz-relay/src/audio/handler.rs | 403 ++++++++++++++++++------ crates/buzz-relay/src/connection.rs | 64 +++- crates/buzz-relay/src/handlers/auth.rs | 155 +++++++-- crates/buzz-relay/src/nip_fi_session.rs | 51 +-- crates/buzz-relay/src/state.rs | 92 +++--- scripts/run-tests.sh | 1 + 7 files changed, 568 insertions(+), 199 deletions(-) diff --git a/Justfile b/Justfile index 390d8e30f8a..4364f3de862 100644 --- a/Justfile +++ b/Justfile @@ -514,6 +514,7 @@ test-unit: + test(=connection::tests::f3_root_pre_built_expired_gate_terminates_connection) + test(=handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated) + test(=handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable) + + test(=handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames) + test(=handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected) + test(=handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path) + test(=handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence) diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 3a50f65fd2a..cac21f89fa3 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -232,16 +232,18 @@ pub(crate) async fn handle_audio_connection( // the inner handler drains it via ws_send. let (pre_terminal_ctrl_tx, pre_terminal_ctrl_rx) = tokio::sync::mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel); + let drain_reason = control.disconnect_reason(); let pre_expiry_task = audio_session_deadline.map(|deadline| { crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, Arc::clone(&pre_gate), + control.clone(), pre_terminal_ctrl_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Audio, ) }); - let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -308,6 +310,16 @@ pub(crate) async fn handle_audio_connection( .await; } } + // A published reason (e.g. an expiry denial) closes with its + // own frame, exactly as the writer would. + let close = drain_reason.borrow().map(|reason| reason.close_message()); + if let Some(close) = close { + let _ = tokio::time::timeout( + crate::connection::WS_TERMINAL_FLUSH_TIMEOUT, + futures_util::SinkExt::send(&mut ws_send, close), + ) + .await; + } let _ = tokio::time::timeout( crate::connection::WS_TERMINAL_FLUSH_TIMEOUT, futures_util::SinkExt::close(&mut ws_send), @@ -320,27 +332,28 @@ pub(crate) async fn handle_audio_connection( } /// Mid-admission cancellation fence for the audio route: if `$cancel` fired, -/// run any exit cleanup, drain the terminal channel (which holds the denial -/// frame queued by the expiry task) through the bounded exit writer while the -/// handler still owns the socket, and return. Used at every async boundary in -/// the admission sequence before `send_loop` takes the socket. +/// run any exit cleanup, write the [`terminal_exit_frames`] (the denial frame +/// queued by the expiry task or a denial writer, then the reason's close) +/// through the bounded exit writer while the handler still owns the socket, +/// and return. Used at every async boundary in the admission sequence before +/// `send_loop` takes the socket. macro_rules! check_cancel { - ($cancel:ident, $terminal_rx:ident, $ws_send:ident) => { - check_cancel!($cancel, $terminal_rx, $ws_send, cleanup: ()) + ($cancel:ident, $terminal_rx:ident, $reason:ident, $ws_send:ident) => { + check_cancel!($cancel, $terminal_rx, $reason, $ws_send, cleanup: ()) }; - ($cancel:ident, $terminal_rx:ident, $ws_send:ident, cleanup: $cleanup:expr) => { + ($cancel:ident, $terminal_rx:ident, $reason:ident, $ws_send:ident, cleanup: $cleanup:expr) => { if $cancel.is_cancelled() { $cleanup; crate::connection::send_exit_frames_bounded( &mut $ws_send, - std::iter::from_fn(|| $terminal_rx.try_recv().ok()), + terminal_exit_frames(&mut $terminal_rx, &$reason), ) .await; return; } }; - ($cancel:ident, $terminal_rx:ident, $ws_send:ident, release_lease: $lease:expr) => { - check_cancel!($cancel, $terminal_rx, $ws_send, cleanup: { + ($cancel:ident, $terminal_rx:ident, $reason:ident, $ws_send:ident, release_lease: $lease:expr) => { + check_cancel!($cancel, $terminal_rx, $reason, $ws_send, cleanup: { // Release any acquired lease before returning. Pre-guard path: // staged_lease may hold a lease that must be released before we // return, since the guard hasn't been built yet. @@ -361,6 +374,7 @@ async fn send_challenge_unless_cancelled( ws_send: &mut S, cancel: &CancellationToken, terminal_rx: &mut mpsc::Receiver, + disconnect_reason: &watch::Receiver>, challenge: WsMessage, ) -> bool where @@ -371,7 +385,7 @@ where _ = cancel.cancelled() => { crate::connection::send_exit_frames_bounded( ws_send, - std::iter::from_fn(|| terminal_rx.try_recv().ok()), + terminal_exit_frames(terminal_rx, disconnect_reason), ) .await; false @@ -449,6 +463,7 @@ pub(crate) async fn handle_active_audio_connection( crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, std::sync::Arc::clone(&gate), + control.clone(), tx.clone(), crate::nip_fi_session::NipFiWsRoute::Audio, ) @@ -461,24 +476,17 @@ pub(crate) async fn handle_active_audio_connection( control.set_terminal_frame_sender(terminal_ctrl_tx); // Already-expired fast path: catch a deadline already past at upgrade time - // before spending the AUTH_TIMEOUT window. Send the canonical denial frame - // directly (do not race against the spawned expiry task via try_recv — - // the task may not have run yet, leaving the channel empty). [FI-TRACE-DENIAL-ORACLE] + // before spending the AUTH_TIMEOUT window. Deny through the shared + // transition directly (do not wait on the spawned expiry task — it may not + // have run yet); whichever of the two wins queues the one frame. + // [FI-TRACE-DENIAL-ORACLE] if let Some(deadline) = audio_session_deadline { if chrono::Utc::now() >= deadline { warn!( channel_id = %channel_id, "NIP-FI session deadline already expired at audio upgrade — rejecting before auth" ); - crate::connection::send_exit_frames_bounded( - &mut ws_send, - [crate::nip_fi_session::denial_frame( - crate::nip_fi_session::NipFiWsRoute::Audio, - buzz_auth::DenialClass::AuthorizationDenied, - )], - ) - .await; - cancel.cancel(); + deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } } @@ -490,6 +498,7 @@ pub(crate) async fn handle_active_audio_connection( &mut ws_send, &cancel, &mut terminal_ctrl_rx, + &disconnect_reason, WsMessage::Text(challenge_msg.into()), ) .await @@ -503,7 +512,7 @@ pub(crate) async fn handle_active_audio_connection( // Gate or external cancel fired during auth. Drain denial frame. crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -558,7 +567,7 @@ pub(crate) async fn handle_active_audio_connection( // terminal channel so the denial frame reaches the client. crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -617,7 +626,8 @@ pub(crate) async fn handle_active_audio_connection( pubkey, crate::nip_fi_session::PairingDenialTarget::Audio { ws_send: &mut ws_send, - cancel: &cancel, + control: &control, + terminal_rx: &mut terminal_ctrl_rx, channel_id, }, ) @@ -638,15 +648,7 @@ pub(crate) async fn handle_active_audio_connection( pubkey = %pubkey_hex, "NIP-FI session deadline already expired at pairing — rejecting audio admission" ); - crate::connection::send_exit_frames_bounded( - &mut ws_send, - [crate::nip_fi_session::denial_frame( - crate::nip_fi_session::NipFiWsRoute::Audio, - buzz_auth::DenialClass::AuthorizationDenied, - )], - ) - .await; - cancel.cancel(); + deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } } @@ -678,19 +680,7 @@ pub(crate) async fn handle_active_audio_connection( pubkey = %pubkey_hex, "NIP-FI deny-set hit at audio post-registration check — denying" ); - crate::connection::send_exit_frames_bounded( - &mut ws_send, - [ - crate::nip_fi_session::denial_frame( - crate::nip_fi_session::NipFiWsRoute::Audio, - buzz_auth::DenialClass::AuthorizationDenied, - ), - crate::state::CommunityDisconnectReason::AuthorizationDenied - .close_message(), - ], - ) - .await; - cancel.cancel(); + deny_audio_authorization(&mut ws_send, &control, &mut terminal_ctrl_rx).await; return; } } @@ -720,15 +710,18 @@ pub(crate) async fn handle_active_audio_connection( }; if let Some(class) = relay_refusal { // Off mode keeps one legacy frame for both outcomes. - let deny_frame = authorization_exit_frame( + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, nip_fi_assertion.is_some(), class, serde_json::json!({"type": "error", "message": "restricted: not a relay member"}), - ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; + ) + .await; return; } - check_cancel!(cancel, terminal_ctrl_rx, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); // ── Step 3: membership check / auto-add ─────────────────────────────────── let membership_admission = match check_membership_for_admission( @@ -743,12 +736,15 @@ pub(crate) async fn handle_active_audio_connection( Ok(admission) => admission, Err(refusal) => { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {refusal}"); - let deny_frame = authorization_exit_frame( + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, nip_fi_assertion.is_some(), refusal.denial_class(), serde_json::json!({"type": "error", "message": "not a member"}), - ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; + ) + .await; return; } }; @@ -760,7 +756,7 @@ pub(crate) async fn handle_active_audio_connection( parent_channel_id, .. } => *parent_channel_id, }; - check_cancel!(cancel, terminal_ctrl_rx, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); // Huddle cross-pod routing (mesh) OR single-pod guardrail. // @@ -815,7 +811,7 @@ pub(crate) async fn handle_active_audio_connection( } crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -863,7 +859,7 @@ pub(crate) async fn handle_active_audio_connection( } // I1 residual: staged_lease may now hold an acquired lease. Release // it (awaited, not detached) before returning on cancel. - check_cancel!(cancel, terminal_ctrl_rx, ws_send, release_lease: staged_lease); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send, release_lease: staged_lease); } None => { if !state.config.huddle_audio_available { @@ -945,7 +941,7 @@ pub(crate) async fn handle_active_audio_connection( } // I1 residual: staged_lease may hold an acquired lease. Release it // (awaited, not detached) before returning on cancel. - check_cancel!(cancel, terminal_ctrl_rx, ws_send, release_lease: staged_lease); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send, release_lease: staged_lease); // Reject unsupported future versions up-front so we don't accidentally // pin a room to a version we can't speak. Versions 1..=CURRENT are OK. @@ -1084,7 +1080,7 @@ pub(crate) async fn handle_active_audio_connection( let _ = guard.release_before_commit().await; // pre-add-peer; owner_generation not set crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -1108,7 +1104,7 @@ pub(crate) async fn handle_active_audio_connection( let _ = guard.release_before_commit().await; // pre-add-peer; owner_generation not set crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -1273,7 +1269,7 @@ pub(crate) async fn handle_active_audio_connection( } crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -1575,7 +1571,7 @@ pub(crate) async fn handle_active_audio_connection( // Drain the terminal denial frame (already queued by expiry task). crate::connection::send_exit_frames_bounded( &mut ws_send, - std::iter::from_fn(|| terminal_ctrl_rx.try_recv().ok()), + terminal_exit_frames(&mut terminal_ctrl_rx, &disconnect_reason), ) .await; return; @@ -1625,12 +1621,15 @@ pub(crate) async fn handle_active_audio_connection( mesh.owners.release(channel_id, generation); } } - let deny_frame = authorization_exit_frame( + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, nip_fi_assertion.is_some(), buzz_auth::DenialClass::AuthorizationDenied, serde_json::json!({"type": "error", "message": "error: not a member"}), - ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; + ) + .await; return; } Err(JoinCommitError::HuddleLinkGone) => { @@ -1652,12 +1651,15 @@ pub(crate) async fn handle_active_audio_connection( } } // The creator-signed link is the authority for the auto-add. - let deny_frame = authorization_exit_frame( + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, nip_fi_assertion.is_some(), buzz_auth::DenialClass::AuthorizationDenied, serde_json::json!({"type":"error","message":"huddle has ended"}), - ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; + ) + .await; return; } Err(JoinCommitError::Db(e)) => { @@ -1677,12 +1679,15 @@ pub(crate) async fn handle_active_audio_connection( mesh.owners.release(channel_id, generation); } } - let deny_frame = authorization_exit_frame( + exit_authorization_refusal( + &mut ws_send, + &control, + &mut terminal_ctrl_rx, nip_fi_assertion.is_some(), buzz_auth::DenialClass::AuthorizationUnavailable, serde_json::json!({"type":"error","message":"error: join commit failed"}), - ); - crate::connection::send_exit_frames_bounded(&mut ws_send, [deny_frame]).await; + ) + .await; return; } } @@ -2578,6 +2583,64 @@ fn authorization_exit_frame( } } +/// Terminal `authorization_denied` exit for the audio admission path, before +/// `send_loop` owns the socket: runs the control's first-writer-wins denial +/// transition, then writes whatever the winner queued followed by the +/// reason's close. A concurrent registry scan therefore cannot add a second +/// denial, and every audio `authorization_denied` is the same restricted frame +/// plus 1008. [FI-TRACE-DENIAL-ORACLE] +pub(crate) async fn deny_audio_authorization( + ws_send: &mut S, + control: &CommunityConnectionControl, + terminal_rx: &mut mpsc::Receiver, +) where + S: futures_util::Sink + Unpin, +{ + control.disconnect_nip_fi(); + crate::connection::send_exit_frames_bounded( + ws_send, + terminal_exit_frames(terminal_rx, &control.disconnect_reason()), + ) + .await; +} + +/// Exit frames for an audio socket leaving before `send_loop` owns it: every +/// queued terminal frame, then the close for the published disconnect reason. +/// Without a reason (plain cancellation) no close is appended, as before. +fn terminal_exit_frames<'a>( + terminal_rx: &'a mut mpsc::Receiver, + disconnect_reason: &watch::Receiver>, +) -> impl Iterator + 'a { + let close = disconnect_reason + .borrow() + .map(|reason| reason.close_message()); + std::iter::from_fn(move || terminal_rx.try_recv().ok()).chain(close) +} + +/// Writes an authorization refusal on the audio admission path: a NIP-FI +/// `authorization_denied` takes [`deny_audio_authorization`]; any other +/// refusal writes its single [`authorization_exit_frame`]. +async fn exit_authorization_refusal( + ws_send: &mut S, + control: &CommunityConnectionControl, + terminal_rx: &mut mpsc::Receiver, + nip_fi: bool, + class: buzz_auth::DenialClass, + off_mode: serde_json::Value, +) where + S: futures_util::Sink + Unpin, +{ + if nip_fi && class == buzz_auth::DenialClass::AuthorizationDenied { + deny_audio_authorization(ws_send, control, terminal_rx).await; + } else { + crate::connection::send_exit_frames_bounded( + ws_send, + [authorization_exit_frame(nip_fi, class, off_mode)], + ) + .await; + } +} + /// Validate membership for audio admission — **no durable write**. /// /// Loads the channel, checks archival status, resolves the parent-channel @@ -3836,6 +3899,26 @@ mod tests { key: &nostr::Keys, sign_issued_challenge: bool, ) -> (Vec, bool) { + let (frames, cancelled) = + run_audio_auth_wire(state, assertion, key, sign_issued_challenge).await; + let texts = frames + .into_iter() + .filter_map(|frame| match frame { + tokio_tungstenite::tungstenite::Message::Text(t) => Some(t.to_string()), + _ => None, + }) + .collect(); + (texts, cancelled) + } + + /// [`run_audio_auth`], returning every Text and Close frame the client + /// received after the challenge, in wire order. + async fn run_audio_auth_wire( + state: std::sync::Arc, + assertion: Option, + key: &nostr::Keys, + sign_issued_challenge: bool, + ) -> (Vec, bool) { use std::sync::Arc; let tenant = buzz_core::tenant::TenantContext::resolved( buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::nil()), @@ -3908,12 +3991,14 @@ mod tests { .await .expect("handler must close the connection within the read budget"); match next { - Some(Ok(tokio_tungstenite::tungstenite::Message::Text(t))) => { - frames.push(t.to_string()) + Some(Ok(frame @ tokio_tungstenite::tungstenite::Message::Text(_))) => { + frames.push(frame) + } + Some(Ok(frame @ tokio_tungstenite::tungstenite::Message::Close(_))) => { + frames.push(frame); + break; } - Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) - | Some(Err(_)) - | None => break, + Some(Err(_)) | None => break, Some(Ok(_)) => {} } } @@ -3921,6 +4006,104 @@ mod tests { (frames, cancel_for_assert.is_cancelled()) } + /// FI-TRACE-DENIAL-ORACLE: an assertion–key mismatch and an active + /// deny-set entry are both `authorization_denied`, so the audio client + /// must see byte-identical frames: the restricted JSON, then 1008. + /// + /// Mutation: send the pairing denial frame directly (bypassing + /// `deny_audio_authorization`) → no Close(1008) and the sequences differ. + #[tokio::test] + async fn audio_key_mismatch_and_deny_set_emit_identical_frames() { + use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame}; + use tokio_tungstenite::tungstenite::Message; + let deadline = chrono::Utc::now() + chrono::Duration::hours(1); + + let asserted = nostr::Keys::generate(); + let assertion = + buzz_auth::VerifiedAssertion::for_test(Some(asserted.public_key()), vec![deadline]); + let (mismatch, mismatch_cancelled) = run_audio_auth_wire( + audio_deny_state(None).await, + Some(assertion), + &nostr::Keys::generate(), + true, + ) + .await; + + let denied = nostr::Keys::generate(); + let assertion = + buzz_auth::VerifiedAssertion::for_test(Some(denied.public_key()), vec![deadline]); + let (deny_set, deny_set_cancelled) = run_audio_auth_wire( + audio_deny_state(Some(&denied.public_key())).await, + Some(assertion), + &denied, + true, + ) + .await; + + assert!(mismatch_cancelled && deny_set_cancelled); + assert_eq!(mismatch, deny_set); + assert_eq!( + mismatch, + [ + Message::Text(audio_denial(buzz_auth::DenialClass::AuthorizationDenied).into()), + Message::Close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "authorization denied".into(), + })), + ] + ); + } + + /// Records every frame the denial writer emits for a registered audio + /// control, then lets a registry scan race it in `scan_first` order. + async fn audio_denial_with_concurrent_scan( + scan_first: bool, + ) -> (Vec, mpsc::Receiver) { + let registry = crate::state::CommunityConnectionRegistry::new(); + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let key = vec![0x42u8; 32]; + let control = CommunityConnectionControl::new(CancellationToken::new()); + let (terminal_tx, mut terminal_rx) = mpsc::channel::(1); + control.set_terminal_frame_sender(terminal_tx); + audio_post_auth_register(&control, key.clone(), Some("test-issuer".to_owned())); + let _guard = registry.register(uuid::Uuid::new_v4(), community, control.clone()); + + let mut wire = Vec::new(); + if scan_first { + assert_eq!(registry.disconnect_nip_fi("test-issuer", &key), 1); + } + deny_audio_authorization(&mut wire, &control, &mut terminal_rx).await; + if !scan_first { + registry.disconnect_nip_fi("test-issuer", &key); + } + (wire, terminal_rx) + } + + /// Winner-only enqueue: whichever of the handler's own denial and a + /// registry `disconnect_nip_fi` scan runs second writes nothing, so the + /// socket sees exactly one restricted frame and one 1008. + /// + /// Mutation: have `deny_audio_authorization` write its frames without the + /// reason transition → the later scan wins and queues a second denial. + #[tokio::test] + async fn audio_denial_and_registry_scan_emit_one_frame_sequence() { + let expected = [ + crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Audio, + buzz_auth::DenialClass::AuthorizationDenied, + ), + crate::state::CommunityDisconnectReason::AuthorizationDenied.close_message(), + ]; + for scan_first in [false, true] { + let (wire, mut terminal_rx) = audio_denial_with_concurrent_scan(scan_first).await; + assert_eq!(wire, expected, "scan_first = {scan_first}"); + assert!( + terminal_rx.try_recv().is_err(), + "no second denial may be queued (scan_first = {scan_first})" + ); + } + } + async fn run_audio_bad_nip42_proof( assertion: Option, ) -> (Vec, bool) { @@ -4579,7 +4762,9 @@ mod tests { // - Delete/change the audio enqueue in `spawn_nip_fi_expiry_task` → // output lacks or mismatches frame 0. // - Revert the audio send_loop cancellation drain → output begins with - // Close(None) or lacks the restricted frame entirely. + // a Close or lacks the restricted frame entirely. + // - Enqueue the expiry frame without the reason transition → frame 1 + // is Close(None), not 1008. // - Replace audio's production constructor call with a copied local task → // structural requirement: exactly one `spawn_nip_fi_expiry_task` // definition (in `nip_fi_session`) and two production invocations (root @@ -4591,7 +4776,7 @@ mod tests { use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; - use tokio::sync::{mpsc, watch}; + use tokio::sync::mpsc; // Recording sink that stores every message in order. struct RecordSink(Arc>>); @@ -4632,8 +4817,9 @@ mod tests { let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); let (terminal_tx, terminal_rx) = mpsc::channel::(1); let cancel = CancellationToken::new(); - let (disconnect_tx, disconnect_rx) = watch::channel(None); - drop(disconnect_tx); // plain Close(None) + // The expiry publishes its reason on the socket's own control. + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let disconnect_rx = control.disconnect_reason(); // Step 1: spawn audio send_loop and yield so it parks in its select. let send_cancel = cancel.clone(); @@ -4655,6 +4841,7 @@ mod tests { let expiry_handle = crate::nip_fi_session::spawn_nip_fi_expiry_task( already_expired, gate, + control, terminal_tx, crate::nip_fi_session::NipFiWsRoute::Audio, ); @@ -4690,11 +4877,11 @@ mod tests { other => panic!("frame 0 must be Text(restricted JSON); got {other:?}"), } - // Frame 1: Close(None). - assert!( - matches!(frames[1], WsMessage::Close(None)), - "frame 1 must be Close(None); got {:?}", - frames[1] + // Frame 1: the `authorization_denied` 1008 close. + assert_eq!( + frames[1], + crate::state::CommunityDisconnectReason::AuthorizationDenied.close_message(), + "frame 1 must be the 1008 authorization-denied close" ); } @@ -5256,14 +5443,19 @@ mod tests { ); let conn_cancel = CancellationToken::new(); - let (pre_terminal_tx, _pre_terminal_rx) = + let (pre_terminal_tx, pre_terminal_rx) = tokio::sync::mpsc::channel::(1); + // One control and one terminal channel shared by the expiry task and + // the handler, as `handle_audio_connection` wires them. + let control = crate::state::CommunityConnectionControl::new(conn_cancel.clone()); + let pre_terminal_rx = Arc::new(std::sync::Mutex::new(Some(pre_terminal_rx))); let pre_gate = crate::nip_fi_gate::SessionAdmissionGate::new(deadline, conn_cancel.clone()); // Fire the expiry task so the gate is expired and the token is // cancelled before the handler even inspects it. let pre_expiry = crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, Arc::clone(&pre_gate), + control.clone(), pre_terminal_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Audio, ); @@ -5273,7 +5465,8 @@ mod tests { let tenant_c = tenant.clone(); let assertion_c = assertion.clone(); let pre_gate_c = Arc::clone(&pre_gate); - let conn_cancel_c = conn_cancel.clone(); + let control_c = control.clone(); + let pre_terminal_rx_c = Arc::clone(&pre_terminal_rx); let pre_terminal_tx_c = pre_terminal_tx.clone(); let listener = TcpListener::bind("127.0.0.1:0") @@ -5289,14 +5482,16 @@ mod tests { let tenant_i = tenant_c.clone(); let assertion_i = assertion_c.clone(); let gate_i = Arc::clone(&pre_gate_c); - let cancel_i = conn_cancel_c.clone(); + let control_i = control_c.clone(); + let rx_i = Arc::clone(&pre_terminal_rx_c); let tx_i = pre_terminal_tx_c.clone(); move |ws: WebSocketUpgrade| { let state_i = Arc::clone(&state_i); let tenant_i = tenant_i.clone(); let assertion_i = assertion_i.clone(); let gate_i = Arc::clone(&gate_i); - let cancel_i = cancel_i.clone(); + let control_i = control_i.clone(); + let rx_i = Arc::clone(&rx_i); let tx_i = tx_i.clone(); let conn_time = chrono::Utc::now(); async move { @@ -5304,14 +5499,17 @@ mod tests { // Provide the pre-built terminal receive end. // The expiry task was spawned in the outer scope; // pass None for the JoinHandle (cannot move across). - let (_, rx) = - tokio::sync::mpsc::channel::(1); + let rx = rx_i + .lock() + .expect("rx slot") + .take() + .expect("single connection"); handle_active_audio_connection( socket, state_i, tenant_i, uuid::Uuid::new_v4(), - crate::state::CommunityConnectionControl::new(cancel_i), + control_i, Some(assertion_i), conn_time, Some((gate_i, tx_i, rx, None)), @@ -5363,6 +5561,19 @@ mod tests { ), other => panic!("F3-audio: expected Text(restricted JSON); got {other:?}"), } + let close = tokio::time::timeout(std::time::Duration::from_millis(500), client.next()) + .await + .expect("F3-audio: close must follow the denial"); + assert!( + matches!( + &close, + Some(Ok(tokio_tungstenite::tungstenite::Message::Close(Some(frame)))) + if frame.code + == tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy + && frame.reason.as_str() == "authorization denied" + ), + "F3-audio: expiry is `authorization_denied` and closes 1008; got {close:?}" + ); // Drain pre_expiry to avoid leaking tasks. let _ = tokio::time::timeout(std::time::Duration::from_secs(1), pre_expiry).await; @@ -13017,9 +13228,10 @@ mod tests { .expect("queue terminal frame"); let cancel = CancellationToken::new(); cancel.cancel(); + let disconnect_reason = tokio::sync::watch::channel(None).1; let fence = async { - check_cancel!(cancel, terminal_ctrl_rx, ws_send); + check_cancel!(cancel, terminal_ctrl_rx, disconnect_reason, ws_send); panic!("check_cancel! must return on a cancelled token"); }; tokio::time::timeout( @@ -13066,6 +13278,7 @@ mod tests { &mut ws_send, &cancel, &mut terminal_rx, + &tokio::sync::watch::channel(None).1, WsMessage::Text("challenge".into()), ), ) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index cceee16b070..7a892e126a4 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -382,16 +382,18 @@ pub async fn handle_connection( crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()) }; let (pre_terminal_ctrl_tx, pre_terminal_ctrl_rx) = mpsc::channel::(1); + let control = CommunityConnectionControl::new(cancel); + let drain_reason = control.disconnect_reason(); let pre_expiry_task = pre_session_deadline.map(|deadline| { crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, Arc::clone(&pre_gate), + control.clone(), pre_terminal_ctrl_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Root, ) }); - let control = CommunityConnectionControl::new(cancel); let community_id = tenant.community(); let registry = Arc::clone(&state.community_connections); let check_state = Arc::clone(&state); @@ -467,6 +469,16 @@ pub async fn handle_connection( .await; } } + // A published reason (e.g. an expiry denial) closes with its + // own frame, exactly as the writer would. + let close = drain_reason.borrow().map(|reason| reason.close_message()); + if let Some(close) = close { + let _ = tokio::time::timeout( + WS_TERMINAL_FLUSH_TIMEOUT, + futures_util::SinkExt::send(&mut ws_send, close), + ) + .await; + } // Close the socket so the client sees a clean close rather than // an abrupt TCP reset. let _ = tokio::time::timeout( @@ -693,6 +705,7 @@ async fn handle_active_connection( crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, Arc::clone(&nip_fi_gate), + conn.community_control.clone(), conn.terminal_ctrl_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Root, ) @@ -2028,6 +2041,30 @@ pub(crate) mod tests { rx } + /// Every frame the production root `send_loop_inner` writes for a + /// cancelled `conn` whose terminal receiver is `terminal_rx`: the terminal + /// drain followed by the close chosen from the connection's disconnect + /// reason. Shared with `handlers::auth`'s wire-equality tests. + pub(crate) async fn root_wire_frames( + conn: &ConnectionState, + terminal_rx: mpsc::Receiver, + ) -> Vec { + assert!(conn.cancel.is_cancelled(), "denial must cancel the socket"); + let (sink, state) = MockSink::new(None); + send_loop_inner( + sink, + mpsc::channel(1).1, + mpsc::channel(1).1, + terminal_rx, + mpsc::channel(1).1, + conn.cancel.clone(), + conn.community_control.disconnect_reason(), + ) + .await; + let mut recorded = state.lock().expect("mock sink poisoned"); + std::mem::take(&mut recorded.messages) + } + fn deleted_community_disconnect_reason() -> watch::Receiver> { let (tx, rx) = watch::channel(None); tx.send_replace(Some(CommunityDisconnectReason::CommunityDeleted)); @@ -2562,6 +2599,7 @@ pub(crate) mod tests { let expiry_task = crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, gate, + crate::state::CommunityConnectionControl::new(cancel.clone()), terminal_ctrl_tx, crate::nip_fi_session::NipFiWsRoute::Root, ); @@ -2771,8 +2809,15 @@ pub(crate) mod tests { // cancel the token. let already_expired = Utc::now() - chrono::Duration::seconds(1); let gate = crate::nip_fi_gate::SessionAdmissionGate::new(already_expired, cancel.clone()); - let expiry_handle = - spawn_nip_fi_expiry_task(already_expired, gate, terminal_ctrl_tx, NipFiWsRoute::Root); + let control = crate::state::CommunityConnectionControl::new(cancel.clone()); + let disconnect_reason = control.disconnect_reason(); + let expiry_handle = spawn_nip_fi_expiry_task( + already_expired, + gate, + control, + terminal_ctrl_tx, + NipFiWsRoute::Root, + ); // Wait for the expiry task to fire before we run the send_loop. expiry_handle.await.expect("expiry task must complete"); @@ -2784,7 +2829,7 @@ pub(crate) mod tests { terminal_ctrl_rx, restart_rx, cancel, - ordinary_disconnect_reason(), + disconnect_reason, ) .await; @@ -2805,6 +2850,11 @@ pub(crate) mod tests { denial_pos < close_pos, "B3: expiry denial frame (pos {denial_pos}) must precede Close frame (pos {close_pos})" ); + // Expiry is `authorization_denied`: the same 1008 as every other row. + assert_eq!( + msgs[close_pos], + CommunityDisconnectReason::AuthorizationDenied.close_message() + ); } // ── F3: bootstrap deadline witness — root WS route ────────────────────────── @@ -2882,15 +2932,15 @@ pub(crate) mod tests { deadline, conn_cancel.clone(), ); + let control = + crate::state::CommunityConnectionControl::new(conn_cancel); let pre_expiry = crate::nip_fi_session::spawn_nip_fi_expiry_task( deadline, Arc::clone(&pre_gate), + control.clone(), pre_tx.clone(), crate::nip_fi_session::NipFiWsRoute::Root, ); - - let control = - crate::state::CommunityConnectionControl::new(conn_cancel); handle_active_connection( socket, state_i, diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index e8e34b4a975..b1ba2ee5845 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -85,14 +85,23 @@ pub(crate) fn nip42_denial_class(error: &buzz_auth::AuthError) -> buzz_auth::Den /// NIP-FI post-upgrade AUTH denial: queue the canonical Root NOTICE for /// `class` on the terminal channel, then close. Callers invoke this only when /// `conn.nip_fi_assertion` is present, so every FI denial is uniform in frame -/// type, body, and close behaviour. [FI-TRACE-DENIAL-ORACLE] +/// type, body, and close behaviour. `authorization_denied` goes through the +/// shared first-writer-wins transition, so it closes with the same 1008 as a +/// deny-set hit or admin disconnect. [FI-TRACE-DENIAL-ORACLE] fn deny_nip_fi_auth(conn: &ConnectionState, class: buzz_auth::DenialClass) { - let _ = conn - .terminal_ctrl_tx - .try_send(crate::nip_fi_session::denial_frame( + if class == buzz_auth::DenialClass::AuthorizationDenied { + conn.community_control.deny_authorization( + &conn.terminal_ctrl_tx, crate::nip_fi_session::NipFiWsRoute::Root, - class, - )); + ); + } else { + let _ = conn + .terminal_ctrl_tx + .try_send(crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + class, + )); + } conn.cancel.cancel(); } @@ -501,15 +510,16 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: "reason" => "deny_set_post_registration" ) .increment(1); - conn.community_control.auth_deny_terminal( - &conn.terminal_ctrl_tx, - crate::nip_fi_session::NipFiWsRoute::Root, - ); - conn.cancel.cancel(); + deny_nip_fi_auth(&conn, buzz_auth::DenialClass::AuthorizationDenied); return; } } } + // A concurrent `disconnect_nip_fi` may have closed this session + // after the deny-set check; its denial is the terminal reply. + if conn.cancel.is_cancelled() { + return; + } conn.send(RelayMessage::ok(&event_id_hex, true, "")); // _auth_permit drops here — expiry's write guard may proceed. } @@ -794,6 +804,12 @@ mod tests { } other => panic!("terminal frame must be Text(NOTICE); got {other:?}"), } + // Same public class as a deny-set hit, so the send loop closes 1008. + assert_eq!( + *conn.community_control.disconnect_reason().borrow(), + Some(crate::state::CommunityDisconnectReason::AuthorizationDenied), + "pairing mismatch must publish AuthorizationDenied" + ); } // ── B2: Cancelled connection is never admitted to Authenticated state ────── @@ -947,13 +963,25 @@ mod tests { } fn auth_event(&self) -> nostr::Event { + self.auth_event_signed_by(&self.key) + } + + /// An AUTH over the issued challenge signed by `key`; a key other + /// than the asserted one is a pairing mismatch. + fn auth_event_signed_by(&self, key: &Keys) -> nostr::Event { EventBuilder::new(Kind::Authentication, "") .tag(Tag::parse(["relay", "ws://test.local"]).unwrap()) .tag(Tag::parse(["challenge", &self.challenge]).unwrap()) - .sign_with_keys(&self.key) + .sign_with_keys(key) .unwrap() } + /// The frames the production root send loop writes for this + /// (already denied) connection. + async fn wire_frames(self) -> Vec { + crate::connection::tests::root_wire_frames(&self.conn, self.terminal_rx).await + } + async fn run(&self, event: nostr::Event, state: std::sync::Arc) { handle_auth(event, std::sync::Arc::clone(&self.conn), state).await; } @@ -983,6 +1011,14 @@ mod tests { "FI denial must close the socket" ); assert!(matches!(self.conn.auth_state_snapshot(), AuthState::Failed)); + // `authorization_denied` closes 1008 like a deny-set hit; the + // other classes keep the bare close. + let expected_reason = (class == buzz_auth::DenialClass::AuthorizationDenied) + .then_some(crate::state::CommunityDisconnectReason::AuthorizationDenied); + assert_eq!( + *self.conn.community_control.disconnect_reason().borrow(), + expected_reason + ); } fn assert_off_mode_ok(mut self, reason: &str) { @@ -1096,6 +1132,86 @@ mod tests { harness.assert_fi_terminal(buzz_auth::DenialClass::AuthorizationUnavailable); } + /// Root wire frames for an FI session whose NIP-42 key is not the + /// asserted one, driven through the production `handle_auth`. + async fn root_key_mismatch_wire_frames( + state: std::sync::Arc, + ) -> Vec { + let harness = AuthHarness::new(true); + harness + .run(harness.auth_event_signed_by(&Keys::generate()), state) + .await; + harness.wire_frames().await + } + + /// FI-TRACE-DENIAL-ORACLE: an assertion–key mismatch, an admin disconnect + /// (deny-set entry), and a lease expiry are all `authorization_denied`, so + /// the root socket must see byte-identical frames: the NOTICE, then 1008. + /// (The handler's own deny-set hit is compared against the same mismatch + /// frames in the Postgres lane's `w_deny_pre_registration_denied_by_handler_check`.) + /// + /// Mutation: route the pairing or expiry denial around the shared + /// transition (bare terminal enqueue) → its close is `Close(None)` and the + /// sequences differ. + #[tokio::test] + async fn fi_root_authorization_denied_rows_emit_identical_frames() { + let state = auth_test_state().await; + let mismatch = root_key_mismatch_wire_frames(std::sync::Arc::clone(&state)).await; + + let harness = AuthHarness::new(true); + let conn = &harness.conn; + state.conn_manager.register( + conn.conn_id, + conn.send_tx.clone(), + conn.ctrl_tx.clone(), + conn.terminal_ctrl_tx.clone(), + None, + conn.cancel.clone(), + conn.tenant.community(), + std::sync::Arc::clone(&conn.backpressure_count), + std::sync::Arc::clone(&conn.subscriptions), + conn.grace_limit, + conn.community_control.clone(), + ); + let pubkey = harness.key.public_key().to_bytes().to_vec(); + state.conn_manager.set_authenticated_identity( + conn.conn_id, + pubkey.clone(), + Some("test-issuer".to_owned()), + ); + assert_eq!( + state.conn_manager.disconnect_nip_fi("test-issuer", &pubkey), + 1 + ); + let admin_disconnect = harness.wire_frames().await; + + let harness = AuthHarness::new(true); + let expired = chrono::Utc::now() - chrono::Duration::seconds(1); + crate::nip_fi_session::spawn_nip_fi_expiry_task( + expired, + crate::nip_fi_gate::SessionAdmissionGate::new(expired, harness.conn.cancel.clone()), + harness.conn.community_control.clone(), + harness.conn.terminal_ctrl_tx.clone(), + crate::nip_fi_session::NipFiWsRoute::Root, + ) + .await + .expect("expiry task"); + let expiry = harness.wire_frames().await; + + assert_eq!(mismatch, admin_disconnect); + assert_eq!(mismatch, expiry); + assert_eq!( + mismatch, + [ + crate::nip_fi_session::denial_frame( + crate::nip_fi_session::NipFiWsRoute::Root, + buzz_auth::DenialClass::AuthorizationDenied, + ), + crate::state::CommunityDisconnectReason::AuthorizationDenied.close_message(), + ] + ); + } + // ── W1 (auth barrier): expiry fired mid-flight blocks AUTH commit ───────── // // This test requires a real PostgreSQL instance. It lives in `postgres_tests` @@ -1341,7 +1457,7 @@ mod tests { // The denial frame must be on the terminal channel (authorization_denied). // Both the close-scan side (manager_disconnect_nip_fi) and the check side - // (auth_deny_terminal) enqueue on terminal_ctrl_tx, which has capacity-1 + // (deny_authorization) enqueue on terminal_ctrl_tx, which has capacity-1 // and first-writer-wins semantics — exactly one frame lands there. let terminal_frame = terminal_ctrl_rx .try_recv() @@ -1688,13 +1804,14 @@ mod tests { cancel.is_cancelled(), "handler check must cancel the session" ); - let expected = crate::protocol::RelayMessage::notice( - buzz_auth::DenialClass::AuthorizationDenied.nostr_text(), + // The deny-set row is byte-identical on the wire to the + // assertion–key mismatch row. [FI-TRACE-DENIAL-ORACLE] + let frames = + crate::connection::tests::root_wire_frames(&conn, terminal_ctrl_rx).await; + assert_eq!( + frames, + super::root_key_mismatch_wire_frames(Arc::clone(&state)).await ); - match terminal_ctrl_rx.try_recv() { - Ok(WsMessage::Text(t)) => assert_eq!(t.as_str(), expected.as_str()), - other => panic!("expected authorization_denied NOTICE; got {other:?}"), - } } else { assert!(ok_true, "clean key must receive OK(true)"); assert!(!cancel.is_cancelled(), "clean key must not be cancelled"); diff --git a/crates/buzz-relay/src/nip_fi_session.rs b/crates/buzz-relay/src/nip_fi_session.rs index 64a2ab5904c..fc9926ffb99 100644 --- a/crates/buzz-relay/src/nip_fi_session.rs +++ b/crates/buzz-relay/src/nip_fi_session.rs @@ -17,7 +17,6 @@ use axum::extract::ws::Message as WsMessage; use tokio::sync::mpsc; -use tokio_util::sync::CancellationToken; use tracing::warn; use uuid::Uuid; @@ -53,7 +52,8 @@ pub(crate) enum PairingDenialTarget<'a> { axum::extract::ws::WebSocket, axum::extract::ws::Message, >, - cancel: &'a CancellationToken, + control: &'a crate::state::CommunityConnectionControl, + terminal_rx: &'a mut mpsc::Receiver, channel_id: Uuid, }, } @@ -106,16 +106,17 @@ pub(crate) async fn enforce_nip_fi_key_pairing( ); conn.reject_auth(crate::metrics::AuthOutcome::PairingMismatch); // Use the dedicated terminal channel — guaranteed one free slot even - // when ctrl_tx (capacity 8) is saturated by ordinary control traffic. - let _ = conn.terminal_ctrl_tx.try_send(denial_frame( - NipFiWsRoute::Root, - buzz_auth::DenialClass::AuthorizationDenied, - )); + // when ctrl_tx (capacity 8) is saturated by ordinary control traffic — + // through the shared first-writer-wins transition, so the close is + // the same 1008 as a deny-set hit. [FI-TRACE-DENIAL-ORACLE] + conn.community_control + .deny_authorization(&conn.terminal_ctrl_tx, NipFiWsRoute::Root); conn.cancel.cancel(); } PairingDenialTarget::Audio { ws_send, - cancel, + control, + terminal_rx, channel_id, } => { warn!( @@ -124,15 +125,7 @@ pub(crate) async fn enforce_nip_fi_key_pairing( proven_pubkey = %proven_pubkey.to_hex(), "NIP-FI key pairing mismatch — closing connection" ); - crate::connection::send_exit_frames_bounded( - ws_send, - [denial_frame( - NipFiWsRoute::Audio, - buzz_auth::DenialClass::AuthorizationDenied, - )], - ) - .await; - cancel.cancel(); + crate::audio::handler::deny_audio_authorization(ws_send, control, terminal_rx).await; } } @@ -165,8 +158,10 @@ pub(crate) fn denial_frame(route: NipFiWsRoute, class: buzz_auth::DenialClass) - /// At `deadline`, the task: /// 1. Calls `gate.expire(terminal)` with the route-specific terminal closure. /// Inside `gate.expire()`: -/// a. The terminal closure enqueues the denial frame on `terminal_ctrl_tx` -/// and increments the lease-expiration metric. +/// a. The terminal closure publishes `authorization_denied` through +/// `control`'s first-writer-wins transition (enqueueing the denial frame +/// on `terminal_ctrl_tx` only if it wins, so the writer closes 1008) and +/// increments the lease-expiration metric. /// b. `cancel.cancel()` — socket termination starts immediately. /// c. The gate acquires the write guard (quiescence barrier) — blocks until /// all outstanding effect permits are released, then records `Expired`. @@ -177,6 +172,7 @@ pub(crate) fn denial_frame(route: NipFiWsRoute, class: buzz_auth::DenialClass) - pub(crate) fn spawn_nip_fi_expiry_task( deadline: chrono::DateTime, gate: std::sync::Arc, + control: crate::state::CommunityConnectionControl, terminal_ctrl_tx: mpsc::Sender, route: NipFiWsRoute, ) -> tokio::task::JoinHandle<()> { @@ -191,10 +187,7 @@ pub(crate) fn spawn_nip_fi_expiry_task( std::time::Duration::ZERO }; let terminal = || { - let _ = terminal_ctrl_tx.try_send(denial_frame( - route, - buzz_auth::DenialClass::AuthorizationDenied, - )); + control.deny_authorization(&terminal_ctrl_tx, route); metrics::counter!("buzz_nip_fi_lease_expirations_total").increment(1); warn!( route = ?route, @@ -355,8 +348,13 @@ mod tests { let already_expired = Utc::now() - chrono::Duration::seconds(1); let gate = crate::nip_fi_gate::SessionAdmissionGate::new(already_expired, cancel.clone()); - let handle = - spawn_nip_fi_expiry_task(already_expired, gate, terminal_tx, NipFiWsRoute::Root); + let handle = spawn_nip_fi_expiry_task( + already_expired, + gate, + crate::state::CommunityConnectionControl::new(cancel.clone()), + terminal_tx, + NipFiWsRoute::Root, + ); handle.await.expect("expiry task must complete"); assert!( @@ -414,6 +412,7 @@ mod tests { let worker = spawn_nip_fi_expiry_task( deadline, Arc::clone(&gate), + crate::state::CommunityConnectionControl::new(cancel.clone()), terminal_tx, NipFiWsRoute::Audio, ); @@ -440,6 +439,7 @@ mod tests { let worker = spawn_nip_fi_expiry_task( deadline, Arc::clone(&gate), + crate::state::CommunityConnectionControl::new(cancel.clone()), terminal_tx, NipFiWsRoute::Audio, ); @@ -463,6 +463,7 @@ mod tests { let worker = spawn_nip_fi_expiry_task( deadline, Arc::clone(&gate), + crate::state::CommunityConnectionControl::new(cancel.clone()), terminal_tx, NipFiWsRoute::Audio, ); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index c42fe26e23f..06f23947d9f 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -134,16 +134,19 @@ impl CommunityConnectionControl { *slot = Some(tx); } - /// Terminal transition for the post-registration deny-set check in the - /// auth handler: under the transition lock, publishes `AuthorizationDenied` - /// first-writer-wins and enqueues the denial frame only if it won. Does - /// not cancel; the caller cancels after this returns. [FI-TRACE-DENY-SET] - pub(crate) fn auth_deny_terminal( + /// First-writer-wins `authorization_denied` transition shared by every + /// NIP-FI denial writer: under the transition lock, publishes + /// `AuthorizationDenied` only if no reason is set yet, and only the winner + /// enqueues `route`'s denial frame — on `frame_tx`, else on the sender + /// registered by `set_terminal_frame_sender`. Every writer therefore + /// yields the same single frame plus the reason's 1008 close. + /// Does not cancel. [FI-TRACE-DENIAL-ORACLE] + fn publish_authorization_denied( &self, - frame_tx: &mpsc::Sender, route: crate::nip_fi_session::NipFiWsRoute, + frame_tx: Option<&mpsc::Sender>, ) { - let _lock = self + let slot = self .terminal_frame_tx .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -154,62 +157,45 @@ impl CommunityConnectionControl { } Some(_) => false, }); - if won { - let _ = frame_tx.try_send(crate::nip_fi_session::denial_frame( + if !won { + return; + } + if let Some(tx) = frame_tx.or(slot.as_ref()) { + let _ = tx.try_send(crate::nip_fi_session::denial_frame( route, buzz_auth::DenialClass::AuthorizationDenied, )); } } - /// Denial transition for `ConnectionManager::disconnect_nip_fi`: same - /// winner-only enqueue as `auth_deny_terminal`, on the root connection's - /// `terminal_ctrl_tx` (drained first by `send_loop` on cancel), then cancels. + /// `authorization_denied` decided on the socket itself (key mismatch, + /// deny-set hit, ban/allowlist/membership refusal, lease expiry): the + /// shared first-writer-wins transition on `frame_tx`. Does not cancel; + /// the caller cancels after this returns. [FI-TRACE-DENY-SET] + pub(crate) fn deny_authorization( + &self, + frame_tx: &mpsc::Sender, + route: crate::nip_fi_session::NipFiWsRoute, + ) { + self.publish_authorization_denied(route, Some(frame_tx)); + } + + /// Denial transition for `ConnectionManager::disconnect_nip_fi`: the shared + /// transition on the root connection's `terminal_ctrl_tx` (drained first + /// by `send_loop` on cancel), then cancels. pub(crate) fn manager_disconnect_nip_fi(&self, frame_tx: &mpsc::Sender) { - let slot = self - .terminal_frame_tx - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let won = self.reason_tx.send_if_modified(|current| match current { - None => { - *current = Some(CommunityDisconnectReason::AuthorizationDenied); - true - } - Some(_) => false, - }); - if won { - let _ = frame_tx.try_send(crate::nip_fi_session::denial_frame( - crate::nip_fi_session::NipFiWsRoute::Root, - buzz_auth::DenialClass::AuthorizationDenied, - )); - } - drop(slot); + self.publish_authorization_denied( + crate::nip_fi_session::NipFiWsRoute::Root, + Some(frame_tx), + ); self.cancel.cancel(); } - /// Denial transition for registry sockets (audio): winner-only enqueue on - /// the sender registered by `set_terminal_frame_sender`, then cancel. - fn disconnect_nip_fi(&self) { - let slot = self - .terminal_frame_tx - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let won = self.reason_tx.send_if_modified(|current| match current { - None => { - *current = Some(CommunityDisconnectReason::AuthorizationDenied); - true - } - Some(_) => false, - }); - if won { - if let Some(ref tx) = *slot { - let _ = tx.try_send(crate::nip_fi_session::denial_frame( - crate::nip_fi_session::NipFiWsRoute::Audio, - buzz_auth::DenialClass::AuthorizationDenied, - )); - } - } - drop(slot); + /// Audio-route denial transition, used by the registry scan and the audio + /// handler's own denials: the shared transition on the sender registered + /// by `set_terminal_frame_sender`, then cancels. + pub(crate) fn disconnect_nip_fi(&self) { + self.publish_authorization_denied(crate::nip_fi_session::NipFiWsRoute::Audio, None); self.cancel.cancel(); } diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 08c2818dced..dd75c368a43 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -257,6 +257,7 @@ run_unit_tests() { connection::tests::f3_root_pre_built_expired_gate_terminates_connection handlers::auth::tests::b2_pre_cancelled_connection_never_becomes_authenticated handlers::auth::tests::fi_ban_check_error_emits_terminal_authorization_unavailable + handlers::auth::tests::fi_root_authorization_denied_rows_emit_identical_frames handlers::auth::tests::fi_invalid_nip42_proof_emits_terminal_evidence_rejected handlers::auth::tests::handle_auth_pairing_mismatch_runs_full_root_denial_path handlers::auth::tests::nip42_denial_class_separates_internal_failure_from_bad_evidence From 655ba2c35c52b58b67d1d85fce1cf593304994dd Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 30 Sep 2026 14:07:36 -0400 Subject: [PATCH 08/14] fix(nip-fi): fence command jti replay across pods with a shared Redis claim The (iss, jti) reservation lived only in the receiving pod's deny map, so one captured command JWT was accepted once per pod, each acceptance re-publishing a cluster-wide disconnect. The verifier now takes an atomic Redis SET NX EX claim after authentication, mirroring the NIP-98 guard, and fails closed when Redis errors. The claim is released when the local insert hits capacity so a 503 deny set full stays retryable. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .github/workflows/_ci-relay.yml | 8 + crates/buzz-auth/src/lib.rs | 20 +- crates/buzz-auth/src/nip_fi/command.rs | 110 ++++++++-- crates/buzz-auth/src/nip_fi/command_replay.rs | 109 ++++++++++ crates/buzz-auth/src/nip_fi/mod.rs | 4 + crates/buzz-pubsub/src/lib.rs | 2 + .../buzz-pubsub/src/nip_fi_command_replay.rs | 81 ++++++++ crates/buzz-relay/src/api/nip_fi.rs | 192 +++++++++++++++++- crates/buzz-relay/src/state.rs | 11 +- 9 files changed, 502 insertions(+), 35 deletions(-) create mode 100644 crates/buzz-auth/src/nip_fi/command_replay.rs create mode 100644 crates/buzz-pubsub/src/nip_fi_command_replay.rs diff --git a/.github/workflows/_ci-relay.yml b/.github/workflows/_ci-relay.yml index cf87d7a6cad..a498336ae98 100644 --- a/.github/workflows/_ci-relay.yml +++ b/.github/workflows/_ci-relay.yml @@ -511,6 +511,14 @@ jobs: --archive-file target/ci/backend-integration-tests.tar.zst \ -E 'package(buzz-relay) and (test(/^rejection::/) or test(/^admission::/))' \ --run-ignored all + - name: NIP-FI cross-pod command replay regressions + env: + BUZZ_TEST_REDIS_URL: redis://localhost:6379 + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/^api::nip_fi::route_integration_tests::external_infra_redis::/)' \ + --run-ignored ignored-only - name: Workflow message provenance unit tests # The relay's workflow_sink suite is not selected by the infra-free # unit job. Its ignored database cases run in the isolated PostgreSQL diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index 7dc2b59b532..ce16d4e00d4 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -46,15 +46,15 @@ pub use rate_limit::{ pub use scope::{parse_scopes, Scope}; pub use nip_fi::{ - validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, CanonicalCapabilities, - ClientSubjectPosture, CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, - CommandVerifier, ConfidentialAssertion, CrossPodMergeResult, DenialClass, DenySetFull, - FederatedAssertionVerifier, FederatedIdentity, FederatedIdentityDiscovery, FreshnessClass, - HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, IssuerKeySource, IssuerPolicy, - IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, JwksSourceContract, - NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, RevalidationDependencies, - SubjectClass, SubjectClassContract, TokenClass, TransportContractId, VerifiedAssertion, - VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, + command_replay_key, validate_nip_fi_config, AssertionKeySet, AssertionPolicyId, + CanonicalCapabilities, ClientSubjectPosture, CommandError, CommandIssuerPolicy, + CommandPolicyError, CommandReplayGuard, CommandResult, CommandVerifier, ConfidentialAssertion, + CrossPodMergeResult, DenialClass, DenySetFull, FederatedAssertionVerifier, FederatedIdentity, + FederatedIdentityDiscovery, FreshnessClass, HttpJwksFetcher, IssuerCapacity, IssuerJwksConfig, + IssuerKeySource, IssuerPolicy, IssuerPolicyError, IssuerRegistry, JwksFetchError, JwksFetcher, + JwksSourceContract, NipFiDenyMap, NipFiMode, NipFiStartupError, ProductionJwksSource, + RevalidationDependencies, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, + VerifiedAssertion, VerifierError, VerifyAssertion, CLIENT_ATTACHED_HEADER, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, NOSTR_PUBKEY_CLAIM, OAUTH_CLIENT_ID_CLAIM, }; @@ -65,6 +65,8 @@ pub use nip98_replay::AlwaysFreshReplayGuard; #[cfg(any(test, feature = "test-utils"))] pub use nip_fi::jwks::ScriptedJwksFetcher; #[cfg(any(test, feature = "test-utils"))] +pub use nip_fi::InMemoryCommandReplayGuard; +#[cfg(any(test, feature = "test-utils"))] pub use nip_fi::StaticIssuerKeySource; #[cfg(any(test, feature = "test-utils"))] pub use nip_fi::ToggleJwksFetcher; diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index 739f96d9151..eb97a24da9c 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -11,7 +11,8 @@ //! target_pubkey, and until ceiling. //! 4. Principal authorization (issuer-configured authorized `sub` list). //! 5. Signed-target / request-body agreement. -//! 6. Atomic jti reservation + deny-entry insertion (both-or-neither). +//! 6. Shared `(iss, jti)` claim ([`super::command_replay`]), then atomic local +//! jti reservation + deny-entry insertion (both-or-neither). //! 7. Return [`CommandResult`]. //! //! Fail-closed: any failure returns an error without side effects. The jti is @@ -22,6 +23,7 @@ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use nostr::PublicKey; use serde_json::{Map, Value}; +use super::command_replay::CommandReplayGuard; use super::config::{ IssuerPolicy, IssuerRegistry, MAX_JTI_BYTES, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, }; @@ -247,28 +249,57 @@ impl CommandVerifier { &self.deny_map } - /// Execute `VerifyCommandJwt` at current clock time. + /// Execute `VerifyCommandJwt` at current clock time, fenced cluster-wide + /// by a shared `(iss, jti)` claim. /// /// Parameters: /// * `token` — compact JWS from `Nostr-Federated-Identity: Bearer`. /// * `request_method` — HTTP method (expected `"POST"`). /// * `request_path` — HTTP path (expected `"/api/nip-fi/disconnect"`). /// * `body_pubkey` — the `pubkey` field parsed from the JSON body. + /// * `replay` — the shared command replay guard. /// - /// On `Ok`, the jti is reserved and the deny entry is inserted. - /// On `Err`, no side effects have occurred (or, on `DenySetFull`, neither - /// mutation was applied, so retry is safe). - pub fn verify( + /// The shared claim is taken only after authentication, and released if + /// the local reservation fails for capacity, so the jti is burned + /// cluster-wide only when the local deny entry is inserted. A guard error + /// fails closed as `AuthorizationUnavailable` with no side effects. + pub async fn verify( &self, token: &str, request_method: &str, request_path: &str, body_pubkey: &PublicKey, + replay: &dyn CommandReplayGuard, ) -> Result { - self.verify_at(token, request_method, request_path, body_pubkey, Utc::now()) + let now = Utc::now(); + let cmd = self.authenticate_at(token, request_method, request_path, body_pubkey, now)?; + // Cover the command's remaining validity plus this pod's skew, so a + // pod whose clock trails still sees the claim; the guard floors this. + let remaining = (cmd.effective_expiry - now).num_seconds().max(0) as u64; + match replay + .try_claim(&cmd.issuer, &cmd.jti, remaining + cmd.skew_seconds) + .await + { + Ok(true) => {} + Ok(false) => return Err(CommandError::AuthorizationDenied), + Err(_) => return Err(CommandError::AuthorizationUnavailable), + } + let result = self.commit(&cmd, now); + if result == Err(CommandError::DenySetFull) { + if let Err(e) = replay.release(&cmd.issuer, &cmd.jti).await { + // The jti stays claimed until its TTL; a retry gets 403. + tracing::warn!("nip-fi command: replay claim release failed: {e}"); + } + } + result } - /// Verify with an injectable clock for deterministic testing. + /// Verify against this pod's deny map only, with an injectable clock. + /// + /// On `Ok`, the jti is reserved and the deny entry is inserted locally. + /// On `Err`, no side effects have occurred (or, on `DenySetFull`, neither + /// mutation was applied, so retry is safe). Production uses + /// [`Self::verify`], which adds the cross-pod replay claim. pub fn verify_at( &self, token: &str, @@ -277,6 +308,19 @@ impl CommandVerifier { body_pubkey: &PublicKey, now: DateTime, ) -> Result { + let cmd = self.authenticate_at(token, request_method, request_path, body_pubkey, now)?; + self.commit(&cmd, now) + } + + /// Steps 1–5: authenticate and authorize the command with no side effects. + fn authenticate_at( + &self, + token: &str, + request_method: &str, + request_path: &str, + body_pubkey: &PublicKey, + now: DateTime, + ) -> Result { // ── Step 1: bounded decode + typ check ─────────────────────────────── if token.is_empty() || token.len() > MAX_TOKEN_BYTES { return Err(CommandError::EvidenceRejected); @@ -417,17 +461,30 @@ impl CommandVerifier { return Err(CommandError::AuthorizationDenied); } - // ── Steps 6+7: atomic jti reservation + deny-entry insertion ───────── - // - // effective_expiry = min(exp, iat + maximum_command_age). - let effective_expiry = exp.min(iat_plus_cmd_age); + Ok(AuthenticatedCommand { + target_pubkey, + issuer: base_policy.issuer().to_owned(), + sub: sub.to_owned(), + jti: jti.to_owned(), + // effective_expiry = min(exp, iat + maximum_command_age). + effective_expiry: exp.min(iat_plus_cmd_age), + until, + skew_seconds: base_policy.skew_seconds(), + }) + } + /// Steps 6+7: atomic local jti reservation + deny-entry insertion. + fn commit( + &self, + cmd: &AuthenticatedCommand, + now: DateTime, + ) -> Result { match self.deny_map.atomic_reserve_and_insert( - base_policy.issuer(), - jti, - effective_expiry, - &target_pubkey, - until, + &cmd.issuer, + &cmd.jti, + cmd.effective_expiry, + &cmd.target_pubkey, + cmd.until, now, ) { Ok(()) => {} @@ -436,14 +493,25 @@ impl CommandVerifier { } Ok(CommandResult { - target_pubkey, - caller_iss: base_policy.issuer().to_owned(), - caller_sub: sub.to_owned(), - until, + target_pubkey: cmd.target_pubkey, + caller_iss: cmd.issuer.clone(), + caller_sub: cmd.sub.clone(), + until: cmd.until, }) } } +/// A command that passed steps 1–5 and has not yet touched any state. +struct AuthenticatedCommand { + target_pubkey: PublicKey, + issuer: String, + sub: String, + jti: String, + effective_expiry: DateTime, + until: DateTime, + skew_seconds: u64, +} + // ── Internal helpers ────────────────────────────────────────────────────────── fn claim_str<'a>(claims: &'a Map, key: &str) -> Option<&'a str> { diff --git a/crates/buzz-auth/src/nip_fi/command_replay.rs b/crates/buzz-auth/src/nip_fi/command_replay.rs new file mode 100644 index 00000000000..efec6cb62c4 --- /dev/null +++ b/crates/buzz-auth/src/nip_fi/command_replay.rs @@ -0,0 +1,109 @@ +//! NIP-FI command replay protection — shared, deployment-wide `(iss, jti)` claim. +//! +//! [`super::command::CommandVerifier`] reserves `(iss, jti)` in the receiving +//! pod's deny map, but a command accepted by one pod is not visible to another +//! pod's map. Like NIP-98 replay protection ([`crate::nip98_replay`]), the +//! cross-pod fence is an atomic set-if-absent in shared state (Redis). +//! +//! The claim is taken after the command is fully authenticated (so a forgery +//! cannot burn a legitimate `jti`) and released if the local deny-entry +//! insertion then fails for capacity, so a `503 deny set full` leaves the +//! command retryable. + +use std::{future::Future, pin::Pin}; + +use sha2::{Digest, Sha256}; + +use crate::error::AuthError; + +/// Shared seen-set for NIP-FI command `(iss, jti)` pairs. +/// +/// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). +/// An in-memory implementation is provided behind +/// `cfg(any(test, feature = "test-utils"))`. +pub trait CommandReplayGuard: Send + Sync { + /// Atomically claim `(issuer, jti)`. + /// + /// Returns `Ok(true)` when newly claimed (proceed) and `Ok(false)` when + /// already claimed (the caller MUST reject the command as replay). On + /// `Err` callers MUST fail closed. Implementations MUST use an atomic + /// set-if-absent and clamp `ttl_secs` to + /// [`crate::DEFAULT_REPLAY_TTL_SECS`]..=[`crate::MAX_REPLAY_TTL_SECS`], + /// as the NIP-98 guard does. + fn try_claim<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ttl_secs: u64, + ) -> Pin> + Send + 'a>>; + + /// Release a claim this caller took whose command then failed locally. + fn release<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ) -> Pin> + Send + 'a>>; +} + +/// Redis key for a command replay claim: +/// `buzz:nip-fi:command:{sha256(len(iss) || iss || len(jti) || jti)}`. +/// +/// `iss` is an arbitrary URI and `jti` is up to 512 bytes, so the pair is +/// hashed with big-endian `u64` length prefixes: the key is fixed-length and +/// no two distinct pairs share an encoding. Commands are deployment-wide +/// (they close sessions in every community), so the key has no community +/// scope. The `nip-fi:command` segment never matches a NIP-98 key, whose +/// final segments are always `:nip98:{event_id_hex}`. +pub fn command_replay_key(issuer: &str, jti: &str) -> String { + let mut hasher = Sha256::new(); + for part in [issuer, jti] { + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part.as_bytes()); + } + format!("buzz:nip-fi:command:{}", hex::encode(hasher.finalize())) +} + +/// Process-local seen-set for tests. Instances shared via `Arc` model pods +/// sharing one Redis. +#[cfg(any(test, feature = "test-utils"))] +#[derive(Default)] +pub struct InMemoryCommandReplayGuard(std::sync::Mutex>); + +#[cfg(any(test, feature = "test-utils"))] +impl CommandReplayGuard for InMemoryCommandReplayGuard { + fn try_claim<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + _ttl_secs: u64, + ) -> Pin> + Send + 'a>> { + let key = command_replay_key(issuer, jti); + Box::pin(async move { Ok(self.0.lock().expect("replay set").insert(key)) }) + } + + fn release<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ) -> Pin> + Send + 'a>> { + let key = command_replay_key(issuer, jti); + Box::pin(async move { + self.0.lock().expect("replay set").remove(&key); + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_is_length_prefixed_and_fixed_length() { + // Concatenation-ambiguous pairs must map to distinct keys. + assert_ne!(command_replay_key("ab", "c"), command_replay_key("a", "bc")); + let long = command_replay_key("https://issuer.example", &"j".repeat(512)); + assert_eq!(long.len(), "buzz:nip-fi:command:".len() + 64); + assert!(!long.contains(":nip98:")); + } +} diff --git a/crates/buzz-auth/src/nip_fi/mod.rs b/crates/buzz-auth/src/nip_fi/mod.rs index fb55dfee745..5e0bb346aa3 100644 --- a/crates/buzz-auth/src/nip_fi/mod.rs +++ b/crates/buzz-auth/src/nip_fi/mod.rs @@ -10,6 +10,7 @@ pub const CLIENT_ATTACHED_HEADER: &str = "Nostr-Federated-Identity"; pub mod assertion; pub mod command; +pub mod command_replay; pub mod config; pub mod denial; pub mod deny_map; @@ -26,6 +27,9 @@ pub use command::{ CommandError, CommandIssuerPolicy, CommandPolicyError, CommandResult, CommandVerifier, COMMAND_JWT_TYP, MAX_COMMAND_AGE_SECONDS, }; +#[cfg(any(test, feature = "test-utils"))] +pub use command_replay::InMemoryCommandReplayGuard; +pub use command_replay::{command_replay_key, CommandReplayGuard}; pub use config::{ AssertionPolicyId, ClientSubjectPosture, FreshnessClass, IssuerPolicy, IssuerPolicyError, IssuerRegistry, SubjectClass, SubjectClassContract, TokenClass, TransportContractId, diff --git a/crates/buzz-pubsub/src/lib.rs b/crates/buzz-pubsub/src/lib.rs index 0240af66484..2bf86598a39 100644 --- a/crates/buzz-pubsub/src/lib.rs +++ b/crates/buzz-pubsub/src/lib.rs @@ -30,6 +30,8 @@ pub mod error; /// Redis-backed NIP-98 replay seen-set. pub mod nip98_replay; pub use nip98_replay::RedisNip98ReplayGuard; +pub mod nip_fi_command_replay; +pub use nip_fi_command_replay::RedisCommandReplayGuard; /// Online/offline presence tracking in Redis. pub mod presence; /// Redis PUBLISH for channel event fan-out. diff --git a/crates/buzz-pubsub/src/nip_fi_command_replay.rs b/crates/buzz-pubsub/src/nip_fi_command_replay.rs new file mode 100644 index 00000000000..ed58ddd38a0 --- /dev/null +++ b/crates/buzz-pubsub/src/nip_fi_command_replay.rs @@ -0,0 +1,81 @@ +//! Redis-backed NIP-FI command replay seen-set. +//! +//! Implements [`CommandReplayGuard`] from `buzz-auth` with the same +//! `SET NX EX` shape and TTL clamp as [`crate::RedisNip98ReplayGuard`]. + +use buzz_auth::{ + command_replay_key, error::AuthError, CommandReplayGuard, DEFAULT_REPLAY_TTL_SECS, + MAX_REPLAY_TTL_SECS, +}; + +/// Redis-backed NIP-FI command replay seen-set. +/// +/// `try_claim` issues `SET buzz:nip-fi:command:{hash} 1 NX EX `; `OK` +/// is a first claim and `nil` is a replay. `release` is a `DEL`. +pub struct RedisCommandReplayGuard { + pool: deadpool_redis::Pool, +} + +impl RedisCommandReplayGuard { + /// Create a new replay guard backed by the given Redis connection pool. + pub fn new(pool: deadpool_redis::Pool) -> Self { + Self { pool } + } + + async fn conn(&self) -> Result { + self.pool.get().await.map_err(|e| { + tracing::warn!(error = %e, "nip-fi command replay: redis pool acquire failed"); + AuthError::Internal(format!("Redis pool: {e}")) + }) + } +} + +impl CommandReplayGuard for RedisCommandReplayGuard { + fn try_claim<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ttl_secs: u64, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let ttl = ttl_secs.clamp(DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS); + let mut conn = self.conn().await?; + let result: Option = redis::cmd("SET") + .arg(command_replay_key(issuer, jti)) + .arg("1") + .arg("NX") + .arg("EX") + .arg(ttl) + .query_async(&mut *conn) + .await + .map_err(|e| { + tracing::warn!(error = %e, "nip-fi command replay: redis SET NX EX failed"); + AuthError::Internal(format!("Redis SET NX EX: {e}")) + })?; + match result.as_deref() { + Some("OK") => Ok(true), + None => Ok(false), + Some(other) => Err(AuthError::Internal(format!( + "unexpected SET NX EX reply: {other}" + ))), + } + }) + } + + fn release<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + let mut conn = self.conn().await?; + redis::cmd("DEL") + .arg(command_replay_key(issuer, jti)) + .query_async::<()>(&mut *conn) + .await + .map_err(|e| AuthError::Internal(format!("Redis DEL: {e}"))) + }) + } +} diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 97592cf467b..0a03426ed76 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -126,7 +126,15 @@ pub async fn disconnect( } }; - let result = verifier.verify(token, "POST", "/api/nip-fi/disconnect", &body_pubkey); + let result = verifier + .verify( + token, + "POST", + "/api/nip-fi/disconnect", + &body_pubkey, + state.nip_fi_command_replay.as_ref(), + ) + .await; match result { Ok(cmd) => { @@ -935,6 +943,7 @@ mod route_integration_tests { state.nip_fi_deny_map = Some(Arc::clone(&deny_map)); state.nip_fi_command_verifier = Some(verifier); + state.nip_fi_command_replay = Arc::new(buzz_auth::InMemoryCommandReplayGuard::default()); state } @@ -1835,7 +1844,7 @@ mod route_integration_tests { let target = nostr::Keys::generate().public_key(); let token = mint_token(&target.to_hex(), 300, serde_json::json!({})); let verifier = state.nip_fi_command_verifier.as_ref().unwrap(); - let result = verifier.verify(&token, "POST", TEST_PATH, &target); + let result = verifier.verify_at(&token, "POST", TEST_PATH, &target, chrono::Utc::now()); assert!( result.is_ok(), "verifier must accept a valid command: {result:?}" @@ -2010,7 +2019,8 @@ mod route_integration_tests { let target = nostr::Keys::generate().public_key(); let command = mint_token(&target.to_hex(), 300, serde_json::json!({})); let command_verifier = state.nip_fi_command_verifier.as_ref().unwrap(); - let result = command_verifier.verify(&command, "POST", TEST_PATH, &target); + let result = + command_verifier.verify_at(&command, "POST", TEST_PATH, &target, chrono::Utc::now()); assert!( result.is_ok(), "command verifier must read the same warmed shared source; got: {result:?}" @@ -2576,4 +2586,180 @@ mod route_integration_tests { .expect("body"); assert_eq!(got, want, "Off-mode legacy response must be byte-identical"); } + + // ── Cross-pod command replay fence ─────────────────────────────────────── + // + // Each "pod" is its own AppState with its own deny map and sessions; the + // pods share only the command replay guard, as production pods share Redis. + + async fn pod(capacity: usize, replay: Arc) -> Arc { + let mut state = build_test_app_state(capacity, crate::config::Config::for_test()).await; + state.nip_fi_command_replay = replay; + Arc::new(state) + } + + async fn post_command( + state: &Arc, + token: &str, + target: &str, + ) -> (StatusCode, axum::body::Bytes) { + let resp = do_request( + Arc::clone(state), + "POST", + vec![ + ("Content-Type", "application/json".into()), + (CLIENT_ATTACHED_HEADER, format!("Bearer {token}")), + ], + Some(serde_json::json!({"pubkey": target})), + ) + .await; + let status = resp.status(); + ( + status, + axum::body::to_bytes(resp.into_body(), 64).await.unwrap(), + ) + } + + fn is_denied(state: &AppState, key: &nostr::PublicKey) -> bool { + state + .nip_fi_deny_map + .as_deref() + .expect("deny map") + .is_denied(TEST_ISS, key, chrono::Utc::now()) + } + + async fn command_replayed_on_second_pod_is_denied_without_effect( + replay_a: Arc, + replay_b: Arc, + ) { + let pod_a = pod(1000, replay_a).await; + let pod_b = pod(1000, replay_b).await; + let key = nostr::Keys::generate().public_key(); + let on_b = IssuerSessions::register(&pod_b, TEST_ISS, &key); + let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + + let (status, _) = post_command(&pod_a, &token, &key.to_hex()).await; + assert_eq!(status, StatusCode::OK, "first use is accepted on pod A"); + + let (status, body) = post_command(&pod_b, &token, &key.to_hex()).await; + assert_eq!(status, StatusCode::FORBIDDEN, "replay on pod B is denied"); + assert_eq!(body.as_ref(), b"authorization denied\n"); + assert!( + !is_denied(&pod_b, &key), + "replay must not insert a deny entry on B" + ); + on_b.assert_open("pod B after rejected replay"); + } + + #[tokio::test] + async fn command_replay_on_second_pod_is_denied() { + let shared: Arc = + Arc::new(buzz_auth::InMemoryCommandReplayGuard::default()); + command_replayed_on_second_pod_is_denied_without_effect(Arc::clone(&shared), shared).await; + } + + struct FailingReplayGuard; + + impl buzz_auth::CommandReplayGuard for FailingReplayGuard { + fn try_claim<'a>( + &'a self, + _issuer: &'a str, + _jti: &'a str, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { + Err(buzz_auth::AuthError::Internal( + "simulated Redis outage".into(), + )) + }) + } + + fn release<'a>( + &'a self, + _issuer: &'a str, + _jti: &'a str, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + Box::pin(async { Ok(()) }) + } + } + + #[tokio::test] + async fn command_replay_guard_error_fails_closed_without_effect() { + let state = pod(1000, Arc::new(FailingReplayGuard)).await; + let key = nostr::Keys::generate().public_key(); + let sessions = IssuerSessions::register(&state, TEST_ISS, &key); + let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + + let (status, body) = post_command(&state, &token, &key.to_hex()).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body.as_ref(), b"authorization unavailable\n"); + assert!(!is_denied(&state, &key), "no deny entry on guard error"); + sessions.assert_open("guard error"); + } + + async fn deny_set_full_leaves_command_retryable( + replay: Arc, + ) { + let state = pod(1, replay).await; + let filler = target_hex(); + let target = target_hex(); + // The filler entry expires within two seconds and frees the only slot. + let filler_token = mint_token(&filler, 1, serde_json::json!({})); + assert_eq!( + post_command(&state, &filler_token, &filler).await.0, + StatusCode::OK + ); + + let token = mint_token(&target, 300, serde_json::json!({})); + let (status, body) = post_command(&state, &token, &target).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body.as_ref(), b"deny set full\n"); + + tokio::time::sleep(std::time::Duration::from_millis(2100)).await; + let (status, _) = post_command(&state, &token, &target).await; + assert_eq!( + status, + StatusCode::OK, + "the 503 must not burn the shared jti claim" + ); + } + + #[tokio::test] + async fn command_deny_set_full_leaves_shared_claim_retryable() { + deny_set_full_leaves_command_retryable(Arc::new( + buzz_auth::InMemoryCommandReplayGuard::default(), + )) + .await; + } + + mod external_infra_redis { + use super::*; + + fn redis_guard() -> Arc { + let url = std::env::var("BUZZ_TEST_REDIS_URL") + .or_else(|_| std::env::var("REDIS_URL")) + .unwrap_or_else(|_| "redis://127.0.0.1:6379".into()); + let pool = deadpool_redis::Config::from_url(url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + Arc::new(buzz_pubsub::RedisCommandReplayGuard::new(pool)) + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_command_replay_on_second_pod_is_denied() { + command_replayed_on_second_pod_is_denied_without_effect(redis_guard(), redis_guard()) + .await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_deny_set_full_leaves_shared_claim_retryable() { + deny_set_full_leaves_command_retryable(redis_guard()).await; + } + } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 3e00239d2bb..75e21db3fdd 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use buzz_audit::AuditService; -use buzz_auth::{AuthService, Nip98ReplayGuard}; +use buzz_auth::{AuthService, CommandReplayGuard, Nip98ReplayGuard}; use buzz_core::tenant::TenantContext; use buzz_core::CommunityId; use buzz_db::Db; @@ -24,7 +24,7 @@ use buzz_media::MediaStorage; use buzz_pubsub::cache_invalidation::CacheInvalidation; use buzz_pubsub::conn_control::ConnControl; use buzz_pubsub::rate_limiter::RedisRateLimiter; -use buzz_pubsub::{PubSubManager, RedisNip98ReplayGuard}; +use buzz_pubsub::{PubSubManager, RedisCommandReplayGuard, RedisNip98ReplayGuard}; use buzz_search::SearchService; use buzz_workflow::WorkflowEngine; use deadpool_redis; @@ -1204,6 +1204,10 @@ pub struct AppState { /// atomic jti-reservation + deny-entry insertion happens inside `verify()`. pub nip_fi_command_verifier: Option>>>, + /// Shared NIP-FI command `(iss, jti)` replay claim — the cross-pod fence + /// on top of the verifier's per-pod reservation. Redis `SET NX EX`, like + /// `nip98_replay`; callers fail closed on error. + pub nip_fi_command_replay: Arc, } impl AppState { @@ -1289,6 +1293,8 @@ impl AppState { ); let nip98_replay: Arc = Arc::new(RedisNip98ReplayGuard::new(redis_pool.clone())); + let nip_fi_command_replay: Arc = + Arc::new(RedisCommandReplayGuard::new(redis_pool.clone())); 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(); @@ -1394,6 +1400,7 @@ impl AppState { // call: the endpoint returns 503 when the verifier is absent. nip_fi_deny_map: None, nip_fi_command_verifier: None, + nip_fi_command_replay, }; ( state, From b769d2b189c9a293f8a9cd253d64044cd071e151 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 30 Sep 2026 15:24:48 -0400 Subject: [PATCH 09/14] fix(nip-fi): round the command claim TTL up to whole seconds Truncating the remaining validity let the shared claim expire up to a second before a verifier trailing by the full skew stopped accepting the JWT; with skew above the guard's 120s floor the floor did not hide it. Also gate the local-only verify_at behind test-utils and state the fail-closed guarantee precisely. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/command.rs | 51 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index eb97a24da9c..33dc5a76d94 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -15,8 +15,10 @@ //! jti reservation + deny-entry insertion (both-or-neither). //! 7. Return [`CommandResult`]. //! -//! Fail-closed: any failure returns an error without side effects. The jti is -//! burned and the deny entry is inserted only on success. +//! Fail-closed: any failure inserts no deny entry, closes no session and +//! publishes nothing. A failure after step 6's shared claim may leave that +//! claim in place until its TTL (a guard error whose `SET` reply was lost, or a +//! failed release); that is fail closed, since a retry is then denied. use chrono::{DateTime, Utc}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; @@ -262,7 +264,9 @@ impl CommandVerifier { /// The shared claim is taken only after authentication, and released if /// the local reservation fails for capacity, so the jti is burned /// cluster-wide only when the local deny entry is inserted. A guard error - /// fails closed as `AuthorizationUnavailable` with no side effects. + /// fails closed as `AuthorizationUnavailable`: no deny entry, no session + /// close, no publish. The shared claim may still exist (the guard's reply + /// was lost, or a release failed), so a retry is denied until its TTL. pub async fn verify( &self, token: &str, @@ -275,9 +279,12 @@ impl CommandVerifier { let cmd = self.authenticate_at(token, request_method, request_path, body_pubkey, now)?; // Cover the command's remaining validity plus this pod's skew, so a // pod whose clock trails still sees the claim; the guard floors this. - let remaining = (cmd.effective_expiry - now).num_seconds().max(0) as u64; match replay - .try_claim(&cmd.issuer, &cmd.jti, remaining + cmd.skew_seconds) + .try_claim( + &cmd.issuer, + &cmd.jti, + claim_ttl_secs(cmd.effective_expiry, now, cmd.skew_seconds), + ) .await { Ok(true) => {} @@ -300,6 +307,7 @@ impl CommandVerifier { /// On `Err`, no side effects have occurred (or, on `DenySetFull`, neither /// mutation was applied, so retry is safe). Production uses /// [`Self::verify`], which adds the cross-pod replay claim. + #[cfg(any(test, feature = "test-utils"))] pub fn verify_at( &self, token: &str, @@ -514,6 +522,15 @@ struct AuthenticatedCommand { // ── Internal helpers ────────────────────────────────────────────────────────── +/// Shared-claim TTL: the command's remaining validity rounded up to whole +/// seconds, plus skew, so the claim outlives acceptance by a verifier whose +/// clock trails by the full skew. +fn claim_ttl_secs(effective_expiry: DateTime, now: DateTime, skew_seconds: u64) -> u64 { + let remaining = effective_expiry - now; + let whole = remaining.num_seconds() + i64::from(remaining.subsec_nanos() > 0); + whole.max(0) as u64 + skew_seconds +} + fn claim_str<'a>(claims: &'a Map, key: &str) -> Option<&'a str> { claims.get(key)?.as_str().filter(|s| !s.is_empty()) } @@ -583,6 +600,30 @@ fn verify_jwt_signature( mod tests { use super::*; use crate::nip_fi::deny_map::IssuerCapacity; + + #[test] + fn claim_ttl_outlives_acceptance_by_verifier_lagging_full_skew() { + let expiry = DateTime::from_timestamp(1060, 0).unwrap(); + let now = DateTime::from_timestamp(1000, 250_000_000).unwrap(); + let skew = 300; + let ttl = claim_ttl_secs(expiry, now, skew); + assert!( + ttl > 120, + "above the guard's 120s floor, so the floor cannot mask it" + ); + // A verifier trailing by `skew` accepts until this instant on our clock. + let last_lagging_acceptance = expiry + chrono::Duration::seconds(skew as i64); + let claim_expiry = now + chrono::Duration::seconds(ttl as i64); + assert!( + claim_expiry >= last_lagging_acceptance, + "claim expires at {claim_expiry}, lagging verifier accepts until {last_lagging_acceptance}" + ); + assert_eq!(claim_ttl_secs(expiry, expiry, skew), skew); + assert_eq!( + claim_ttl_secs(expiry, expiry + chrono::Duration::milliseconds(250), skew), + skew + ); + } use chrono::{Duration, Utc}; // ── CommandIssuerPolicy validation ──────────────────────────────────────── From d55d89dd69ac9f93373ae7fb3734a78ceb421462 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 30 Sep 2026 15:29:14 -0400 Subject: [PATCH 10/14] test(nip-fi): pin authenticate-before-claim and observe disconnect publishes A forged command carrying a real command's (iss, jti) must be rejected without touching the shared claim, so the legitimate command still succeeds on another pod. The Redis variants subscribe to the disconnect channel to prove rejected replays and guard errors publish nothing while the accepted command publishes once. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/nip_fi.rs | 191 +++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 0a03426ed76..71decd1d6ff 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2628,18 +2628,86 @@ mod route_integration_tests { .is_denied(TEST_ISS, key, chrono::Utc::now()) } + /// Counts cross-pod disconnect publications for one target on the Redis + /// channel the pods publish to. Tests filter by target, so concurrent + /// tests sharing Redis do not interfere. + struct PublishObserver { + rx: tokio::sync::mpsc::UnboundedReceiver, + target: Vec, + seen: usize, + } + + impl PublishObserver { + async fn subscribe(state: &AppState, target: &nostr::PublicKey) -> Self { + use futures::StreamExt; + let client = redis::Client::open(state.config.redis_url.as_str()).expect("redis url"); + let mut conn = client.get_async_pubsub().await.expect("redis pubsub"); + conn.subscribe(buzz_pubsub::conn_control::NIP_FI_DISCONNECT_CHANNEL) + .await + .expect("subscribe"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut messages = conn.into_on_message(); + while let Some(msg) = messages.next().await { + let payload: String = msg.get_payload().expect("payload"); + let cmd = buzz_pubsub::decode_nip_fi_disconnect(&payload).expect("decode"); + if tx.send(cmd).is_err() { + break; + } + } + }); + Self { + rx, + target: target.to_bytes().to_vec(), + seen: 0, + } + } + + /// Publications for the target so far, after letting spawned + /// publishes land. + async fn count(&mut self) -> usize { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + while let Ok(cmd) = self.rx.try_recv() { + if cmd.pubkey_bytes == self.target { + self.seen += 1; + } + } + self.seen + } + } + + async fn observe( + enabled: bool, + state: &AppState, + target: &nostr::PublicKey, + ) -> Option { + match enabled { + true => Some(PublishObserver::subscribe(state, target).await), + false => None, + } + } + + async fn assert_publishes(observer: &mut Option, expected: usize, what: &str) { + if let Some(observer) = observer { + assert_eq!(observer.count().await, expected, "publications: {what}"); + } + } + async fn command_replayed_on_second_pod_is_denied_without_effect( replay_a: Arc, replay_b: Arc, + observe_publishes: bool, ) { let pod_a = pod(1000, replay_a).await; let pod_b = pod(1000, replay_b).await; let key = nostr::Keys::generate().public_key(); let on_b = IssuerSessions::register(&pod_b, TEST_ISS, &key); let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + let mut publishes = observe(observe_publishes, &pod_a, &key).await; let (status, _) = post_command(&pod_a, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::OK, "first use is accepted on pod A"); + assert_publishes(&mut publishes, 1, "accepted command").await; let (status, body) = post_command(&pod_b, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::FORBIDDEN, "replay on pod B is denied"); @@ -2649,13 +2717,104 @@ mod route_integration_tests { "replay must not insert a deny entry on B" ); on_b.assert_open("pod B after rejected replay"); + assert_publishes(&mut publishes, 1, "rejected replay adds none").await; } #[tokio::test] async fn command_replay_on_second_pod_is_denied() { let shared: Arc = Arc::new(buzz_auth::InMemoryCommandReplayGuard::default()); - command_replayed_on_second_pod_is_denied_without_effect(Arc::clone(&shared), shared).await; + command_replayed_on_second_pod_is_denied_without_effect(Arc::clone(&shared), shared, false) + .await; + } + + /// Delegates to a real guard and counts `try_claim` calls. + struct CountingReplayGuard { + inner: buzz_auth::InMemoryCommandReplayGuard, + claims: std::sync::atomic::AtomicUsize, + } + + impl buzz_auth::CommandReplayGuard for CountingReplayGuard { + fn try_claim<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ttl_secs: u64, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + self.claims + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.inner.try_claim(issuer, jti, ttl_secs) + } + + fn release<'a>( + &'a self, + issuer: &'a str, + jti: &'a str, + ) -> std::pin::Pin< + Box> + Send + 'a>, + > { + self.inner.release(issuer, jti) + } + } + + /// A command whose signature does not verify but which carries a real + /// command's `(iss, jti)` must not consume that command's shared claim. + async fn forged_command_leaves_shared_claim_unconsumed( + replay: Arc, + claims: Option<&CountingReplayGuard>, + observe_publishes: bool, + ) { + let pod_a = pod(1000, Arc::clone(&replay)).await; + let pod_b = pod(1000, replay).await; + let key = nostr::Keys::generate().public_key(); + let on_a = IssuerSessions::register(&pod_a, TEST_ISS, &key); + let jti = serde_json::json!({ "jti": uuid::Uuid::new_v4().to_string() }); + let legit = mint_token(&key.to_hex(), 300, jti.clone()); + // Legitimate header and payload, with a well-formed signature over a + // different payload. + let other = mint_token(&target_hex(), 300, jti); + let (signed_part, _) = legit.rsplit_once('.').expect("compact JWS"); + let (_, foreign_sig) = other.rsplit_once('.').expect("compact JWS"); + let forged = format!("{signed_part}.{foreign_sig}"); + let mut publishes = observe(observe_publishes, &pod_a, &key).await; + + let (status, body) = post_command(&pod_a, &forged, &key.to_hex()).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "forged signature is rejected" + ); + assert_eq!(body.as_ref(), b"evidence rejected\n"); + assert!(!is_denied(&pod_a, &key), "forgery inserts no deny entry"); + on_a.assert_open("pod A after forgery"); + assert_publishes(&mut publishes, 0, "forgery").await; + if let Some(guard) = claims { + assert_eq!( + guard.claims.load(std::sync::atomic::Ordering::SeqCst), + 0, + "invalid evidence must never reach the shared guard" + ); + } + + let (status, _) = post_command(&pod_b, &legit, &key.to_hex()).await; + assert_eq!( + status, + StatusCode::OK, + "the legitimate command's jti was not burned by the forgery" + ); + assert!(is_denied(&pod_b, &key)); + assert_publishes(&mut publishes, 1, "accepted command").await; + } + + #[tokio::test] + async fn forged_command_does_not_consume_shared_claim() { + let guard = Arc::new(CountingReplayGuard { + inner: buzz_auth::InMemoryCommandReplayGuard::default(), + claims: std::sync::atomic::AtomicUsize::new(0), + }); + forged_command_leaves_shared_claim_unconsumed(guard.clone(), Some(&guard), false).await; } struct FailingReplayGuard; @@ -2687,18 +2846,24 @@ mod route_integration_tests { } } - #[tokio::test] - async fn command_replay_guard_error_fails_closed_without_effect() { + async fn guard_error_fails_closed_without_effect(observe_publishes: bool) { let state = pod(1000, Arc::new(FailingReplayGuard)).await; let key = nostr::Keys::generate().public_key(); let sessions = IssuerSessions::register(&state, TEST_ISS, &key); let token = mint_token(&key.to_hex(), 300, serde_json::json!({})); + let mut publishes = observe(observe_publishes, &state, &key).await; let (status, body) = post_command(&state, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(body.as_ref(), b"authorization unavailable\n"); assert!(!is_denied(&state, &key), "no deny entry on guard error"); sessions.assert_open("guard error"); + assert_publishes(&mut publishes, 0, "guard error").await; + } + + #[tokio::test] + async fn command_replay_guard_error_fails_closed_without_effect() { + guard_error_fails_closed_without_effect(false).await; } async fn deny_set_full_leaves_command_retryable( @@ -2752,8 +2917,24 @@ mod route_integration_tests { #[tokio::test] #[ignore = "requires Redis"] async fn redis_command_replay_on_second_pod_is_denied() { - command_replayed_on_second_pod_is_denied_without_effect(redis_guard(), redis_guard()) - .await; + command_replayed_on_second_pod_is_denied_without_effect( + redis_guard(), + redis_guard(), + true, + ) + .await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_forged_command_does_not_consume_shared_claim() { + forged_command_leaves_shared_claim_unconsumed(redis_guard(), None, true).await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn redis_guard_error_publishes_nothing() { + guard_error_fails_closed_without_effect(true).await; } #[tokio::test] From 40ed911131422405501b2848ed62a3a92dbe6a12 Mon Sep 17 00:00:00 2001 From: Duncan Date: Wed, 30 Sep 2026 16:02:46 -0400 Subject: [PATCH 11/14] test(nip-fi): barrier publish counts on a sentinel command A fixed sleep could miss a late prohibited publish or fail on a slow legitimate one, and a dead collector read as zero. Each count now waits for an accepted sentinel command's publication, giving every case a positive control, and fails loudly if the collector stops. Also split the verify() doc into the normal claim lifecycle and the separate ways a claim can outlive a failure. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/command.rs | 14 ++++--- crates/buzz-relay/src/api/nip_fi.rs | 52 +++++++++++++++++++------- 2 files changed, 48 insertions(+), 18 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index 33dc5a76d94..7bbcf1bb604 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -262,11 +262,15 @@ impl CommandVerifier { /// * `replay` — the shared command replay guard. /// /// The shared claim is taken only after authentication, and released if - /// the local reservation fails for capacity, so the jti is burned - /// cluster-wide only when the local deny entry is inserted. A guard error - /// fails closed as `AuthorizationUnavailable`: no deny entry, no session - /// close, no publish. The shared claim may still exist (the guard's reply - /// was lost, or a release failed), so a retry is denied until its TTL. + /// the local reservation fails for capacity, so a `DenySetFull` command + /// can be retried. + /// + /// A claim can nevertheless remain without a local deny entry: when Redis + /// applied the claim but its reply was lost (`AuthorizationUnavailable`), + /// when the release after a capacity failure fails (`DenySetFull`), or when + /// the process is interrupted between claim and insertion. Every such + /// failure inserts no deny entry, closes no session and publishes nothing; + /// it fails closed, since a retry is denied until the claim's TTL. pub async fn verify( &self, token: &str, diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 71decd1d6ff..631e5db6aaa 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2642,12 +2642,14 @@ mod route_integration_tests { use futures::StreamExt; let client = redis::Client::open(state.config.redis_url.as_str()).expect("redis url"); let mut conn = client.get_async_pubsub().await.expect("redis pubsub"); + // Acknowledged by Redis before returning, so no later publish is missed. conn.subscribe(buzz_pubsub::conn_control::NIP_FI_DISCONNECT_CHANNEL) .await .expect("subscribe"); let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); tokio::spawn(async move { let mut messages = conn.into_on_message(); + // A decode panic or stream end drops `tx`, which `count` reports. while let Some(msg) = messages.next().await { let payload: String = msg.get_payload().expect("payload"); let cmd = buzz_pubsub::decode_nip_fi_disconnect(&payload).expect("decode"); @@ -2663,16 +2665,28 @@ mod route_integration_tests { } } - /// Publications for the target so far, after letting spawned - /// publishes land. - async fn count(&mut self) -> usize { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - while let Ok(cmd) = self.rx.try_recv() { + /// Publications for the target so far. First sends an accepted + /// sentinel command through `via` and waits for its publication, a + /// positive control proving the publisher and this collector both + /// work before the target count is read. + async fn count(&mut self, via: &Arc) -> usize { + let sentinel = nostr::Keys::generate().public_key(); + let token = mint_token(&sentinel.to_hex(), 300, serde_json::json!({})); + let (status, _) = post_command(via, &token, &sentinel.to_hex()).await; + assert_eq!(status, StatusCode::OK, "sentinel command must be accepted"); + let sentinel = sentinel.to_bytes().to_vec(); + let deadline = std::time::Duration::from_secs(10); + loop { + let cmd = tokio::time::timeout(deadline, self.rx.recv()) + .await + .expect("sentinel publication did not arrive within 10s") + .expect("publish collector stopped"); if cmd.pubkey_bytes == self.target { self.seen += 1; + } else if cmd.pubkey_bytes == sentinel { + return self.seen; } } - self.seen } } @@ -2687,9 +2701,14 @@ mod route_integration_tests { } } - async fn assert_publishes(observer: &mut Option, expected: usize, what: &str) { + async fn assert_publishes( + observer: &mut Option, + via: &Arc, + expected: usize, + what: &str, + ) { if let Some(observer) = observer { - assert_eq!(observer.count().await, expected, "publications: {what}"); + assert_eq!(observer.count(via).await, expected, "publications: {what}"); } } @@ -2707,7 +2726,7 @@ mod route_integration_tests { let (status, _) = post_command(&pod_a, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::OK, "first use is accepted on pod A"); - assert_publishes(&mut publishes, 1, "accepted command").await; + assert_publishes(&mut publishes, &pod_a, 1, "accepted command").await; let (status, body) = post_command(&pod_b, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::FORBIDDEN, "replay on pod B is denied"); @@ -2717,7 +2736,7 @@ mod route_integration_tests { "replay must not insert a deny entry on B" ); on_b.assert_open("pod B after rejected replay"); - assert_publishes(&mut publishes, 1, "rejected replay adds none").await; + assert_publishes(&mut publishes, &pod_b, 1, "rejected replay adds none").await; } #[tokio::test] @@ -2789,7 +2808,7 @@ mod route_integration_tests { assert_eq!(body.as_ref(), b"evidence rejected\n"); assert!(!is_denied(&pod_a, &key), "forgery inserts no deny entry"); on_a.assert_open("pod A after forgery"); - assert_publishes(&mut publishes, 0, "forgery").await; + assert_publishes(&mut publishes, &pod_a, 0, "forgery").await; if let Some(guard) = claims { assert_eq!( guard.claims.load(std::sync::atomic::Ordering::SeqCst), @@ -2805,7 +2824,7 @@ mod route_integration_tests { "the legitimate command's jti was not burned by the forgery" ); assert!(is_denied(&pod_b, &key)); - assert_publishes(&mut publishes, 1, "accepted command").await; + assert_publishes(&mut publishes, &pod_b, 1, "accepted command").await; } #[tokio::test] @@ -2858,7 +2877,14 @@ mod route_integration_tests { assert_eq!(body.as_ref(), b"authorization unavailable\n"); assert!(!is_denied(&state, &key), "no deny entry on guard error"); sessions.assert_open("guard error"); - assert_publishes(&mut publishes, 0, "guard error").await; + // The failing guard accepts nothing, so the sentinel goes through a + // healthy pod publishing to the same Redis channel. + let healthy = pod( + 1000, + Arc::new(buzz_auth::InMemoryCommandReplayGuard::default()), + ) + .await; + assert_publishes(&mut publishes, &healthy, 0, "guard error").await; } #[tokio::test] From 76b553a452049d96c3aa368d4da59ff733ecfd89 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 1 Oct 2026 11:35:27 -0400 Subject: [PATCH 12/14] fix(auth): reserve the local deny slot before the shared jti claim A capacity failure after the Redis claim relied on a DEL to keep the command retryable; a failed DEL left the jti burned. Reserving a pending slot under the shard lock first means DenySetFull never touches Redis, and an RAII Reservation frees the slot on replay, guard error, or cancellation. CommandReplayGuard::release is removed as it has no callers. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/command.rs | 259 +++++++++++++++--- crates/buzz-auth/src/nip_fi/command_replay.rs | 24 +- crates/buzz-auth/src/nip_fi/deny_map.rs | 192 ++++++++++--- .../buzz-pubsub/src/nip_fi_command_replay.rs | 18 +- crates/buzz-relay/src/api/nip_fi.rs | 20 -- 5 files changed, 381 insertions(+), 132 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index 7bbcf1bb604..b27c983f67d 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -11,8 +11,8 @@ //! target_pubkey, and until ceiling. //! 4. Principal authorization (issuer-configured authorized `sub` list). //! 5. Signed-target / request-body agreement. -//! 6. Shared `(iss, jti)` claim ([`super::command_replay`]), then atomic local -//! jti reservation + deny-entry insertion (both-or-neither). +//! 6. Reserve a local jti + deny-entry slot, then take the shared `(iss, jti)` +//! claim ([`super::command_replay`]), then commit the slot (both-or-neither). //! 7. Return [`CommandResult`]. //! //! Fail-closed: any failure inserts no deny entry, closes no session and @@ -29,7 +29,7 @@ use super::command_replay::CommandReplayGuard; use super::config::{ IssuerPolicy, IssuerRegistry, MAX_JTI_BYTES, MAX_SUBJECT_BYTES, MAX_TOKEN_BYTES, }; -use super::deny_map::{NipFiDenyMap, ReserveError}; +use super::deny_map::{NipFiDenyMap, Reservation, ReserveError}; use super::verifier::{ enforce_compact_structure, enforce_signature_shape, parse_header, parse_numeric_date, parse_unique_claims, select_unique_jwk, validate_jwk, AssertionKeySet, IssuerKeySource, @@ -261,16 +261,17 @@ impl CommandVerifier { /// * `body_pubkey` — the `pubkey` field parsed from the JSON body. /// * `replay` — the shared command replay guard. /// - /// The shared claim is taken only after authentication, and released if - /// the local reservation fails for capacity, so a `DenySetFull` command - /// can be retried. + /// Order: authenticate, reserve a local slot, claim `(iss, jti)` in the + /// shared guard, then commit the slot. A full deny set fails before the + /// shared claim, so `DenySetFull` never consumes the command and it can be + /// retried. A replayed or failed claim, or a cancelled future, drops the + /// local slot; the commit after a successful claim cannot fail. /// - /// A claim can nevertheless remain without a local deny entry: when Redis - /// applied the claim but its reply was lost (`AuthorizationUnavailable`), - /// when the release after a capacity failure fails (`DenySetFull`), or when - /// the process is interrupted between claim and insertion. Every such - /// failure inserts no deny entry, closes no session and publishes nothing; - /// it fails closed, since a retry is denied until the claim's TTL. + /// A claim can still remain without a local deny entry when Redis applied + /// it but its reply was lost (`AuthorizationUnavailable`), or when the + /// process stops between claim and commit. That inserts no deny entry, + /// closes no session and publishes nothing; it fails closed, since a retry + /// is denied until the claim's TTL. pub async fn verify( &self, token: &str, @@ -281,6 +282,7 @@ impl CommandVerifier { ) -> Result { let now = Utc::now(); let cmd = self.authenticate_at(token, request_method, request_path, body_pubkey, now)?; + let reservation = self.reserve(&cmd, now)?; // Cover the command's remaining validity plus this pod's skew, so a // pod whose clock trails still sees the claim; the guard floors this. match replay @@ -295,14 +297,7 @@ impl CommandVerifier { Ok(false) => return Err(CommandError::AuthorizationDenied), Err(_) => return Err(CommandError::AuthorizationUnavailable), } - let result = self.commit(&cmd, now); - if result == Err(CommandError::DenySetFull) { - if let Err(e) = replay.release(&cmd.issuer, &cmd.jti).await { - // The jti stays claimed until its TTL; a retry gets 403. - tracing::warn!("nip-fi command: replay claim release failed: {e}"); - } - } - result + Ok(Self::commit(&cmd, reservation)) } /// Verify against this pod's deny map only, with an injectable clock. @@ -321,7 +316,8 @@ impl CommandVerifier { now: DateTime, ) -> Result { let cmd = self.authenticate_at(token, request_method, request_path, body_pubkey, now)?; - self.commit(&cmd, now) + let reservation = self.reserve(&cmd, now)?; + Ok(Self::commit(&cmd, reservation)) } /// Steps 1–5: authenticate and authorize the command with no side effects. @@ -485,31 +481,29 @@ impl CommandVerifier { }) } - /// Steps 6+7: atomic local jti reservation + deny-entry insertion. - fn commit( + /// Step 6, local half: hold this pod's jti and deny-entry slots. + fn reserve( &self, cmd: &AuthenticatedCommand, now: DateTime, - ) -> Result { - match self.deny_map.atomic_reserve_and_insert( - &cmd.issuer, - &cmd.jti, - cmd.effective_expiry, - &cmd.target_pubkey, - cmd.until, - now, - ) { - Ok(()) => {} - Err(ReserveError::JtiAlreadyReserved) => return Err(CommandError::AuthorizationDenied), - Err(ReserveError::CapacityExceeded) => return Err(CommandError::DenySetFull), - } + ) -> Result { + self.deny_map + .reserve(&cmd.issuer, &cmd.jti, &cmd.target_pubkey, now) + .map_err(|e| match e { + ReserveError::JtiAlreadyReserved => CommandError::AuthorizationDenied, + ReserveError::CapacityExceeded => CommandError::DenySetFull, + }) + } - Ok(CommandResult { + /// Step 7: record the jti and deny entry; cannot fail. + fn commit(cmd: &AuthenticatedCommand, reservation: Reservation) -> CommandResult { + reservation.commit(cmd.effective_expiry, cmd.until); + CommandResult { target_pubkey: cmd.target_pubkey, caller_iss: cmd.issuer.clone(), caller_sub: cmd.sub.clone(), until: cmd.until, - }) + } } } @@ -1333,6 +1327,195 @@ mod tests { ); } + // ── Reserve-first ordering through verify() ─────────────────────────────── + // + // verify() reserves the local slot before the shared claim, so a full deny + // set never reaches the guard, and every uncommitted path frees the slot. + + #[derive(Clone, Copy)] + enum Claim { + Accept, + Replay, + Fail, + Hang, + } + + /// A replay guard with a fixed reply that counts its `try_claim` calls. + struct ScriptedGuard { + claim: Claim, + calls: std::sync::atomic::AtomicUsize, + } + + impl ScriptedGuard { + fn new(claim: Claim) -> Self { + Self { + claim, + calls: std::sync::atomic::AtomicUsize::new(0), + } + } + + fn calls(&self) -> usize { + self.calls.load(std::sync::atomic::Ordering::SeqCst) + } + } + + impl CommandReplayGuard for ScriptedGuard { + fn try_claim<'a>( + &'a self, + _issuer: &'a str, + _jti: &'a str, + _ttl_secs: u64, + ) -> std::pin::Pin< + Box< + dyn std::future::Future> + Send + 'a, + >, + > { + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let claim = self.claim; + Box::pin(async move { + // Yield once so concurrent verifies interleave at the claim. + tokio::task::yield_now().await; + match claim { + Claim::Accept => Ok(true), + Claim::Replay => Ok(false), + Claim::Fail => Err(crate::error::AuthError::Internal("simulated".into())), + Claim::Hang => std::future::pending().await, + } + }) + } + } + + /// A verifier whose issuer shard holds exactly one deny entry. + fn one_slot_verifier() -> CommandVerifier { + use crate::nip_fi::verifier::{AssertionKeySet, StaticIssuerKeySource}; + let future = Utc::now() + Duration::seconds(3600); + let key_set = AssertionKeySet::new(ISS.to_owned(), 1, test_jwks_cmd(), future) + .expect("valid key set"); + let mut registry = IssuerRegistry::new(); + registry.insert(test_issuer_policy()); + let deny_map = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: ISS.to_owned(), + capacity: 1, + }], + ); + CommandVerifier::new( + registry, + StaticIssuerKeySource::new([key_set]), + vec![test_command_policy()], + deny_map, + ) + } + + /// The single slot is free: a fresh command for a new key is accepted. + async fn assert_slot_free( + cv: &CommandVerifier, + ) { + let target = target_key(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let accept = ScriptedGuard::new(Claim::Accept); + assert!( + cv.verify(&token, METHOD, PATH, &target, &accept) + .await + .is_ok(), + "the only slot must have been freed" + ); + } + + #[tokio::test] + async fn full_deny_set_fails_before_shared_claim_and_retry_succeeds() { + let cv = one_slot_verifier(); + let held = cv + .deny_map() + .reserve(ISS, "filler", &target_key(), Utc::now()) + .expect("filler takes the only slot"); + let target = target_key(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let guard = ScriptedGuard::new(Claim::Accept); + + assert_eq!( + cv.verify(&token, METHOD, PATH, &target, &guard).await, + Err(CommandError::DenySetFull) + ); + assert_eq!( + guard.calls(), + 0, + "a full deny set must not touch the shared guard" + ); + + drop(held); + assert!(cv + .verify(&token, METHOD, PATH, &target, &guard) + .await + .is_ok()); + assert_eq!(guard.calls(), 1); + assert!(cv.deny_map().is_denied(ISS, &target, Utc::now())); + } + + #[tokio::test] + async fn rejected_or_failed_claim_frees_reserved_slot() { + let cv = one_slot_verifier(); + for (claim, expected) in [ + (Claim::Replay, CommandError::AuthorizationDenied), + (Claim::Fail, CommandError::AuthorizationUnavailable), + ] { + let target = target_key(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let guard = ScriptedGuard::new(claim); + assert_eq!( + cv.verify(&token, METHOD, PATH, &target, &guard).await, + Err(expected) + ); + assert_eq!(guard.calls(), 1); + assert!(!cv.deny_map().is_denied(ISS, &target, Utc::now())); + } + assert_slot_free(&cv).await; + } + + #[tokio::test] + async fn cancelled_verify_frees_reserved_slot() { + let cv = one_slot_verifier(); + let target = target_key(); + let token = mint_cmd_jwt(&target, 300, serde_json::json!({})); + let guard = ScriptedGuard::new(Claim::Hang); + let verify = cv.verify(&token, METHOD, PATH, &target, &guard); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), verify) + .await + .is_err(), + "the hanging claim must time out" + ); + assert_eq!(guard.calls(), 1, "the verify reached the shared claim"); + assert_slot_free(&cv).await; + } + + #[tokio::test] + async fn concurrent_commands_for_one_free_slot_admit_exactly_one() { + let cv = one_slot_verifier(); + let (a, b) = (target_key(), target_key()); + let (token_a, token_b) = ( + mint_cmd_jwt(&a, 300, serde_json::json!({})), + mint_cmd_jwt(&b, 300, serde_json::json!({})), + ); + let guard = ScriptedGuard::new(Claim::Accept); + let (ra, rb) = tokio::join!( + cv.verify(&token_a, METHOD, PATH, &a, &guard), + cv.verify(&token_b, METHOD, PATH, &b, &guard), + ); + let results = [ra, rb]; + assert_eq!( + results.iter().filter(|r| r.is_ok()).count(), + 1, + "{results:?}" + ); + assert!( + results.contains(&Err(CommandError::DenySetFull)), + "{results:?}" + ); + assert_eq!(guard.calls(), 1, "the loser never reaches the shared guard"); + } + // ── Signed fractional verify_at witness ────────────────────────────────── // // Proves that a real ES256-signed command JWT whose `until` NumericDate is diff --git a/crates/buzz-auth/src/nip_fi/command_replay.rs b/crates/buzz-auth/src/nip_fi/command_replay.rs index efec6cb62c4..20234bad3a1 100644 --- a/crates/buzz-auth/src/nip_fi/command_replay.rs +++ b/crates/buzz-auth/src/nip_fi/command_replay.rs @@ -6,9 +6,8 @@ //! cross-pod fence is an atomic set-if-absent in shared state (Redis). //! //! The claim is taken after the command is fully authenticated (so a forgery -//! cannot burn a legitimate `jti`) and released if the local deny-entry -//! insertion then fails for capacity, so a `503 deny set full` leaves the -//! command retryable. +//! cannot burn a legitimate `jti`) and after this pod has reserved its local +//! deny-entry slot (so a `503 deny set full` never consumes the command). use std::{future::Future, pin::Pin}; @@ -36,13 +35,6 @@ pub trait CommandReplayGuard: Send + Sync { jti: &'a str, ttl_secs: u64, ) -> Pin> + Send + 'a>>; - - /// Release a claim this caller took whose command then failed locally. - fn release<'a>( - &'a self, - issuer: &'a str, - jti: &'a str, - ) -> Pin> + Send + 'a>>; } /// Redis key for a command replay claim: @@ -80,18 +72,6 @@ impl CommandReplayGuard for InMemoryCommandReplayGuard { let key = command_replay_key(issuer, jti); Box::pin(async move { Ok(self.0.lock().expect("replay set").insert(key)) }) } - - fn release<'a>( - &'a self, - issuer: &'a str, - jti: &'a str, - ) -> Pin> + Send + 'a>> { - let key = command_replay_key(issuer, jti); - Box::pin(async move { - self.0.lock().expect("replay set").remove(&key); - Ok(()) - }) - } } #[cfg(test)] diff --git a/crates/buzz-auth/src/nip_fi/deny_map.rs b/crates/buzz-auth/src/nip_fi/deny_map.rs index 9b158932ccf..c70b094f480 100644 --- a/crates/buzz-auth/src/nip_fi/deny_map.rs +++ b/crates/buzz-auth/src/nip_fi/deny_map.rs @@ -23,8 +23,11 @@ //! target's sessions and reports/metrics the outcome. No issuer-wide denial //! is synthesized. Async propagation loss with issuer re-push is the //! sanctioned recovery. [NIP-FI.md:306-336] -//! * **jti reservation** and **deny-entry insertion** are performed atomically -//! in one lock scope (both or neither). [VerifyCommandJwt step 7] +//! * **jti reservation** and **deny-entry insertion** are both-or-neither. +//! [`NipFiDenyMap::reserve`] runs every fallible check and holds a pending +//! slot against both budgets; [`Reservation::commit`] then cannot fail, and +//! dropping an uncommitted [`Reservation`] frees the slot. The command +//! verifier takes the cross-pod claim between the two. [VerifyCommandJwt step 7] //! * **Issuer-global scope**: the deny applies across all communities served //! under that issuer. [FI-TRACE-DENY-SET] //! * **Self-eviction**: expired entries and jtis are pruned on write, only when @@ -35,7 +38,7 @@ use chrono::{DateTime, Utc}; use dashmap::DashMap; use nostr::PublicKey; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; // ── Error type ──────────────────────────────────────────────────────────────── @@ -70,6 +73,13 @@ struct IssuerShard { /// `MAX_JTI_BYTES` this keeps jti memory O(capacity × MAX_JTI_BYTES), with /// headroom for in-flight update commands on already-denied keys. max_jti_count: usize, + /// jtis of uncommitted reservations. Each counts against the jti budget + /// and is treated as already reserved. + pending_jtis: HashSet, + /// Target keys of uncommitted reservations, with their count. A key not + /// in `entries` occupies one entry slot however many reservations name it. + /// Eviction keeps entries for these keys so commit never needs a new slot. + pending_keys: HashMap, } impl IssuerShard { @@ -83,15 +93,34 @@ impl IssuerShard { // one in-flight update command per already-denied key without // blocking normal operation. Still O(capacity) memory. max_jti_count: capacity.saturating_mul(2).max(1), + pending_jtis: HashSet::new(), + pending_keys: HashMap::new(), } } + /// Entry slots in use: live-or-expired entries plus pending new keys. + fn entry_slots(&self) -> usize { + let pending_new = self + .pending_keys + .keys() + .filter(|k| !self.entries.contains_key(*k)) + .count(); + self.entries.len() + pending_new + } + /// Evict expired entries and jtis. O(n); called only from /// [`Self::full_after_eviction`] so the common write path never stalls /// `is_denied` readers behind a sweep. + /// + /// State named by a pending reservation is kept: its slot is already + /// counted, so dropping it would let another reservation take the slot + /// the commit relies on. fn evict_expired(&mut self, now: DateTime) { - self.entries.retain(|_, until| *until > now); - self.jtis.retain(|_, exp| *exp > now); + let (pending_keys, pending_jtis) = (&self.pending_keys, &self.pending_jtis); + self.entries + .retain(|k, until| *until > now || pending_keys.contains_key(k)); + self.jtis + .retain(|j, exp| *exp > now || pending_jtis.contains(j)); } /// True if `full` holds even after evicting expired state. The sweep runs @@ -112,23 +141,19 @@ impl IssuerShard { .unwrap_or(false) } - /// Attempt the atomic jti-reservation + deny-entry insertion. + /// Run every fallible check and hold a pending slot for `(jti, key)`. /// - /// **Atomicity**: every fallible check runs before the first insert. The - /// deny-entry merge is the only fallible write and fails before mutating - /// anything; the jti insert after it is infallible. Eviction may run - /// first, but it only drops already-expired state, which is always safe. - fn atomic_reserve_and_insert( + /// Eviction may run first, but it only drops expired state that no + /// pending reservation names, which is always safe. + fn reserve( &mut self, jti: &str, - jti_effective_expiry: DateTime, pubkey_hex: &str, - until: DateTime, now: DateTime, ) -> Result<(), ReserveError> { - // Replay check: live jti already in set → AuthorizationDenied. An + // Replay check: a live or pending jti → AuthorizationDenied. An // expired, not-yet-evicted reservation is treated as absent. - if self.jtis.get(jti).is_some_and(|exp| *exp > now) { + if self.pending_jtis.contains(jti) || self.jtis.get(jti).is_some_and(|exp| *exp > now) { return Err(ReserveError::JtiAlreadyReserved); } @@ -138,16 +163,55 @@ impl IssuerShard { // responds 503 and the command remains replayable (jti not burned). // Overwriting an expired reservation of the same jti does not grow the map. if !self.jtis.contains_key(jti) - && self.full_after_eviction(now, |s| s.jtis.len() >= s.max_jti_count) + && self.full_after_eviction(now, |s| { + s.jtis.len() + s.pending_jtis.len() >= s.max_jti_count + }) { return Err(ReserveError::CapacityExceeded); } - self.merge_entry(pubkey_hex, until, now)?; - self.jtis.insert(jti.to_owned(), jti_effective_expiry); + // Entry capacity: an existing or already-pending key needs no new slot. + if !self.entries.contains_key(pubkey_hex) + && !self.pending_keys.contains_key(pubkey_hex) + && self.full_after_eviction(now, |s| s.entry_slots() >= s.capacity) + { + return Err(ReserveError::CapacityExceeded); + } + + self.pending_jtis.insert(jti.to_owned()); + *self.pending_keys.entry(pubkey_hex.to_owned()).or_default() += 1; Ok(()) } + /// Drop a pending reservation's hold on its jti and key. + fn release(&mut self, jti: &str, pubkey_hex: &str) { + self.pending_jtis.remove(jti); + if let Some(count) = self.pending_keys.get_mut(pubkey_hex) { + *count -= 1; + if *count == 0 { + self.pending_keys.remove(pubkey_hex); + } + } + } + + /// Turn a pending reservation into the jti reservation plus deny entry. + /// Infallible: [`Self::reserve`] already holds both slots. + fn commit( + &mut self, + jti: &str, + jti_effective_expiry: DateTime, + pubkey_hex: &str, + until: DateTime, + ) { + self.release(jti, pubkey_hex); + let effective_until = match self.entries.get(pubkey_hex) { + Some(&existing) => existing.max(until), + None => until, + }; + self.entries.insert(pubkey_hex.to_owned(), effective_until); + self.jtis.insert(jti.to_owned(), jti_effective_expiry); + } + /// Insert or `max(existing_until, until)`-merge a deny entry. /// /// Used directly for cross-pod propagation, where replay idempotency is @@ -162,7 +226,7 @@ impl IssuerShard { ) -> Result<(), CapacityExceeded> { // Overwriting an existing (active or expired) entry never grows the map. if !self.entries.contains_key(pubkey_hex) - && self.full_after_eviction(now, |s| s.entries.len() >= s.capacity) + && self.full_after_eviction(now, |s| s.entry_slots() >= s.capacity) { return Err(CapacityExceeded); } @@ -176,6 +240,47 @@ impl IssuerShard { } } +/// A pending slot in one issuer shard for a command's jti and deny entry. +/// +/// [`Self::commit`] turns it into the jti reservation plus deny entry; +/// dropping it uncommitted (any error, or a cancelled future) frees the slot. +#[must_use = "dropping a reservation releases it"] +pub(crate) struct Reservation { + shards: Arc>>, + issuer: String, + jti: String, + /// `None` once committed. + pubkey_hex: Option, +} + +impl Reservation { + /// Record the jti and max-merge the deny entry. Cannot fail on capacity. + pub(crate) fn commit(mut self, jti_effective_expiry: DateTime, until: DateTime) { + let pubkey_hex = self + .pubkey_hex + .take() + .expect("reservation not yet committed"); + self.with_shard(|shard| shard.commit(&self.jti, jti_effective_expiry, &pubkey_hex, until)); + } + + /// Shards are never removed, so the reserved shard exists. A poisoned + /// lock is entered anyway: `is_denied` already fails closed for it, and + /// release must not panic in `Drop`. + fn with_shard(&self, f: impl FnOnce(&mut IssuerShard)) { + if let Some(shard) = self.shards.get(&self.issuer) { + f(&mut shard.lock().unwrap_or_else(|e| e.into_inner())); + } + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + if let Some(pubkey_hex) = self.pubkey_hex.take() { + self.with_shard(|shard| shard.release(&self.jti, &pubkey_hex)); + } + } +} + /// The shard has no room for a new deny entry, even after eviction. struct CapacityExceeded; @@ -273,26 +378,23 @@ impl NipFiDenyMap { } } - /// Atomically reserve `(iss, jti)` and insert/merge the deny entry. + /// Hold a pending slot for `(iss, jti)` and the deny entry for `pubkey`. /// - /// Both mutations happen under the same per-issuer lock (both-or-neither). - /// - /// * `Ok(())` — success. - /// * `Err(ReserveError::JtiAlreadyReserved)` — replay; map is unchanged, - /// caller responds `AuthorizationDenied`. - /// * `Err(ReserveError::CapacityExceeded)` — full; map is unchanged, - /// caller responds `503 deny set full`. + /// * `Ok(reservation)` — both budgets hold a slot until the reservation is + /// committed or dropped. + /// * `Err(ReserveError::JtiAlreadyReserved)` — replay (live or pending + /// jti); map is unchanged, caller responds `AuthorizationDenied`. + /// * `Err(ReserveError::CapacityExceeded)` — full or poisoned; map is + /// unchanged, caller responds `503 deny set full`. /// /// [VerifyCommandJwt step 7] - pub(crate) fn atomic_reserve_and_insert( + pub(crate) fn reserve( &self, issuer: &str, jti: &str, - jti_effective_expiry: DateTime, pubkey: &PublicKey, - until: DateTime, now: DateTime, - ) -> Result<(), ReserveError> { + ) -> Result { let pubkey_hex = pubkey.to_hex(); let shard = self .shards @@ -300,10 +402,30 @@ impl NipFiDenyMap { .or_insert_with(|| Mutex::new(IssuerShard::new(self.default_capacity))); shard .lock() - .map_err(|_| ReserveError::CapacityExceeded) // poisoned = fail closed - .and_then(|mut guard| { - guard.atomic_reserve_and_insert(jti, jti_effective_expiry, &pubkey_hex, until, now) - }) + .map_err(|_| ReserveError::CapacityExceeded)? // poisoned = fail closed + .reserve(jti, &pubkey_hex, now)?; + Ok(Reservation { + shards: Arc::clone(&self.shards), + issuer: issuer.to_owned(), + jti: jti.to_owned(), + pubkey_hex: Some(pubkey_hex), + }) + } + + /// Reserve and immediately commit, for tests of the local map alone. + #[cfg(test)] + pub(crate) fn atomic_reserve_and_insert( + &self, + issuer: &str, + jti: &str, + jti_effective_expiry: DateTime, + pubkey: &PublicKey, + until: DateTime, + now: DateTime, + ) -> Result<(), ReserveError> { + self.reserve(issuer, jti, pubkey, now)? + .commit(jti_effective_expiry, until); + Ok(()) } /// Merge a cross-pod deny entry (e.g. from Redis propagation). diff --git a/crates/buzz-pubsub/src/nip_fi_command_replay.rs b/crates/buzz-pubsub/src/nip_fi_command_replay.rs index ed58ddd38a0..49232da9b8d 100644 --- a/crates/buzz-pubsub/src/nip_fi_command_replay.rs +++ b/crates/buzz-pubsub/src/nip_fi_command_replay.rs @@ -11,7 +11,7 @@ use buzz_auth::{ /// Redis-backed NIP-FI command replay seen-set. /// /// `try_claim` issues `SET buzz:nip-fi:command:{hash} 1 NX EX `; `OK` -/// is a first claim and `nil` is a replay. `release` is a `DEL`. +/// is a first claim and `nil` is a replay. pub struct RedisCommandReplayGuard { pool: deadpool_redis::Pool, } @@ -62,20 +62,4 @@ impl CommandReplayGuard for RedisCommandReplayGuard { } }) } - - fn release<'a>( - &'a self, - issuer: &'a str, - jti: &'a str, - ) -> std::pin::Pin> + Send + 'a>> - { - Box::pin(async move { - let mut conn = self.conn().await?; - redis::cmd("DEL") - .arg(command_replay_key(issuer, jti)) - .query_async::<()>(&mut *conn) - .await - .map_err(|e| AuthError::Internal(format!("Redis DEL: {e}"))) - }) - } } diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 631e5db6aaa..5dc47451c82 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2766,16 +2766,6 @@ mod route_integration_tests { .fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.inner.try_claim(issuer, jti, ttl_secs) } - - fn release<'a>( - &'a self, - issuer: &'a str, - jti: &'a str, - ) -> std::pin::Pin< - Box> + Send + 'a>, - > { - self.inner.release(issuer, jti) - } } /// A command whose signature does not verify but which carries a real @@ -2853,16 +2843,6 @@ mod route_integration_tests { )) }) } - - fn release<'a>( - &'a self, - _issuer: &'a str, - _jti: &'a str, - ) -> std::pin::Pin< - Box> + Send + 'a>, - > { - Box::pin(async { Ok(()) }) - } } async fn guard_error_fails_closed_without_effect(observe_publishes: bool) { From 9a9def8c22090528891ebd779939d483e8e0d0a1 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 1 Oct 2026 12:11:57 -0400 Subject: [PATCH 13/14] test(relay): make the NIP-FI publish-count barrier strict Disconnect publishes are detached tasks, so a sentinel publish alone did not prove earlier ones had landed. Spawning them through a TaskTracker on AppState lets the test wait for completion first; production still never waits on it. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/nip_fi.rs | 20 +++++++++++++++----- crates/buzz-relay/src/state.rs | 5 +++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 5dc47451c82..62f2a383299 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -166,7 +166,7 @@ pub async fn disconnect( { let pubsub = Arc::clone(&state.pubsub); let msg = nip_fi_disconnect_message(&cmd); - tokio::spawn(async move { + state.nip_fi_publish_tasks.spawn(async move { if let Err(e) = pubsub.publish_nip_fi_disconnect(&msg).await { // [FI-TRACE-PRIVACY-NONPUBLIC]: no iss or pubkey in logs tracing::warn!("nip-fi: cross-pod propagation publish failed: {e}"); @@ -2665,11 +2665,21 @@ mod route_integration_tests { } } - /// Publications for the target so far. First sends an accepted - /// sentinel command through `via` and waits for its publication, a - /// positive control proving the publisher and this collector both - /// work before the target count is read. + /// Publications for the target so far. + /// + /// Barrier: wait until every publish `via` has spawned has finished, + /// so each earlier `PUBLISH` is complete. Then send an accepted + /// sentinel command and read up to its publication. It is published + /// strictly after the earlier ones and Redis delivers in publish + /// order, so the read drains them all; it is also a positive control + /// that the publisher and this collector both work. async fn count(&mut self, via: &Arc) -> usize { + let tasks = &via.nip_fi_publish_tasks; + tasks.close(); + tokio::time::timeout(std::time::Duration::from_secs(10), tasks.wait()) + .await + .expect("spawned publishes did not finish within 10s"); + tasks.reopen(); let sentinel = nostr::Keys::generate().public_key(); let token = mint_token(&sentinel.to_hex(), 300, serde_json::json!({})); let (status, _) = post_command(via, &token, &sentinel.to_hex()).await; diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 75e21db3fdd..3d6d5447945 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1208,6 +1208,10 @@ pub struct AppState { /// on top of the verifier's per-pod reservation. Redis `SET NX EX`, like /// `nip98_replay`; callers fail closed on error. pub nip_fi_command_replay: Arc, + /// Detached cross-pod NIP-FI disconnect publishes. Spawning through it + /// lets tests wait for every publish to finish; nothing waits on it in + /// production. + pub nip_fi_publish_tasks: tokio_util::task::TaskTracker, } impl AppState { @@ -1401,6 +1405,7 @@ impl AppState { nip_fi_deny_map: None, nip_fi_command_verifier: None, nip_fi_command_replay, + nip_fi_publish_tasks: tokio_util::task::TaskTracker::new(), }; ( state, From 5711c84e234c10f10b5ce8463525d0b1e847af04 Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 1 Oct 2026 12:57:14 -0400 Subject: [PATCH 14/14] fix(auth): let a cross-pod deny fill its pending key's slot merge_entry only exempted keys already in entries, so with a full shard a remote deny for a key this pod had reserved was dropped, and lost entirely if the local attempt then lost the shared claim. The publish-count test now drains every pod that could publish, not just the sentinel pod. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-auth/src/nip_fi/command.rs | 3 +- crates/buzz-auth/src/nip_fi/deny_map.rs | 71 +++++++++++++++++++++++-- crates/buzz-relay/src/api/nip_fi.rs | 71 +++++++++++++++++++------ 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/crates/buzz-auth/src/nip_fi/command.rs b/crates/buzz-auth/src/nip_fi/command.rs index b27c983f67d..11d810e76f8 100644 --- a/crates/buzz-auth/src/nip_fi/command.rs +++ b/crates/buzz-auth/src/nip_fi/command.rs @@ -18,7 +18,8 @@ //! Fail-closed: any failure inserts no deny entry, closes no session and //! publishes nothing. A failure after step 6's shared claim may leave that //! claim in place until its TTL (a guard error whose `SET` reply was lost, or a -//! failed release); that is fail closed, since a retry is then denied. +//! process stopped between claim and commit); that is fail closed, since a +//! retry is then denied. use chrono::{DateTime, Utc}; use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; diff --git a/crates/buzz-auth/src/nip_fi/deny_map.rs b/crates/buzz-auth/src/nip_fi/deny_map.rs index c70b094f480..047786083dd 100644 --- a/crates/buzz-auth/src/nip_fi/deny_map.rs +++ b/crates/buzz-auth/src/nip_fi/deny_map.rs @@ -56,8 +56,8 @@ pub struct DenySetFull; /// One issuer's worth of deny entries and jti deduplication state. /// -/// The shard mutex is acquired once per `AtomicReserveJtiAndDenyEntry` call -/// so both mutations happen under the same lock (both-or-neither atomicity). +/// Reserve and commit each take the shard mutex separately; the pending slot +/// held between them keeps the jti and deny entry both-or-neither. struct IssuerShard { /// Active deny entries: hex-encoded pubkey → until. entries: HashMap>, @@ -224,8 +224,10 @@ impl IssuerShard { until: DateTime, now: DateTime, ) -> Result<(), CapacityExceeded> { - // Overwriting an existing (active or expired) entry never grows the map. + // Overwriting an existing (active or expired) entry never grows the + // map, and a pending key already holds the slot this entry fills. if !self.entries.contains_key(pubkey_hex) + && !self.pending_keys.contains_key(pubkey_hex) && self.full_after_eviction(now, |s| s.entry_slots() >= s.capacity) { return Err(CapacityExceeded); @@ -954,6 +956,69 @@ mod tests { // unrelated-key is_denied assertions fail // (c) admit at exact equality (use <= instead of <) → equality assertion fails + #[test] + fn remote_merge_of_pending_key_uses_its_reserved_slot() { + // Another pod wins the shared claim for k while this pod's reservation + // for k is pending; the propagated deny must land and survive the + // local reservation being dropped, without exceeding capacity. + let now = Utc::now(); + let until = now + Duration::seconds(300); + let (k, other) = (key(), key()); + let m = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 1, + }], + ); + let pending = m.reserve(iss(), "jti-local", &k, now).expect("slot free"); + + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, until, now), + CrossPodMergeResult::Merged + ); + drop(pending); + + assert!(m.is_denied(iss(), &k, now), "the remote deny must survive"); + assert_eq!( + m.merge_cross_pod_deny(iss(), &other, until, now), + CrossPodMergeResult::CapacityExceeded, + "k still holds the only slot" + ); + } + + #[test] + fn commit_after_remote_merge_of_pending_key_max_merges_into_one_entry() { + let now = Utc::now(); + let (remote_until, local_until) = + (now + Duration::seconds(600), now + Duration::seconds(300)); + let (k, other) = (key(), key()); + let m = NipFiDenyMap::new( + 1, + vec![IssuerCapacity { + issuer: iss().to_owned(), + capacity: 1, + }], + ); + let pending = m.reserve(iss(), "jti-local", &k, now).expect("slot free"); + assert_eq!( + m.merge_cross_pod_deny(iss(), &k, remote_until, now), + CrossPodMergeResult::Merged + ); + pending.commit(local_until, local_until); + + assert!( + m.is_denied(iss(), &k, local_until + Duration::seconds(1)), + "the later until wins" + ); + assert!(!m.is_denied(iss(), &k, remote_until)); + assert_eq!( + m.merge_cross_pod_deny(iss(), &other, remote_until, now), + CrossPodMergeResult::CapacityExceeded, + "still one entry" + ); + } + #[test] fn remote_merge_capacity_exceeded_preserves_active_entry_and_does_not_deny_missed_or_unrelated() { diff --git a/crates/buzz-relay/src/api/nip_fi.rs b/crates/buzz-relay/src/api/nip_fi.rs index 62f2a383299..3dc7248b207 100644 --- a/crates/buzz-relay/src/api/nip_fi.rs +++ b/crates/buzz-relay/src/api/nip_fi.rs @@ -2667,19 +2667,17 @@ mod route_integration_tests { /// Publications for the target so far. /// - /// Barrier: wait until every publish `via` has spawned has finished, - /// so each earlier `PUBLISH` is complete. Then send an accepted + /// Barrier: wait until every publish spawned by each pod in `drain` + /// (every pod that could have published) has finished, so each + /// earlier `PUBLISH` is complete. Then send an accepted /// sentinel command and read up to its publication. It is published /// strictly after the earlier ones and Redis delivers in publish /// order, so the read drains them all; it is also a positive control /// that the publisher and this collector both work. - async fn count(&mut self, via: &Arc) -> usize { - let tasks = &via.nip_fi_publish_tasks; - tasks.close(); - tokio::time::timeout(std::time::Duration::from_secs(10), tasks.wait()) - .await - .expect("spawned publishes did not finish within 10s"); - tasks.reopen(); + async fn count(&mut self, drain: &[&Arc], via: &Arc) -> usize { + for state in drain { + drain_publishes(state).await; + } let sentinel = nostr::Keys::generate().public_key(); let token = mint_token(&sentinel.to_hex(), 300, serde_json::json!({})); let (status, _) = post_command(via, &token, &sentinel.to_hex()).await; @@ -2700,6 +2698,16 @@ mod route_integration_tests { } } + /// Wait until every publish `state` has spawned so far has finished. + async fn drain_publishes(state: &AppState) { + let tasks = &state.nip_fi_publish_tasks; + tasks.close(); + tokio::time::timeout(std::time::Duration::from_secs(10), tasks.wait()) + .await + .expect("spawned publishes did not finish within 10s"); + tasks.reopen(); + } + async fn observe( enabled: bool, state: &AppState, @@ -2713,12 +2721,17 @@ mod route_integration_tests { async fn assert_publishes( observer: &mut Option, + drain: &[&Arc], via: &Arc, expected: usize, what: &str, ) { if let Some(observer) = observer { - assert_eq!(observer.count(via).await, expected, "publications: {what}"); + assert_eq!( + observer.count(drain, via).await, + expected, + "publications: {what}" + ); } } @@ -2736,7 +2749,14 @@ mod route_integration_tests { let (status, _) = post_command(&pod_a, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::OK, "first use is accepted on pod A"); - assert_publishes(&mut publishes, &pod_a, 1, "accepted command").await; + assert_publishes( + &mut publishes, + &[&pod_a, &pod_b], + &pod_a, + 1, + "accepted command", + ) + .await; let (status, body) = post_command(&pod_b, &token, &key.to_hex()).await; assert_eq!(status, StatusCode::FORBIDDEN, "replay on pod B is denied"); @@ -2746,7 +2766,14 @@ mod route_integration_tests { "replay must not insert a deny entry on B" ); on_b.assert_open("pod B after rejected replay"); - assert_publishes(&mut publishes, &pod_b, 1, "rejected replay adds none").await; + assert_publishes( + &mut publishes, + &[&pod_a, &pod_b], + &pod_b, + 1, + "rejected replay adds none", + ) + .await; } #[tokio::test] @@ -2808,7 +2835,7 @@ mod route_integration_tests { assert_eq!(body.as_ref(), b"evidence rejected\n"); assert!(!is_denied(&pod_a, &key), "forgery inserts no deny entry"); on_a.assert_open("pod A after forgery"); - assert_publishes(&mut publishes, &pod_a, 0, "forgery").await; + assert_publishes(&mut publishes, &[&pod_a, &pod_b], &pod_a, 0, "forgery").await; if let Some(guard) = claims { assert_eq!( guard.claims.load(std::sync::atomic::Ordering::SeqCst), @@ -2824,7 +2851,14 @@ mod route_integration_tests { "the legitimate command's jti was not burned by the forgery" ); assert!(is_denied(&pod_b, &key)); - assert_publishes(&mut publishes, &pod_b, 1, "accepted command").await; + assert_publishes( + &mut publishes, + &[&pod_a, &pod_b], + &pod_b, + 1, + "accepted command", + ) + .await; } #[tokio::test] @@ -2874,7 +2908,14 @@ mod route_integration_tests { Arc::new(buzz_auth::InMemoryCommandReplayGuard::default()), ) .await; - assert_publishes(&mut publishes, &healthy, 0, "guard error").await; + assert_publishes( + &mut publishes, + &[&state, &healthy], + &healthy, + 0, + "guard error", + ) + .await; } #[tokio::test]