mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
b7480e875c
commit
23b522d992
@@ -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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user