Skip to content

Commit c38301b

Browse files
wesbillmanBrainnpub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
authored
fix(dm): keep hidden DMs hidden across refetch via relay-signed visibility snapshot (NIP-DV) (#857)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
1 parent 4dfae61 commit c38301b

15 files changed

Lines changed: 1147 additions & 70 deletions

File tree

crates/sprout-core/src/filter.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool {
1111
filters.iter().any(|f| filter_match_one(f, event))
1212
}
1313

14+
/// Result-level read authorization for relay-signed events whose content is
15+
/// private to a single viewer. Currently only `KIND_DM_VISIBILITY`: the reader
16+
/// MUST equal the snapshot's `#p` (owner). Returns `true` for every other kind.
17+
///
18+
/// This guards the delivery surfaces directly, so a query that bypasses the
19+
/// filter-level `#p` gate (e.g. a kindless `ids:[…]` lookup of a known snapshot
20+
/// id) still cannot read another viewer's hidden-DM set.
21+
pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool {
22+
if crate::kind::event_kind_u32(event) != crate::kind::KIND_DM_VISIBILITY {
23+
return true;
24+
}
25+
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
26+
event
27+
.tags
28+
.filter(nostr::TagKind::SingleLetter(p))
29+
.any(|t| t.content() == Some(reader_pubkey_hex))
30+
}
31+
1432
fn filter_match_one(f: &Filter, ev: &StoredEvent) -> bool {
1533
if let Some(kinds) = &f.kinds {
1634
if !kinds.contains(&ev.event.kind) {
@@ -213,4 +231,34 @@ mod tests {
213231
"explicit h-tag must be authoritative — channel_id fallback must not override it"
214232
);
215233
}
234+
235+
#[test]
236+
fn reader_authorized_for_event_gates_dm_visibility_by_p() {
237+
let relay = Keys::generate();
238+
let owner = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
239+
let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
240+
241+
let snapshot = EventBuilder::new(Kind::Custom(crate::kind::KIND_DM_VISIBILITY as u16), "")
242+
.tags([
243+
Tag::parse(["d", owner]).unwrap(),
244+
Tag::parse(["p", owner]).unwrap(),
245+
])
246+
.sign_with_keys(&relay)
247+
.expect("sign");
248+
249+
assert!(
250+
reader_authorized_for_event(&snapshot, owner),
251+
"owner must be authorized to read their own snapshot"
252+
);
253+
assert!(
254+
!reader_authorized_for_event(&snapshot, other),
255+
"a third party must NOT be authorized to read another viewer's snapshot"
256+
);
257+
258+
// Non-DV events are unaffected by this gate.
259+
let note = EventBuilder::new(Kind::TextNote, "hi")
260+
.sign_with_keys(&relay)
261+
.expect("sign");
262+
assert!(reader_authorized_for_event(&note, other));
263+
}
216264
}

crates/sprout-core/src/kind.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ pub const KIND_WORKFLOW_DEF: u32 = 30620;
161161
/// of mesh status, including EndpointAddr dial pointers for serving nodes.
162162
pub const KIND_MESH_LLM_RELAY_STATUS: u32 = 30621;
163163

164+
/// NIP-DV: per-viewer DM visibility snapshot (relay-signed, parameterized
165+
/// replaceable, d=viewer_pubkey). Carries one `h` tag per DM the viewer has
166+
/// hidden from their sidebar. Re-published by the relay on every hide/unhide so
167+
/// the latest event is always the authoritative hidden set. The relay knows
168+
/// `hidden_at` per viewer; this is the only Nostr-visible projection of it.
169+
pub const KIND_DM_VISIBILITY: u32 = 30622;
170+
164171
/// Lower bound of the NIP-33 parameterized replaceable range (30000–39999).
165172
pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000;
166173
/// Upper bound of the NIP-33 parameterized replaceable range (30000–39999).
@@ -408,6 +415,7 @@ pub const ALL_KINDS: &[u32] = &[
408415
KIND_CHANNEL_SUMMARY,
409416
KIND_PRESENCE_SNAPSHOT,
410417
KIND_MESH_LLM_RELAY_STATUS,
418+
KIND_DM_VISIBILITY,
411419
KIND_DM_OPEN,
412420
KIND_DM_ADD_MEMBER,
413421
KIND_DM_HIDE,
@@ -520,7 +528,10 @@ pub const fn is_command_kind(kind: u32) -> bool {
520528
pub const fn is_relay_only_kind(kind: u32) -> bool {
521529
matches!(
522530
kind,
523-
KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT | KIND_MESH_LLM_RELAY_STATUS
531+
KIND_CHANNEL_SUMMARY
532+
| KIND_PRESENCE_SNAPSHOT
533+
| KIND_MESH_LLM_RELAY_STATUS
534+
| KIND_DM_VISIBILITY
524535
)
525536
}
526537

@@ -540,6 +551,7 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 {
540551
const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999
541552
const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999
542553
const _: () = assert!(is_parameterized_replaceable(KIND_MESH_LLM_RELAY_STATUS)); // 30621 ∈ 30000–39999
554+
const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999
543555

544556
// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range.
545557
const _: () = assert!(

crates/sprout-db/src/dm.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,32 @@ pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result
412412
Ok(())
413413
}
414414

415+
/// Return the channel IDs of all DMs the given user currently has hidden
416+
/// (`hidden_at IS NOT NULL`) while still being an active member. Used to build
417+
/// the relay-signed NIP-DV visibility snapshot.
418+
pub async fn list_hidden_dms(pool: &PgPool, pubkey: &[u8]) -> Result<Vec<Uuid>> {
419+
let rows = sqlx::query(
420+
r#"
421+
SELECT cm.channel_id
422+
FROM channel_members cm
423+
JOIN channels c ON c.id = cm.channel_id
424+
WHERE cm.pubkey = $1
425+
AND cm.removed_at IS NULL
426+
AND cm.hidden_at IS NOT NULL
427+
AND c.channel_type = 'dm'
428+
AND c.deleted_at IS NULL
429+
ORDER BY cm.channel_id
430+
"#,
431+
)
432+
.bind(pubkey)
433+
.fetch_all(pool)
434+
.await?;
435+
436+
rows.into_iter()
437+
.map(|r| r.try_get::<Uuid, _>("channel_id").map_err(Into::into))
438+
.collect()
439+
}
440+
415441
// -- Row mapping --------------------------------------------------------------
416442

417443
fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {

crates/sprout-db/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,11 @@ impl Db {
657657
dm::unhide_dm(&self.pool, channel_id, pubkey).await
658658
}
659659

660+
/// List the channel IDs of all DMs the given user currently has hidden.
661+
pub async fn list_hidden_dms(&self, pubkey: &[u8]) -> Result<Vec<Uuid>> {
662+
dm::list_hidden_dms(&self.pool, pubkey).await
663+
}
664+
660665
// ── Threads ──────────────────────────────────────────────────────────────
661666

662667
/// Insert thread metadata.

crates/sprout-relay/src/api/bridge.rs

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,8 @@ pub async fn query_events(
266266

267267
// ── NIP-50 search: route to Typesense if any filter has a `search` field ──
268268
if filters.iter().any(|f| f.search.is_some()) {
269-
return handle_bridge_search(&state, &filters, &accessible_channels).await;
269+
return handle_bridge_search(&state, &filters, &accessible_channels, &authed_pubkey_hex)
270+
.await;
270271
}
271272

272273
// ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ──
@@ -438,6 +439,14 @@ pub async fn query_events(
438439
if !sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) {
439440
continue;
440441
}
442+
// Result-level read auth: never hand a viewer-private snapshot
443+
// (kind:30622) to anyone but its owner, even via kindless `ids`.
444+
if !sprout_core::filter::reader_authorized_for_event(
445+
&se.event,
446+
&authed_pubkey_hex,
447+
) {
448+
continue;
449+
}
441450
if let Ok(v) = serde_json::to_value(&se.event) {
442451
events.push(v);
443452
}
@@ -596,6 +605,7 @@ fn search_hit_accepted(
596605
filter: &nostr::Filter,
597606
stored: &sprout_core::StoredEvent,
598607
accessible_channels: &[uuid::Uuid],
608+
reader_pubkey_hex: &str,
599609
) -> bool {
600610
if !sprout_core::filter::filters_match(std::slice::from_ref(filter), stored) {
601611
return false;
@@ -605,6 +615,9 @@ fn search_hit_accepted(
605615
return false;
606616
}
607617
}
618+
if !sprout_core::filter::reader_authorized_for_event(&stored.event, reader_pubkey_hex) {
619+
return false;
620+
}
608621
true
609622
}
610623

@@ -614,6 +627,7 @@ async fn handle_bridge_search(
614627
state: &AppState,
615628
filters: &[nostr::Filter],
616629
accessible_channels: &[uuid::Uuid],
630+
reader_pubkey_hex: &str,
617631
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
618632
// Bridge always includes global (non-channel) events — same as WS with full scopes.
619633
let channel_scope = match crate::handlers::req::build_search_channel_scope_filter(
@@ -727,7 +741,7 @@ async fn handle_bridge_search(
727741
Some(ev) => ev,
728742
None => continue,
729743
};
730-
if !search_hit_accepted(filter, stored, accessible_channels) {
744+
if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) {
731745
continue;
732746
}
733747
// Dedup across filters.
@@ -1004,12 +1018,14 @@ mod tests {
10041018
.kind(Kind::Custom(30174))
10051019
.custom_tags(p_tag, [&owner_a]);
10061020

1021+
// 30174 is not owner-gated, so any reader hex is fine here.
1022+
let reader = Keys::generate().public_key().to_hex();
10071023
assert!(
1008-
search_hit_accepted(&filter, &env_for_a, &[]),
1024+
search_hit_accepted(&filter, &env_for_a, &[], &reader),
10091025
"envelope addressed to owner_a must be returned"
10101026
);
10111027
assert!(
1012-
!search_hit_accepted(&filter, &env_for_b, &[]),
1028+
!search_hit_accepted(&filter, &env_for_b, &[], &reader),
10131029
"envelope addressed to owner_b must NOT be returned for a #p=[owner_a] search"
10141030
);
10151031
}
@@ -1031,9 +1047,10 @@ mod tests {
10311047
.kind(Kind::Custom(30174))
10321048
.author(agent_a.public_key());
10331049

1034-
assert!(search_hit_accepted(&filter, &env_a, &[]));
1050+
let reader = Keys::generate().public_key().to_hex();
1051+
assert!(search_hit_accepted(&filter, &env_a, &[], &reader));
10351052
assert!(
1036-
!search_hit_accepted(&filter, &env_b, &[]),
1053+
!search_hit_accepted(&filter, &env_b, &[], &reader),
10371054
"authors=[agent_a] search must not return events authored by agent_b"
10381055
);
10391056
}
@@ -1053,12 +1070,13 @@ mod tests {
10531070
.kind(Kind::Custom(30174))
10541071
.custom_tags(p_tag, [&owner]);
10551072

1073+
let reader = Keys::generate().public_key().to_hex();
10561074
assert!(
1057-
!search_hit_accepted(&filter, &stored, &[]),
1075+
!search_hit_accepted(&filter, &stored, &[], &reader),
10581076
"channel-scoped hit must be rejected when caller has no channel access"
10591077
);
10601078
assert!(
1061-
search_hit_accepted(&filter, &stored, &[scoped_channel]),
1079+
search_hit_accepted(&filter, &stored, &[scoped_channel], &reader),
10621080
"channel-scoped hit must be accepted when caller has access to that channel"
10631081
);
10641082
}
@@ -1216,4 +1234,45 @@ mod tests {
12161234
se.channel_id = Some(ch);
12171235
assert!(!event_in_accessible_channel(&se, &[other]));
12181236
}
1237+
1238+
/// NIP-DV regression: a relay-signed kind:30622 snapshot must not leak via
1239+
/// search through a kindless `ids:[snapshot_id]` filter that carries no #p.
1240+
/// `filters_match` passes (id matches), channel check passes (channel_id =
1241+
/// None), so only the result-level `reader_authorized_for_event` check
1242+
/// stands between a third party and the owner's private hide set.
1243+
#[test]
1244+
fn search_hit_rejects_dm_visibility_for_kindless_ids_third_party() {
1245+
let relay = Keys::generate();
1246+
let viewer = Keys::generate().public_key().to_hex();
1247+
let third_party = Keys::generate().public_key().to_hex();
1248+
1249+
let d_tag = Tag::custom(
1250+
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::D)),
1251+
[&viewer],
1252+
);
1253+
let p_tag = Tag::custom(
1254+
nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)),
1255+
[&viewer],
1256+
);
1257+
let ev = EventBuilder::new(
1258+
Kind::Custom(sprout_core::kind::KIND_DM_VISIBILITY as u16),
1259+
"",
1260+
)
1261+
.tags([d_tag, p_tag])
1262+
.sign_with_keys(&relay)
1263+
.expect("sign snapshot");
1264+
let stored = sprout_core::StoredEvent::new(ev.clone(), None);
1265+
1266+
// Kindless filter — the exact bypass shape: no #p, just the id.
1267+
let filter = nostr::Filter::new().id(ev.id);
1268+
1269+
assert!(
1270+
!search_hit_accepted(&filter, &stored, &[], &third_party),
1271+
"third party must not receive a DM-visibility snapshot via kindless ids search"
1272+
);
1273+
assert!(
1274+
search_hit_accepted(&filter, &stored, &[], &viewer),
1275+
"owner must still receive their own snapshot"
1276+
);
1277+
}
12191278
}

crates/sprout-relay/src/handlers/command_executor.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::webhook_secret;
2727
use super::ingest::{extract_channel_id, IngestAuth, IngestError, IngestResult};
2828
use super::side_effects::{
2929
emit_group_discovery_events, emit_membership_notification, emit_system_message,
30+
publish_dm_visibility_snapshot,
3031
};
3132

3233
/// Route a command-kind event to the appropriate handler.
@@ -330,6 +331,12 @@ async fn handle_dm_open(
330331
warn!("DM open: membership notification failed: {e}");
331332
}
332333
}
334+
} else {
335+
// Re-open of an existing DM cleared the caller's hidden_at; refresh
336+
// their NIP-DV snapshot so the DM reappears in the sidebar.
337+
if let Err(e) = publish_dm_visibility_snapshot(state, &self_bytes).await {
338+
warn!("DM re-open: visibility snapshot failed: {e}");
339+
}
333340
}
334341

335342
// 6. Return response
@@ -532,7 +539,13 @@ async fn handle_dm_hide(
532539
.await
533540
.map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?;
534541

535-
// 5. Return response
542+
// 5. Side effect (post-commit, best-effort): refresh the caller's NIP-DV
543+
// visibility snapshot so clients can filter this DM out of the sidebar.
544+
if let Err(e) = publish_dm_visibility_snapshot(state, &self_bytes).await {
545+
warn!("DM hide: visibility snapshot failed: {e}");
546+
}
547+
548+
// 6. Return response
536549
Ok(IngestResult {
537550
event_id: event.id.to_hex(),
538551
accepted: true,

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,30 @@ pub(crate) async fn dispatch_persistent_event(
125125

126126
let event_json = serde_json::to_string(&stored_event.event)
127127
.expect("nostr::Event serialization is infallible for well-formed events");
128+
// For viewer-private snapshots (kind:30622), live fan-out must reach only the
129+
// owner — a kindless `ids:[…]` subscription can otherwise match it. Pull paths
130+
// (HTTP /query, WS historical) are gated separately by reader_authorized_for_event.
131+
let dm_visibility_owner: Option<String> = (kind_u32 == sprout_core::kind::KIND_DM_VISIBILITY)
132+
.then(|| {
133+
let p = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P);
134+
stored_event
135+
.event
136+
.tags
137+
.filter(nostr::TagKind::SingleLetter(p))
138+
.find_map(|t| t.content().map(|s| s.to_string()))
139+
})
140+
.flatten();
128141
let mut drop_count = 0u32;
129142
for (target_conn_id, sub_id) in &matches {
143+
if let Some(ref owner_hex) = dm_visibility_owner {
144+
let is_owner = state
145+
.conn_manager
146+
.pubkey_for(*target_conn_id)
147+
.is_some_and(|pk| hex::encode(pk) == *owner_hex);
148+
if !is_owner {
149+
continue;
150+
}
151+
}
130152
let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json);
131153
if !state.conn_manager.send_to(*target_conn_id, msg) {
132154
drop_count += 1;
@@ -140,8 +162,10 @@ pub(crate) async fn dispatch_persistent_event(
140162
);
141163
}
142164

143-
// Skip search indexing for NIP-17 gift wraps — content is ciphertext.
165+
// Skip search indexing for NIP-17 gift wraps (ciphertext) and NIP-DV
166+
// visibility snapshots (per-viewer private hide state, owner-gated reads).
144167
if kind_u32 != KIND_GIFT_WRAP
168+
&& kind_u32 != sprout_core::kind::KIND_DM_VISIBILITY
145169
&& state
146170
.search_index_tx
147171
.try_send(stored_event.clone())

0 commit comments

Comments
 (0)