Skip to content
Closed
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
75 changes: 0 additions & 75 deletions crates/buzz-relay/src/api/agents.rs

This file was deleted.

1 change: 0 additions & 1 deletion crates/buzz-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge.

pub mod agents;
pub mod bridge;
pub mod events;
pub mod git;
Expand Down
244 changes: 226 additions & 18 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,42 @@ struct AgentObserverRoute {
direction: AgentObserverDirection,
}

/// Resolve the verified owner of `agent` from its live `kind:0` NIP-OA proof.
///
/// Reads the agent's latest global `kind:0` profile, extracts the single
/// well-formed `auth` tag, and cryptographically verifies it. Returns the
/// attested owner pubkey on success, or `None` when the agent has no live
/// profile, the profile carries no/invalid `auth` tag, or the author does not
/// match the agent. This is the kind:0 authority the desktop UI gates on; the
/// relay consults it so delivery and visibility agree.
async fn resolve_live_kind0_owner(state: &Arc<AppState>, agent: &PublicKey) -> Option<PublicKey> {
use buzz_core::kind::KIND_PROFILE;
use buzz_db::EventQuery;

let profile = state
.db
.query_events(&EventQuery {
kinds: Some(vec![KIND_PROFILE as i32]),
authors: Some(vec![agent.to_bytes().to_vec()]),
limit: Some(1),
global_only: true,
..Default::default()
})
.await
.ok()?
.into_iter()
.next()?;

// Defensive: the query filters by author, but never trust ownership of a
// frame to a profile whose signer doesn't match the agent.
if profile.event.pubkey != *agent {
return None;
}

let auth_tag = buzz_sdk::nip_oa::extract_single_auth_tag_json(&profile.event).ok()?;
buzz_sdk::nip_oa::verify_auth_tag(&auth_tag, agent).ok()
}

/// Handle encrypted agent observer frames (kind 24200).
///
/// These frames bypass storage and are routed as global ephemeral events. The
Expand Down Expand Up @@ -712,29 +748,39 @@ async fn handle_agent_observer_event(
let agent_bytes = route.agent.to_bytes().to_vec();
let owner_bytes = route.owner.to_bytes().to_vec();
let cache_key = (agent_bytes.clone(), owner_bytes.clone());
// Authority order: session NIP-OA fast path → live kind:0 proof → DB cache
// fallback. kind:0 is the single source of truth the desktop UI also gates
// on; the relay defers to it so delivery and visibility agree. The DB column
// (written from NIP-42 AUTH at handlers/auth.rs) is only consulted when the
// agent has no live kind:0 auth tag (BYO/CLI agents that AUTH without
// publishing a profile). When kind:0 *is* present, the DB must not be able to
// contradict it — a kind:0 owner mismatch denies rather than falling through.
let is_owner = if session_owner_match {
true
} else {
match state.observer_owner_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
match result {
Ok(v) => {
state.observer_owner_cache.insert(cache_key, v);
v
}
Err(e) => {
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
conn.send(RelayMessage::ok(
event_id_hex,
false,
"error: internal server error",
));
return;
match resolve_live_kind0_owner(&state, &route.agent).await {
Some(kind0_owner) => kind0_owner == route.owner,
None => match state.observer_owner_cache.get(&cache_key) {
Some(cached) => cached,
None => {
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
match result {
Ok(v) => {
state.observer_owner_cache.insert(cache_key, v);
v
}
Err(e) => {
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
conn.send(RelayMessage::ok(
event_id_hex,
false,
"error: internal server error",
));
return;
}
}
}
}
},
}
};
if !is_owner {
Expand Down Expand Up @@ -1001,6 +1047,168 @@ mod tests {
assert!(err.contains("NIP-44"));
}

/// Tests for `resolve_live_kind0_owner`, the kind:0 authority the relay's
/// observer-frame delivery gate consults before falling back to the DB
/// cache. These mirror the DB-backed harness in `handlers::identity_archive`
/// and no-op gracefully when no Postgres is reachable.
mod live_kind0_owner {
use std::sync::Arc;

use nostr::{Event, EventBuilder, Keys, Kind, Tag};

use crate::handlers::event::resolve_live_kind0_owner;
use crate::state::AppState;

async fn test_pool() -> Option<sqlx::PgPool> {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".into());
sqlx::PgPool::connect(&url).await.ok()
}

async fn test_state(pool: sqlx::PgPool) -> Option<Arc<AppState>> {
let db = buzz_db::Db::from_pool(pool.clone());
let config = crate::config::Config::from_env().ok()?;
let redis_pool = deadpool_redis::Config::from_url(&config.redis_url)
.create_pool(Some(deadpool_redis::Runtime::Tokio1))
.ok()?;
let pubsub = Arc::new(
buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone())
.await
.ok()?,
);
let audit = buzz_audit::AuditService::new(pool);
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(buzz_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
});
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
));
let media_storage = buzz_media::MediaStorage::new(&config.media).ok()?;
let (state, _audit_shutdown) = crate::state::AppState::new(
config,
db,
redis_pool,
audit,
pubsub,
auth,
search,
workflow_engine,
Keys::generate(),
media_storage,
);
Some(Arc::new(state))
}

