diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5dbb2aaf50c..31fdb019bfc 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -55,17 +55,28 @@ pub(crate) 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, + }); } } @@ -723,7 +742,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, @@ -736,8 +759,16 @@ pub async fn submit_event( // runs inside the helper. The thin wrapper here owns the single terminal // attribution line so it fires for every outcome, including admission/ // replay/membership failures that previously returned before any log fired. - let outcome = - submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let outcome = submit_event_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &outcome { SubmitOutcome::Ok { accepted, kind, .. } => { @@ -846,6 +877,7 @@ async fn submit_event_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> SubmitOutcome { // Admission and replay checks fire before body parse — a 429 or replay // reject on a malformed body must still be attributed. @@ -888,18 +920,23 @@ async fn submit_event_authed( }; // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); let nip_oa_owner = match super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await { Ok(owner) => owner.or_else(|| { if !state.config.require_relay_membership { - super::relay_members::extract_nip_oa_owner(&pubkey_bytes, auth_tag) + super::relay_members::extract_nip_oa_owner( + &pubkey_bytes, + auth_tag, + signed_auth_created_at, + ) } else { None } @@ -994,7 +1031,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, @@ -1007,8 +1048,16 @@ pub async fn query_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - query_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = query_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(Value::Array(events))) => { tracing::info!( @@ -1044,17 +1093,19 @@ async fn query_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -1523,7 +1574,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, @@ -1536,8 +1591,16 @@ pub async fn count_events( // helper. The single terminal attribution line fires here from the Result // so every outcome — including admission/replay/membership failures that // previously returned before any log — is attributed. - let result = - count_events_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; + let result = count_events_authed( + &state, + &tenant, + &headers, + &body, + pubkey, + event_id_bytes, + signed_created_at, + ) + .await; match &result { Ok(Json(value)) => { let count = value.get("count").and_then(Value::as_u64); @@ -1571,17 +1634,19 @@ async fn count_events_authed( body: &[u8], pubkey: nostr::PublicKey, event_id_bytes: [u8; 32], + signed_auth_created_at: Option, ) -> Result, (StatusCode, Json)> { enforce_http_admission(state, tenant, &pubkey).await?; check_nip98_replay(state, tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_auth_created_at, ) .await?; @@ -2309,8 +2374,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(); @@ -2921,14 +2989,21 @@ 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) = - verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) - .expect("matching-host NIP-98 event must verify"); + let VerifiedBridgeAuth { + pubkey, + signed_created_at, + .. + } = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) + .expect("matching-host NIP-98 event must verify"); assert_eq!( pubkey, keys.public_key(), "returned pubkey must be the signer's" ); + assert!( + signed_created_at.is_some(), + "verified NIP-98 auth must retain its signed timestamp" + ); } /// Mirror of the query-reconstruction `authorize_moderation_read` performs @@ -2970,7 +3045,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()); @@ -3027,7 +3102,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()); @@ -3052,7 +3127,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/gifs.rs b/crates/buzz-relay/src/api/gifs.rs index a8848af295a..c29df6746bb 100644 --- a/crates/buzz-relay/src/api/gifs.rs +++ b/crates/buzz-relay/src/api/gifs.rs @@ -138,7 +138,11 @@ async fn authenticate( })?; let expected_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, + signed_created_at, + } = bridge::verify_bridge_auth_with_options( headers, "POST", &expected_url, @@ -152,9 +156,8 @@ async fn authenticate( state, tenant.community(), &pubkey.to_bytes(), - headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()), + relay_members::extract_auth_tag_header(headers), + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index ec7af3aac65..94fd7f8758e 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -200,22 +200,21 @@ impl axum::extract::FromRequestParts> for GitAuth { let event: nostr::Event = serde_json::from_str(&event_json) .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid auth event").into_response())?; + let signed_auth_created_at = event.created_at.as_secs(); // Relay membership gate (NIP-43). Git cannot carry a standalone // x-auth-tag header through the credential-helper protocol, so agents // attach their NIP-OA attestation to the signed NIP-98 event, matching // the WebSocket NIP-42 flow. let event_auth_tag = crate::handlers::auth::extract_auth_tag_json(&event); - let header_auth_tag = parts - .headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let header_auth_tag = crate::api::relay_members::extract_auth_tag_header(&parts.headers); let auth_tag = event_auth_tag.as_deref().or(header_auth_tag); if crate::api::relay_members::enforce_relay_membership( state, tenant.community(), pubkey.as_bytes(), auth_tag, + Some(signed_auth_created_at), ) .await .is_err() @@ -224,7 +223,14 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } - deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; + deny_banned_git_principal( + &state.db, + tenant.community(), + &pubkey, + auth_tag, + Some(signed_auth_created_at), + ) + .await?; Ok(GitAuth { pubkey, tenant }) } @@ -246,6 +252,7 @@ async fn deny_banned_git_principal( community: buzz_core::CommunityId, pubkey: &nostr::PublicKey, auth_tag: Option<&str>, + signed_auth_created_at: Option, ) -> Result<(), Response> { let agent = git_restriction_state(db, community, pubkey).await?; @@ -254,7 +261,11 @@ async fn deny_banned_git_principal( let owner = if agent.banned { None } else { - crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + crate::api::relay_members::extract_nip_oa_owner( + pubkey.as_bytes(), + auth_tag, + signed_auth_created_at, + ) }; let owner_state = match owner { Some(owner) => Some(git_restriction_state(db, community, &owner).await?), @@ -3652,7 +3663,7 @@ mod sec005_read_gate_tests { db.ensure_user(community, &member_pk).await.expect("member"); assert!( - deny_banned_git_principal(&db, community, &member.public_key(), None) + deny_banned_git_principal(&db, community, &member.public_key(), None, None) .await .is_ok(), "precondition: an unbanned member passes the git ban gate" @@ -3663,7 +3674,7 @@ mod sec005_read_gate_tests { .expect("ban"); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &member.public_key(), None).await, + deny_banned_git_principal(&db, community, &member.public_key(), None, None).await, ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -3686,9 +3697,15 @@ mod sec005_read_gate_tests { .expect("auth tag"); assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) - .await - .is_ok(), + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await + .is_ok(), "precondition: neither agent nor owner is banned" ); @@ -3698,7 +3715,14 @@ mod sec005_read_gate_tests { .expect("ban owner"); let (status, _) = denial_parts( - deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + deny_banned_git_principal( + &db, + community, + &agent.public_key(), + Some(&auth_tag), + Some(200), + ) + .await, ) .await; assert_eq!( @@ -3710,7 +3734,7 @@ mod sec005_read_gate_tests { // An unattested request from the same agent key is unaffected: the // cascade must follow a verified owner, not punish every agent. assert!( - deny_banned_git_principal(&db, community, &agent.public_key(), None) + deny_banned_git_principal(&db, community, &agent.public_key(), None, None) .await .is_ok(), "without an attestation there is no owner to inherit from" @@ -3732,7 +3756,8 @@ mod sec005_read_gate_tests { let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); let (status, body) = denial_parts( - deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None).await, + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None, None) + .await, ) .await; assert_eq!( diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d09c7fc6119..e3d05165e0d 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -247,7 +247,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 7a9f8816fed..780532ec5d0 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -208,12 +208,13 @@ impl FromRequestParts> for AuthenticatedUpload { // storage and of `require_auth_token` (which governs the REST API, not // media). On open relays (membership disabled) any valid Blossom signer // may upload, matching the WS door's admission policy. - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, tenant.community(), auth_event.pubkey.as_bytes(), auth_tag, + Some(auth_event.created_at.as_secs()), ) .await .map_err(|_| MediaError::RelayMembershipRequired)?; @@ -534,12 +535,13 @@ async fn authenticate_media_read( let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; - let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + let auth_tag = crate::api::relay_members::extract_auth_tag_header(headers); crate::api::relay_members::enforce_relay_membership( state, 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 204ec360c3f..b7a0458f5a8 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -37,7 +37,10 @@ pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { /// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, /// `git/transport.rs`, and `audio/handler.rs`. pub mod relay_members { - use axum::{http::StatusCode, response::Json}; + use axum::{ + http::{HeaderMap, StatusCode}, + response::Json, + }; use buzz_core::{tenant::CommunityId, TenantContext}; use tracing::{debug, info}; @@ -56,15 +59,30 @@ pub mod relay_members { Denied, } + /// Return the sole NIP-OA credential header, if one was supplied. + /// + /// Repeated security-sensitive headers are ambiguous across HTTP stacks, + /// so they are treated as no credential instead of silently selecting one. + pub fn extract_auth_tag_header(headers: &HeaderMap) -> Option<&str> { + let mut values = headers.get_all("x-auth-tag").iter(); + let (Some(value), None) = (values.next(), values.next()) else { + return None; + }; + value.to_str().ok() + } + /// Check relay membership without committing to an HTTP response shape. /// /// `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); @@ -84,8 +102,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 @@ -128,8 +154,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(( @@ -150,16 +185,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}"); @@ -236,9 +277,22 @@ pub mod relay_members { #[cfg(test)] mod tests { use super::*; + use axum::http::{HeaderMap, HeaderValue}; use buzz_sdk::nip_oa::compute_auth_tag; use nostr::Keys; + #[test] + fn auth_tag_header_must_be_unique() { + let mut headers = HeaderMap::new(); + assert_eq!(extract_auth_tag_header(&headers), None); + + headers.insert("x-auth-tag", HeaderValue::from_static("credential-one")); + assert_eq!(extract_auth_tag_header(&headers), Some("credential-one")); + + headers.append("x-auth-tag", HeaderValue::from_static("credential-two")); + assert_eq!(extract_auth_tag_header(&headers), None); + } + /// Valid NIP-OA auth tag → returns Some(owner_pubkey). #[test] fn valid_nip_oa_returns_owner() { @@ -249,18 +303,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); } @@ -271,7 +369,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 f19ac17d4c1..b59fd840c6d 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/api/workflows.rs b/crates/buzz-relay/src/api/workflows.rs index a3d5a6c729e..c7fa09bebd0 100644 --- a/crates/buzz-relay/src/api/workflows.rs +++ b/crates/buzz-relay/src/api/workflows.rs @@ -62,20 +62,22 @@ async fn authorize_workflow_read( let path_with_query = request_path(path, raw_query); let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); - let (pubkey, event_id_bytes) = - bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; + let bridge::VerifiedBridgeAuth { + pubkey, + event_id_bytes, + signed_created_at, + } = bridge::verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; bridge::enforce_http_admission(state, &tenant, &pubkey).await?; bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; let pubkey_bytes = pubkey.to_bytes().to_vec(); - let auth_tag = headers - .get("x-auth-tag") - .and_then(|value| value.to_str().ok()); + let auth_tag = super::relay_members::extract_auth_tag_header(headers); super::relay_members::enforce_relay_membership( state, tenant.community(), &pubkey_bytes, auth_tag, + signed_created_at, ) .await?; diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index de8f1e14591..06d3a32b43d 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -220,6 +220,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 @@ -251,6 +252,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 127f1fc40e0..02e2cc03a64 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 2dff81bcf7a..f8a994bd0c5 100644 --- a/crates/buzz-sdk/src/nip_oa.rs +++ b/crates/buzz-sdk/src/nip_oa.rs @@ -165,22 +165,18 @@ pub fn compute_auth_tag( Ok(tag_json.to_string()) } -/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. -/// -/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature -/// against the owner pubkey embedded in the tag. -/// -/// Returns the owner's [`PublicKey`] on success. -/// -/// # Errors -/// -/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, -/// bad hex, self-attestation, or signature verification failure. -pub fn verify_auth_tag( - auth_tag_json: &str, - agent_pubkey: &PublicKey, -) -> Result { - let arr = parse_json_array(auth_tag_json)?; +struct ParsedAuthTag { + owner_pubkey_hex: String, + conditions: String, + sig_hex: String, +} + +/// Parse and validate the canonical wire representation shared by every +/// verification path. Keeping this check in one place prevents the crypto +/// verifier from accepting non-canonical values that the structural parser +/// rejects. +fn parse_auth_tag_fields(json_str: &str) -> Result { + let arr = parse_json_array(json_str)?; if arr.len() != 4 { return Err(SdkError::InvalidInput(format!( @@ -201,17 +197,41 @@ pub fn verify_auth_tag( let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) })?; + if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "owner pubkey must be 64 lowercase hex chars, got {:?}", + owner_pubkey_hex + ))); + } + let conditions = arr[2] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + validate_conditions(conditions)?; + let sig_hex = arr[3] .as_str() .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; + if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "signature must be 128 lowercase hex chars, got length {}", + sig_hex.len() + ))); + } - let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex) - .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; + Ok(ParsedAuthTag { + owner_pubkey_hex: owner_pubkey_hex.to_owned(), + conditions: conditions.to_owned(), + sig_hex: sig_hex.to_owned(), + }) +} - validate_conditions(conditions)?; +fn verify_parsed_auth_tag( + parsed: &ParsedAuthTag, + agent_pubkey: &PublicKey, +) -> Result { + let owner_pubkey = PublicKey::from_hex(&parsed.owner_pubkey_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; if owner_pubkey == *agent_pubkey { return Err(SdkError::InvalidInput( @@ -219,10 +239,9 @@ pub fn verify_auth_tag( )); } - let sig = Signature::from_str(sig_hex) + let sig = Signature::from_str(&parsed.sig_hex) .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; - - let preimage = build_preimage(agent_pubkey, conditions); + let preimage = build_preimage(agent_pubkey, &parsed.conditions); let message = hash_preimage(&preimage); let xonly = owner_pubkey.xonly().map_err(|e| { @@ -235,6 +254,71 @@ pub fn verify_auth_tag( Ok(owner_pubkey) } +/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. +/// +/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature +/// against the owner pubkey embedded in the tag. +/// +/// Returns the owner's [`PublicKey`] on success. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, +/// bad hex, self-attestation, or signature verification failure. +pub fn verify_auth_tag( + auth_tag_json: &str, + agent_pubkey: &PublicKey, +) -> Result { + let parsed = parse_auth_tag_fields(auth_tag_json)?; + verify_parsed_auth_tag(&parsed, agent_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 parsed = parse_auth_tag_fields(auth_tag_json)?; + let owner_pubkey = verify_parsed_auth_tag(&parsed, agent_pubkey)?; + + for clause in parsed.conditions.split('&') { + let 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}")))?; + 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}")))?; + 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}" + ))); + } + } + + Ok(owner_pubkey) +} + /// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the /// signature. /// @@ -250,52 +334,14 @@ pub fn verify_auth_tag( /// /// Returns [`SdkError::InvalidInput`] for any structural violation. pub fn parse_auth_tag(json_str: &str) -> Result { - let arr = parse_json_array(json_str)?; - - if arr.len() != 4 { - return Err(SdkError::InvalidInput(format!( - "auth tag must have 4 elements, got {}", - arr.len() - ))); - } - - let label = arr[0] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?; - if label != "auth" { - return Err(SdkError::InvalidInput(format!( - "first element must be \"auth\", got \"{label}\"" - ))); - } - - let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { - SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) - })?; - if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "owner pubkey must be 64 hex chars, got {:?}", - owner_pubkey_hex - ))); - } - - let conditions = arr[2] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; - - validate_conditions(conditions)?; - - let sig_hex = arr[3] - .as_str() - .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; - if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { - return Err(SdkError::InvalidInput(format!( - "signature must be 128 hex chars, got length {}", - sig_hex.len() - ))); - } - - Tag::parse(["auth", owner_pubkey_hex, conditions, sig_hex]) - .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) + let parsed = parse_auth_tag_fields(json_str)?; + Tag::parse([ + "auth", + &parsed.owner_pubkey_hex, + &parsed.conditions, + &parsed.sig_hex, + ]) + .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) } #[cfg(test)] @@ -412,6 +458,32 @@ mod tests { assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err()); } + #[test] + fn test_verify_rejects_noncanonical_hex() { + let owner_keys = Keys::generate(); + let agent_pubkey = Keys::generate().public_key(); + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + + tag[1] = Value::String(owner_keys.public_key().to_hex().to_uppercase()); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase owner pubkeys must not reach the permissive hex decoder" + ); + + let mut tag: Value = serde_json::from_str(&tag_json).expect("auth tag is valid JSON"); + let uppercase_sig = tag[3] + .as_str() + .expect("signature is a string") + .to_uppercase(); + tag[3] = Value::String(uppercase_sig); + assert!( + verify_auth_tag(&tag.to_string(), &agent_pubkey).is_err(), + "uppercase signatures must not reach the permissive hex decoder" + ); + } + /// parse_auth_tag with a well-formed JSON array returns a Tag. #[test] fn test_parse_auth_tag_valid() { @@ -586,6 +658,40 @@ 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<1") + .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 failed_second_clause = + compute_auth_tag(&owner_keys, &agent_pubkey, "created_at<201&created_at<200") + .expect("sign credential with two upper bounds"); + assert!( + verify_auth_tag_for_auth_event(&failed_second_clause, &agent_pubkey, 200).is_err(), + "every clause must pass, even when an earlier clause succeeds" + ); + + 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 =