diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index befb7aa6a..c8fd8dfcb 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -70,8 +70,8 @@ pub enum MultipleEventHandling { /// the new events as a **steering message** — one that arrived while the /// agent was working, to be woven into the in-progress task rather than /// treated as a replacement. Fires for any author the inbound author gate - /// admits (owner ∪ allowlist ∪ siblings). This is the default mid-turn - /// delivery path. Requires DedupMode::Queue. + /// admits under the configured `respond-to` mode. This is the default + /// mid-turn delivery path. Requires DedupMode::Queue. Steer, /// Cancel the in-flight turn and re-dispatch a merged prompt combining /// the original events with the new ones, framed as a **supersede** (the diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 808a34a79..f5e49091d 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -118,175 +118,45 @@ fn resolve_agent_owner(config: &Config) -> Option { /// Cache for the agent's owner pubkey. /// -/// Owner is now provided via `--agent-owner` config flag (no REST lookup). -/// Cache for the agent's owner pubkey + sibling lookups. -/// -/// Siblings are other agents whose NIP-OA auth tag proves the same owner. -/// Lookup results are cached for the process lifetime (attestations are immutable). +/// Owner is provided via `--agent-owner` config or the harness's own verified +/// NIP-OA credential. Other agents are never inferred from profile metadata; +/// they must be named explicitly in `respond-to-allowlist`. struct OwnerCache { pubkey: Option, - /// author_hex → is_sibling (true = same owner, false = not) - siblings: std::sync::Mutex>, } impl OwnerCache { fn new(initial: Option) -> Self { - Self { - pubkey: initial, - siblings: std::sync::Mutex::new(HashMap::new()), - } + Self { pubkey: initial } } /// Return the cached owner pubkey. fn get(&self) -> Option<&str> { self.pubkey.as_deref() } - - /// Check if author is a known sibling (cached result). - fn is_known_sibling(&self, author: &str) -> Option { - self.siblings.lock().ok()?.get(author).copied() - } - - /// Cache a sibling lookup result. - fn cache_sibling(&self, author: String, is_sibling: bool) { - if let Ok(mut map) = self.siblings.lock() { - // Cap at 256 entries to prevent unbounded growth. - if map.len() >= 256 { - map.clear(); - } - map.insert(author, is_sibling); - } - } -} - -/// Check if `author` is the owner OR a sibling (same owner via NIP-OA). -/// -/// For unknown authors, queries their kind:0 profile to extract the NIP-OA -/// auth tag and verify the owner matches. Result is cached. -async fn is_owner_or_sibling( - author: &str, - owner_cache: &OwnerCache, - rest_client: &relay::RestClient, -) -> bool { - let my_owner = match owner_cache.get() { - Some(o) => o, - None => return false, // no owner configured — fail closed - }; - - // Direct owner check. - if author == my_owner { - return true; - } - - // Check sibling cache. - if let Some(cached) = owner_cache.is_known_sibling(author) { - return cached; - } - - // Query the author's kind:0 profile to check for NIP-OA auth tag. - let is_sibling = check_sibling_via_profile(author, my_owner, rest_client).await; - owner_cache.cache_sibling(author.to_string(), is_sibling); - is_sibling } /// Inbound author gate decision: does this author's event fire a turn? /// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. -async fn author_allowed( +/// Coarse security policy applied before subscription rules. `OwnerOnly` is a +/// literal pubkey equality check. `Allowlist` accepts the owner plus explicitly +/// configured pubkeys; it does not infer authority from NIP-OA profile tags. +fn author_allowed( respond_to: &RespondTo, allowlist: &HashSet, author: &str, owner_cache: &OwnerCache, - rest_client: &relay::RestClient, ) -> bool { match respond_to { RespondTo::Anyone => true, RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::OwnerOnly => owner_cache.get().is_some_and(|owner| author == owner), RespondTo::Allowlist => { - allowlist.contains(author) - || is_owner_or_sibling(author, owner_cache, rest_client).await + allowlist.contains(author) || owner_cache.get().is_some_and(|owner| author == owner) } } } -/// Query an author's kind:0 profile and check if their NIP-OA auth tag -/// proves the same owner as us. -async fn check_sibling_via_profile( - author: &str, - expected_owner: &str, - rest_client: &relay::RestClient, -) -> bool { - let filter = nostr::Filter::new() - .kind(nostr::Kind::Metadata) - .author(match nostr::PublicKey::from_hex(author) { - Ok(pk) => pk, - Err(_) => return false, - }) - .limit(1); - - let resp = match tokio::time::timeout(Duration::from_millis(2000), rest_client.query(&[filter])) - .await - { - Ok(Ok(v)) => v, - _ => return false, // timeout or error — fail closed - }; - - // Look for an "auth" tag in the profile event. - let events = match resp.as_array() { - Some(arr) => arr, - None => return false, - }; - let event = match events.first() { - Some(e) => e, - None => return false, - }; - let tags = match event.get("tags").and_then(|t| t.as_array()) { - Some(t) => t, - None => return false, - }; - - // Find ["auth", owner_pk, conditions, sig] and verify the Schnorr signature. - // Don't trust the relay — verify ourselves. - let agent_pk = match nostr::PublicKey::from_hex(author) { - Ok(pk) => pk, - Err(_) => return false, - }; - - for tag in tags { - let parts = match tag.as_array() { - Some(p) if p.len() >= 4 => p, - _ => continue, - }; - if parts[0].as_str() != Some("auth") { - continue; - } - let tag_owner = match parts[1].as_str() { - Some(o) => o, - None => continue, - }; - // Only verify if the owner field matches ours. - if !tag_owner.eq_ignore_ascii_case(expected_owner) { - continue; - } - // Cryptographically verify the NIP-OA attestation signature. - let tag_json = serde_json::to_string(tag).unwrap_or_default(); - match buzz_sdk::nip_oa::verify_auth_tag(&tag_json, &agent_pk) { - Ok(_) => { - tracing::debug!(author, expected_owner, "sibling verified via NIP-OA"); - return true; - } - Err(e) => { - tracing::debug!(author, "NIP-OA auth tag verification failed: {e}"); - } - } - } - - false -} - fn spawn_relay_observer_publisher( observer: observer::ObserverHandle, publisher: RelayEventPublisher, @@ -1974,12 +1844,9 @@ async fn tokio_main() -> Result<()> { // agent. Must be AFTER !shutdown (owner can always // shut down regardless of gate mode). // - // Both OwnerOnly and Allowlist accept events from - // "siblings" — pubkeys whose agent_owner_pubkey - // matches this agent's owner (e.g. other bots - // launched by the same human). Allowlist adds the - // explicit pubkey list on top, for external people; - // it never revokes same-owner team bots. + // OwnerOnly is literal: only the configured owner + // pubkey passes. Allowlist adds only explicit + // pubkeys; profile metadata never grants authority. { let author = buzz_event.event.pubkey.to_hex(); let allowed = author_allowed( @@ -1987,9 +1854,7 @@ async fn tokio_main() -> Result<()> { &config.respond_to_allowlist, &author, &owner_cache, - &ctx.rest_client, - ) - .await; + ); if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, @@ -2047,10 +1912,10 @@ async fn tokio_main() -> Result<()> { // the channel has an in-flight task, fire cancel — // OR take the non-cancelling (ACP steer) fork for Steer signals. if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { - // Author eligibility (owner ∪ allowlist ∪ siblings) - // is already enforced by the inbound author gate - // above, so the mid-turn signal fires for every - // event that reaches here. + // Author eligibility for the configured mode is + // already enforced by the inbound author gate, + // so the mid-turn signal fires for every event + // that reaches here. let signal = mode_gate_signal( config.multiple_event_handling, &author_hex, @@ -2490,10 +2355,10 @@ fn is_owner_control_command( /// new, already-author-gated event arrives for that channel. /// /// Returns `None` to leave the in-flight turn untouched (the event waits in the -/// queue and is delivered when the turn completes). Author eligibility — owner -/// ∪ allowlist ∪ siblings — is enforced upstream by the inbound author gate, so +/// queue and is delivered when the turn completes). Author eligibility for the +/// configured mode is enforced upstream by the inbound author gate, so /// `Steer`/`Interrupt` apply to every event that reaches this point; only -/// `OwnerInterrupt` re-checks authorship (owner-only) here. +/// `OwnerInterrupt` re-checks literal owner authorship here. /// /// `owner` is the resolved owner pubkey hex, if known. fn mode_gate_signal( @@ -3905,95 +3770,51 @@ mod owner_cache_tests { mod author_gate_tests { use super::*; - /// A `RestClient` for tests. The author-gate decisions exercised here all - /// resolve from the owner pubkey or sibling cache before any HTTP call, so - /// this client is never actually used to make a request. - fn dummy_rest_client() -> relay::RestClient { - relay::RestClient { - http: reqwest::Client::new(), - base_url: "http://localhost:0".into(), - keys: nostr::Keys::generate(), - auth_tag_json: None, - } - } - const OWNER: &str = "00"; const SIBLING: &str = "11"; const EXTERNAL: &str = "22"; const STRANGER: &str = "33"; - /// Owner + a known sibling, none of them on the explicit allowlist. - fn cache_with_sibling() -> OwnerCache { - let cache = OwnerCache::new(Some(OWNER.into())); - cache.cache_sibling(SIBLING.into(), true); - cache.cache_sibling(STRANGER.into(), false); - cache + fn owner_cache() -> OwnerCache { + OwnerCache::new(Some(OWNER.into())) } - #[tokio::test] - async fn test_allowlist_accepts_sibling_not_in_allowlist() { - let cache = cache_with_sibling(); + #[test] + fn test_allowlist_rejects_unlisted_sibling() { + let cache = owner_cache(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( - &RespondTo::Allowlist, - &allowlist, - SIBLING, - &cache, - &dummy_rest_client() - ) - .await, - "a same-owner sibling must fire a turn under Allowlist even when not listed" + !author_allowed(&RespondTo::Allowlist, &allowlist, SIBLING, &cache), + "a sibling must be named explicitly in the allowlist" ); } - #[tokio::test] - async fn test_allowlist_accepts_explicit_external_pubkey() { - let cache = cache_with_sibling(); + #[test] + fn test_allowlist_accepts_explicit_external_pubkey() { + let cache = owner_cache(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( - &RespondTo::Allowlist, - &allowlist, - EXTERNAL, - &cache, - &dummy_rest_client() - ) - .await, + author_allowed(&RespondTo::Allowlist, &allowlist, EXTERNAL, &cache), "an explicitly allowlisted external pubkey must still be accepted" ); } - #[tokio::test] - async fn test_allowlist_rejects_non_sibling_not_in_allowlist() { - let cache = cache_with_sibling(); + #[test] + fn test_allowlist_rejects_unlisted_stranger() { + let cache = owner_cache(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( - &RespondTo::Allowlist, - &allowlist, - STRANGER, - &cache, - &dummy_rest_client() - ) - .await, - "a non-sibling absent from the allowlist must be dropped" + !author_allowed(&RespondTo::Allowlist, &allowlist, STRANGER, &cache), + "an absent pubkey must be dropped" ); } - #[tokio::test] - async fn test_allowlist_accepts_owner() { - let cache = cache_with_sibling(); + #[test] + fn test_allowlist_accepts_owner() { + let cache = owner_cache(); let allowlist = HashSet::new(); assert!( - author_allowed( - &RespondTo::Allowlist, - &allowlist, - OWNER, - &cache, - &dummy_rest_client() - ) - .await, + author_allowed(&RespondTo::Allowlist, &allowlist, OWNER, &cache), "the owner must always be accepted under Allowlist" ); } @@ -4002,38 +3823,26 @@ mod author_gate_tests { // author must NOT steer" is enforced *here* — author_allowed drops the // event before it reaches the mode gate — not in the gate itself. These // pin that invariant against the default mode. - #[tokio::test] - async fn test_owner_only_rejects_stranger_so_no_steer() { - let cache = cache_with_sibling(); + #[test] + fn test_owner_only_rejects_stranger_so_no_steer() { + let cache = owner_cache(); assert!( - !author_allowed( - &RespondTo::OwnerOnly, - &HashSet::new(), - STRANGER, - &cache, - &dummy_rest_client() - ) - .await, + !author_allowed(&RespondTo::OwnerOnly, &HashSet::new(), STRANGER, &cache), "under the default OwnerOnly, a stranger must be dropped — so it can never reach the mode gate to steer" ); } - #[tokio::test] - async fn test_owner_only_admits_owner_and_sibling_to_steer() { - let cache = cache_with_sibling(); - for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { - assert!( - author_allowed( - &RespondTo::OwnerOnly, - &HashSet::new(), - who, - &cache, - &dummy_rest_client() - ) - .await, - "under default OwnerOnly, the {label} must be admitted so steering can fire" - ); - } + #[test] + fn test_owner_only_admits_owner_but_rejects_sibling() { + let cache = owner_cache(); + assert!( + author_allowed(&RespondTo::OwnerOnly, &HashSet::new(), OWNER, &cache), + "the literal owner must pass OwnerOnly" + ); + assert!( + !author_allowed(&RespondTo::OwnerOnly, &HashSet::new(), SIBLING, &cache), + "a same-owner sibling must not pass OwnerOnly" + ); } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 363786996..1ad7f4ed7 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -2521,9 +2521,8 @@ fn collect_prompt_pubkeys( /// /// Agents carry a NIP-OA `["auth", owner_pk, conditions, sig]` tag in their /// profile; humans do not. This checks for the tag's presence/shape only — a -/// cheap routing heuristic for reply anchoring, not a verified security gate -/// (the signing path in `lib.rs::check_sibling_via_profile` does full -/// verification where it matters). +/// cheap routing heuristic for reply anchoring, never an author-authorization +/// decision. fn profile_event_is_agent(ev: &serde_json::Value) -> bool { ev.get("tags") .and_then(|t| t.as_array()) diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index dda785726..6d913e21a 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -381,7 +381,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } let publisher = relay.event_publisher(); - let rest_client = relay.rest_client(); // Deduplicate by event-id so reconnect replay cannot double-nudge. let mut nudged_event_ids: HashSet = HashSet::new(); @@ -431,9 +430,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> &config.respond_to_allowlist, &author_hex, &owner_cache, - &rest_client, - ) - .await; + ); // Apply channel/kind filter rules. let filter_matched = filter::match_event( diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index f30e24b79..7f72edf95 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -55,17 +55,28 @@ async fn enforce_http_admission( } } +/// Values retained from an already-verified bridge authentication event. +#[derive(Debug)] +pub(crate) struct VerifiedBridgeAuth { + pub(crate) pubkey: nostr::PublicKey, + pub(crate) event_id_bytes: [u8; 32], + pub(crate) signed_created_at: Option, +} + +type BridgeAuthResult = Result)>; + /// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode). /// -/// Returns the authenticated public key and an event ID for replay detection. -/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +/// Returns the authenticated public key, an event ID for replay detection, and +/// the verified signed auth timestamp. For X-Pubkey dev mode, the event ID is +/// a zero hash and the timestamp is absent. pub(crate) fn verify_bridge_auth( headers: &HeaderMap, method: &str, url: &str, body: Option<&[u8]>, require_auth_token: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { verify_bridge_auth_with_options(headers, method, url, body, require_auth_token, false) } @@ -76,7 +87,7 @@ pub(crate) fn verify_bridge_auth_with_options( body: Option<&[u8]>, require_auth_token: bool, require_payload: bool, -) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { +) -> BridgeAuthResult { // Try NIP-98 first (Authorization: Nostr ) if let Some(auth_str) = headers .get("authorization") @@ -111,7 +122,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body) .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; - return Ok((pubkey, event_id_bytes)); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at: Some(event.created_at.as_secs()), + }); } // Dev-mode fallback: X-Pubkey header (only when require_auth_token is false) @@ -120,7 +135,11 @@ pub(crate) fn verify_bridge_auth_with_options( let pubkey = nostr::PublicKey::from_hex(hex_val) .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; // Zero event ID — no replay detection needed for dev mode - return Ok((pubkey, [0u8; 32])); + return Ok(VerifiedBridgeAuth { + pubkey, + event_id_bytes: [0u8; 32], + signed_created_at: None, + }); } } @@ -611,7 +630,11 @@ pub async fn submit_event( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -631,6 +654,7 @@ pub async fn submit_event( tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; @@ -681,7 +705,11 @@ pub async fn query_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -698,6 +726,7 @@ pub async fn query_events( tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; @@ -1065,7 +1094,11 @@ pub async fn count_events( })?; let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); - let (pubkey, event_id_bytes) = verify_bridge_auth( + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = verify_bridge_auth( &headers, "POST", &url, @@ -1082,6 +1115,7 @@ pub async fn count_events( tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; @@ -1718,8 +1752,11 @@ async fn authorize_moderation_read( _ => path.to_string(), }; let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); @@ -2180,7 +2217,7 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) .expect("matching-host NIP-98 event must verify"); assert_eq!( @@ -2229,7 +2266,7 @@ mod tests { Some("limit=20&status=open"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-bearing moderation read must verify against the same query"); assert_eq!(pubkey, keys.public_key()); @@ -2286,7 +2323,7 @@ mod tests { Some("limit=20"), ); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("audit query-bearing read must verify"); assert_eq!(pubkey, keys.public_key()); @@ -2311,7 +2348,7 @@ mod tests { ); assert_eq!(expected_url, "https://host-a.example/moderation/restricted"); - let (pubkey, _event_id_bytes) = + let VerifiedBridgeAuth { pubkey, .. } = verify_bridge_auth(&headers, "GET", &expected_url, None, true) .expect("query-less restricted read must verify against the bare path"); assert_eq!(pubkey, keys.public_key()); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index fcd86f7bd..cded991f2 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -107,6 +107,10 @@ impl axum::extract::FromRequestParts> for GitAuth { .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; let event_json = String::from_utf8(event_bytes) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + let signed_auth_created_at = serde_json::from_str::(&event_json) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid NIP-98 event").into_response())? + .created_at + .as_secs(); // Row zero for Git HTTP: bind the request Host to a server-resolved // tenant before URL verification. We still do not trust forwarded @@ -206,6 +210,7 @@ impl axum::extract::FromRequestParts> for GitAuth { tenant.community(), pubkey.as_bytes(), auth_tag, + Some(signed_auth_created_at), ) .await .is_err() diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6719e85f7..3eefa2e61 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -210,7 +210,11 @@ async fn authenticate( })?; let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, "POST", &url, diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc2..cbf4bb195 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -213,6 +213,7 @@ impl FromRequestParts> for AuthenticatedUpload { tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; @@ -507,6 +508,7 @@ async fn authenticate_media_read( tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 6e83f5534..d821f808a 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -58,11 +58,14 @@ pub mod relay_members { /// /// `community` is the server-resolved tenant of the request; membership is /// scoped to it so admitting a pubkey to community A never admits it to B. + /// A NIP-OA credential is usable only when `signed_auth_created_at` came + /// from the already-verified authentication event carrying that request. pub async fn check_relay_membership( state: &AppState, community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result { if !state.config.require_relay_membership { return Ok(MembershipDecision::OpenRelay); @@ -82,8 +85,16 @@ pub mod relay_members { if let Some(tag_json) = auth_tag_header { let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes) .map_err(|e| format!("invalid agent pubkey for NIP-OA check: {e}"))?; + let Some(auth_created_at) = signed_auth_created_at else { + info!(agent = %pubkey_hex, "NIP-OA auth tag has no verified signed auth timestamp"); + return Ok(MembershipDecision::Denied); + }; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner_pubkey) => { let owner_hex = owner_pubkey.to_hex(); let owner_is_member = state @@ -126,8 +137,17 @@ pub mod relay_members { community: CommunityId, pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { - match check_relay_membership(state, community, pubkey_bytes, auth_tag_header).await { + match check_relay_membership( + state, + community, + pubkey_bytes, + auth_tag_header, + signed_auth_created_at, + ) + .await + { Ok(MembershipDecision::OpenRelay) | Ok(MembershipDecision::Member) => Ok(None), Ok(MembershipDecision::ViaOwner(owner)) => Ok(Some(owner)), Ok(MembershipDecision::Denied) => Err(( @@ -148,16 +168,22 @@ pub mod relay_members { /// /// Used on open relays (`require_relay_membership = false`) to opportunistically /// extract the owner pubkey for agent→owner backfill. The NIP-OA signature is - /// cryptographically self-proving, so no feature flag is needed — if the tag - /// verifies, the owner relationship is authentic. Returns `None` if the tag - /// is absent or invalid. + /// cryptographically self-proving, so no feature flag is needed. Temporal + /// conditions are evaluated against `signed_auth_created_at`. Returns + /// `None` if the tag, timestamp, or conditions are absent or invalid. pub fn extract_nip_oa_owner( pubkey_bytes: &[u8], auth_tag_header: Option<&str>, + signed_auth_created_at: Option, ) -> Option { let tag_json = auth_tag_header?; + let auth_created_at = signed_auth_created_at?; let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; - match buzz_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + match buzz_sdk::nip_oa::verify_auth_tag_for_auth_event( + tag_json, + &agent_pubkey, + auth_created_at, + ) { Ok(owner) => Some(owner), Err(e) => { info!("extract_nip_oa_owner: invalid auth tag: {e}"); @@ -182,18 +208,62 @@ pub mod relay_members { let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") .expect("compute_auth_tag must succeed"); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&tag_json)); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some(&tag_json), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, Some(owner_keys.public_key())); } + #[test] + fn nip_oa_time_conditions_use_signed_auth_event_time() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<200") + .expect("sign expired credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&expired), Some(200)), + None + ); + + let future = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&future), Some(200)), + None + ); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), Some(200)), + Some(owner_keys.public_key()) + ); + assert_eq!( + extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&in_window), None), + None, + "a credential without a verified signed auth timestamp must fail closed" + ); + } + /// No auth tag → returns None. #[test] fn no_auth_tag_returns_none() { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), None); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + None, + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } @@ -204,7 +274,11 @@ pub mod relay_members { let agent_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); - let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some("not valid json")); + let result = extract_nip_oa_owner( + &agent_pubkey.to_bytes(), + Some("not valid json"), + Some(nostr::Timestamp::now().as_secs()), + ); assert_eq!(result, None); } diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a4387..1aadb7da5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -75,7 +75,11 @@ async fn authorize_operator_request( _ => path.to_string(), }; let url = format!("{origin}{path_with_query}"); - let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + .. + } = bridge::verify_bridge_auth_with_options( headers, method, &url, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209..170d6c77c 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -215,6 +215,7 @@ async fn handle_active_audio_connection( // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let signed_auth_created_at = auth_msg.event.created_at.as_secs(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); let auth_ctx = match state @@ -246,6 +247,7 @@ async fn handle_active_audio_connection( tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await .is_err() diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 481d9f4b5..56d97a37f 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -76,6 +76,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // The tag is integrity-protected by the event's Schnorr signature — if // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); + let signed_auth_created_at = event.created_at.as_secs(); let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); @@ -137,6 +138,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: if let Some(owner) = crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) { outcome = match state .db @@ -219,6 +221,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: conn.tenant.community(), pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) .await { @@ -246,6 +249,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: crate::api::relay_members::extract_nip_oa_owner( pubkey.as_bytes(), auth_tag_json.as_deref(), + Some(signed_auth_created_at), ) } else { None diff --git a/crates/buzz-sdk/src/nip_oa.rs b/crates/buzz-sdk/src/nip_oa.rs index 2dff81bcf..277741827 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -235,6 +235,55 @@ pub fn verify_auth_tag( Ok(owner_pubkey) } +/// Verify a NIP-OA credential for relay admission at a signed auth event. +/// +/// This performs the normal signature and syntax checks, then evaluates every +/// `created_at<` and `created_at>` clause against the signed NIP-42, NIP-98, or +/// equivalent authentication event's `created_at`. Both operators are strict: +/// equality does not satisfy either clause. `kind=` clauses are deliberately +/// not evaluated at connection admission, matching NIP-AA's connection-wide +/// credential semantics. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] when the credential is invalid or the +/// signed authentication event does not satisfy a time condition. +pub fn verify_auth_tag_for_auth_event( + auth_tag_json: &str, + agent_pubkey: &PublicKey, + auth_event_created_at: u64, +) -> Result { + let owner_pubkey = verify_auth_tag(auth_tag_json, agent_pubkey)?; + let arr = parse_json_array(auth_tag_json)?; + let conditions = arr[2] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + + for clause in conditions.split('&') { + let (bound, satisfied) = if let Some(value) = clause.strip_prefix("created_at<") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at< bound: {e}")))?; + (bound, auth_event_created_at < bound) + } else if let Some(value) = clause.strip_prefix("created_at>") { + let bound = value + .parse::() + .map_err(|e| SdkError::InvalidInput(format!("invalid created_at> bound: {e}")))?; + (bound, auth_event_created_at > bound) + } else { + continue; + }; + + if !satisfied { + return Err(SdkError::InvalidInput(format!( + "auth event created_at {auth_event_created_at} does not satisfy {clause} (bound {bound})" + ))); + } + } + + Ok(owner_pubkey) +} + /// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the /// signature. /// @@ -586,6 +635,32 @@ mod tests { assert!(matches!(err, SdkError::InvalidInput(_))); } + #[test] + fn auth_event_time_conditions_are_enforced_strictly() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + + let expired = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<200") + .expect("sign expired credential"); + assert!(verify_auth_tag_for_auth_event(&expired, &agent_pubkey, 200).is_err()); + + let not_yet_valid = compute_auth_tag(&owner_keys, &agent_pubkey, "created_at>200") + .expect("sign future credential"); + assert!(verify_auth_tag_for_auth_event(¬_yet_valid, &agent_pubkey, 200).is_err()); + + let in_window = compute_auth_tag( + &owner_keys, + &agent_pubkey, + "kind=9&created_at>199&created_at<201", + ) + .expect("sign in-window credential"); + assert_eq!( + verify_auth_tag_for_auth_event(&in_window, &agent_pubkey, 200) + .expect("in-window credential passes"), + owner_keys.public_key() + ); + } + #[test] fn test_parse_rejects_invalid_conditions() { let bad =