mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(nip-fi): admin disconnect/deny API with in-memory deny-until-TTL map (S4) (#7977)
This adds the NIP-FI S4 admin command surface. An authorized issuer principal can deny an `(issuer, pubkey)` pair until a TTL expires. When that happens, the relay records the entry in a bounded in-memory deny map, closes matching live sockets on every pod, and refuses new admissions while the entry is live. Supersedes #7265 ## Breaking In `BUZZ_NIP_FI_MODE=enforce`, every entry in `BUZZ_NIP_FI_ISSUERS` now has to carry `maximum_command_age_seconds` (1–60) and a non-empty `authorized_principals` list, and startup fails if any issuer is missing either one. That means an S3-only enforce config that boots on `main` won't boot with this change. No deployment sets `BUZZ_NIP_FI_*` yet. The per-issuer field table and a working example are in the `nip_fi_config.rs` module docs. ## What changes - **`buzz-auth`**: - `nip_fi/command.rs` verifies signed admin commands. It checks issuer policy, command age, the authorized principal (exact, case-sensitive `sub` match), and a `jti` of at most 512 bytes. - `nip_fi/deny_map.rs` is a per-issuer, capacity-bounded deny-until-TTL map on top of `DashMap`. Expired entries and `jti` reservations are only swept when a write would otherwise hit capacity. The sweep is O(n) and holds the lock every admission check takes, so this keeps it off the admission path. Reads compare against `now`, so an expired entry that hasn't been swept yet never counts as live. - **Cross-pod command replay fence**: `nip_fi/command_replay.rs` adds a `CommandReplayGuard` trait, and `buzz-pubsub` implements it in Redis (`RedisCommandReplayGuard`), following the NIP-98 replay guard. Once a command is authenticated, the verifier takes an atomic `SET buzz:nip-fi:command:{sha256(len(iss)‖iss‖len(jti)‖jti)} 1 NX EX <ttl>` claim, with the TTL set to the command's remaining validity rounded up to whole seconds plus skew, clamped like NIP-98, so the claim outlives acceptance by a verifier whose clock trails by the full skew. Before the claim, the verifier reserves a pending `jti` and deny-entry slot in this pod's deny map under the issuer lock, so a full deny set returns `503 deny set full` without touching Redis and the same signed command can be retried. The claim is taken only after the signature and claims verify, so a forged token cannot burn a real `jti`. A `jti` already claimed on any pod returns `403`. A Redis error fails closed with `503 authorization unavailable`: no deny entry, no session close, no publish. A replayed claim, a Redis error, or a cancelled request drops the reservation. After a successful claim the reservation commits to the `jti` record and deny entry, which cannot fail on capacity. A claim can still remain with no deny entry if Redis applied `SET` but the reply was lost, or the pod stops between claim and commit; that fails closed, and a retry gets `403` until the TTL expires. - **`api/nip_fi.rs`**: the admin disconnect/deny endpoint. It verifies the command, inserts the deny entry, disconnects locally, and publishes over `buzz-pubsub` `conn_control` so every other pod runs the same disconnect. A receiving pod clamps `until` to its own ceiling instead of rejecting it. Otherwise a pod whose clock trails the origin would drop a command the origin already answered with 200, and the target's sockets on that pod would stay open. Malformed pubkeys, unknown issuers, and unrepresentable timestamps are still rejected. - **Admission and live enforcement**: - WS upgrade (`router.rs`) and HTTP (`nip_fi_http.rs`) refuse keys that have a live entry with `403 authorization_denied`. - Root NIP-42 auth and audio auth register the proven identity (pubkey + admitting issuer) first, and only then check the deny map. With that order, a concurrent disconnect either finds the socket in its close scan or the check finds the entry. `OK(true)` isn't sent if a concurrent disconnect already cancelled the session. - `ConnectionManager::disconnect_nip_fi` and `CommunityConnectionRegistry::disconnect_nip_fi` are scoped to the issuer. - **One wire form for `authorization_denied`** (FI-TRACE-DENIAL-ORACLE): every root and audio WebSocket `authorization_denied` denial now goes through one first-writer-wins transition on `CommunityConnectionControl`. That covers the S4 deny-set hit and admin disconnect, and also the existing S3 key-mismatch, lease-expiry, and membership refusals. The transition sets the `AuthorizationDenied` reason only if nothing else has set a reason, and only the winner enqueues the denial frame. So every one of these denials is the class's denial frame followed by `Close(1008, "authorization denied")`, and a concurrent registry scan can't queue a second one. Before this, S3 key mismatch and expiry closed with `Close(None)`, which made them distinguishable from a deny-set hit on the wire. `evidence_rejected` and `authorization_unavailable` keep the bare close. - **Config**: see Breaking. `deny_set_capacity` is optional and defaults to 50000. Setting any command field without a command age fails closed. - **Boot** (`main.rs`): installs the command verifier and deny map. It subscribes to cross-pod disconnects before the Redis subscriber starts, so disconnects relayed during boot are buffered instead of dropped. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -226,6 +231,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
|
||||
|
||||
@@ -249,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
|
||||
@@ -256,6 +265,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_disconnect_sets_reason_enqueues_frame_then_cancels
|
||||
)
|
||||
local name
|
||||
for name in "${nip_fi_exact_tests[@]}"; do
|
||||
|
||||
Reference in New Issue
Block a user