fix(security): enforce NIP-OA authorization bounds (BUZZ-SEC-002)

This commit is contained in:
Jordan Mecom
2026-07-20 09:24:49 -07:00
parent a4dfead21a
commit 522556eec7
13 changed files with 294 additions and 282 deletions
+2 -2
View File
@@ -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
+56 -247
View File
@@ -118,175 +118,45 @@ fn resolve_agent_owner(config: &Config) -> Option<String> {
/// 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<String>,
/// author_hex → is_sibling (true = same owner, false = not)
siblings: std::sync::Mutex<HashMap<String, bool>>,
}
impl OwnerCache {
fn new(initial: Option<String>) -> 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<bool> {
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<String>,
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"
);
}
}
+2 -3
View File
@@ -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())
+1 -4
View File
@@ -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<EventId> = 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(
+52 -15
View File
@@ -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<u64>,
}
type BridgeAuthResult = Result<VerifiedBridgeAuth, (StatusCode, Json<Value>)>;
/// 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<Value>)> {
) -> 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<Value>)> {
) -> BridgeAuthResult {
// Try NIP-98 first (Authorization: Nostr <base64>)
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());
@@ -107,6 +107,10 @@ impl axum::extract::FromRequestParts<Arc<AppState>> 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::<nostr::Event>(&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<Arc<AppState>> for GitAuth {
tenant.community(),
pubkey.as_bytes(),
auth_tag,
Some(signed_auth_created_at),
)
.await
.is_err()
+5 -1
View File
@@ -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,
+2
View File
@@ -213,6 +213,7 @@ impl FromRequestParts<Arc<AppState>> 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)?;
+83 -9
View File
@@ -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<u64>,
) -> Result<MembershipDecision, String> {
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<u64>,
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
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<u64>,
) -> Option<nostr::PublicKey> {
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);
}
+5 -1
View File
@@ -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,
+2
View File
@@ -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()
+4
View File
@@ -76,6 +76,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, 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<ConnectionState>, 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<ConnectionState>, 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<ConnectionState>, state:
crate::api::relay_members::extract_nip_oa_owner(
pubkey.as_bytes(),
auth_tag_json.as_deref(),
Some(signed_auth_created_at),
)
} else {
None
+75
View File
@@ -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<PublicKey, SdkError> {
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::<u64>()
.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::<u64>()
.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(&not_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 =