Skip to content

Commit ee70f45

Browse files
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7wpfleger96
andcommitted
fix(relay): centralize NIP-ER author-only delivery gate in fan-out path
The author-only delivery gate for NIP-ER reminders (AUTHOR_ONLY_KINDS) lived inline in dispatch_persistent_event, so it covered only the in-process fan-out path. The Redis cross-node path (subscribe_local in main.rs) calls fan_out + filter_fanout_by_access but never applied the author-only check — a reminder published on one node and received by another was fanned out to any matching subscriber, leaking author-private reminders across nodes. Move the gate into filter_fanout_by_access, the single post-fan_out chokepoint both delivery paths already share. It now runs independent of channel scope (author-only kinds are stored globally, channel_id=None) and cannot be bypassed by any path that delivers through the shared filter. The redundant inline check is removed. Document the Redis routing trust boundary at publish_event: per-author routing is not the isolation boundary (every node PSUBSCRIBEs buzz:channel:* and must receive every event since the author may connect to any node); filter_fanout_by_access is the actual author-only delivery boundary, now enforced on both fan-out paths. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1 parent 139d97e commit ee70f45

2 files changed

Lines changed: 72 additions & 11 deletions

File tree

crates/buzz-pubsub/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ impl PubSubManager {
100100
}
101101

102102
/// Publish an event to the Redis channel. Returns subscriber count.
103+
///
104+
/// Routing note (NIP-ER author-private reminders): events are keyed by
105+
/// `channel_id` (`buzz:channel:{id}`), and every relay node's subscriber
106+
/// `PSUBSCRIBE buzz:channel:*` — so the channel key is a routing label, not
107+
/// an isolation boundary; every node already receives every published event.
108+
/// Author-private reminders (kind:30300, stored under the nil channel
109+
/// sentinel) are therefore NOT protected by per-author Redis routing, and
110+
/// adding it would be pointless: the reminder's author may be connected to
111+
/// any node, so every node must still receive it. The actual author-only
112+
/// delivery boundary is `filter_fanout_by_access` in the relay, which runs
113+
/// on BOTH the in-process and the Redis cross-node (`subscribe_local`)
114+
/// fan-out paths and drops every recipient that is not the event author.
115+
/// Redis only ever carries events between nodes inside the relay trust
116+
/// domain; the ciphertext is NIP-44-encrypted to the author regardless.
103117
pub async fn publish_event(
104118
&self,
105119
channel_id: Uuid,

crates/buzz-relay/src/handlers/event.rs

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,27 @@ pub async fn filter_fanout_by_access(
6161
stored_event: &StoredEvent,
6262
matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>,
6363
) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> {
64+
// Author-only kinds (NIP-ER reminders) may only ever be delivered to the
65+
// event's own author. This gate lives here — the chokepoint shared by the
66+
// ingest fan-out path and the Redis cross-node `subscribe_local` path, the
67+
// only paths that route author-only kinds — so no such delivery can bypass
68+
// it. It runs before (and independent of) the channel-membership filter
69+
// below because author-only kinds are stored globally (channel_id = None).
70+
let matches = if AUTHOR_ONLY_KINDS.contains(&event_kind_u32(&stored_event.event)) {
71+
let author = stored_event.event.pubkey.to_bytes();
72+
matches
73+
.into_iter()
74+
.filter(|(conn_id, _)| {
75+
state
76+
.conn_manager
77+
.pubkey_for_conn(*conn_id)
78+
.is_some_and(|pk| pk == author)
79+
})
80+
.collect()
81+
} else {
82+
matches
83+
};
84+
6485
let Some(channel_id) = stored_event.channel_id else {
6586
return matches;
6687
};
@@ -138,8 +159,8 @@ pub(crate) async fn dispatch_persistent_event(
138159
.find_map(|t| t.content().map(|s| s.to_string()))
139160
})
140161
.flatten();
141-
// Author-only kinds: only deliver to the event's author.
142-
let is_author_only_kind = AUTHOR_ONLY_KINDS.contains(&kind_u32);
162+
// Author-only delivery gating (NIP-ER reminders) is enforced centrally in
163+
// filter_fanout_by_access, applied to `matches` above before this loop.
143164
let mut drop_count = 0u32;
144165
for (target_conn_id, sub_id) in &matches {
145166
if let Some(ref owner_hex) = dm_visibility_owner {
@@ -151,15 +172,6 @@ pub(crate) async fn dispatch_persistent_event(
151172
continue;
152173
}
153174
}
154-
if is_author_only_kind {
155-
let is_author = state
156-
.conn_manager
157-
.pubkey_for(*target_conn_id)
158-
.is_some_and(|pk| pk == stored_event.event.pubkey.to_bytes());
159-
if !is_author {
160-
continue;
161-
}
162-
}
163175
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
164176
if !state.conn_manager.send_to(*target_conn_id, msg) {
165177
drop_count += 1;
@@ -1129,5 +1141,40 @@ mod tests {
11291141
filter_fanout_by_access(&state, &channel_event(Some(channel_id)), matches).await;
11301142
assert_eq!(out, vec![(member, "m".to_string())]);
11311143
}
1144+
1145+
#[tokio::test]
1146+
async fn author_only_reminder_delivers_to_author_only() {
1147+
let state = test_state().await;
1148+
1149+
let author_keys = Keys::generate();
1150+
let author_pk = author_keys.public_key().to_bytes().to_vec();
1151+
let other_pk = vec![9u8; 32];
1152+
1153+
// KIND_EVENT_REMINDER (30300) is in AUTHOR_ONLY_KINDS and is stored
1154+
// globally (channel_id = None), so the gate must apply independent
1155+
// of any channel-membership check.
1156+
let reminder = EventBuilder::new(
1157+
Kind::Custom(buzz_core::kind::KIND_EVENT_REMINDER as u16),
1158+
"{}",
1159+
)
1160+
.sign_with_keys(&author_keys)
1161+
.expect("sign reminder");
1162+
let stored = StoredEvent::new(reminder, None);
1163+
1164+
let author_conn = register_conn(&state, Some(author_pk));
1165+
let other_conn = register_conn(&state, Some(other_pk));
1166+
let unauthed_conn = register_conn(&state, None);
1167+
1168+
let matches = vec![
1169+
(author_conn, "a".to_string()),
1170+
(other_conn, "o".to_string()),
1171+
(unauthed_conn, "u".to_string()),
1172+
];
1173+
let out = filter_fanout_by_access(&state, &stored, matches).await;
1174+
1175+
// Only the author's subscription survives; the non-author and the
1176+
// unauthenticated connection are both dropped.
1177+
assert_eq!(out, vec![(author_conn, "a".to_string())]);
1178+
}
11321179
}
11331180
}

0 commit comments

Comments
 (0)