From 01c7fc8a0cf4c2ec1caeee0f2e71afa616d2aa96 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 24 Jul 2026 17:26:32 -0400 Subject: [PATCH 1/4] feat(relay): add author-only-unless-shared read gate for kind 30175 Kind 30175 (persona) events carry a system prompt and respond_to_allowlist that should not be visible to all community members as a side effect of device sync. Add a shared-tag-gated read model: - Events WITHOUT ["shared","true"] are author-only. - Events WITH exactly ["shared","true"] are community-readable, enabling the opt-in agent catalog ({kinds:[30175]} all-authors). Unlike AUTHOR_ONLY_KINDS (30300/30350), persona filters from foreign readers are legitimate (that IS the catalog query), so enforcement is per-event at every read chokepoint rather than at the filter layer: - REQ historical delivery: is_unshared_persona_event gate in main loop and search lane silently omits foreign unshared events. - Live fan-out: filter_fanout_by_access skips foreign connections when the event is unshared. - COUNT: filter_can_match_persona_shared_kinds forces the per-event fallback path, preventing existence-leak via fast SQL count_events(). - ids-lookup: result-gated via the per-event check in the delivery loop. - Ingest: validate_persona_envelope rejects malformed shared tags (wrong value, duplicates) so no ambiguous heads can be stored. The shared tag is a tag (not a content field) so toggling sharing does not change content bytes or the drift/source_version hash (persona_content_hash) used by persona sync. No data migration: existing 30175 heads have no tag -> become author-only on deploy. Device sync is unaffected (usePersonaSync.ts already filters authors:[self]). Changes: - kind.rs: is_persona_shared_kind, is_unshared_persona_event, persona_event_is_shared helpers + 8 unit tests; updated KIND_PERSONA doc - ingest.rs: shared-tag validation in validate_persona_envelope + 6 tests - req.rs: is_unshared_persona_event gate in REQ main loop + search lane; filter_can_match_persona_shared_kinds helper - event.rs: persona shared-read gate in filter_fanout_by_access - count.rs: needs_persona_filtering flag forces fallback at both count sites - e2e_persona.rs: 6 acceptance-criteria tests covering foreign isolation, ids-lookup, COUNT fallback, live fan-out (including toggle), ingest rejection, and mixed-kind filter non-leak - NIP-AP.md: Relay Behavior section updated with shared-tag-gated access model and updated Security considerations Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/kind.rs | 153 ++++++- crates/buzz-relay/src/handlers/count.rs | 17 +- crates/buzz-relay/src/handlers/event.rs | 27 +- crates/buzz-relay/src/handlers/ingest.rs | 91 +++- crates/buzz-relay/src/handlers/req.rs | 31 +- crates/buzz-test-client/tests/e2e_persona.rs | 435 ++++++++++++++++++- docs/nips/NIP-AP.md | 32 +- 7 files changed, 776 insertions(+), 10 deletions(-) diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b912169801..79ae5b53ad 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -161,9 +161,80 @@ pub const P_GATED_KINDS: &[u32] = &[ /// `(pubkey, kind, d_tag)` where `d_tag` is the plaintext persona slug. /// Content is a JSON body containing persona fields (system_prompt, /// display_name, avatar_url, runtime, model, provider, name_pool). -/// Designed for discoverability and sharing — d-tag is not blinded. +/// +/// # Access control: author-only-unless-shared +/// +/// Kind 30175 uses **shared-tag-gated** read semantics to protect system +/// prompts and `respond_to_allowlist` pubkeys from being visible to all +/// community members as a side-effect of device sync: +/// +/// - Events WITHOUT a `["shared", "true"]` tag are readable only by their +/// author. Foreign REQ/COUNT/fan-out/ids-lookup requests silently omit them. +/// - Events WITH exactly `["shared", "true"]` are readable community-wide, +/// enabling the opt-in agent catalog (`{kinds:[30175]}` all-authors). +/// +/// Device sync already queries `authors:[self]`, so this gate never affects +/// self-reads. The `shared` tag is a tag (not a content field) so toggling +/// sharing does not change content bytes or the drift/`source_version` hash +/// (`persona_content_hash`) used by persona sync. +/// +/// Ingest rejects malformed `shared` tags (any value other than `"true"`, +/// or more than one `shared` tag) so no ambiguous heads can exist. pub const KIND_PERSONA: u32 = 30175; +/// Returns `true` if `kind` uses the author-only-unless-shared read model +/// (currently only `KIND_PERSONA` / 30175). +/// +/// Events of these kinds may only be delivered to foreign readers when the +/// event carries exactly `["shared", "true"]`. Used by all relay read +/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback, +/// and the `ids`-lookup result gate. +pub fn is_persona_shared_kind(kind: u32) -> bool { + kind == KIND_PERSONA +} + +/// Returns `true` if the event is a persona-shared-catalog kind AND the +/// requester is NOT the author AND the event does NOT carry `["shared", +/// "true"]`. All three conditions must hold to withhold the event. +/// +/// This is the per-event gate used by REQ historical delivery, live fan-out, +/// and COUNT fallback paths. It is intentionally independent of +/// `is_author_only_event` — persona events with `["shared", "true"]` MUST +/// reach foreign readers; stripping them at the author-only layer would break +/// the catalog query. +pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { + let kind = event.kind.as_u16() as u32; + if !is_persona_shared_kind(kind) { + return false; + } + // Author reads are always allowed. + if event.pubkey.to_bytes() == requester_pubkey_bytes { + return false; + } + // Foreign reader: allowed only if the event is explicitly shared. + !persona_event_is_shared(event) +} + +/// Returns `true` if the event carries exactly one `["shared", "true"]` tag. +/// +/// The relay guarantees at ingest that no persona event with a malformed +/// `shared` tag (wrong value, duplicate) is stored, so a stored event either +/// has no `shared` tag (author-only) or exactly one with value `"true"` +/// (community-readable). This helper is safe to use at read time. +pub fn persona_event_is_shared(event: &nostr::Event) -> bool { + let mut count = 0usize; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() >= 2 && parts[0].as_str() == "shared" { + if parts[1].as_str() != "true" { + return false; + } + count += 1; + } + } + count == 1 +} + /// NIP-AP: Agent Team (parameterized replaceable, owner-authored). /// /// Team definition event published by the workspace owner. Addressed by @@ -781,4 +852,84 @@ mod tests { ); } } + + // ── persona_event_is_shared / is_unshared_persona_event ────────────── + + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let tag_vec: Vec = tags + .iter() + .map(|parts| Tag::parse(parts.iter().copied()).unwrap()) + .collect(); + EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + .tags(tag_vec) + .sign_with_keys(&keys) + .unwrap() + } + + #[test] + fn persona_event_is_shared_true_tag() { + let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); + assert!(persona_event_is_shared(&ev)); + } + + #[test] + fn persona_event_is_shared_no_tag() { + let ev = make_persona_event(&[&["d", "my-agent"]]); + assert!(!persona_event_is_shared(&ev)); + } + + #[test] + fn persona_event_is_shared_wrong_value() { + let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]); + assert!(!persona_event_is_shared(&ev)); + } + + #[test] + fn persona_event_is_shared_duplicate_shared_tags() { + // Two ["shared","true"] tags → ambiguous; not considered shared. + let ev = + make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]); + assert!(!persona_event_is_shared(&ev)); + } + + #[test] + fn is_unshared_persona_event_author_always_allowed() { + // Even without a shared tag the event author should not be blocked. + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + .tags(vec![Tag::parse(["d", "my-agent"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let author_bytes = keys.public_key().to_bytes(); + assert!(!is_unshared_persona_event(&ev, &author_bytes)); + } + + #[test] + fn is_unshared_persona_event_foreign_no_tag() { + let ev = make_persona_event(&[&["d", "my-agent"]]); + let foreign = [0u8; 32]; + assert!(is_unshared_persona_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_persona_event_foreign_shared_tag() { + let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); + let foreign = [0u8; 32]; + assert!(!is_unshared_persona_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_persona_event_non_persona_kind_passthrough() { + use nostr::{EventBuilder, Keys, Kind}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "") + .sign_with_keys(&keys) + .unwrap(); + let foreign = [0u8; 32]; + // Non-persona kinds are never blocked by this gate. + assert!(!is_unshared_persona_event(&ev, &foreign)); + } } diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4689826f23..9371f134cd 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,10 +7,12 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - filter_can_match_result_gated_kinds, is_author_only_event, result_gated_count_safe_for_pushdown, + filter_can_match_persona_shared_kinds, filter_can_match_result_gated_kinds, + is_author_only_event, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; +use buzz_core::kind::is_unshared_persona_event; /// Extract a channel UUID from a single filter's `#h` tag. fn extract_channel_from_filter(filter: &Filter) -> Option { @@ -102,6 +104,11 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); + // Determine if this filter can match kind 30175 (persona) — if so, the + // fast-path must be bypassed because it has no per-event shared-tag check. + // A fast count over 30175 would include foreign unshared persona events, + // leaking the existence of private agent activity. + let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter); // Determine if this filter can match result-gated kinds (44200, 30622) // that require a per-event owner check. When the fast SQL path would // count matching rows without calling reader_authorized_for_event, a @@ -160,6 +167,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_persona_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -190,6 +198,9 @@ pub async fn handle_count( if is_author_only_event(&se.event, &pubkey_bytes) { continue; } + if is_unshared_persona_event(&se.event, &pubkey_bytes) { + continue; + } if !buzz_core::filter::reader_authorized_for_event( &se.event, &authed_pubkey_hex, @@ -230,6 +241,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_persona_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events(&query).await { @@ -260,6 +272,9 @@ pub async fn handle_count( if is_author_only_event(&se.event, &pubkey_bytes) { continue; } + if is_unshared_persona_event(&se.event, &pubkey_bytes) { + continue; + } if !buzz_core::filter::reader_authorized_for_event( &se.event, &authed_pubkey_hex, diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index ecb3e41fcc..88dd5f5180 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,8 +7,8 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, - KIND_PRESENCE_UPDATE, + event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, + KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -151,6 +151,29 @@ pub async fn filter_fanout_by_access( matches }; + // Persona shared-read gate (fan-out): kind 30175 events fan out to all + // connections only when carrying ["shared","true"]. Unshared personas + // are delivered only to the author's own connections, matching REQ semantics. + let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) { + let author = stored_event.event.pubkey.to_bytes(); + matches + .into_iter() + .filter(|(conn_id, _)| { + let Some(pk) = state.conn_manager.pubkey_for_conn(*conn_id) else { + return false; + }; + // Author always receives their own events. + if pk == author { + return true; + } + // Foreign connection: allowed only if the event is shared. + !is_unshared_persona_event(&stored_event.event, &pk) + }) + .collect() + } else { + matches + }; + let Some(channel_id) = stored_event.channel_id else { return matches; }; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ca529d1db6..1e7322bbc8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1021,16 +1021,39 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { /// Enforces: /// * exactly one `d` tag with a non-empty value matching the slug grammar /// `^[a-z0-9][a-z0-9_-]{0,63}$`. +/// * at most one `shared` tag; if present, its value must be exactly `"true"`. /// -/// Without this, an empty d-tag collapses every persona into the +/// Without the `d`-tag check, an empty d-tag collapses every persona into the /// `(pubkey, 30175, "")` slot — last-write-wins data loss. +/// +/// The `shared` tag rule ensures no ambiguous heads: either an event has no +/// `shared` tag (author-only) or exactly `["shared", "true"]` (community- +/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at +/// ingest so read-path helpers can treat stored events as unambiguously one or +/// the other. fn validate_persona_envelope(event: &Event) -> Result<(), String> { let mut d_tags: Vec<&str> = Vec::new(); + let mut shared_count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); if parts.len() >= 2 && parts[0].as_str() == "d" { d_tags.push(&parts[1]); } + if !parts.is_empty() && parts[0].as_str() == "shared" { + // Value must be exactly "true"; anything else is rejected. + if parts.len() < 2 || parts[1].as_str() != "true" { + return Err(format!( + "persona event `shared` tag value must be exactly \"true\" (got {:?})", + parts.get(1).map(|s| s.as_str()).unwrap_or("") + )); + } + shared_count += 1; + } + } + if shared_count > 1 { + return Err(format!( + "persona event must have at most one `shared` tag (got {shared_count})" + )); } if d_tags.len() != 1 { return Err(format!( @@ -3534,6 +3557,72 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + // ─── persona shared-tag envelope tests ─────────────────────────────────── + + #[test] + fn persona_envelope_accepts_shared_true() { + // A persona event with exactly one ["shared","true"] tag must be accepted. + let ev = make_persona(&[&["d", "my-persona"], &["shared", "true"]]); + assert!(validate_persona_envelope(&ev).is_ok()); + } + + #[test] + fn persona_envelope_accepts_no_shared_tag() { + // The shared tag is optional; omitting it is the author-only default. + let ev = make_persona(&[&["d", "my-persona"]]); + assert!(validate_persona_envelope(&ev).is_ok()); + } + + #[test] + fn persona_envelope_rejects_shared_false() { + let ev = make_persona(&[&["d", "my-persona"], &["shared", "false"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_shared_wrong_value() { + let ev = make_persona(&[&["d", "my-persona"], &["shared", "yes"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_shared_missing_value() { + // A "shared" tag with no value argument must be rejected. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"x"}"#, + &[&["d", "slug"], &["shared"]], + ); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("\"true\""), + "expected 'true' in error, got: {err}" + ); + } + + #[test] + fn persona_envelope_rejects_duplicate_shared_tags() { + // More than one shared tag, even if both are "true", must be rejected. + let ev = make_persona(&[ + &["d", "my-persona"], + &["shared", "true"], + &["shared", "true"], + ]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("at most one"), + "expected 'at most one' in error, got: {err}" + ); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index ecc3ba6777..9a85ce3b9a 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, KIND_DM_VISIBILITY, - P_GATED_KINDS, RESULT_GATED_KINDS, + is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -388,6 +388,13 @@ pub async fn handle_req( continue; } + // Persona shared-read gate: kind 30175 events are author-only by + // default; only events carrying ["shared","true"] are community- + // readable. Foreign unshared personas are silently omitted. + if is_unshared_persona_event(&stored.event, &pubkey_bytes) { + continue; + } + // Dedup AFTER acceptance — an event that fails filter A's constraints // must remain eligible for filter B (NIP-01 OR semantics). if !seen_ids.insert(stored.event.id) { @@ -709,6 +716,11 @@ async fn handle_search_req( if is_author_only_event(&stored.event, reader_pubkey_bytes) { continue; } + // Persona shared-read gate (search lane): same semantics as + // the main delivery loop — foreign unshared personas are omitted. + if is_unshared_persona_event(&stored.event, reader_pubkey_bytes) { + continue; + } // Dedup AFTER acceptance — an event that fails filter A's constraints // must remain eligible for filter B (NIP-01 OR semantics). if !seen_ids.insert(stored.event.id) { @@ -1141,6 +1153,21 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } +/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it +/// either has no `kinds` constraint (wildcard) or explicitly includes 30175. +/// +/// Used by the COUNT handler to force the per-event fallback path, which calls +/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path +/// has no per-event access check, so it would over-count foreign unshared +/// persona events — leaking the existence of persona activity even without +/// returning content. +pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool { + filter + .kinds + .as_ref() + .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA)) +} + /// Returns `true` if the filter CAN match result-gated kinds — meaning it /// either has no `kinds` constraint (wildcard) or includes at least one kind /// that carries a per-event result-level read gate (currently diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 54a0f19c30..a353d49b80 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -15,7 +15,7 @@ use std::time::Duration; -use buzz_test_client::BuzzTestClient; +use buzz_test_client::{BuzzTestClient, RelayMessage}; use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; const PERSONA_KIND: u16 = 30175; @@ -504,3 +504,436 @@ async fn test_persona_multiple_per_author() { client.disconnect().await.expect("disconnect"); } + +// ───────────────────────────────────────────────────────────────────────────── +// Shared-read-gate tests (author-only-unless-shared semantics for kind:30175) +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a persona event with an optional `["shared","true"]` tag. +fn persona_event_with_shared(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +/// AC-1: Foreign reader receives ONLY shared heads; author receives all own heads. +/// +/// Gate changed: `test_persona_publish_and_query` queries by id (author, always +/// allowed), so it is unaffected. The "all personas by author" variant in +/// `test_persona_multiple_per_author` uses `authors:[self]`, also unaffected. +/// This test is the cross-author assertion. +#[tokio::test] +#[ignore] +async fn test_persona_shared_read_gate_foreign_sees_only_shared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Author publishes one unshared and one shared persona. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false); + let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true); + let shared_id = ev_shared.id; + + let ok = author.send_event(ev_unshared).await.expect("send unshared"); + assert!(ok.accepted, "unshared ingest rejected: {}", ok.message); + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared ingest rejected: {}", ok.message); + + // Foreign reader queries all personas by the author. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fg-all"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + // Foreign should see ONLY the shared event. + assert!( + !events.iter().any(|e| e.tags.iter().any(|t| t + .as_slice() + .get(1) + .is_some_and(|v| v.as_str() == d_unshared))), + "foreign should NOT see unshared persona" + ); + assert!( + events.iter().any(|e| e.id == shared_id), + "foreign should see the shared persona" + ); + + // Author queries all own personas — must see both. + let sid_author = sub_id("auth-all"); + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_author, vec![filter_self]) + .await + .expect("subscribe author"); + let author_events = author + .collect_until_eose(&sid_author, Duration::from_secs(5)) + .await + .expect("collect author"); + assert!( + author_events.len() >= 2, + "author should see both own personas, got {}", + author_events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-2: `{ids:[unshared-foreign-30175-id]}` returns nothing to a foreign reader. +#[tokio::test] +#[ignore] +async fn test_persona_ids_lookup_unshared_returns_nothing_to_foreign() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let ev = persona_event_with_shared(&author_keys, &d_tag, false); + let event_id = ev.id; + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author.send_event(ev).await.expect("send"); + assert!(ok.accepted, "ingest rejected: {}", ok.message); + author.disconnect().await.expect("disconnect"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("ids-unshared"); + let filter = Filter::new().id(event_id); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.is_empty(), + "ids-lookup of unshared persona must return nothing to foreign reader, got {:?}", + events.iter().map(|e| e.id).collect::>() + ); + + foreign.disconnect().await.expect("disconnect"); +} + +/// AC-3: COUNT over 30175 uses the fallback path; foreign unshared events are excluded. +/// +/// The relay does not pre-block COUNT on persona kinds (unlike AUTHOR_ONLY_KINDS). +/// We verify the per-event fallback fires correctly by cross-checking: the foreign +/// reader's REQ count equals the shared-persona count, not total-persona count. +#[tokio::test] +#[ignore] +async fn test_persona_count_excludes_foreign_unshared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Publish one unshared and one shared persona. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author + .send_event(persona_event_with_shared(&author_keys, &d_unshared, false)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + let ok = author + .send_event(persona_event_with_shared(&author_keys, &d_shared, true)) + .await + .expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + // Foreign sends COUNT for {kinds:[30175], authors:[author]} — uses fallback path + // and must exclude the unshared event. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("count-persona"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let count_msg = serde_json::json!(["COUNT", sid, filter]); + foreign.send_raw(&count_msg).await.expect("send COUNT"); + + // The relay returns ["COUNT", sub_id, {"count": N}]. + // BuzzTestClient::recv_event maps WsClientError::UnexpectedMessage to + // TestClientError::UnexpectedMessage (via the From impl in lib.rs). + let result = foreign.recv_event(Duration::from_secs(5)).await; + let count: u64 = match result { + Err(buzz_test_client::TestClientError::UnexpectedMessage(raw)) => { + // Parse ["COUNT", sub_id, {"count": N}] + let arr: serde_json::Value = serde_json::from_str(&raw).expect("parse COUNT response"); + arr.get(2) + .and_then(|o| o.get("count")) + .and_then(|c| c.as_u64()) + .expect("count field in COUNT response") + } + Ok(RelayMessage::Closed { message, .. }) => { + panic!("COUNT closed unexpectedly: {message}"); + } + Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"), + Err(e) => panic!("unexpected error for COUNT: {e}"), + }; + + assert_eq!( + count, 1, + "foreign COUNT should see only the 1 shared persona, got {count}" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-4a: Unshared persona publish is NOT delivered to foreign live subscription. +/// AC-4b: Shared persona publish IS delivered to foreign live subscription. +/// AC-4c: NIP-33 replace shared→unshared makes subsequent foreign REQs return nothing. +#[tokio::test] +#[ignore] +async fn test_persona_live_fanout_shared_gate() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("gate-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Foreign subscribes to all kind:30175 events BEFORE the author publishes. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fanout-gate"); + let filter = Filter::new().kind(Kind::Custom(PERSONA_KIND)); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + // Drain historical EOSE. + let _ = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("drain eose"); + + // Author publishes an UNSHARED persona — foreign must NOT receive it. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev_unshared = persona_event_with_shared(&author_keys, &d_tag, false); + let ok = author.send_event(ev_unshared).await.expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // No event should arrive for foreign. + let result = foreign.recv_event(Duration::from_millis(500)).await; + match result { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { + panic!("foreign MUST NOT receive unshared persona via live fan-out") + } + _ => {} + } + + // Author republishes with ["shared","true"] — foreign MUST now receive it. + let ev_shared = persona_event_with_shared(&author_keys, &d_tag, true); + let shared_id = ev_shared.id; + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let msg = foreign + .recv_event(Duration::from_secs(3)) + .await + .expect("recv shared event"); + match msg { + RelayMessage::Event { event, .. } => { + assert_eq!(event.id, shared_id, "received wrong event via fanout"); + } + other => panic!("expected shared persona event via fanout, got: {other:?}"), + } + + // AC-4c: Author republishes WITHOUT shared tag — NIP-33 replaces the head. + // A subsequent foreign REQ for this author's personas should return nothing. + let ev_unshared2 = persona_event_with_shared(&author_keys, &d_tag, false); + let ok = author + .send_event(ev_unshared2) + .await + .expect("send unshared2"); + assert!(ok.accepted, "unshared2 rejected: {}", ok.message); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let sid2 = sub_id("fanout-gate-post-unshare"); + let filter2 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + foreign + .subscribe(&sid2, vec![filter2]) + .await + .expect("subscribe2"); + let events = foreign + .collect_until_eose(&sid2, Duration::from_secs(5)) + .await + .expect("collect post-unshare"); + assert!( + events.is_empty(), + "after removing shared tag, foreign REQ must return nothing, got {} events", + events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// AC-5: Ingest rejects ["shared","false"], ["shared","x"], and duplicate shared tags. +/// Accepts ["shared","true"] and tag-absent. +#[tokio::test] +#[ignore] +async fn test_persona_ingest_shared_tag_validation() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + // Accept: no shared tag + let ev = persona_event_with_shared( + &keys, + &format!("no-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]), + false, + ); + let ok = client.send_event(ev).await.expect("send no-shared"); + assert!( + ok.accepted, + "no-shared-tag persona must be accepted: {}", + ok.message + ); + + // Accept: ["shared","true"] + let ev = persona_event_with_shared( + &keys, + &format!("shared-true-{}", &uuid::Uuid::new_v4().to_string()[..8]), + true, + ); + let ok = client.send_event(ev).await.expect("send shared-true"); + assert!( + ok.accepted, + "shared=true persona must be accepted: {}", + ok.message + ); + + // Reject: ["shared","false"] + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-false-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "false"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-false"); + assert!(!ok.accepted, "shared=false persona must be rejected"); + assert!( + ok.message.contains("shared") || ok.message.contains("invalid"), + "unexpected rejection message: {}", + ok.message + ); + + // Reject: duplicate shared tags + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("dup-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + Tag::parse(["shared", "true"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send dup-shared"); + assert!(!ok.accepted, "duplicate shared tags must be rejected"); + + client.disconnect().await.expect("disconnect"); +} + +/// AC-6: Mixed-kind filter {kinds:[30175, 9]} does not leak foreign unshared personas. +/// The kind:9 events from other authors pass through normally. +#[tokio::test] +#[ignore] +async fn test_persona_mixed_kind_filter_does_not_leak() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = format!("mixed-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Author publishes an unshared persona. + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ev = persona_event_with_shared(&author_keys, &d_tag, false); + let unshared_id = ev.id; + let ok = author.send_event(ev).await.expect("send unshared"); + assert!(ok.accepted, "unshared persona rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + // Foreign queries with mixed-kind filter. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("mixed-kind"); + let filter = Filter::new() + .kinds(vec![Kind::Custom(PERSONA_KIND), Kind::Custom(9)]) + .author(author_keys.public_key()); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + !events.iter().any(|e| e.id == unshared_id), + "mixed-kind filter must NOT leak foreign unshared persona (id {})", + unshared_id + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index 85690075de..aaf925a2c5 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -218,15 +218,43 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an ## Relay behavior +### Ingest validation + - The relay MUST accept `kind:30175` events that pass standard NIP-33 validation (valid signature, exactly one `d` tag with a non-empty value). - The relay stores persona events globally (`channel_id = NULL`); they are not channel-scoped. - The relay is NOT required to validate that `content` parses as valid `PersonaEventContent` JSON. Relays are dumb stores per Nostr convention; content validation is a client responsibility. - The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events). +- The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST have exactly the value `"true"` and there MUST be at most one `shared` tag. Events with `["shared","false"]`, `["shared","1"]`, or duplicate `shared` tags are rejected with `invalid:`. + +### Access control: author-only-unless-shared + +Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts and `respond_to_allowlist` from being visible to all community members as a side-effect of device sync. + +**Rules:** + +| Event state | Author reads | Foreign reads | +|---|---|---| +| No `shared` tag | ✅ allowed | ❌ withheld | +| `["shared", "true"]` tag | ✅ allowed | ✅ allowed | + +These rules are enforced at every relay read chokepoint: + +- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). +- **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. +- **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. +- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. +- **FTS (NIP-50 search)** — kind `30175` is not in the relay's FTS allowlist; no change needed. + +**Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state. + +**Opting in to community sharing.** Publish a NIP-33 replacement head for the persona with a `["shared", "true"]` tag. Unsharing is the reverse: republish without the tag. NIP-33 replacement semantics apply (newest `created_at` wins). + +**`shared` is a tag, not a content field.** Content bytes are hash-pinned as the NIP-01 event id and also used as the `source_version` for persona drift detection. A content-field toggle would look like a definition edit; a tag does not affect content bytes. ## Security considerations -- **No encryption.** System prompts, model names, runtime identifiers, and all configuration are visible to anyone with relay read access. Operators MUST NOT store secrets in persona event content. -- **System prompt sensitivity.** System prompts may contain security-relevant behavioral instructions. Publishing them unencrypted enables adversarial prompt extraction. Operators who consider system prompts confidential SHOULD NOT publish them in persona events, or SHOULD use a relay with appropriate access controls. +- **No encryption.** System prompts, model names, runtime identifiers, and all configuration are stored unencrypted. Shared persona events are readable community-wide. Operators MUST NOT store secrets in persona event content. +- **System prompt protection.** System prompts and `respond_to_allowlist` pubkeys are sensitive. The relay's author-only-unless-shared gate ensures they are not visible to other community members unless the owner explicitly opts in by publishing a `["shared", "true"]` head. Shared persona events are readable community-wide; operators who need additional confidentiality should use relay-level access controls or choose not to share. - **Write authority.** Only the holder of `seckey_o` can publish or replace persona events. NIP-33 replacement is scoped by pubkey — no spoofing risk from other relay members. - **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug. - **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history. From 312014d5e97b588b168c60169ca514d5e5a2ab83 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 24 Jul 2026 18:05:22 -0400 Subject: [PATCH 2/4] fix(relay): close NIP-98 HTTP bridge bypass in persona shared-gate Apply the author-only-unless-shared read gate to the NIP-98 HTTP bridge (/query, /count, search) and centralize the per-event visibility decision into a single event_visible_to_reader helper callable from both WS and HTTP adapters. Bridge parity (CRITICAL): - /query catchall: replaced the two-step is_author_only_event + reader_authorized_for_event checks with event_visible_to_reader, which also covers is_unshared_persona_event. A foreign authenticated caller POSTing {kinds:[30175],authors:[victim]} or {ids:[unshared-id]} now receives nothing. - /count: added needs_persona_filtering to the fast-path guard (forces fallback when the filter can match kind:30175) and replaced the two-step checks in both fallback loops with event_visible_to_reader. Existence leak via HTTP COUNT is closed on both the channel-scoped and unconstrained paths. - FTS search helper: replaced is_author_only_event with event_visible_to_reader as defense-in-depth. Kind 30175 is not in the FTS allowlist today so the branch is not reachable, but the check prevents a future allowlist change from silently reopening the bypass (comment explains this at the site). Structural centralization (IMPORTANT): - Added event_visible_to_reader to handlers/req.rs, combining the three per-event access predicates (is_author_only_event, is_unshared_persona_event, reader_authorized_for_event) behind a single call. Both the WS and HTTP adapters now call this helper at their result-visibility sites. Test gaps closed (IMPORTANT): - Reworked test_persona_live_fanout_shared_gate with explicit monotonic created_at timestamps (t0 < t1 < t2) so NIP-33 head ordering is deterministic, not dependent on event-id tie-break. Added self-query assertions after each publish to verify the correct NIP-33 head is live, and added a live-fan-out assertion that foreign receives nothing when the shared head goes unshared. - Added shared=x and missing-value wire-level rejection cases to test_persona_ingest_shared_tag_validation (AC-5; previously only shared=false and duplicate were tested at the wire level). - Fixed test_persona_mixed_kind_filter_does_not_leak (AC-6): now publishes a kind:9 event from the same author and asserts it IS returned while the unshared persona is omitted. Previously the absence-only assertion would pass if the whole mixed filter were dropped. - Added two new HTTP bridge e2e tests: test_persona_http_query_cross_author_gate: verifies /query returns only shared persona to foreign, returns nothing for {ids:[unshared-id]}, and returns the shared event for {ids:[shared-id]}. test_persona_http_count_cross_author_gate: verifies /count returns 1 (shared only) to foreign and 3 (all) to author, on author-scoped and wildcard filters. NIP-AP.md (doc): - Replaced aspirational every relay read chokepoint wording with an enumerated list of gated surfaces including NIP-98 /query, /count, and FTS/search with their exact enforcement mechanism named. - Added Non-goal: side-band existence oracles note documenting the adjudicated decision not to gate reaction/report/deletion target resolution (leaks one bit of existence, never content; requires a known event id unshared personas never hand out through any gated path). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-relay/src/api/bridge.rs | 43 +- crates/buzz-relay/src/handlers/req.rs | 34 ++ crates/buzz-test-client/tests/e2e_persona.rs | 444 ++++++++++++++++++- docs/nips/NIP-AP.md | 8 +- 4 files changed, 501 insertions(+), 28 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8372e49a2a..018fbe7067 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1284,15 +1284,16 @@ async fn query_events_authed( } // Result-level read auth: never hand a viewer-private snapshot // (kind:30622) to anyone but its owner, even via kindless `ids`. - if !buzz_core::filter::reader_authorized_for_event( + // Also enforces author-only kinds (30300/30350) and the persona + // shared-gate (kind:30175 without ["shared","true"]). Single call + // covers all three gated event classes. + if !crate::handlers::req::event_visible_to_reader( &se.event, + &pubkey_bytes, &authed_pubkey_hex, ) { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } if let Ok(v) = serde_json::to_value(&se.event) { events.push(v); } @@ -1439,6 +1440,11 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); + // Force per-event fallback for filters that can match kind:30175 — + // the fast SQL count_events() path has no per-event gate and would + // over-count foreign unshared persona events (existence leak). + let needs_persona_filtering = + crate::handlers::req::filter_can_match_persona_shared_kinds(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1462,6 +1468,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_persona_filtering { match state.db.count_events(&query).await { Ok(n) => total += n as u64, @@ -1487,12 +1494,9 @@ async fn count_events_authed( { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) - { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !crate::handlers::req::event_visible_to_reader( &se.event, + &pubkey_bytes, &authed_pubkey_hex, ) { continue; @@ -1526,6 +1530,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering + && !needs_persona_filtering { query.limit = None; match state.db.count_events(&query).await { @@ -1551,12 +1556,9 @@ async fn count_events_authed( { continue; } - if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) - { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( + if !crate::handlers::req::event_visible_to_reader( &se.event, + &pubkey_bytes, &authed_pubkey_hex, ) { continue; @@ -1732,7 +1734,18 @@ async fn handle_bridge_search( if !search_hit_accepted(filter, stored, accessible_channels, reader_pubkey_hex) { continue; } - if crate::handlers::req::is_author_only_event(&stored.event, pubkey_bytes) { + // Defense-in-depth: apply the full per-event visibility gate, which + // covers author-only kinds, the persona shared-gate (kind:30175), and + // result-gated kinds. Kind:30175 is not in the FTS positive allowlist + // today (migration 8 indexes only 0,9,40002,45001,45003), so this + // branch cannot currently return unshared persona content — but the + // check here ensures that a future FTS allowlist change cannot silently + // reopen the bypass. + if !crate::handlers::req::event_visible_to_reader( + &stored.event, + pubkey_bytes, + reader_pubkey_hex, + ) { continue; } // Dedup across filters. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 9a85ce3b9a..39db6e150b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1215,6 +1215,40 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: AUTHOR_ONLY_KINDS.contains(&kind_u32) && event.pubkey.to_bytes() != requester_pubkey_bytes } +/// Combined per-event result-visibility check for all gated event classes. +/// +/// Returns `true` if the `event` should be delivered to / counted for the +/// reader identified by `requester_pubkey_bytes` + `requester_pubkey_hex`. +/// Returns `false` and the event must be silently omitted if any of the +/// following hold: +/// +/// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only +/// the author may read their own events. +/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the +/// event is only visible to the author unless explicitly opted into sharing. +/// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` +/// carries the per-event ownership check. +/// +/// Call this from every read surface — both WS (REQ/COUNT/fan-out) and HTTP +/// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three +/// individual predicates at each site. +pub(crate) fn event_visible_to_reader( + event: &nostr::Event, + requester_pubkey_bytes: &[u8], + requester_pubkey_hex: &str, +) -> bool { + if is_author_only_event(event, requester_pubkey_bytes) { + return false; + } + if is_unshared_persona_event(event, requester_pubkey_bytes) { + return false; + } + if !buzz_core::filter::reader_authorized_for_event(event, requester_pubkey_hex) { + return false; + } + true +} + /// Pre-filter authorization for filters that exclusively target author-only kinds. /// /// If a filter targets ONLY author-only kinds (e.g. `{kinds:[30300]}`), the diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index a353d49b80..3e6d387b3b 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -17,6 +17,8 @@ use std::time::Duration; use buzz_test_client::{BuzzTestClient, RelayMessage}; use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use reqwest::Client; +use serde_json::Value; const PERSONA_KIND: u16 = 30175; @@ -24,6 +26,88 @@ fn relay_url() -> String { std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) } +fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +/// Submit an event via the NIP-98 HTTP bridge (`POST /events`). +async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let pubkey_hex = keys.public_key().to_hex(); + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(event).unwrap()) + .send() + .await + .expect("submit event"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse response"); + if status == 200 { + let accepted = body["accepted"].as_bool().unwrap_or(false); + let message = body["message"].as_str().unwrap_or("").to_string(); + (accepted, message) + } else { + let message = body["error"].as_str().unwrap_or("").to_string(); + (false, message) + } +} + +/// Query events via the NIP-98 HTTP bridge (`POST /query`). +async fn query_events_http(client: &Client, pubkey_hex: &str, filters: Vec) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") +} + +/// Count events via the NIP-98 HTTP bridge (`POST /count`). +async fn count_events_http( + client: &Client, + pubkey_hex: &str, + filters: Vec, +) -> Result { + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", pubkey_hex) + .header("Content-Type", "application/json") + .json(&filters) + .send() + .await + .expect("count events"); + let status = resp.status().as_u16(); + let body: Value = resp.json().await.expect("parse count response"); + if status == 200 { + Ok(body["count"].as_u64().unwrap_or(0)) + } else { + let msg = body["error"].as_str().unwrap_or("").to_string(); + Err((status, msg)) + } +} + fn sub_id(name: &str) -> String { format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4()) } @@ -521,6 +605,27 @@ fn persona_event_with_shared(keys: &Keys, d_tag: &str, shared: bool) -> nostr::E .unwrap() } +/// Build a persona event with an optional `["shared","true"]` tag and an +/// explicit `created_at` — needed for NIP-33 replacement tests where two +/// events could otherwise land in the same second and be ordered by event-id +/// tie-break rather than timestamp. +fn persona_event_with_shared_at( + keys: &Keys, + d_tag: &str, + shared: bool, + created_at: u64, +) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + /// AC-1: Foreign reader receives ONLY shared heads; author receives all own heads. /// /// Gate changed: `test_persona_publish_and_query` queries by id (author, always @@ -718,7 +823,12 @@ async fn test_persona_count_excludes_foreign_unshared() { /// AC-4a: Unshared persona publish is NOT delivered to foreign live subscription. /// AC-4b: Shared persona publish IS delivered to foreign live subscription. -/// AC-4c: NIP-33 replace shared→unshared makes subsequent foreign REQs return nothing. +/// AC-4c: NIP-33 replace shared→unshared makes subsequent foreign REQs return nothing, +/// and the live subscription for the foreign reader receives no event. +/// +/// Uses explicit monotonic `created_at` timestamps so NIP-33 head ordering is +/// deterministic — same-second events would otherwise be ordered by event-id +/// tie-break, making it possible for the "wrong" head to win. #[tokio::test] #[ignore] async fn test_persona_live_fanout_shared_gate() { @@ -728,6 +838,11 @@ async fn test_persona_live_fanout_shared_gate() { let d_tag = format!("gate-{}", &uuid::Uuid::new_v4().to_string()[..8]); + // Use strictly increasing timestamps: t0 < t1 < t2. + let t0: u64 = 1_700_000_000; + let t1: u64 = t0 + 1; + let t2: u64 = t1 + 1; + // Foreign subscribes to all kind:30175 events BEFORE the author publishes. let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) .await @@ -744,11 +859,12 @@ async fn test_persona_live_fanout_shared_gate() { .await .expect("drain eose"); - // Author publishes an UNSHARED persona — foreign must NOT receive it. + // Author publishes an UNSHARED persona (t0) — foreign must NOT receive it. let mut author = BuzzTestClient::connect(&url, &author_keys) .await .expect("connect author"); - let ev_unshared = persona_event_with_shared(&author_keys, &d_tag, false); + let ev_unshared = persona_event_with_shared_at(&author_keys, &d_tag, false, t0); + let unshared_id = ev_unshared.id; let ok = author.send_event(ev_unshared).await.expect("send unshared"); assert!(ok.accepted, "unshared rejected: {}", ok.message); @@ -764,8 +880,27 @@ async fn test_persona_live_fanout_shared_gate() { _ => {} } - // Author republishes with ["shared","true"] — foreign MUST now receive it. - let ev_shared = persona_event_with_shared(&author_keys, &d_tag, true); + // Verify the unshared event IS the NIP-33 head for the author (self-query). + let sid_head_check = sub_id("head-unshared"); + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_check, vec![filter_self]) + .await + .expect("subscribe head-check"); + let author_events = author + .collect_until_eose(&sid_head_check, Duration::from_secs(5)) + .await + .expect("collect head-check"); + assert!( + author_events.iter().any(|e| e.id == unshared_id), + "unshared event must be the NIP-33 head visible to author" + ); + + // Author republishes with ["shared","true"] at t1 — t1 > t0 so it wins. + // Foreign MUST receive it via live fan-out. + let ev_shared = persona_event_with_shared_at(&author_keys, &d_tag, true, t1); let shared_id = ev_shared.id; let ok = author.send_event(ev_shared).await.expect("send shared"); assert!(ok.accepted, "shared rejected: {}", ok.message); @@ -783,9 +918,29 @@ async fn test_persona_live_fanout_shared_gate() { other => panic!("expected shared persona event via fanout, got: {other:?}"), } - // AC-4c: Author republishes WITHOUT shared tag — NIP-33 replaces the head. - // A subsequent foreign REQ for this author's personas should return nothing. - let ev_unshared2 = persona_event_with_shared(&author_keys, &d_tag, false); + // Verify shared event is now the NIP-33 head (self-query sees shared_id). + let sid_head_shared = sub_id("head-shared"); + let filter_self2 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_shared, vec![filter_self2]) + .await + .expect("subscribe head-shared"); + let author_events2 = author + .collect_until_eose(&sid_head_shared, Duration::from_secs(5)) + .await + .expect("collect head-shared"); + assert!( + author_events2.iter().any(|e| e.id == shared_id), + "shared event must be the NIP-33 head visible to author" + ); + + // AC-4c: Author republishes WITHOUT shared tag at t2 — NIP-33 replaces the head. + // The foreign live subscription must NOT receive any event for this publish + // (unshared fan-out is blocked), and a subsequent foreign REQ must return nothing. + let ev_unshared2 = persona_event_with_shared_at(&author_keys, &d_tag, false, t2); + let unshared2_id = ev_unshared2.id; let ok = author .send_event(ev_unshared2) .await @@ -794,6 +949,38 @@ async fn test_persona_live_fanout_shared_gate() { tokio::time::sleep(Duration::from_millis(300)).await; + // Foreign live subscription must NOT receive the unshared replacement. + let live_result = foreign.recv_event(Duration::from_millis(500)).await; + match live_result { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { + panic!( + "foreign MUST NOT receive unshared replacement via live fan-out (got {:?})", + event.id + ) + } + _ => {} + } + + // Verify unshared2 is now the NIP-33 head for the author. + let sid_head_unshared2 = sub_id("head-unshared2"); + let filter_self3 = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + author + .subscribe(&sid_head_unshared2, vec![filter_self3]) + .await + .expect("subscribe head-unshared2"); + let author_events3 = author + .collect_until_eose(&sid_head_unshared2, Duration::from_secs(5)) + .await + .expect("collect head-unshared2"); + assert!( + author_events3.iter().any(|e| e.id == unshared2_id), + "unshared2 event must be the NIP-33 head visible to author" + ); + + // Foreign REQ post-unshare must see nothing for this author's personas. let sid2 = sub_id("fanout-gate-post-unshare"); let filter2 = Filter::new() .kind(Kind::Custom(PERSONA_KIND)) @@ -872,6 +1059,44 @@ async fn test_persona_ingest_shared_tag_validation() { ok.message ); + // Reject: ["shared","x"] — any value other than "true" is malformed. + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-x-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "x"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-x"); + assert!( + !ok.accepted, + "shared=x persona must be rejected: {}", + ok.message + ); + + // Reject: ["shared"] — value is missing (tag has only 1 element after the key). + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("shared-no-value-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok = client.send_event(ev).await.expect("send shared-no-value"); + assert!( + !ok.accepted, + "shared tag with missing value must be rejected: {}", + ok.message + ); + // Reject: duplicate shared tags let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) .tags(vec![ @@ -891,8 +1116,10 @@ async fn test_persona_ingest_shared_tag_validation() { client.disconnect().await.expect("disconnect"); } -/// AC-6: Mixed-kind filter {kinds:[30175, 9]} does not leak foreign unshared personas. -/// The kind:9 events from other authors pass through normally. +/// AC-6: Mixed-kind filter {kinds:[30175, 9]} does not leak foreign unshared personas, +/// but DOES pass through kind:9 events from the same author. This pins the +/// correctness property in both directions — an implementation that silently +/// drops the whole mixed-kind filter would satisfy only the absence assertion. #[tokio::test] #[ignore] async fn test_persona_mixed_kind_filter_does_not_leak() { @@ -902,7 +1129,7 @@ async fn test_persona_mixed_kind_filter_does_not_leak() { let d_tag = format!("mixed-{}", &uuid::Uuid::new_v4().to_string()[..8]); - // Author publishes an unshared persona. + // Author publishes an unshared persona AND a kind:9 message. let mut author = BuzzTestClient::connect(&url, &author_keys) .await .expect("connect author"); @@ -910,6 +1137,15 @@ async fn test_persona_mixed_kind_filter_does_not_leak() { let unshared_id = ev.id; let ok = author.send_event(ev).await.expect("send unshared"); assert!(ok.accepted, "unshared persona rejected: {}", ok.message); + + // Publish a kind:9 event so the mixed-kind filter has something to return. + let ev9 = EventBuilder::new(Kind::Custom(9), "hello from author") + .sign_with_keys(&author_keys) + .unwrap(); + let msg9_id = ev9.id; + let ok9 = author.send_event(ev9).await.expect("send kind:9"); + assert!(ok9.accepted, "kind:9 rejected: {}", ok9.message); + author.disconnect().await.expect("disconnect author"); // Foreign queries with mixed-kind filter. @@ -929,11 +1165,197 @@ async fn test_persona_mixed_kind_filter_does_not_leak() { .await .expect("collect"); + // Foreign must NOT see the unshared persona. assert!( !events.iter().any(|e| e.id == unshared_id), "mixed-kind filter must NOT leak foreign unshared persona (id {})", unshared_id ); + // Foreign MUST see the kind:9 event — filter is not wholesale dropped. + assert!( + events.iter().any(|e| e.id == msg9_id), + "mixed-kind filter must pass through kind:9 events (id {})", + msg9_id + ); foreign.disconnect().await.expect("disconnect foreign"); } + +// ─── NIP-98 HTTP bridge persona gate tests ─────────────────────────────────── +// +// These tests verify that `POST /query` and `POST /count` enforce the same +// author-only-unless-shared gate as the WebSocket paths. A foreign +// authenticated caller must not receive or count unshared kind:30175 events +// belonging to another author, even via the HTTP bridge. + +/// NIP-98 bridge AC-query: `/query` cross-author gate. +/// +/// - A foreign pubkey posting `{kinds:[30175],authors:[victim]}` receives only +/// the shared head, not the unshared one. +/// - A kindless `{ids:[unshared-id]}` returns nothing. +/// - A `{ids:[shared-id]}` returns the shared event. +#[tokio::test] +#[ignore] +async fn test_persona_http_query_cross_author_gate() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + let author_pubkey_hex = author_keys.public_key().to_hex(); + + let d_unshared = format!("priv-http-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-http-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + // Publish via HTTP bridge (ingest path is the same). + let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false); + let unshared_id_hex = ev_unshared.id.to_hex(); + let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true); + let shared_id_hex = ev_shared.id.to_hex(); + + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_unshared).await; + assert!(ok, "unshared ingest rejected: {msg}"); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await; + assert!(ok, "shared ingest rejected: {msg}"); + + // Foreign queries all author's kind:30175 — only shared must come back. + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let results = query_events_http(&client, &foreign_pubkey_hex, vec![filter]).await; + + let has_unshared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == unshared_id_hex) + }); + let has_shared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex) + }); + + assert!( + !has_unshared, + "/query (authors:[victim]) must NOT return unshared persona to foreign" + ); + assert!( + has_shared, + "/query (authors:[victim]) must return shared persona to foreign" + ); + + // Author self-query must see both. + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let self_results = query_events_http(&client, &author_pubkey_hex, vec![filter_self]).await; + assert!( + self_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == unshared_id_hex)), + "author self-query must see own unshared persona" + ); + assert!( + self_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex)), + "author self-query must see own shared persona" + ); + + // Kindless ids lookup for unshared event: foreign must get nothing. + let unshared_event_id = nostr::EventId::from_hex(&unshared_id_hex).unwrap(); + let filter_ids = Filter::new().id(unshared_event_id); + let id_results = query_events_http(&client, &foreign_pubkey_hex, vec![filter_ids]).await; + assert!( + id_results.is_empty(), + "/query {{ids:[unshared-id]}} must return nothing to foreign, got {}", + id_results.len() + ); + + // Kindless ids lookup for shared event: foreign must get it. + let shared_event_id = nostr::EventId::from_hex(&shared_id_hex).unwrap(); + let filter_shared_ids = Filter::new().id(shared_event_id); + let shared_id_results = + query_events_http(&client, &foreign_pubkey_hex, vec![filter_shared_ids]).await; + assert!( + shared_id_results.iter().any(|e| e + .get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex)), + "/query {{ids:[shared-id]}} must return shared event to foreign" + ); +} + +/// NIP-98 bridge AC-count: `/count` cross-author gate. +/// +/// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}` +/// must count only shared heads — not unshared ones — on both the fast SQL +/// path (prevented by `needs_persona_filtering`) and the fallback path. +#[tokio::test] +#[ignore] +async fn test_persona_http_count_cross_author_gate() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + let author_pubkey_hex = author_keys.public_key().to_hex(); + + // Publish two unshared + one shared persona for the author. + let d1 = format!("priv1-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d2 = format!("priv2-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let d_shared = format!("pub-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]); + + for (d, shared) in [(&d1, false), (&d2, false), (&d_shared, true)] { + let ev = persona_event_with_shared(&author_keys, d, shared); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await; + assert!(ok, "ingest rejected for {d}: {msg}"); + } + + // Foreign counts author's personas — must see only 1 (the shared one). + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let foreign_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter]) + .await + .expect("count should succeed"); + assert_eq!( + foreign_count, 1, + "foreign /count should return 1 (only shared persona), got {foreign_count}" + ); + + // Author self-count must see all 3. + let filter_self = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let author_count = count_events_http(&client, &author_pubkey_hex, vec![filter_self]) + .await + .expect("author count should succeed"); + assert_eq!( + author_count, 3, + "author /count should return 3, got {author_count}" + ); + + // Wildcard count (no authors filter) for the foreign reader — must not + // include foreign unshared personas in the total. + let filter_wildcard = Filter::new().kind(Kind::Custom(PERSONA_KIND)); + let wildcard_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter_wildcard]) + .await + .expect("wildcard count should succeed"); + // We can't assert the exact number (other tests may have published shared + // personas), but we can assert it's ≥ 1 (the shared one) and verify the + // unshared ones aren't counted by checking author-scoped again. + assert!( + wildcard_count >= 1, + "wildcard count must include the shared persona" + ); + // The foreign author-scoped count must still be exactly 1. + let filter_scoped = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); + let scoped_count2 = count_events_http(&client, &foreign_pubkey_hex, vec![filter_scoped]) + .await + .expect("scoped count2 should succeed"); + assert_eq!( + scoped_count2, 1, + "foreign author-scoped /count must still return 1 after wildcard query, got {scoped_count2}" + ); +} diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index aaf925a2c5..c2fab1ce37 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -237,13 +237,15 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts | No `shared` tag | ✅ allowed | ❌ withheld | | `["shared", "true"]` tag | ✅ allowed | ✅ allowed | -These rules are enforced at every relay read chokepoint: +These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them): - **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). - **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. - **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. - **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. -- **FTS (NIP-50 search)** — kind `30175` is not in the relay's FTS allowlist; no change needed. +- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. +- **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. +- **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. **Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state. @@ -251,6 +253,8 @@ These rules are enforced at every relay read chokepoint: **`shared` is a tag, not a content field.** Content bytes are hash-pinned as the NIP-01 event id and also used as the `source_version` for persona drift detection. A content-field toggle would look like a definition edit; a tag does not affect content bytes. +**Non-goal: side-band existence oracles.** Reaction, report, and event-deletion validation resolves target events by id to check that they exist. These paths intentionally accept arbitrary event references by design — they leak one bit (existence) but never content, and exploiting them requires already possessing a 64-hex event id that unshared personas never expose through any gated read path. Gating these side-band resolvers would require teaching reaction/report validation about persona read semantics with no realistic attack mitigated. If a stricter "zero existence leakage" property is required in future, it is a separate scoped task. + ## Security considerations - **No encryption.** System prompts, model names, runtime identifiers, and all configuration are stored unencrypted. Shared persona events are readable community-wide. Operators MUST NOT store secrets in persona event content. From e5df95b70a1e9bdc1bcd539d775ac573ba4075d9 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 24 Jul 2026 19:08:56 -0400 Subject: [PATCH 3/4] fix(relay): close pass-3 findings for persona shared-read gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1 — SQL visibility before LIMIT: add `persona_reader: Option>` to `EventQuery`. When set, `query_events` appends `AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]')` before ORDER/LIMIT, preventing newer private personas from starving older shared ones off the candidate page. Wired from WS REQ, bridge /query, and both COUNT fallback paths (conditioned on filter_can_match_persona_shared_kinds to avoid the clause on non-persona queries). Two E2E tests added (WS REQ + /query limit-gate scenario) that fail on 312014d5e. Task 2 — Exact tag shape: tighten validate_persona_envelope to require parts.len() == 2 (was >= 2) so ["shared","true","extra"] is rejected at ingest. persona_event_is_shared independently fails closed on any non-exact shape (returns false for 3-element tags). Docstring updated. Unit tests in kind.rs (3-element and 1-element variants) and ingest.rs (3-element wire rejection). Wire-level E2E test added. Task 3 — Fix 3 deterministic E2E reds: - test_persona_live_fanout_shared_gate: replace t0=1_700_000_000 with now-relative timestamps (now-2, now-1, now) so ingest skew check passes. - test_persona_mixed_kind_filter_does_not_leak: add create_test_channel helper in e2e_persona.rs; kind:9 control event now carries h tag on an open- visibility channel so the foreign reader can access it. - test_persona_count_excludes_foreign_unshared: add RelayMessage::Count variant to buzz-ws-client parse_relay_message (new typed path replaces raw-string JSON parse that always fell into UnexpectedMessage). Task 4 — Single decision point: refactor event_visible_to_reader to take &[u8] only, deriving hex internally (no more caller-supplied inconsistent byte/hex pair). Replace the inlined 3-predicate sequences in WS REQ main loop, WS REQ search lane, and both WS COUNT fallback loops with the single helper. Remove now-unused viewer_hex variable and reader_pubkey_hex parameter from handle_search_req. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-core/src/kind.rs | 30 ++- crates/buzz-db/src/event.rs | 41 +++ crates/buzz-relay/src/api/bridge.rs | 31 ++- crates/buzz-relay/src/handlers/count.rs | 38 +-- crates/buzz-relay/src/handlers/ingest.rs | 28 +- crates/buzz-relay/src/handlers/req.rs | 56 ++-- crates/buzz-test-client/tests/e2e_persona.rs | 253 +++++++++++++++++-- crates/buzz-ws-client/src/message.rs | 23 ++ docs/nips/NIP-AP.md | 6 +- 9 files changed, 405 insertions(+), 101 deletions(-) diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 79ae5b53ad..afec52305a 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -217,19 +217,24 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: & /// Returns `true` if the event carries exactly one `["shared", "true"]` tag. /// -/// The relay guarantees at ingest that no persona event with a malformed -/// `shared` tag (wrong value, duplicate) is stored, so a stored event either -/// has no `shared` tag (author-only) or exactly one with value `"true"` -/// (community-readable). This helper is safe to use at read time. +/// Requires the tag to have exactly two elements so that a three-element shape +/// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces +/// the same exact shape, so a well-stored event either has no `shared` tag +/// (author-only) or exactly one with precisely two elements and value `"true"` +/// (community-readable). This helper fails closed on any non-exact shape +/// independently of ingest guarantees. pub fn persona_event_is_shared(event: &nostr::Event) -> bool { let mut count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); - if parts.len() >= 2 && parts[0].as_str() == "shared" { + if parts.len() == 2 && parts[0].as_str() == "shared" { if parts[1].as_str() != "true" { return false; } count += 1; + } else if !parts.is_empty() && parts[0].as_str() == "shared" { + // Non-exact shape (wrong length) — fail closed: not shared. + return false; } } count == 1 @@ -894,6 +899,21 @@ mod tests { assert!(!persona_event_is_shared(&ev)); } + #[test] + fn persona_event_is_shared_three_element_tag_not_shared() { + // ["shared","true","extra"] — three elements — must NOT be treated as shared. + // The helper fails closed on any non-exact shape independently of ingest guarantees. + let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]); + assert!(!persona_event_is_shared(&ev)); + } + + #[test] + fn persona_event_is_shared_one_element_tag_not_shared() { + // ["shared"] — only one element — not shared (fails the == 2 check). + let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]); + assert!(!persona_event_is_shared(&ev)); + } + #[test] fn is_unshared_persona_event_author_always_allowed() { // Even without a shared tag the event author should not be blocked. diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 5adcb05bdc..520fd1536f 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -71,6 +71,21 @@ pub struct EventQuery { /// which needs to fetch all matching events for post-filter counting. /// When None, the default clamp of 1000 applies. pub max_limit: Option, + /// Persona visibility reader: when set, append an SQL visibility clause + /// for kind 30175 before ORDER/LIMIT so private personas are excluded from + /// the candidate page rather than discarded after it. + /// + /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`, + /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on + /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast. + /// + /// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which + /// matches any tag array that is a superset of `[["shared","true"]]` — it + /// would match `["shared","true","extra"]` too. The ingest `parts.len() == + /// 2` exact-shape check ensures such malformed tags are never stored, so the + /// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter + /// defense-in-depth catches any residual mismatch. + pub persona_reader: Option>, } impl EventQuery { @@ -99,6 +114,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, + persona_reader: None, } } } @@ -485,6 +501,31 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result '[["shared","true"]]') + // + // The JSONB containment check is served by idx_events_tags_gin (migration + // 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array + // that contains exactly the sub-array — a two-element `["shared","true"]` + // tag passes; a tag-absent event does not. Because ingest now requires + // exactly two elements for the shared tag (parts.len() == 2), no stored + // event can carry a three-element superset. + if let Some(ref reader_bytes) = q.persona_reader { + let kind_30175: i32 = 30175; + let shared_containment = serde_json::json!([["shared", "true"]]); + qb.push(format!(" AND ({col_prefix}kind != ")); + qb.push_bind(kind_30175); + qb.push(format!(" OR {col_prefix}pubkey = ")); + qb.push_bind(reader_bytes.clone()); + qb.push(format!(" OR {col_prefix}tags @> ")); + qb.push_bind(shared_containment); + qb.push(")"); + } + // Composite ordering for deterministic pagination across ALL callers of // query_events (WebSocket REQ, REST endpoints, canvas, notes, etc.). // The `id ASC` tiebreaker ensures stable results when events share the diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 018fbe7067..c8ec0cdbc9 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1224,6 +1224,11 @@ async fn query_events_authed( extract_channel_from_filter(filter), &accessible_channels, ); + // Persona visibility pushdown: must mirror WS REQ so that a page of newer + // private personas does not starve older shared ones off the candidate page. + if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) { + query.persona_reader = Some(pubkey_bytes.clone()); + } match extract_before_id(raw) { BeforeId::Malformed => { @@ -1287,11 +1292,7 @@ async fn query_events_authed( // Also enforces author-only kinds (30300/30350) and the persona // shared-gate (kind:30175 without ["shared","true"]). Single call // covers all three gated event classes. - if !crate::handlers::req::event_visible_to_reader( - &se.event, - &pubkey_bytes, - &authed_pubkey_hex, - ) { + if !crate::handlers::req::event_visible_to_reader(&se.event, &pubkey_bytes) { continue; } if let Ok(v) = serde_json::to_value(&se.event) { @@ -1452,13 +1453,18 @@ async fn count_events_authed( continue; // Skip filters targeting inaccessible channels. } // Channel is accessible — count with pushability check. - let query = crate::handlers::req::build_event_query_from_filter( + let mut query = crate::handlers::req::build_event_query_from_filter( filter, &pubkey_bytes, state, tenant.community(), ) .await; + // Persona visibility pushdown: same as REQ and /query paths, so the + // fallback's query_events call doesn't over-fetch private persona rows. + if needs_persona_filtering { + query.persona_reader = Some(pubkey_bytes.clone()); + } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() && authors @@ -1497,7 +1503,6 @@ async fn count_events_authed( if !crate::handlers::req::event_visible_to_reader( &se.event, &pubkey_bytes, - &authed_pubkey_hex, ) { continue; } @@ -1520,6 +1525,11 @@ async fn count_events_authed( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); + // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the + // fallback query_events path. + if needs_persona_filtering { + query.persona_reader = Some(pubkey_bytes.clone()); + } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -1559,7 +1569,6 @@ async fn count_events_authed( if !crate::handlers::req::event_visible_to_reader( &se.event, &pubkey_bytes, - &authed_pubkey_hex, ) { continue; } @@ -1741,11 +1750,7 @@ async fn handle_bridge_search( // branch cannot currently return unshared persona content — but the // check here ensures that a future FTS allowlist change cannot silently // reopen the bypass. - if !crate::handlers::req::event_visible_to_reader( - &stored.event, - pubkey_bytes, - reader_pubkey_hex, - ) { + if !crate::handlers::req::event_visible_to_reader(&stored.event, pubkey_bytes) { continue; } // Dedup across filters. diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 9371f134cd..614e54d7a0 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,12 +7,11 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - filter_can_match_persona_shared_kinds, filter_can_match_result_gated_kinds, - is_author_only_event, result_gated_count_safe_for_pushdown, + event_visible_to_reader, filter_can_match_persona_shared_kinds, + filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; -use buzz_core::kind::is_unshared_persona_event; /// Extract a channel UUID from a single filter's `#h` tag. fn extract_channel_from_filter(filter: &Filter) -> Option { @@ -151,13 +150,18 @@ pub async fn handle_count( continue; // Skip filters targeting inaccessible channels. } // Channel is accessible — count with pushability check. - let query = super::req::build_event_query_from_filter( + let mut query = super::req::build_event_query_from_filter( filter, &pubkey_bytes, &state, conn.tenant.community(), ) .await; + // Persona visibility pushdown: pre-filter the fallback query_events + // candidate page before ORDER/LIMIT. + if needs_persona_filtering { + query.persona_reader = Some(pubkey_bytes.clone()); + } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() && authors @@ -195,16 +199,7 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if is_unshared_persona_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( - &se.event, - &authed_pubkey_hex, - ) { + if !event_visible_to_reader(&se.event, &pubkey_bytes) { continue; } total += 1; @@ -231,6 +226,10 @@ pub async fn handle_count( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); + // Persona visibility pushdown for the fallback query_events path. + if needs_persona_filtering { + query.persona_reader = Some(pubkey_bytes.clone()); + } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -269,16 +268,7 @@ pub async fn handle_count( { continue; } - if is_author_only_event(&se.event, &pubkey_bytes) { - continue; - } - if is_unshared_persona_event(&se.event, &pubkey_bytes) { - continue; - } - if !buzz_core::filter::reader_authorized_for_event( - &se.event, - &authed_pubkey_hex, - ) { + if !event_visible_to_reader(&se.event, &pubkey_bytes) { continue; } total += 1; diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1e7322bbc8..497adc6c73 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1040,11 +1040,14 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { d_tags.push(&parts[1]); } if !parts.is_empty() && parts[0].as_str() == "shared" { - // Value must be exactly "true"; anything else is rejected. - if parts.len() < 2 || parts[1].as_str() != "true" { + // Exact shape required: ["shared", "true"] — exactly two elements, + // second element exactly "true". Extra elements are rejected so that + // a three-element tag like ["shared","true","extra"] cannot be stored + // and later misread as shared by the SQL-level visibility clause. + if parts.len() != 2 || parts[1].as_str() != "true" { return Err(format!( - "persona event `shared` tag value must be exactly \"true\" (got {:?})", - parts.get(1).map(|s| s.as_str()).unwrap_or("") + "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", + parts.iter().map(|s| s.as_str()).collect::>() )); } shared_count += 1; @@ -3623,6 +3626,23 @@ mod tests { ); } + #[test] + fn persona_envelope_rejects_shared_three_elements() { + // ["shared","true","extra"] must be rejected — only exactly two elements + // are valid so the SQL containment check tags @> '[["shared","true"]]' + // cannot match a three-element stored tag. + let ev = make_event_with_tags( + KIND_PERSONA, + r#"{"display_name":"x"}"#, + &[&["d", "slug"], &["shared", "true", "extra"]], + ); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!( + err.contains("[\"shared\",\"true\"]"), + "expected exact-shape error, got: {err}" + ); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 39db6e150b..d3ddd3e5d3 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -223,7 +223,6 @@ pub async fn handle_req( &accessible_channels, token_channel_ids.is_none(), &conn.tenant, - &hex::encode(&pubkey_bytes), &pubkey_bytes, &conn, &state, @@ -264,7 +263,6 @@ pub async fn handle_req( // per-filter limits or non-overlapping time windows. let mut seen_ids: HashSet = HashSet::new(); let mut total_sent: usize = 0; - let viewer_hex = hex::encode(&pubkey_bytes); // Phase 1 — pure query construction, in filter order. let filter_queries: Vec<(usize, Option, EventQuery)> = filters @@ -292,6 +290,12 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); + // Persona visibility pushdown: set reader bytes so query_events appends + // the SQL visibility clause before ORDER/LIMIT, preventing newer private + // personas from starving older shared ones off the page. + if filter_can_match_persona_shared_kinds(filter) { + params.persona_reader = Some(pubkey_bytes.clone()); + } (idx, per_filter_channel, params) }) .collect(); @@ -378,20 +382,10 @@ pub async fn handle_req( // Result-level read auth: a viewer-private snapshot (kind:30622) is // delivered only to its owner, even if reached via a kindless // `ids:[…]` subscription that skips the filter-level `#p` gate. - if !buzz_core::filter::reader_authorized_for_event(&stored.event, &viewer_hex) { - continue; - } - - // Author-only kinds: only the event author may see these events. - // Mixed-kind filters still serve other kinds normally. - if is_author_only_event(&stored.event, &pubkey_bytes) { - continue; - } - - // Persona shared-read gate: kind 30175 events are author-only by - // default; only events carrying ["shared","true"] are community- - // readable. Foreign unshared personas are silently omitted. - if is_unshared_persona_event(&stored.event, &pubkey_bytes) { + // Also enforces author-only kinds (30300/30350) and the persona + // shared-gate (kind:30175 without ["shared","true"]). Single call + // covers all three gated event classes. + if !event_visible_to_reader(&stored.event, &pubkey_bytes) { continue; } @@ -514,7 +508,6 @@ async fn handle_search_req( accessible_channels: &[uuid::Uuid], include_global: bool, tenant: &TenantContext, - reader_pubkey_hex: &str, reader_pubkey_bytes: &[u8], conn: &ConnectionState, state: &AppState, @@ -707,18 +700,9 @@ async fn handle_search_req( continue; } } - if !buzz_core::filter::reader_authorized_for_event( - &stored.event, - reader_pubkey_hex, - ) { - continue; - } - if is_author_only_event(&stored.event, reader_pubkey_bytes) { - continue; - } - // Persona shared-read gate (search lane): same semantics as - // the main delivery loop — foreign unshared personas are omitted. - if is_unshared_persona_event(&stored.event, reader_pubkey_bytes) { + // Result-level gate: covers author-only, persona shared-gate, + // and result-gated kinds in one call. + if !event_visible_to_reader(&stored.event, reader_pubkey_bytes) { continue; } // Dedup AFTER acceptance — an event that fails filter A's constraints @@ -1218,7 +1202,7 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// Combined per-event result-visibility check for all gated event classes. /// /// Returns `true` if the `event` should be delivered to / counted for the -/// reader identified by `requester_pubkey_bytes` + `requester_pubkey_hex`. +/// reader identified by `requester_pubkey_bytes` (raw 32-byte public key). /// Returns `false` and the event must be silently omitted if any of the /// following hold: /// @@ -1229,21 +1213,21 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` /// carries the per-event ownership check. /// +/// The hex representation required by `reader_authorized_for_event` is derived +/// internally so callers cannot supply inconsistent byte/hex identities. +/// /// Call this from every read surface — both WS (REQ/COUNT/fan-out) and HTTP /// (NIP-98 `/query`, `/count`, FTS search) — instead of inlining the three /// individual predicates at each site. -pub(crate) fn event_visible_to_reader( - event: &nostr::Event, - requester_pubkey_bytes: &[u8], - requester_pubkey_hex: &str, -) -> bool { +pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { if is_author_only_event(event, requester_pubkey_bytes) { return false; } if is_unshared_persona_event(event, requester_pubkey_bytes) { return false; } - if !buzz_core::filter::reader_authorized_for_event(event, requester_pubkey_hex) { + let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); + if !buzz_core::filter::reader_authorized_for_event(event, &requester_pubkey_hex) { return false; } true diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 3e6d387b3b..0422242603 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -112,6 +112,28 @@ fn sub_id(name: &str) -> String { format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4()) } +/// Create an open-visibility channel so a kind:9 event can be read by any relay member. +/// +/// Uses `visibility=open` so the foreign reader does not need an explicit membership +/// entry — the relay allows any authenticated member to read open-channel events. +async fn create_test_channel(keys: &Keys) -> String { + let channel_uuid = uuid::Uuid::new_v4(); + let channel_name = format!("persona-e2e-{channel_uuid}"); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &channel_name]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + + let (ok, msg) = submit_event_http(&http_client(), keys, &event).await; + assert!(ok, "channel creation rejected: {msg}"); + channel_uuid.to_string() +} + /// Build a minimal persona event with the given d-tag and content. fn persona_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event { EventBuilder::new(Kind::Custom(PERSONA_KIND), content) @@ -794,18 +816,10 @@ async fn test_persona_count_excludes_foreign_unshared() { foreign.send_raw(&count_msg).await.expect("send COUNT"); // The relay returns ["COUNT", sub_id, {"count": N}]. - // BuzzTestClient::recv_event maps WsClientError::UnexpectedMessage to - // TestClientError::UnexpectedMessage (via the From impl in lib.rs). + // buzz-ws-client parses this into RelayMessage::Count. let result = foreign.recv_event(Duration::from_secs(5)).await; let count: u64 = match result { - Err(buzz_test_client::TestClientError::UnexpectedMessage(raw)) => { - // Parse ["COUNT", sub_id, {"count": N}] - let arr: serde_json::Value = serde_json::from_str(&raw).expect("parse COUNT response"); - arr.get(2) - .and_then(|o| o.get("count")) - .and_then(|c| c.as_u64()) - .expect("count field in COUNT response") - } + Ok(RelayMessage::Count { count, .. }) => count, Ok(RelayMessage::Closed { message, .. }) => { panic!("COUNT closed unexpectedly: {message}"); } @@ -838,10 +852,14 @@ async fn test_persona_live_fanout_shared_gate() { let d_tag = format!("gate-{}", &uuid::Uuid::new_v4().to_string()[..8]); - // Use strictly increasing timestamps: t0 < t1 < t2. - let t0: u64 = 1_700_000_000; - let t1: u64 = t0 + 1; - let t2: u64 = t1 + 1; + // Use strictly increasing timestamps relative to now: now-2 < now-1 < now. + // This satisfies the relay's timestamp-skew check while keeping NIP-33 + // ordering deterministic (t0 < t1 < t2 so same d-tag replacements always + // promote the highest timestamp regardless of event-id ordering). + let now = nostr::Timestamp::now().as_secs(); + let t0: u64 = now.saturating_sub(2); + let t1: u64 = now.saturating_sub(1); + let t2: u64 = now; // Foreign subscribes to all kind:30175 events BEFORE the author publishes. let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) @@ -1129,7 +1147,11 @@ async fn test_persona_mixed_kind_filter_does_not_leak() { let d_tag = format!("mixed-{}", &uuid::Uuid::new_v4().to_string()[..8]); - // Author publishes an unshared persona AND a kind:9 message. + // Create an open-visibility channel so the kind:9 control event is accessible + // to the foreign reader without an explicit membership entry. + let channel_id = create_test_channel(&author_keys).await; + + // Author publishes an unshared persona AND a kind:9 message in the open channel. let mut author = BuzzTestClient::connect(&url, &author_keys) .await .expect("connect author"); @@ -1138,8 +1160,11 @@ async fn test_persona_mixed_kind_filter_does_not_leak() { let ok = author.send_event(ev).await.expect("send unshared"); assert!(ok.accepted, "unshared persona rejected: {}", ok.message); - // Publish a kind:9 event so the mixed-kind filter has something to return. + // Publish a kind:9 event in the open channel so the mixed-kind filter has + // something to return and we can assert the persona gate is per-event, not + // a wholesale filter drop. let ev9 = EventBuilder::new(Kind::Custom(9), "hello from author") + .tags(vec![Tag::parse(["h", &channel_id]).unwrap()]) .sign_with_keys(&author_keys) .unwrap(); let msg9_id = ev9.id; @@ -1359,3 +1384,199 @@ async fn test_persona_http_count_cross_author_gate() { "foreign author-scoped /count must still return 1 after wildcard query, got {scoped_count2}" ); } + +/// Task-1 regression: WS REQ visibility-before-LIMIT gate. +/// +/// Publishes N unshared (newer) personas followed by 1 shared (older) persona, +/// then queries with `limit < N`. Without the SQL-level visibility clause, the +/// page is filled with unshared events and the shared one never appears. With +/// the clause, unshared events are excluded before ORDER/LIMIT so the shared +/// event is returned. +/// +/// Verifies at `312014d5e`: this test fails there because `query_events` did +/// not have the `persona_reader` SQL clause and the private rows starved the +/// shared one off the page. +#[tokio::test] +#[ignore] +async fn test_persona_ws_req_shared_visible_with_newer_private_ahead() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + // Publish N=3 private personas with the highest timestamps, then 1 shared + // with the lowest timestamp. limit=2 means the first page has only 2 + // candidates in the old (no-SQL-clause) world — both private — and the + // shared one is invisible. + let now = nostr::Timestamp::now().as_secs(); + let shared_ts = now.saturating_sub(10); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + + // Publish 3 private personas at t=now, now-1, now-2 (all newer than shared). + for i in 0..3u64 { + let d = format!( + "priv-limit-{}-{}", + i, + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i)); + let ok = author.send_event(ev).await.expect("send private"); + assert!(ok.accepted, "private persona {i} rejected: {}", ok.message); + } + + // Publish 1 shared persona at a lower timestamp (older than all private ones). + let shared_d = format!("shared-limit-{}", &uuid::Uuid::new_v4().to_string()[..8]); + let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts); + let shared_id = ev_shared.id; + let ok = author.send_event(ev_shared).await.expect("send shared"); + assert!(ok.accepted, "shared persona rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + // Foreign queries with limit=2: private rows are excluded at SQL level, + // so the shared event must appear despite having a lower timestamp. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("limit-gate"); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()) + .limit(2); + foreign + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == shared_id), + "shared persona must appear even when newer private personas fill the LIMIT (got {} events)", + events.len() + ); + assert!( + events.iter().all(|e| { + e.tags + .iter() + .any(|t| t.as_slice().first().is_some_and(|v| v.as_str() == "shared")) + }), + "foreign must NOT see any unshared persona" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Task-1 regression: HTTP `/query` visibility-before-LIMIT gate. +/// +/// Same scenario as the WS variant above, over the NIP-98 HTTP bridge. +#[tokio::test] +#[ignore] +async fn test_persona_http_query_shared_visible_with_newer_private_ahead() { + let client = http_client(); + let author_keys = Keys::generate(); + let foreign_pubkey_hex = Keys::generate().public_key().to_hex(); + + let now = nostr::Timestamp::now().as_secs(); + let shared_ts = now.saturating_sub(10); + + // Publish 3 private (newer) + 1 shared (older). + for i in 0..3u64 { + let d = format!( + "http-priv-limit-{}-{}", + i, + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i)); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await; + assert!(ok, "private persona {i} rejected: {msg}"); + } + + let shared_d = format!( + "http-shared-limit-{}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts); + let shared_id_hex = ev_shared.id.to_hex(); + let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await; + assert!(ok, "shared persona rejected: {msg}"); + + // Foreign /query with limit=2: shared event must appear. + let filter = serde_json::json!({ + "kinds": [PERSONA_KIND], + "authors": [author_keys.public_key().to_hex()], + "limit": 2 + }); + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", &foreign_pubkey_hex) + .header("Content-Type", "application/json") + .json(&vec![filter]) + .send() + .await + .expect("query"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + let results: Vec = resp.json().await.expect("parse"); + + let has_shared = results.iter().any(|e| { + e.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| id == shared_id_hex) + }); + assert!( + has_shared, + "shared persona must appear in /query result even with newer private ones ahead (got {} events)", + results.len() + ); +} + +/// Task-2 wire-level: ingest must reject ["shared","true","extra"] over the wire. +/// +/// The unit tests verify the validator directly; this test confirms the rejection +/// propagates end-to-end through the WebSocket ingest path. +#[tokio::test] +#[ignore] +async fn test_persona_ingest_rejects_three_element_shared_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + // Build a persona event with a three-element ["shared","true","extra"] tag. + // nostr::Tag::parse accepts variable-length slices, so this is straightforward. + let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#) + .tags(vec![ + Tag::parse([ + "d", + &format!("extra-{}", &uuid::Uuid::new_v4().to_string()[..8]), + ]) + .unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let ok = client + .send_event(ev) + .await + .expect("send three-element shared tag"); + assert!( + !ok.accepted, + "three-element [\"shared\",\"true\",\"extra\"] tag must be rejected at ingest: {}", + ok.message + ); + assert!( + ok.message.contains("[\"shared\",\"true\"]") || ok.message.contains("shared"), + "rejection message should reference the shared tag constraint, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/crates/buzz-ws-client/src/message.rs b/crates/buzz-ws-client/src/message.rs index 4950c117d6..3c646bc8ab 100644 --- a/crates/buzz-ws-client/src/message.rs +++ b/crates/buzz-ws-client/src/message.rs @@ -37,6 +37,13 @@ pub enum RelayMessage { /// The challenge string to sign. challenge: String, }, + /// A NIP-45 COUNT response. + Count { + /// The subscription ID this count belongs to. + subscription_id: String, + /// The number of matching events. + count: u64, + }, } /// The relay's response to a published event (NIP-01 `OK` message). @@ -137,6 +144,22 @@ pub fn parse_relay_message(text: &str) -> Result { .to_string(); Ok(RelayMessage::Auth { challenge }) } + "COUNT" => { + let sub_id = arr + .get(1) + .and_then(|v| v.as_str()) + .ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))? + .to_string(); + let count = arr + .get(2) + .and_then(|o| o.get("count")) + .and_then(|c| c.as_u64()) + .ok_or_else(|| WsClientError::UnexpectedMessage(text.to_string()))?; + Ok(RelayMessage::Count { + subscription_id: sub_id, + count, + }) + } other => Err(WsClientError::UnexpectedMessage(format!( "unknown message type: {other}" ))), diff --git a/docs/nips/NIP-AP.md b/docs/nips/NIP-AP.md index c2fab1ce37..b990f55bef 100644 --- a/docs/nips/NIP-AP.md +++ b/docs/nips/NIP-AP.md @@ -224,7 +224,7 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an - The relay stores persona events globally (`channel_id = NULL`); they are not channel-scoped. - The relay is NOT required to validate that `content` parses as valid `PersonaEventContent` JSON. Relays are dumb stores per Nostr convention; content validation is a client responsibility. - The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events). -- The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST have exactly the value `"true"` and there MUST be at most one `shared` tag. Events with `["shared","false"]`, `["shared","1"]`, or duplicate `shared` tags are rejected with `invalid:`. +- The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST consist of **exactly two elements** — `["shared", "true"]`. Extra elements (e.g. `["shared","true","extra"]`), wrong values (`["shared","false"]`), missing values (`["shared"]`), or duplicate `shared` tags are all rejected with `invalid:`. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (`tags @> '[["shared","true"]]'`) never matches a stored malformed tag via JSONB containment supersets. ### Access control: author-only-unless-shared @@ -239,11 +239,11 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them): -- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). +- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`persona_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. - **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. - **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. - **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. -- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. +- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `persona_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. - **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. - **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. From 59c248d4b44e005cda140be8283373950cddaece Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Fri, 24 Jul 2026 20:21:23 -0400 Subject: [PATCH 4/4] test(relay): scope live fanout gate subscription to test author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel persona tests all publish kind:30175 events. The foreign live subscription in test_persona_live_fanout_shared_gate used an unscoped kind-only filter, so any concurrent test's 30175 tripped the leak-panic (false positive in the parallel Relay E2E job, CI run 30133257310). Scope the subscription to author_keys.public_key() — still a valid cross-author live fan-out test; scoping isolates it from the parallel suite. Also include event id and author pubkey in both leak-panic messages so any future failure identifies what was actually received. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- crates/buzz-test-client/tests/e2e_persona.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index 0422242603..b3b1f7f6b2 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -861,12 +861,16 @@ async fn test_persona_live_fanout_shared_gate() { let t1: u64 = now.saturating_sub(1); let t2: u64 = now; - // Foreign subscribes to all kind:30175 events BEFORE the author publishes. + // Foreign subscribes to the test author's kind:30175 events BEFORE the author + // publishes. Scoped to this author so concurrent persona tests publishing their + // own 30175s don't trip the leak-panic (parallel suite interference). let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) .await .expect("connect foreign"); let sid = sub_id("fanout-gate"); - let filter = Filter::new().kind(Kind::Custom(PERSONA_KIND)); + let filter = Filter::new() + .kind(Kind::Custom(PERSONA_KIND)) + .author(author_keys.public_key()); foreign .subscribe(&sid, vec![filter]) .await @@ -893,7 +897,11 @@ async fn test_persona_live_fanout_shared_gate() { match result { Err(buzz_test_client::TestClientError::Timeout) => {} Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { - panic!("foreign MUST NOT receive unshared persona via live fan-out") + panic!( + "foreign MUST NOT receive unshared persona via live fan-out \ + (got event id={} author={})", + event.id, event.pubkey + ) } _ => {} } @@ -973,8 +981,9 @@ async fn test_persona_live_fanout_shared_gate() { Err(buzz_test_client::TestClientError::Timeout) => {} Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => { panic!( - "foreign MUST NOT receive unshared replacement via live fan-out (got {:?})", - event.id + "foreign MUST NOT receive unshared replacement via live fan-out \ + (got event id={} author={})", + event.id, event.pubkey ) } _ => {}