From 23b522d992c50744bdf8070daa18e77f68eb7413 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Wed, 1 Jul 2026 16:58:25 -0400 Subject: [PATCH] fix(relay/core): close result-level read gate for kind:44200 (NIP-AM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reader_authorized_for_event in filter.rs now gates KIND_AGENT_TURN_METRIC alongside KIND_DM_VISIBILITY — reader must match the #p tag (owner). This single function closes all kindless-ids retrieval paths: WS historical pull (req.rs:330, req.rs:652), HTTP bridge (bridge.rs:608, bridge.rs:863), and live fan-out (event.rs). Live fan-out extended likewise: owner_only_kind now covers both 44200 and 30622, so kindless-ids subscriptions cannot receive 44200 events for non-owners. Tests added: reader_authorized_for_event_gates_agent_turn_metric_by_p (owner allow, non-owner deny, authoring-agent deny). Case-2 rationale in the existing req.rs test updated: pass-through at the filter-authorization gate is correct because the result-level gate is now the enforcement point for this path. NIP-AM ref: docs/nips/NIP-AM.md at 19889ba0c (PR #1441). Resolves blocking gap from PR #1445 review. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/filter.rs | 48 +++++++++++++++++++++---- crates/buzz-relay/src/handlers/event.rs | 13 ++++--- crates/buzz-relay/src/handlers/req.rs | 14 ++++---- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index a3c0bef59..1671f7622 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -12,14 +12,17 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { } /// Result-level read authorization for relay-signed events whose content is -/// private to a single viewer. Currently only `KIND_DM_VISIBILITY`: the reader -/// MUST equal the snapshot's `#p` (owner). Returns `true` for every other kind. +/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and +/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag +/// (owner). Returns `true` for every other kind. /// -/// This guards the delivery surfaces directly, so a query that bypasses the -/// filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of a known snapshot -/// id) still cannot read another viewer's hidden-DM set. +/// This guards every delivery surface — WS historical pull (`req.rs`), HTTP +/// bridge (`bridge.rs`), and live fan-out (`event.rs`) — so a query that +/// bypasses the filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of +/// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { - if crate::kind::event_kind_u32(event) != crate::kind::KIND_DM_VISIBILITY { + let kind = crate::kind::event_kind_u32(event); + if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { return true; } let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); @@ -261,4 +264,37 @@ mod tests { .expect("sign"); assert!(reader_authorized_for_event(¬e, other)); } + + #[test] + fn reader_authorized_for_event_gates_agent_turn_metric_by_p() { + let agent_keys = Keys::generate(); + let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let attacker = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + + // Agent turn metric event: pubkey=agent, p tag=owner (NIP-AM envelope shape). + let metric = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_TURN_METRIC as u16), + "encrypted-payload", + ) + .tags([ + Tag::parse(["p", owner]).unwrap(), + Tag::parse(["agent", &agent_keys.public_key().to_hex()]).unwrap(), + ]) + .sign_with_keys(&agent_keys) + .expect("sign"); + + assert!( + reader_authorized_for_event(&metric, owner), + "owner must be authorized to read their own agent turn metric" + ); + assert!( + !reader_authorized_for_event(&metric, attacker), + "non-owner must NOT be authorized to read an agent turn metric via kindless ids" + ); + // The authoring agent also does not get read-back (NIP-AM: owner-only read). + assert!( + !reader_authorized_for_event(&metric, &agent_keys.public_key().to_hex()), + "the authoring agent must NOT be authorized to read its own metric event (owner-only)" + ); + } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index f655ae057..a269db32b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -287,10 +287,13 @@ pub(crate) async fn dispatch_persistent_event( let event_json = serde_json::to_string(&stored_event.event) .expect("nostr::Event serialization is infallible for well-formed events"); - // For viewer-private snapshots (kind:30622), live fan-out must reach only the - // owner — a kindless `ids:[…]` subscription can otherwise match it. Pull paths - // (HTTP /query, WS historical) are gated separately by reader_authorized_for_event. - let dm_visibility_owner: Option = (kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY) + // For viewer-private events (kind:30622 DM visibility, kind:44200 agent turn + // metrics), live fan-out must reach only the owner — a kindless `ids:[…]` + // subscription can otherwise match it. Pull paths (HTTP /query, WS historical) + // are gated separately by reader_authorized_for_event. + let owner_only_kind = kind_u32 == buzz_core::kind::KIND_DM_VISIBILITY + || kind_u32 == buzz_core::kind::KIND_AGENT_TURN_METRIC; + let private_event_owner: Option = owner_only_kind .then(|| { let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); stored_event @@ -304,7 +307,7 @@ pub(crate) async fn dispatch_persistent_event( // filter_fanout_by_access, applied to `matches` above before this loop. let mut drop_count = 0u32; for (target_conn_id, sub_id) in &matches { - if let Some(ref owner_hex) = dm_visibility_owner { + if let Some(ref owner_hex) = private_event_owner { let is_owner = state .conn_manager .pubkey_for(*target_conn_id) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index b43c04bca..7fdae503e 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1322,16 +1322,16 @@ mod tests { ); // Case 2: kindless {ids:[...]} — the existing ids exemption applies - // (consistent with other p-gated kinds like member notifications). The - // relay's defense-in-depth for kind:44200 is: (a) the explicit-kind+ids - // carve-out above, (b) NULL tsvector storage preventing search discovery, - // and (c) the subscription delivery layer not returning 44200 events to - // non-owners. A kindless ids filter is authorized here because - // p_gated_filters_authorized cannot know which kind the id resolves to. + // at this filter-authorization gate (consistent with other p-gated kinds). + // The kindless path is closed at the result level by + // `reader_authorized_for_event` (buzz-core/src/filter.rs), which gates + // kind:44200 delivery to the #p owner across all pull paths (WS historical, + // HTTP bridge) and live fan-out. Pass-through here is correct; the + // result-level gate is the enforcement point for this path. let kindless_ids = Filter::new().id(nostr::EventId::from_hex(event_id).unwrap()); assert!( p_gated_filters_authorized(&[kindless_ids], authed), - "kindless ids filter passes this gate (consistent with member-notif behavior)" + "kindless ids filter passes this filter gate — result-level gate closes the path" ); // Case 3: owner querying by #p is allowed.