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
2 changes: 2 additions & 0 deletions apps/buzz-gpui/Cargo.lock

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

53 changes: 51 additions & 2 deletions apps/buzz-gpui/crates/identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,8 @@ pub const PLUGIN_RELEASE: u16 = 1064;
/// Kinds the app writes as ordinary content.
const CONTENT_KINDS: &[u16] = &[
0, 5, 7, 9, 1984, 9000, 9001, 9002, 9007, 9008, 9021, 9022, 9030, 9031, 9032, 9035, 9036,
28936, 30030, 30175, 30177, 30178, 30315, 30620, 40003, 40100, 41010, 41012, 42000, 45010,
46020,
28936, 30030, 30175, 30177, 30178, 30180, 30315, 30620, 40003, 40100, 41010, 41012, 42000,
45010, 46020,
];

impl Identity {
Expand Down Expand Up @@ -266,6 +266,9 @@ impl Identity {
content: String,
) -> Result<UnsignedEvent, IdentityError> {
check_kind(purpose, kind)?;
if kind == 30180 {
check_channel_agent_grant(&self.pubkey_hex(), &tags, &content)?;
}
if kind == AGENT_PRESENCE {
check_agent_presence(&tags, &content)?;
}
Expand All @@ -290,6 +293,14 @@ impl Identity {
/// Signs an event built by [`Self::template`], keeping its ID.
pub fn sign(&self, purpose: Purpose, unsigned: UnsignedEvent) -> Result<Event, IdentityError> {
check_kind(purpose, unsigned.kind.as_u16())?;
if unsigned.kind.as_u16() == 30180 {
let tags = unsigned
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect::<Vec<_>>();
check_channel_agent_grant(&self.pubkey_hex(), &tags, &unsigned.content)?;
}
if unsigned.kind.as_u16() == AGENT_PRESENCE {
let tags: Vec<Vec<String>> = unsigned
.tags
Expand Down Expand Up @@ -654,6 +665,44 @@ fn check_kind(purpose: Purpose, kind: u16) -> Result<(), IdentityError> {
}
}

/// Grant signing never accepts credentials, an ambiguous scope, or a self grant.
fn check_channel_agent_grant(
viewer: &str,
tags: &[Vec<String>],
content: &str,
) -> Result<(), IdentityError> {
let value: serde_json::Value =
serde_json::from_str(content).map_err(|_| IdentityError::NotAllowed)?;
let object = value.as_object().ok_or(IdentityError::NotAllowed)?;
let agent = value["agent"].as_str().ok_or(IdentityError::NotAllowed)?;
let channel = value["channel"]
.as_str()
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.ok_or(IdentityError::NotAllowed)?;
if object.len() != 4
|| value["version"] != 1
|| value["enabled"].as_bool().is_none()
|| !PublicKey::from_hex(agent).is_ok_and(|key| key.to_hex() == agent)
|| agent == viewer
{
return Err(IdentityError::NotAllowed);
}
for (name, expected) in [
("d", format!("{agent}:{channel}")),
("h", channel.to_string()),
("p", agent.to_owned()),
] {
let matches = tags
.iter()
.filter(|tag| tag.first().map(String::as_str) == Some(name))
.collect::<Vec<_>>();
if matches.len() != 1 || matches[0].as_slice() != [name, &expected] {
return Err(IdentityError::NotAllowed);
}
}
Ok(())
}

/// Owner presence for the viewer's agents.
pub const AGENT_PRESENCE: u16 = 20003;

Expand Down
2 changes: 2 additions & 0 deletions apps/buzz-gpui/crates/relay/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ url = "2"
uuid = { version = "1", features = ["v4"] }

[dev-dependencies]
buzz-sdk = { path = "../../../../crates/buzz-sdk" }
nostr-wire = { package = "nostr", version = "0.44" }
crc32fast = "1"
tempfile = "3"
# Test builds also trust `SSL_CERT_FILE`, for the test relay's own CA.
Expand Down
159 changes: 159 additions & 0 deletions apps/buzz-gpui/crates/relay/src/agent_sharing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//! Owner opt-in for shared channel agents (kind 30180).

#[cfg(test)]
mod tests;

use crate::{Verified, event::kinds, messages::Draft, profiles};
use nostr::prelude::EventId;
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// One agent the viewer may share from channel settings.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ChannelAgent {
/// Agent identity whose owner attestation was verified in this read.
pub pubkey: String,
/// The agent's current profile name.
pub name: String,
/// The confirmed owner permission, rather than an optimistic local value.
pub enabled: bool,
/// Why sharing cannot be enabled; does not block revocation.
pub enable_reason: Option<String>,
/// A journaled operation that still needs delivery or confirmation.
pub pending: Option<PendingGrant>,
}

/// Durable delivery state retained when the settings panel closes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PendingGrant {
/// The exact journaled event, reused on retry.
pub id: EventId,
/// The requested permission value.
pub enabled: bool,
/// Delivery failure or uncertainty retained by the outbox.
pub error: Option<String>,
/// True only for proven non-delivery, which permits discarding this event.
pub failed: bool,
}

/// A verified, fresh channel settings read.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ChannelAgents {
/// Verified owned remote agents and owned agents with existing grants.
pub agents: Vec<ChannelAgent>,
}

#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Grant {
pub version: u32,
pub agent: String,
pub channel: Uuid,
pub enabled: bool,
}

