diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 65c9dd6203..30a8c4e2fc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -114,6 +114,108 @@ fn emit_runtime_lifecycle( } } +#[cfg(test)] +mod workflow_authority_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn workflow_event(signer: &Keys, owner: Option<&str>, marker: bool) -> nostr::Event { + let mut tags = Vec::new(); + if marker { + tags.push(Tag::parse(["buzz:workflow", "true"]).expect("workflow marker")); + } + if let Some(owner) = owner { + tags.push(Tag::parse(["workflow-owner", owner]).expect("workflow-owner tag")); + } + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(signer) + .expect("signed event") + } + + #[test] + fn trusted_relay_workflow_uses_workflow_owner_tag() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event(&relay, Some(&owner), true); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + owner + ); + } + + #[test] + fn p_tag_alone_cannot_grant_workflow_authority() { + let relay = Keys::generate(); + let mentioned = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags([ + Tag::parse(["p", mentioned.as_str()]).expect("p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + relay.public_key().to_hex(), + "mention p tags must not become the author-gate principal" + ); + } + + #[test] + fn workflow_tag_from_non_relay_cannot_forge_owner() { + let relay = Keys::generate(); + let attacker = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event(&attacker, Some(&owner), true); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + attacker.public_key().to_hex() + ); + } + + #[test] + fn relay_signed_non_workflow_keeps_raw_author() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event(&relay, Some(&owner), false); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + relay.public_key().to_hex() + ); + } + + #[test] + fn missing_or_ambiguous_workflow_owner_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + + let missing = workflow_event(&relay, None, true); + assert_eq!( + effective_prompt_author(&missing, Some(&relay_hex)), + relay_hex + ); + + let duplicate = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled") + .tags([ + Tag::parse(["buzz:workflow", "true"]).expect("marker"), + Tag::parse(["workflow-owner", owner.as_str()]).expect("owner"), + Tag::parse(["workflow-owner", owner.as_str()]).expect("owner duplicate"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&duplicate, Some(&relay_hex)), + relay_hex + ); + } +} + /// Resolve the agent's owner pubkey at startup. /// /// Priority: @@ -257,6 +359,54 @@ async fn author_allowed( } } +/// Return the workflow principal asserted by a trusted relay-generated event. +/// +/// Authority requires all of: kind:9, the configured endpoint's NIP-11 `self` +/// as cryptographic author, exactly one `buzz:workflow=true` marker, and exactly +/// one valid `workflow-owner` pubkey. `p` tags are deliberately ignored here: +/// they route mentions and cannot grant authority. +fn trusted_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Option { + let relay_self = relay_self?; + if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE + || !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self) + || event.verify().is_err() + { + return None; + } + + let workflow_markers: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow")) + .collect(); + if workflow_markers.len() != 1 || workflow_markers[0].as_slice() != ["buzz:workflow", "true"] { + return None; + } + + let owner_tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("workflow-owner")) + .collect(); + if owner_tags.len() != 1 || owner_tags[0].as_slice().len() != 2 { + return None; + } + let owner = owner_tags[0].as_slice()[1].to_ascii_lowercase(); + PublicKey::from_hex(&owner) + .ok() + .filter(|_| owner.len() == 64 && owner.chars().all(|c| c.is_ascii_hexdigit())) + .map(|pubkey| pubkey.to_hex()) +} + +/// Resolve the author principal used by the inbound author gate. +/// +/// For trusted relay-signed workflow messages this is the dedicated +/// `workflow-owner` authority tag. Everything else keeps the raw signer so a +/// forged mention `p` tag cannot bypass the gate. +pub(crate) fn effective_prompt_author(event: &nostr::Event, relay_self: Option<&str>) -> String { + trusted_workflow_owner(event, relay_self).unwrap_or_else(|| event.pubkey.to_hex()) +} + /// Resolve whether `channel_id` is a DM, for the inbound author gate. /// /// Resolution order: @@ -1637,6 +1787,26 @@ async fn tokio_main() -> Result<()> { tracing::info!("connected to relay at {}", config.relay_url); + let relay_self = match relay.rest_client().relay_self().await { + Ok(Some(pubkey)) => { + tracing::debug!(relay_self = %pubkey, "resolved relay NIP-11 identity"); + Some(pubkey) + } + Ok(None) => { + tracing::warn!( + "relay NIP-11 document has no valid self pubkey — relay-authored workflows will remain fail-closed" + ); + None + } + Err(error) => { + tracing::warn!( + %error, + "failed to resolve relay NIP-11 identity — relay-authored workflows will remain fail-closed" + ); + None + } + }; + relay .subscribe_membership_notifications() .await @@ -2445,8 +2615,13 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. + let raw_author = buzz_event.event.pubkey.to_hex(); + let workflow_owner = + trusted_workflow_owner(&buzz_event.event, relay_self.as_deref()); + let author_hex = workflow_owner + .clone() + .unwrap_or_else(|| raw_author.clone()); { - let author = buzz_event.event.pubkey.to_hex(); // DM hardening: resolve channel type (fail-closed // to DM) so allowlist/anyone modes cannot be // exercised by non-owner authors inside DMs. @@ -2455,7 +2630,7 @@ async fn tokio_main() -> Result<()> { let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, - &author, + &author_hex, is_dm, &owner_cache, &ctx.rest_client, @@ -2464,7 +2639,9 @@ async fn tokio_main() -> Result<()> { if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), + raw_author = %raw_author, + effective_author = %author_hex, + workflow_delegated = workflow_owner.is_some(), mode = %config.respond_to, is_dm, "inbound author gate — dropping event" @@ -2483,7 +2660,6 @@ async fn tokio_main() -> Result<()> { }; // Capture author pubkey before queue.push() moves // buzz_event.event (needed for mode gate below). - let author_hex = buzz_event.event.pubkey.to_hex(); let event_id_hex = buzz_event.event.id.to_hex(); // Clone for the non-cancelling steer fork, which // needs the event to render the steer body. The @@ -4751,6 +4927,7 @@ mod owner_cache_tests { #[cfg(test)] mod author_gate_tests { use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; /// A `RestClient` for tests. The author-gate decisions exercised here all /// resolve from the owner pubkey or sibling cache before any HTTP call, so @@ -4778,6 +4955,42 @@ mod author_gate_tests { cache } + #[tokio::test] + async fn owner_only_accepts_trusted_relay_workflow_from_sibling_owner() { + let relay = Keys::generate(); + let human_owner = Keys::generate(); + let workflow_owner = Keys::generate(); + let workflow_owner_hex = workflow_owner.public_key().to_hex(); + let mentioned = Keys::generate().public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + // Mention routing only — must not be treated as authority. + Tag::parse(["p", mentioned.as_str()]).expect("mention p tag"), + Tag::parse(["buzz:workflow", "true"]).expect("workflow tag"), + Tag::parse(["workflow-owner", workflow_owner_hex.as_str()]) + .expect("workflow-owner tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let effective_author = effective_prompt_author(&event, Some(&relay.public_key().to_hex())); + assert_eq!(effective_author, workflow_owner_hex); + let cache = OwnerCache::new(Some(human_owner.public_key().to_hex())); + cache.cache_sibling(workflow_owner_hex, true); + + assert!( + author_allowed( + &RespondTo::OwnerOnly, + &HashSet::new(), + &effective_author, + false, + &cache, + &dummy_rest_client(), + ) + .await, + "a relay-signed workflow with workflow-owner sibling must pass OwnerOnly" + ); + } + #[tokio::test] async fn test_allowlist_accepts_sibling_not_in_allowlist() { let cache = cache_with_sibling(); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 2cbb82411f..70df1749c2 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -262,6 +262,34 @@ fn unix_now_secs() -> u64 { } impl RestClient { + /// Fetch the relay's stable signing pubkey from its NIP-11 information + /// document. + /// + /// The value is used only to recognize relay-authored service events. A + /// missing, malformed, or unavailable document must leave callers in the + /// fail-closed path where the event's raw signer remains authoritative. + pub async fn relay_self(&self) -> Result, RelayError> { + let response = self + .http + .get(&self.base_url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|e| RelayError::Http(format!("NIP-11 request failed: {e}")))?; + + if !response.status().is_success() { + return Ok(None); + } + + let document: Value = response + .json() + .await + .map_err(|e| RelayError::Http(format!("invalid NIP-11 document: {e}")))?; + Ok(normalize_relay_self( + document.get("self").and_then(Value::as_str), + )) + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the @@ -439,6 +467,11 @@ impl RestClient { } } +fn normalize_relay_self(value: Option<&str>) -> Option { + let value = value?.to_ascii_lowercase(); + (value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value) +} + /// Events the harness cares about. #[derive(Debug, Clone)] pub struct BuzzEvent { @@ -4011,6 +4044,19 @@ async fn wait_for_any_ok( mod tests { use super::*; + #[test] + fn normalize_relay_self_accepts_and_lowercases_hex_pubkey() { + let upper = "AB".repeat(32); + assert_eq!(normalize_relay_self(Some(&upper)), Some("ab".repeat(32))); + } + + #[test] + fn normalize_relay_self_rejects_missing_or_malformed_values() { + assert_eq!(normalize_relay_self(None), None); + assert_eq!(normalize_relay_self(Some("ab")), None); + assert_eq!(normalize_relay_self(Some(&"zz".repeat(32))), None); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..5baa4fb853 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -342,6 +342,23 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::info!("setup-mode: connected and subscribed to membership notifications"); + let relay_self = match relay.rest_client().relay_self().await { + Ok(Some(pubkey)) => Some(pubkey), + Ok(None) => { + tracing::warn!( + "setup-mode: relay NIP-11 document has no valid self pubkey — relay-authored workflows will remain fail-closed" + ); + None + } + Err(error) => { + tracing::warn!( + %error, + "setup-mode: failed to resolve relay NIP-11 identity — relay-authored workflows will remain fail-closed" + ); + None + } + }; + // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); let owner_cache = crate::OwnerCache::new(startup_owner); @@ -428,7 +445,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_hex = buzz_event.event.pubkey.to_hex(); + let author_hex = crate::effective_prompt_author(&buzz_event.event, relay_self.as_deref()); let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; let allowed = author_allowed( &config.respond_to, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..18ee7f60cb 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -252,12 +252,19 @@ impl ActionSink for RelayActionSink { // 3. Build kind:9 Nostr event // - Signed by relay keypair (event.pubkey = relay pubkey) - // - `p` tag attributes the message to the workflow owner + // - `workflow-owner` is the sole authorization principal for ACP + // (owner-only / allowlist gates). `p` tags stay attribution and + // mention/wake routing only and never grant authority. // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) let mut tags = vec![ + Tag::parse(["workflow-owner", &author_pubkey_hex]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow-owner tag: {e}")) + })?, + // Keep owner `p` for legacy attribution/compat until clients stop + // relying on it; ACP must not treat it as authority. Tag::parse(["p", &author_pubkey_hex]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, Tag::parse(["h", &channel_id_canonical])