diff --git a/Justfile b/Justfile index 312ec115b9..74f1eb43f8 100644 --- a/Justfile +++ b/Justfile @@ -531,7 +531,7 @@ test-unit: # are infra-free; config parses env into a struct and never connects. # `^config::` is anchored so it does not also select nip_fi_config. cargo nextest run -p buzz-relay --lib --bin buzz-relay \ - -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + 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(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' + -E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + 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(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::ingest::postgres_tests::owner_record_envelope_accepts_agent_and_team_records) + test(=handlers::ingest::postgres_tests::owner_record_envelope_rejects_malformed_d_and_h) + test(=handlers::ingest::postgres_tests::owner_record_is_global_users_write) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))' # Note on audio::join::tests scope: the full suite is infra-free (no DB, # no Redis). The infra-free audio/FI regression witnesses — bootstrap # ordering barrier, CommitConfirmed arm, pending-close invisibility, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 30326224de..bba18a146f 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -435,13 +435,7 @@ pub async fn validate_standard_deletion_event( let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key()); let target_ids = extract_target_event_ids(event); - // Every coordinate names its target's kind, so the `k` rule applies to - // each `a` tag, including ones the handler below does not act on: an - // untagged deletion naming an author-only coordinate would be stored - // publicly and reveal that address. - for target_kind in a_tag_target_kinds(event) { - check_deletion_privacy_k_tag(event, target_kind)?; - } + check_coordinate_privacy_k_tags(event)?; if !has_e_tag(event) { // a-tag deletion: verify author owns the addressable event @@ -493,6 +487,19 @@ pub async fn validate_standard_deletion_event( Ok(()) } +/// Every coordinate names its target's kind, so the `k` rule applies to each +/// `a` tag, including ones the deletion handler does not act on: an untagged +/// deletion naming an author-only coordinate would be stored publicly and +/// reveal that address. Ingest's one-target rule makes a second coordinate +/// unreachable today; checking all of them is defense in depth should +/// multi-target deletion ever be allowed. +fn check_coordinate_privacy_k_tags(event: &Event) -> anyhow::Result<()> { + for target_kind in a_tag_target_kinds(event) { + check_deletion_privacy_k_tag(event, target_kind)?; + } + Ok(()) +} + /// Kinds named by the event's `a` tag coordinates (`::`). fn a_tag_target_kinds(event: &Event) -> impl Iterator + '_ { event.tags.iter().filter_map(|tag| match tag.as_slice() { @@ -3902,6 +3909,25 @@ pub async fn publish_nipia_unarchived( mod tests { use super::*; + #[test] + fn coordinate_privacy_check_covers_a_private_second_coordinate() { + let keys = nostr::Keys::generate(); + let owner = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::from(5u16), "") + .tags([ + Tag::parse(["a", &format!("30023:{owner}:public")]).expect("tag"), + Tag::parse(["a", &format!("30180:{owner}:agent:{owner}")]).expect("tag"), + ]) + .sign_with_keys(&keys) + .expect("sign"); + let err = check_coordinate_privacy_k_tags(&event).expect_err("private second coordinate"); + assert!( + err.to_string() + .contains("deletion of an author-only kind 30180 event requires"), + "{err}" + ); + } + #[test] fn nip43_progress_logs_every_interval_inside_the_startup_sweep_only() { use Nip43ReconciliationPurpose::{Bootstrap, Maintenance}; diff --git a/crates/buzz-test-client/tests/e2e_owner_records.rs b/crates/buzz-test-client/tests/e2e_owner_records.rs index 3ded8c62fe..ec9d7ff8e5 100644 --- a/crates/buzz-test-client/tests/e2e_owner_records.rs +++ b/crates/buzz-test-client/tests/e2e_owner_records.rs @@ -511,6 +511,34 @@ async fn test_owner_record_deletion_requires_k_tag() { } } +/// A deletion naming a private address as its second `a` tag is refused by +/// the one-target rule before any privacy check runs, for `30180` and for an +/// existing private kind (`30300`). +#[tokio::test] +#[ignore] +async fn test_deletion_with_private_second_coordinate_is_refused() { + let client = aod::http_client(); + let owner = Keys::generate(); + let pubkey = owner.public_key().to_hex(); + let public = aod::tag(&["a", &format!("30023:{pubkey}:public")]); + for private in [ + coordinate(&owner, &agent_d()), + format!("30300:{pubkey}:reminder"), + ] { + let tags = vec![public.clone(), aod::tag(&["a", &private])]; + let (accepted, msg) = + aod::submit(&client, &owner, &aod::build_deletion(&owner, tags)).await; + assert!( + !accepted, + "two-coordinate deletion naming {private} must be refused" + ); + assert!( + msg.starts_with("invalid: deletion events must reference exactly one target"), + "unexpected refusal for {private}: {msg}" + ); + } +} + /// A coordinate deletion removes the record for its owner and is visible to /// the owner and nobody else on WS REQ, `ids`, WS COUNT, HTTP `/query` and /// HTTP `/count`. A `k`-tagged deletion of a coordinate that never held a