From 0bc15a282e314857eae36df0ca584a4a2abdc172 Mon Sep 17 00:00:00 2001 From: jm Date: Wed, 14 Oct 2026 19:15:56 +0000 Subject: [PATCH] fix(relay): protect fenced memory read metadata Co-authored-by: Codex Signed-off-by: jm Co-authored-by: Codex --- crates/buzz-core/src/filter.rs | 62 ++++- crates/buzz-core/src/kind.rs | 13 +- crates/buzz-relay/src/api/bridge.rs | 333 +++++++++++++++++++++++- crates/buzz-relay/src/handlers/count.rs | 6 +- crates/buzz-relay/src/handlers/event.rs | 189 +++++++++++++- crates/buzz-relay/src/handlers/req.rs | 21 +- 6 files changed, 594 insertions(+), 30 deletions(-) diff --git a/crates/buzz-core/src/filter.rs b/crates/buzz-core/src/filter.rs index 32e3a7ad16..5de42e8532 100644 --- a/crates/buzz-core/src/filter.rs +++ b/crates/buzz-core/src/filter.rs @@ -11,10 +11,11 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { filters.iter().any(|f| filter_match_one(f, event)) } -/// Result-level read authorization for relay-signed events whose content is -/// private to a single viewer. Currently gates `KIND_DM_VISIBILITY` and -/// `KIND_AGENT_TURN_METRIC`: the reader MUST equal the event's `#p` tag -/// (owner). Returns `true` for every other kind. +/// Result-level read authorization for events carrying private metadata. +/// `KIND_DM_VISIBILITY` and `KIND_AGENT_TURN_METRIC` require the reader to +/// equal the event's `#p` tag. A fenced `KIND_AGENT_ENGRAM` additionally +/// permits its author, while an unfenced engram retains its legacy behavior. +/// Returns `true` for every other kind. /// /// This guards every delivery surface — WS historical pull (`req.rs`), HTTP /// bridge (`bridge.rs`), and live fan-out (`event.rs`) — so a query that @@ -22,6 +23,19 @@ pub fn filters_match(filters: &[Filter], event: &StoredEvent) -> bool { /// a known event id) still cannot read another user's private event. pub fn reader_authorized_for_event(event: &nostr::Event, reader_pubkey_hex: &str) -> bool { let kind = crate::kind::event_kind_u32(event); + if kind == crate::kind::KIND_AGENT_ENGRAM && crate::ifc::has_ifc_tag(event) { + if event + .pubkey + .to_hex() + .eq_ignore_ascii_case(reader_pubkey_hex) + { + return true; + } + return event + .tags + .public_keys() + .any(|pubkey| pubkey.to_hex().eq_ignore_ascii_case(reader_pubkey_hex)); + } if kind != crate::kind::KIND_DM_VISIBILITY && kind != crate::kind::KIND_AGENT_TURN_METRIC { return true; } @@ -319,4 +333,44 @@ mod tests { "the authoring agent must NOT be authorized to read its own metric event (owner-only)" ); } + + #[test] + fn reader_authorized_for_event_gates_only_fenced_engrams() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let stranger = Keys::generate(); + let owner_hex = owner.public_key().to_hex(); + let fenced = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_ENGRAM as u16), + "ciphertext", + ) + .tags([ + Tag::parse(["p", owner_hex.as_str()]).expect("p tag"), + Tag::parse(["ifc-unknown", "reserved"]).expect("reserved tag"), + ]) + .sign_with_keys(&agent) + .expect("sign fenced engram"); + + assert!(reader_authorized_for_event( + &fenced, + &agent.public_key().to_hex() + )); + assert!(reader_authorized_for_event(&fenced, &owner_hex)); + assert!(!reader_authorized_for_event( + &fenced, + &stranger.public_key().to_hex() + )); + + let unfenced = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_ENGRAM as u16), + "ciphertext", + ) + .tags([Tag::parse(["p", owner_hex.as_str()]).expect("p tag")]) + .sign_with_keys(&agent) + .expect("sign unfenced engram"); + assert!(reader_authorized_for_event( + &unfenced, + &stranger.public_key().to_hex() + )); + } } diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index fe56dd2fd6..2482f74980 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -161,14 +161,17 @@ pub fn k_tag_kinds(event: &nostr::Event) -> impl Iterator + '_ { }) } -/// Kinds that require a result-level read gate beyond the filter-layer -/// `#p` check: even a reader who knows an event id MUST match the event's -/// `#p` tag to receive the event. This closes the kindless `{ids:[…]}` read -/// path for events whose existence must not be leaked. +/// Kinds that require a result-level read gate beyond filter admission. Even a +/// reader who knows an event id must satisfy that kind's event predicate. This +/// closes the kindless `{ids:[…]}` path for private content or metadata. /// /// Used by `filter_can_match_result_gated_kinds` to force the per-event /// fallback path in COUNT rather than the fast SQL `count_events()`. -pub const RESULT_GATED_KINDS: &[u32] = &[KIND_DM_VISIBILITY, KIND_AGENT_TURN_METRIC]; +pub const RESULT_GATED_KINDS: &[u32] = &[ + KIND_DM_VISIBILITY, + KIND_AGENT_TURN_METRIC, + KIND_AGENT_ENGRAM, +]; /// Kinds whose stored events have `#p`-bound read access — readable only by /// subscribers whose pubkey appears in the event's `#p` tag. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 74e96bcbbc..eee0b82270 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2055,8 +2055,8 @@ async fn count_events_authed( let needs_author_only_filtering = crate::handlers::req::filter_can_match_author_only_kinds(filter); // Same result-gated guard as the WS COUNT handler: force the per-event - // fallback for filters that can match 44200 or 30622 unless #p=[self] - // is safely pushed down (existence leak otherwise). + // fallback for filters that can match 44200, 30622, or fenced 30174 + // unless #p=[self] is safely pushed down (existence leak otherwise). let needs_result_gated_filtering = crate::handlers::req::filter_can_match_result_gated_kinds(filter) && !crate::handlers::req::result_gated_count_safe_for_pushdown( @@ -7605,6 +7605,335 @@ pub(crate) mod postgres_tests { (status, bytes) } + async fn post_read_route( + state: Arc, + host: &str, + route: &str, + pubkey_hex: &str, + body: &[u8], + ) -> (axum::http::StatusCode, axum::body::Bytes) { + use axum::body::Body; + use axum::http::{header, Request}; + use tower::ServiceExt; + + let response = crate::router::build_router(state) + .oneshot( + Request::builder() + .method("POST") + .uri(route) + .header(header::HOST, host) + .header("x-pubkey", pubkey_hex) + .header("content-type", "application/json") + .body(Body::from(body.to_vec())) + .expect("build read request"), + ) + .await + .expect("read route response"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read route body"); + (status, bytes) + } + + async fn ws_read_frames( + state: Arc, + tenant: TenantContext, + reader: &Keys, + delegated_owner: Option, + filter: nostr::Filter, + count: bool, + ) -> Vec { + use tokio_util::sync::CancellationToken; + + let (send_tx, mut send_rx) = tokio::sync::mpsc::channel(32); + let (ctrl_tx, _ctrl_rx) = tokio::sync::mpsc::channel(4); + let cancel = CancellationToken::new(); + let connection = Arc::new(crate::connection::ConnectionState { + conn_id: uuid::Uuid::new_v4(), + tenant, + remote_addr: "127.0.0.1:1234".parse().expect("socket address"), + auth_state: std::sync::Mutex::new(crate::connection::AuthState::Authenticated( + buzz_auth::AuthContext { + pubkey: reader.public_key(), + scopes: Vec::new(), + channel_ids: None, + auth_method: buzz_auth::AuthMethod::Nip42, + agent_owner_pubkey: delegated_owner, + }, + )), + subscriptions: Arc::new(tokio::sync::Mutex::new(Default::default())), + send_tx, + ctrl_tx, + terminal_ctrl_tx: tokio::sync::mpsc::channel(1).0, + cancel: cancel.clone(), + backpressure_count: Arc::new(std::sync::atomic::AtomicU8::new(0)), + grace_limit: 3, + nip_fi_assertion: None, + session_deadline: None, + nip_fi_gate: crate::nip_fi_gate::SessionAdmissionGate::off_mode(cancel.clone()), + community_control: crate::state::CommunityConnectionControl::new(cancel), + }); + if count { + crate::handlers::count::handle_count( + "memory-count".to_owned(), + vec![filter], + connection, + state, + ) + .await; + } else { + crate::handlers::req::handle_req( + "memory-read".to_owned(), + vec![filter], + vec![None], + connection, + state, + ) + .await; + } + let mut frames = Vec::new(); + while let Ok(message) = send_rx.try_recv() { + if let axum::extract::ws::Message::Text(text) = message { + frames.push(text.to_string()); + } + } + frames + } + + #[tokio::test] + #[ignore = "requires Postgres and Redis"] + async fn fenced_memory_production_reads_hide_envelope_and_count_from_strangers() { + use buzz_core::engram::{conversation_key, d_tag}; + use buzz_core::kind::KIND_AGENT_ENGRAM; + use buzz_db::channel::{ChannelType, ChannelVisibility}; + use nostr::nips::nip44::{self, Version}; + + let Some(state) = bridge_handler_test_state().await else { + eprintln!("skipping fenced memory read test: Postgres or Redis unavailable"); + return; + }; + let host = format!("ifc-memory-read-{}.test", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = TenantContext::resolved(community, &host); + let agent = Keys::generate(); + let owner = Keys::generate(); + let stranger = Keys::generate(); + for principal in [&agent, &owner, &stranger] { + state + .db + .ensure_user(community, principal.public_key().as_bytes()) + .await + .expect("ensure test principal"); + } + assert!(state + .db + .set_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("set delegated owner")); + let source = uuid::Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + source, + &format!("ifc-memory-read-{}", source.simple()), + ChannelType::Stream, + ChannelVisibility::Private, + None, + agent.public_key().as_bytes(), + None, + ) + .await + .expect("create source channel"); + + let owner_hex = owner.public_key().to_hex(); + let source_text = source.to_string(); + let community_text = community.to_string(); + let expiry = (chrono::Utc::now().timestamp() + 120).to_string(); + let memory_key = conversation_key(agent.secret_key(), &owner.public_key()); + let memory_event = |slug: &str, fenced: bool| { + let d = d_tag(&memory_key, slug); + let ciphertext = nip44::encrypt( + agent.secret_key(), + &owner.public_key(), + format!(r#"{{"slug":"{slug}","value":"private"}}"#), + Version::V2, + ) + .expect("encrypt owner memory"); + let mut tags = vec![ + Tag::parse(["d", d.as_str()]).expect("d tag"), + Tag::parse(["p", owner_hex.as_str()]).expect("p tag"), + ]; + if fenced { + tags.extend([ + Tag::parse(["ifc", "1", community_text.as_str(), expiry.as_str()]) + .expect("ifc tag"), + Tag::parse(["ifc-source", source_text.as_str(), "1"]).expect("source tag"), + Tag::parse(["ifc-owner-memory", owner_hex.as_str()]).expect("memory target"), + ]); + } + EventBuilder::new(Kind::Custom(KIND_AGENT_ENGRAM as u16), ciphertext) + .tags(tags) + .sign_with_keys(&agent) + .expect("sign memory") + }; + let fenced = memory_event("read/fenced", true); + let legacy = memory_event("read/legacy", false); + for event in [&fenced, &legacy] { + let body = serde_json::to_vec(event).expect("serialize memory"); + let (status, response) = + post_events_with_body(state.clone(), &host, &agent.public_key().to_hex(), &body) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{response}"); + assert!(response.contains(r#""accepted":true"#), "{response}"); + } + + let no_kind = serde_json::to_vec(&[serde_json::json!({ + "ids": [fenced.id.to_hex()] + })]) + .expect("serialize exact-id filter"); + let with_kind = serde_json::to_vec(&[serde_json::json!({ + "ids": [fenced.id.to_hex()], + "kinds": [KIND_AGENT_ENGRAM] + })]) + .expect("serialize exact-id kind filter"); + for (reader, body) in [(&agent, &no_kind), (&owner, &with_kind)] { + let (status, bytes) = post_read_route( + state.clone(), + &host, + "/query", + &reader.public_key().to_hex(), + body, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let events: Vec = serde_json::from_slice(&bytes).expect("query events"); + assert_eq!( + events.iter().map(|event| event.id).collect::>(), + [fenced.id] + ); + let (status, bytes) = post_read_route( + state.clone(), + &host, + "/count", + &reader.public_key().to_hex(), + body, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let count: serde_json::Value = serde_json::from_slice(&bytes).expect("count response"); + assert_eq!(count["count"], 1); + } + for body in [&no_kind, &with_kind] { + let (status, bytes) = post_read_route( + state.clone(), + &host, + "/query", + &stranger.public_key().to_hex(), + body, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + assert_eq!(bytes.as_ref(), b"[]"); + let (status, bytes) = post_read_route( + state.clone(), + &host, + "/count", + &stranger.public_key().to_hex(), + body, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let count: serde_json::Value = serde_json::from_slice(&bytes).expect("count response"); + assert_eq!(count["count"], 0); + } + + let broad = serde_json::to_vec(&[serde_json::json!({ + "kinds": [KIND_AGENT_ENGRAM] + })]) + .expect("serialize broad filter"); + let (status, _) = post_read_route( + state.clone(), + &host, + "/query", + &stranger.public_key().to_hex(), + &broad, + ) + .await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN); + + let legacy_filter = serde_json::to_vec(&[serde_json::json!({ + "ids": [legacy.id.to_hex()] + })]) + .expect("serialize legacy filter"); + let (status, bytes) = post_read_route( + state.clone(), + &host, + "/query", + &stranger.public_key().to_hex(), + &legacy_filter, + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let events: Vec = serde_json::from_slice(&bytes).expect("legacy query"); + assert_eq!( + events.iter().map(|event| event.id).collect::>(), + [legacy.id] + ); + + let id_filter = nostr::Filter::new().id(fenced.id); + let agent_frames = ws_read_frames( + state.clone(), + tenant.clone(), + &agent, + Some(owner.public_key()), + id_filter.clone(), + false, + ) + .await; + assert!(agent_frames + .iter() + .any(|frame| frame.contains(&fenced.id.to_hex()))); + let owner_frames = ws_read_frames( + state.clone(), + tenant.clone(), + &owner, + None, + id_filter.clone(), + false, + ) + .await; + assert!(owner_frames + .iter() + .any(|frame| frame.contains(&fenced.id.to_hex()))); + let stranger_frames = ws_read_frames( + state.clone(), + tenant.clone(), + &stranger, + None, + id_filter.clone(), + false, + ) + .await; + assert!(!stranger_frames + .iter() + .any(|frame| frame.contains("ifc-source"))); + let count_frames = ws_read_frames(state, tenant, &stranger, None, id_filter, true).await; + assert!(count_frames + .iter() + .any(|frame| frame.contains(r#""count":0"#))); + } + #[tokio::test] #[ignore = "requires Postgres and Redis"] async fn ifc_query_attests_exact_nip98_signer_without_owner_or_author_substitution() { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 4dca0beb7f..7836f28fa9 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -134,9 +134,9 @@ pub async fn handle_count( // shared-tag check. A fast count over those kinds would include foreign // unshared events, leaking the existence of private agent activity. let needs_shared_gate_filtering = filter_can_match_shared_gated_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 + // Determine if this filter can match result-gated kinds (44200, 30622, + // or fenced 30174) that require a per-event check. When the fast SQL + // path would count matching rows without calling reader_authorized_for_event, a // non-owner learns the existence of events they are not allowed to see. // The only safe pushdown is when #p is pinned to the authenticated // reader's own pubkey. diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 9a8bd33085..ae3fac6123 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -175,14 +175,11 @@ pub async fn filter_fanout_by_access( matches }; - // Owner-only kinds (kind:30622 DM visibility, kind:44200 agent turn - // metrics) reach only the pubkey named in their `p` tag. Same predicate as - // the pull paths, applied here so local and Redis delivery share it — a - // kindless `ids:[…]` subscription can otherwise match these events. + // Result-gated kinds use the same per-event predicate as pull and count. + // This runs before the channel-less return so local and Redis delivery + // cannot bypass private metadata checks on global events. let kind = event_kind_u32(&stored_event.event); - let matches = if kind == buzz_core::kind::KIND_DM_VISIBILITY - || kind == buzz_core::kind::KIND_AGENT_TURN_METRIC - { + let matches = if buzz_core::kind::RESULT_GATED_KINDS.contains(&kind) { matches .into_iter() .filter(|(conn_id, _)| { @@ -2739,7 +2736,8 @@ mod tests { use axum::extract::ws::Message; use buzz_core::kind::{KIND_MEMBER_ADDED_NOTIFICATION, KIND_PRESENCE_UPDATE}; use buzz_pubsub::{ChannelEvent, EventTopic}; - use nostr::{EventBuilder, Filter, Keys, Kind}; + use nostr::nips::nip44::{self, Version}; + use nostr::{EventBuilder, Filter, Keys, Kind, Tag}; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -2827,6 +2825,49 @@ mod tests { .expect("sign membership notification") } + fn fenced_memory_event(agent: &Keys, owner: &Keys, source: Uuid) -> nostr::Event { + let owner_hex = owner.public_key().to_hex(); + let source_text = source.to_string(); + let community_text = Uuid::nil().to_string(); + let expiry = (chrono::Utc::now().timestamp() + 120).to_string(); + let key = buzz_core::engram::conversation_key(agent.secret_key(), &owner.public_key()); + let d = buzz_core::engram::d_tag(&key, "fanout/read-gate"); + let ciphertext = nip44::encrypt( + agent.secret_key(), + &owner.public_key(), + r#"{"slug":"fanout/read-gate","value":"private"}"#, + Version::V2, + ) + .expect("encrypt memory"); + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_ENGRAM as u16), + ciphertext, + ) + .tags([ + Tag::parse(["d", d.as_str()]).expect("d tag"), + Tag::parse(["p", owner_hex.as_str()]).expect("p tag"), + Tag::parse(["ifc", "1", community_text.as_str(), expiry.as_str()]) + .expect("ifc tag"), + Tag::parse(["ifc-source", source_text.as_str(), "1"]).expect("source tag"), + Tag::parse(["ifc-owner-memory", owner_hex.as_str()]).expect("memory target"), + ]) + .sign_with_keys(agent) + .expect("sign memory") + } + + fn unfenced_memory_event(agent: &Keys, owner: &Keys) -> nostr::Event { + EventBuilder::new( + Kind::Custom(buzz_core::kind::KIND_AGENT_ENGRAM as u16), + "legacy ciphertext", + ) + .tags([ + Tag::parse(["d", &"a".repeat(64)]).expect("d tag"), + Tag::parse(["p", &owner.public_key().to_hex()]).expect("p tag"), + ]) + .sign_with_keys(agent) + .expect("sign legacy memory") + } + fn event_from_ws_message(msg: Message) -> nostr::Event { let Message::Text(text) = msg else { panic!("expected text ws message"); @@ -2956,6 +2997,69 @@ mod tests { } } + #[tokio::test] + async fn local_and_pubsub_fanout_gate_fenced_memory_but_not_legacy_memory() { + for local in [false, true] { + let state = test_state().await; + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "memory-fanout.test", + ); + let agent = Keys::generate(); + let owner = Keys::generate(); + let stranger = Keys::generate(); + let fenced = fenced_memory_event(&agent, &owner, Uuid::new_v4()); + let legacy = unfenced_memory_event(&agent, &owner); + let subscribe = |who: &Keys| { + register_global_sub( + &state, + "ids", + Filter::new().ids([fenced.id, legacy.id]), + Some(who.public_key().to_bytes().to_vec()), + ) + .1 + }; + let mut agent_rx = subscribe(&agent); + let mut owner_rx = subscribe(&owner); + let mut stranger_rx = subscribe(&stranger); + + for event in [fenced.clone(), legacy.clone()] { + if local { + super::super::dispatch_persistent_event_inner( + &tenant, + &state, + &buzz_core::StoredEvent::new(event.clone(), None), + buzz_core::kind::KIND_AGENT_ENGRAM, + &event.pubkey.to_hex(), + false, + None, + ) + .await; + } else { + fan_out_pubsub_event( + &state, + ChannelEvent { + community_id: tenant.community(), + topic: EventTopic::Global, + event, + }, + ) + .await; + } + } + + for receiver in [&mut agent_rx, &mut owner_rx] { + let first = event_from_ws_message(receiver.try_recv().expect("fenced memory")); + let second = event_from_ws_message(receiver.try_recv().expect("legacy memory")); + assert_eq!([first.id, second.id], [fenced.id, legacy.id]); + } + let delivered = + event_from_ws_message(stranger_rx.try_recv().expect("legacy memory")); + assert_eq!(delivered.id, legacy.id); + assert!(stranger_rx.try_recv().is_err()); + } + } + #[tokio::test] async fn global_presence_pubsub_event_fans_out_to_local_subscribers() { let state = test_state().await; @@ -3179,6 +3283,75 @@ mod tests { receiver_fanout.abort(); } + #[tokio::test] + async fn redis_round_trip_withholds_fenced_memory_from_stranger() { + let Some(redis_url) = redis_url_if_available().await else { + eprintln!("skipping Redis memory fan-out test: Redis unavailable"); + return; + }; + let origin = super::fanout_access::test_state_with_redis_url(&redis_url).await; + let receiver = super::fanout_access::test_state_with_redis_url(&redis_url).await; + let origin_subscriber = tokio::spawn(origin.pubsub.clone().run_subscriber()); + let receiver_subscriber = tokio::spawn(receiver.pubsub.clone().run_subscriber()); + let origin_fanout = spawn_pubsub_fanout_loop(origin.clone()); + let receiver_fanout = spawn_pubsub_fanout_loop(receiver.clone()); + let tenant = buzz_core::tenant::TenantContext::resolved( + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + "memory-redis.test", + ); + origin + .pubsub + .retain_topic(&tenant, EventTopic::Global) + .await; + receiver + .pubsub + .retain_topic(&tenant, EventTopic::Global) + .await; + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let agent = Keys::generate(); + let owner = Keys::generate(); + let stranger = Keys::generate(); + let event = fenced_memory_event(&agent, &owner, Uuid::new_v4()); + let subscribe = |who: &Keys| { + register_global_sub( + &receiver, + "memory", + Filter::new().id(event.id), + Some(who.public_key().to_bytes().to_vec()), + ) + .1 + }; + let mut agent_rx = subscribe(&agent); + let mut owner_rx = subscribe(&owner); + let mut stranger_rx = subscribe(&stranger); + + origin + .pubsub + .publish_event(&tenant, EventTopic::Global, &event) + .await + .expect("publish memory through Redis"); + for receiver in [&mut agent_rx, &mut owner_rx] { + let message = + tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv()) + .await + .expect("authorized Redis delivery") + .expect("connection open"); + assert_eq!(event_from_ws_message(message).id, event.id); + } + assert!( + tokio::time::timeout(std::time::Duration::from_millis(250), stranger_rx.recv(),) + .await + .is_err(), + "stranger must not receive fenced memory metadata" + ); + + origin_subscriber.abort(); + receiver_subscriber.abort(); + origin_fanout.abort(); + receiver_fanout.abort(); + } + /// Regression guard: the `EventCreated` audit entry must record the /// caller-resolved *actor*, not the stored event's claimed author. For /// relay-signed events (workflow posts, side-effect emits) the event diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 7572774b3a..968507e535 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1633,14 +1633,13 @@ pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool { /// 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 -/// `KIND_DM_VISIBILITY` and `KIND_AGENT_TURN_METRIC`). +/// that carries a per-event result-level read gate (`KIND_DM_VISIBILITY`, +/// `KIND_AGENT_TURN_METRIC`, and fenced `KIND_AGENT_ENGRAM` events). /// /// Used by the COUNT handler to force the per-event fallback path instead of -/// the fast SQL `count_events()`, which cannot enforce the owner-only result -/// gate. An existence count leaks private event activity even though no content -/// is returned, violating the NIP-AM / NIP-DM requirement that knowing an id -/// MUST NOT grant access. +/// the fast SQL `count_events()`, which cannot enforce the per-event result +/// gate. An existence count can leak private event activity even though no +/// content is returned. pub(crate) fn filter_can_match_result_gated_kinds(filter: &Filter) -> bool { filter.kinds.as_ref().is_none_or(|ks| { ks.iter() @@ -1691,8 +1690,8 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 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. +/// 3. **Result-gated kinds** (kind 44200/30622 and fenced 30174): +/// `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. @@ -3249,6 +3248,12 @@ mod tests { assert!(filter_can_match_result_gated_kinds(&f)); } + #[test] + fn result_gated_explicit_30174_can_match() { + let f = Filter::new().kind(nostr::Kind::Custom(KIND_AGENT_ENGRAM as u16)); + assert!(filter_can_match_result_gated_kinds(&f)); + } + #[test] fn result_gated_kind_9_only_cannot_match() { let f = Filter::new().kind(nostr::Kind::TextNote);