fn auth_tag(owner_keys: &Keys, agent_pubkey: &nostr::PublicKey) -> Tag {
let tag_json = buzz_sdk::nip_oa::compute_auth_tag(owner_keys, agent_pubkey, "")
.expect("compute auth tag");
buzz_sdk::nip_oa::parse_auth_tag(&tag_json).expect("parse auth tag")
}

fn profile_event(agent_keys: &Keys, auth_tag: Tag, created_at: u64) -> Event {
EventBuilder::new(Kind::Metadata, "{}")
.tags([auth_tag])
.custom_created_at(nostr::Timestamp::from(created_at))
.sign_with_keys(agent_keys)
.expect("sign profile")
}

/// A live kind:0 carrying a valid auth tag yields the attested owner.
#[tokio::test]
async fn resolves_owner_from_live_kind0_auth_tag() {
let Some(pool) = test_pool().await else {
return;
};
let Some(state) = test_state(pool).await else {
return;
};

let agent_keys = Keys::generate();
let owner_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
let now = nostr::Timestamp::now().as_secs();

let profile = profile_event(&agent_keys, auth_tag(&owner_keys, &agent_pubkey), now);
state
.db
.replace_addressable_event(&profile, None)
.await
.expect("insert agent kind:0");

let resolved = resolve_live_kind0_owner(&state, &agent_pubkey).await;
assert_eq!(
resolved,
Some(owner_keys.public_key()),
"live kind:0 auth tag must resolve to the attested owner"
);
}

/// When the live kind:0 attests a *different* owner, the resolver returns
/// that owner — the gate then denies (kind:0 mismatch) rather than
/// consulting the DB, so a stale DB row cannot contradict kind:0.
#[tokio::test]
async fn resolves_updated_owner_after_kind0_flip() {
let Some(pool) = test_pool().await else {
return;
};
let Some(state) = test_state(pool).await else {
return;
};

let agent_keys = Keys::generate();
let owner_keys = Keys::generate();
let new_owner_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
let now = nostr::Timestamp::now().as_secs();

let profile = profile_event(&agent_keys, auth_tag(&owner_keys, &agent_pubkey), now);
state
.db
.replace_addressable_event(&profile, None)
.await
.expect("insert agent kind:0");

let flipped =
profile_event(&agent_keys, auth_tag(&new_owner_keys, &agent_pubkey), now + 1);
state
.db
.replace_addressable_event(&flipped, None)
.await
.expect("replace agent kind:0");

let resolved = resolve_live_kind0_owner(&state, &agent_pubkey).await;
assert_eq!(
resolved,
Some(new_owner_keys.public_key()),
"resolver must reflect the latest kind:0 owner, not the original"
);
}

/// An agent with no published kind:0 (BYO/CLI agents that AUTH only)
/// yields None, so the gate falls back to the DB cache.
#[tokio::test]
async fn returns_none_when_agent_has_no_live_kind0() {
let Some(pool) = test_pool().await else {
return;
};
let Some(state) = test_state(pool).await else {
return;
};

// A freshly generated agent with no profile stored.
let agent_keys = Keys::generate();
let resolved = resolve_live_kind0_owner(&state, &agent_keys.public_key()).await;
assert_eq!(
resolved, None,
"an agent with no live kind:0 must yield None so the gate uses the DB fallback"
);
}
}

mod fanout_access {
use std::collections::HashMap;
use std::sync::atomic::AtomicU8;
Expand Down
26 changes: 4 additions & 22 deletions crates/buzz-relay/src/handlers/identity_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,8 @@ async fn verify_owner_consent(
target_hex: &str,
actor_hex: &str,
) -> Result<(), String> {
let request_auth = extract_single_auth_tag_json(event)?;
let request_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(event)
.map_err(|e| e.to_string())?;
let request_owner = verify_auth_tag_owner(&request_auth, target_hex)
.map_err(|e| format!("invalid request auth tag: {e}"))?;
if request_owner != actor_hex {
Expand Down Expand Up @@ -278,7 +279,8 @@ async fn verify_owner_consent(
return Err("live kind:0 author did not match target".to_string());
}

let live_auth = extract_single_auth_tag_json(&profile.event)?;
let live_auth = buzz_sdk::nip_oa::extract_single_auth_tag_json(&profile.event)
.map_err(|e| e.to_string())?;
let live_owner = verify_auth_tag_owner(&live_auth, target_hex)
.map_err(|e| format!("invalid live kind:0 auth tag: {e}"))?;
if live_owner != actor_hex {
Expand All @@ -288,26 +290,6 @@ async fn verify_owner_consent(
Ok(())
}

fn extract_single_auth_tag_json(event: &Event) -> Result<String, String> {
let mut found: Option<Vec<String>> = None;
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(|s| s.as_str()) != Some("auth") {
continue;
}
if parts.len() != 4 {
return Err("auth tag must have exactly four elements".to_string());
}
if found.is_some() {
return Err("multiple auth tags".to_string());
}
found = Some(parts.iter().map(|s| s.to_string()).collect());
}

let parts = found.ok_or_else(|| "missing auth tag".to_string())?;
serde_json::to_string(&parts).map_err(|e| format!("failed to encode auth tag: {e}"))
}

fn verify_auth_tag_owner(auth_tag_json: &str, target_hex: &str) -> Result<String, String> {
let target_pubkey =
PublicKey::from_hex(target_hex).map_err(|e| format!("invalid target pubkey: {e}"))?;
Expand Down
Loading
Loading