impl Grant {
pub(crate) fn parse(event: &Verified) -> Result<Self, String> {
let grant: Self =
serde_json::from_str(&event.content).map_err(|_| "Invalid sharing permission")?;
if event.kind_u16() != kinds::AGENT_CHANNEL_GRANT
|| grant.version != 1
|| !crate::event::is_hex64(&grant.agent)
|| grant.agent == event.pubkey_hex()
{
return Err("Invalid sharing permission".into());
}
for (name, expected) in [
("d", format!("{}:{}", grant.agent, grant.channel)),
("h", grant.channel.to_string()),
("p", grant.agent.clone()),
] {
let tags = event.tags_named(name).collect::<Vec<_>>();
if tags.len() != 1 || tags[0] != [name, &expected] {
return Err("Ambiguous sharing permission".into());
}
}
Ok(grant)
}

pub(crate) fn draft(agent: &str, channel: &str, enabled: bool) -> Result<Draft, String> {
let channel = Uuid::parse_str(channel).map_err(|_| "Invalid channel")?;
if !crate::event::is_hex64(agent) {
return Err("Invalid agent".into());
}
let grant = Self {
version: 1,
agent: agent.into(),
channel,
enabled,
};
Ok(Draft {
kind: kinds::AGENT_CHANNEL_GRANT,
tags: vec![
vec!["d".into(), format!("{agent}:{channel}")],
vec!["h".into(), channel.to_string()],
vec!["p".into(), agent.into()],
],
content: serde_json::to_string(&grant).map_err(|error| error.to_string())?,
})
}
}

/// Match the relay's current NIP-OA authentication-time ownership check.
pub(crate) fn owner(profile: &Verified, now: u64) -> Option<String> {
if profile.kind_u16() != kinds::PROFILE {
return None;
}
let tags = profile
.tags_named("auth")
.map(<[String]>::to_vec)
.collect::<Vec<_>>();
profiles::attested_owner_of(&profile.pubkey_hex(), 22242, now, &tags)
}

pub(crate) fn recovery_key(channel: &str, agent: &str) -> String {
format!("agent-share:{channel}:{agent}")
}

/// Current runtime selection, never a filtered query that can expose an old head.
pub(crate) fn remote_listing(event: &Verified, viewer: &str) -> Option<String> {
if event.kind_u16() != kinds::MANAGED_AGENT || event.pubkey_hex() != viewer {
return None;
}
let ids = event.tags_named("d").collect::<Vec<_>>();
let runtimes = event.tags_named("runtime").collect::<Vec<_>>();
if ids.len() != 1
|| ids[0].len() != 2
|| !crate::event::is_hex64(&ids[0][1])
|| runtimes.len() != 1
|| runtimes[0] != ["runtime", "voltage.remote-workspace/sandbox"]
{
return None;
}
Some(ids[0][1].clone())
}

pub(crate) fn remote_agents(
events: &[Verified],
viewer: &str,
) -> std::collections::BTreeSet<String> {
let mut heads: std::collections::BTreeMap<&str, &Verified> = std::collections::BTreeMap::new();
for event in events
.iter()
.filter(|e| e.kind_u16() == kinds::MANAGED_AGENT && e.pubkey_hex() == viewer)
{
let Some(id) = event.tag("d") else {
continue;
};
if heads
.get(id)
.is_none_or(|old| crate::event::is_newer(event, old))
{
heads.insert(id, event);
}
}
heads
.values()
.filter_map(|event| remote_listing(event, viewer))
.collect()
}
Loading
Loading