fix(relay/core): close result-level read gate for kind:44200 (NIP-AM)

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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-01 16:58:25 -04:00
co-authored by Will Pfleger
parent b7480e875c
commit 23b522d992
3 changed files with 57 additions and 18 deletions
+42 -6
View File
@@ -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(&note, 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)"
);
}
}
+8 -5
View File
@@ -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<String> = (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<String> = 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)
+7 -7
View File
@@ -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.