test(relay): pin multi-coordinate deletion refusal for owner records

The pure kind:30180 envelope tests sat in ingest's postgres_tests module
without #[ignore], so no lane ran them; select them in test-unit by name.
Ingest's one-target rule refuses a second `a` tag before the per-coordinate
privacy loop, so pin that refusal end to end and unit-test the loop directly
as defense in depth for any future multi-target deletion.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Hayt
2026-10-13 18:04:11 -04:00
parent d1224698f8
commit 3bf8e758f5
3 changed files with 62 additions and 8 deletions

View File

@@ -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,

View File

@@ -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 (`<kind>:<pubkey>:<d>`).
fn a_tag_target_kinds(event: &Event) -> impl Iterator<Item = u32> + '_ {
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};

View File

@@ -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