Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

48 changes: 43 additions & 5 deletions crates/buzz-relay/src/handlers/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,8 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ
event.pubkey.to_bytes().to_vec()
}

/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author.
/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author,
/// or the actor must be the owning human of the agent that authored the target message.
async fn validate_edit_ownership(
community_id: CommunityId,
event: &Event,
Expand Down Expand Up @@ -723,8 +724,36 @@ async fn validate_edit_ownership(

let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key());
let actor = event.pubkey.to_bytes().to_vec();
if author != actor {
return Err("must be event author to edit".to_string());
if author == actor {
// Author editing their own message: re-gate on membership/open visibility so that
// a removed private-channel member cannot mutate old messages after access is revoked.
if let Some(ch_id) = target_event.channel_id {
let is_member = state
.is_member_cached(community_id, ch_id, &actor)
.await
.map_err(|e| format!("db error checking membership: {e}"))?;
if !is_member {
let is_open = state
.db
.get_channel(community_id, ch_id)
.await
.map(|ch| ch.visibility == "open")
.unwrap_or(false);
if !is_open {
return Err("restricted: not a channel member".to_string());
}
}
}
} else {
// Allow the owning human to edit messages authored by their agent.
let is_owner = state
.db
.is_agent_owner(community_id, &author, &actor)
.await
.map_err(|e| format!("db error checking agent ownership: {e}"))?;
if !is_owner {
return Err("must be event author to edit".to_string());
}
}
Ok(())
}
Expand Down Expand Up @@ -1379,8 +1408,17 @@ async fn ingest_event_inner(
if let Some(ch_id) = channel_id {
// kind:9021 (join) doesn't require prior membership.
// kind:9007 (create) — channel doesn't exist yet; creator becomes owner in step 16.
let skip_membership =
kind_u32 == KIND_NIP29_JOIN_REQUEST || kind_u32 == KIND_NIP29_CREATE_GROUP;
// kind:40003/9002/9005/9008 — per-kind validators are the authority; they
// individually enforce authorization and fail closed. Bypassing the generic
// member/open gate here lets the owning human act on private agent channels
// without being a member (OQ1 decision; see validate_edit_ownership /
// validate_admin_event for per-kind enforcement).
let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST
|| kind_u32 == KIND_NIP29_CREATE_GROUP
|| kind_u32 == KIND_STREAM_MESSAGE_EDIT
|| kind_u32 == KIND_NIP29_EDIT_METADATA
|| kind_u32 == KIND_NIP29_DELETE_EVENT
|| kind_u32 == KIND_NIP29_DELETE_GROUP;
if !skip_membership {
// Spec AuthCheck (line 794): emit the verdict at the actual
// call site. claimed_community comes from the event's h tag
Expand Down
99 changes: 89 additions & 10 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,30 @@ pub async fn validate_standard_deletion_event(
Ok(())
}

/// Returns `true` if `actor_bytes` is the NIP-OA owner of **any** active owner-role
/// member in `members`. Used by kind:9002 and kind:9008 to authorize the owning
/// human of a channel's agent-owner(s) even when the human is not a channel member.
///
/// Checking all active owners (not just the first) is correct under co-ownership:
/// an agent may be promoted to owner later, or multiple agents may co-own a channel.
async fn actor_owns_any_owner_agent(
state: &Arc<AppState>,
community_id: buzz_core::CommunityId,
members: &[buzz_db::channel::MemberRecord],
actor_bytes: &[u8],
) -> anyhow::Result<bool> {
for owner in members.iter().filter(|m| m.role == "owner") {
if state
.db
.is_agent_owner(community_id, &owner.pubkey, actor_bytes)
.await?
{
return Ok(true);
}
}
Ok(false)
}

/// Validate an admin kind event BEFORE storage.
pub async fn validate_admin_event(
tenant: &TenantContext,
Expand Down Expand Up @@ -459,9 +483,24 @@ pub async fn validate_admin_event(
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
_ => Err(anyhow::anyhow!(
"actor not authorized for name/about/archived/visibility/ttl changes"
)),
_ => {
// Allow the owning human of any active owner-role agent in the
// channel, even when the human is not a channel member —
// diverges from kind:9001 intentionally.
if actor_owns_any_owner_agent(
state,
tenant.community(),
&members,
&actor_bytes,
)
.await?
{
return Ok(());
}
Err(anyhow::anyhow!(
"actor not authorized for name/about/archived/visibility/ttl changes"
))
}
}
} else {
// topic/purpose: any member
Expand Down Expand Up @@ -516,26 +555,66 @@ pub async fn validate_admin_event(
let author =
effective_message_author(&target_event.event, &state.relay_keypair.public_key());
if author == actor_bytes {
return Ok(()); // Author can always delete their own messages
// Author deleting their own message: re-gate on membership/open visibility so that
// a removed private-channel member cannot mutate old messages after access is revoked.
let is_member = state
.is_member_cached(tenant.community(), channel_id, &actor_bytes)
.await?;
if is_member {
return Ok(());
}
let is_open = state
.db
.get_channel(tenant.community(), channel_id)
.await
.map(|ch| ch.visibility == "open")
.unwrap_or(false);
if is_open {
return Ok(());
}
// Not a member and channel is private — fall through to owner/admin/owner-of-agent check.
}

// Not the author — must be owner/admin.
// Not the author, or author who is no longer a member of a private channel —
// must be owner/admin or the owning human of the message's agent-author.
let members = state.db.get_members(tenant.community(), channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
_ => Err(anyhow::anyhow!(
"must be event author or channel owner/admin"
)),
_ => {
// Allow the owning human of the agent that authored the target message,
// even when the human is not a channel member.
if state
.db
.is_agent_owner(tenant.community(), &author, &actor_bytes)
.await?
{
Ok(())
} else {
Err(anyhow::anyhow!(
"must be event author or channel owner/admin"
))
}
}
}
}
9008 => {
// DELETE_GROUP: owner only
// DELETE_GROUP: owner only, or the owning human of the channel's agent-owner.
let members = state.db.get_members(tenant.community(), channel_id).await?;
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
match actor_member {
Some(m) if m.role == "owner" => Ok(()),
_ => Err(anyhow::anyhow!("only owner can delete group")),
_ => {
// Allow the owning human of any active owner-role agent in the
// channel, even when the human is not a channel member —
// diverges from kind:9001 intentionally.
if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes)
.await?
{
return Ok(());
}
Err(anyhow::anyhow!("only owner can delete group"))
}
}
}
9022 => {
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-test-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ rand = { workspace = true }
sha2 = { workspace = true }
chrono = { workspace = true }
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
buzz-sdk = { workspace = true }

[[bin]]
name = "buzz-test-cli"
Expand Down
12 changes: 12 additions & 0 deletions crates/buzz-test-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,18 @@ impl BuzzTestClient {
Ok(())
}

/// Performs NIP-42 authentication with a NIP-OA `auth` tag, establishing
/// the agent→owner relationship in the relay's DB. Call this as the
/// *agent* connection; the tag must be signed by `owner_keys`.
pub async fn authenticate_with_nip_oa(
&mut self,
agent_keys: &Keys,
auth_tag: &Tag,
) -> Result<(), TestClientError> {
self.inner.authenticate(agent_keys, Some(auth_tag)).await?;
Ok(())
}

/// Sends a signed event to the relay and waits for the OK response.
pub async fn send_event(&mut self, event: Event) -> Result<OkResponse, TestClientError> {
Ok(self.inner.send_event(event).await?)
Expand Down
Loading
Loading