mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(security): enforce NIP-OA authorization bounds (BUZZ-SEC-002)
This commit is contained in:
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¬_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 =
|
||||
|
||||
Reference in New Issue
Block a user