mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
fix(relay): protect fenced memory read metadata
Co-authored-by: Codex <codex@openai.com> Signed-off-by: jm <jm@squareup.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,14 +161,17 @@ pub fn k_tag_kinds(event: &nostr::Event) -> impl Iterator<Item = u32> + '_ {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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<crate::state::AppState>,
|
||||
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<crate::state::AppState>,
|
||||
tenant: TenantContext,
|
||||
reader: &Keys,
|
||||
delegated_owner: Option<nostr::PublicKey>,
|
||||
filter: nostr::Filter,
|
||||
count: bool,
|
||||
) -> Vec<String> {
|
||||
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<nostr::Event> = serde_json::from_slice(&bytes).expect("query events");
|
||||
assert_eq!(
|
||||
events.iter().map(|event| event.id).collect::<Vec<_>>(),
|
||||
[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<nostr::Event> = serde_json::from_slice(&bytes).expect("legacy query");
|
||||
assert_eq!(
|
||||
events.iter().map(|event| event.id).collect::<Vec<_>>(),
|
||||
[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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user