mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: materialize agent_owner_pubkey on NIP-OA auth (#491)
This commit is contained in:
@@ -64,6 +64,11 @@ pub struct AuthContext {
|
||||
pub channel_ids: Option<Vec<uuid::Uuid>>,
|
||||
/// How the connection was authenticated.
|
||||
pub auth_method: AuthMethod,
|
||||
/// NIP-OA verified owner pubkey (if authenticated via owner attestation).
|
||||
///
|
||||
/// `None` for direct relay members or non-NIP-OA auth paths.
|
||||
/// Set by the relay membership gate when NIP-OA fallback succeeds.
|
||||
pub agent_owner_pubkey: Option<nostr::PublicKey>,
|
||||
}
|
||||
|
||||
impl AuthContext {
|
||||
@@ -125,6 +130,7 @@ impl AuthService {
|
||||
scopes: Scope::all_known(),
|
||||
channel_ids: None,
|
||||
auth_method: AuthMethod::Nip42,
|
||||
agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -176,6 +182,7 @@ mod tests {
|
||||
scopes: vec![Scope::MessagesRead, Scope::ChannelsRead],
|
||||
channel_ids: None,
|
||||
auth_method: AuthMethod::Nip42,
|
||||
agent_owner_pubkey: None,
|
||||
};
|
||||
assert!(ctx.has_scope(&Scope::MessagesRead));
|
||||
assert!(!ctx.has_scope(&Scope::MessagesWrite));
|
||||
|
||||
@@ -44,13 +44,16 @@ pub mod relay_members {
|
||||
/// - If enabled → checks `relay_members` table for the pubkey.
|
||||
/// - If not a direct member and NIP-OA is enabled → verifies the `auth_tag_header`
|
||||
/// to check if the agent's owner is a relay member.
|
||||
///
|
||||
/// Returns `Ok(None)` for direct members, `Ok(Some(owner_pubkey))` when
|
||||
/// access was granted via NIP-OA owner delegation.
|
||||
pub async fn enforce_relay_membership(
|
||||
state: &AppState,
|
||||
pubkey_bytes: &[u8],
|
||||
auth_tag_header: Option<&str>,
|
||||
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
|
||||
) -> Result<Option<nostr::PublicKey>, (StatusCode, Json<serde_json::Value>)> {
|
||||
if !state.config.require_relay_membership {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
@@ -60,7 +63,7 @@ pub mod relay_members {
|
||||
})?;
|
||||
|
||||
if is_member {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// NIP-OA fallback: check if agent's owner is a relay member.
|
||||
@@ -86,7 +89,7 @@ pub mod relay_members {
|
||||
owner = %owner_hex,
|
||||
"NIP-OA membership granted via owner"
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(Some(owner_pubkey));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -77,7 +77,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
.verify_auth_event(event, &challenge, &relay_url)
|
||||
.await
|
||||
{
|
||||
Ok(auth_ctx) => {
|
||||
Ok(mut auth_ctx) => {
|
||||
let pubkey = auth_ctx.pubkey;
|
||||
|
||||
// Pubkey allowlist gate — only for pubkey-only auth.
|
||||
@@ -107,24 +107,93 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
}
|
||||
|
||||
// Relay membership gate — uses the shared helper with NIP-OA fallback.
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
&pubkey.serialize(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"restricted: not a relay member",
|
||||
));
|
||||
return;
|
||||
Ok(owner) => owner,
|
||||
Err(_) => {
|
||||
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"restricted: not a relay member",
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Stash NIP-OA owner on the auth context (session-scoped) only if
|
||||
// the DB confirms this owner relationship (first-write-wins).
|
||||
if let Some(owner) = nip_oa_owner {
|
||||
// Ensure both agent and owner have users rows (BYO agents may not,
|
||||
// and agent_owner_pubkey has a FK constraint to users.pubkey).
|
||||
if let Err(e) = state.db.ensure_user(&pubkey.serialize()).await {
|
||||
warn!(conn_id = %conn_id, error = %e, "ensure_user(agent) failed during NIP-OA backfill");
|
||||
}
|
||||
if let Err(e) = state.db.ensure_user(&owner.serialize()).await {
|
||||
warn!(conn_id = %conn_id, error = %e, "ensure_user(owner) failed during NIP-OA backfill");
|
||||
}
|
||||
|
||||
// Idempotent backfill: record agent→owner in DB so cross-connection
|
||||
// features (observer frames, channel policy) work for BYO agents.
|
||||
// Returns Ok(true) if written, Ok(false) if already owned by someone else.
|
||||
match state
|
||||
.db
|
||||
.set_agent_owner(&pubkey.serialize(), &owner.serialize())
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// Successfully materialized — this owner is authoritative.
|
||||
auth_ctx.agent_owner_pubkey = Some(owner);
|
||||
// Pre-warm the observer cache to avoid stale negatives.
|
||||
let cache_key = (pubkey.serialize().to_vec(), owner.serialize().to_vec());
|
||||
state.observer_owner_cache.insert(cache_key, true);
|
||||
}
|
||||
Ok(false) => {
|
||||
// Agent already owned by someone else. Verify if this
|
||||
// owner matches the existing DB record before trusting it.
|
||||
match state
|
||||
.db
|
||||
.is_agent_owner(&pubkey.serialize(), &owner.serialize())
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
auth_ctx.agent_owner_pubkey = Some(owner);
|
||||
}
|
||||
Ok(false) => {
|
||||
warn!(
|
||||
conn_id = %conn_id,
|
||||
agent = %pubkey.to_hex(),
|
||||
nip_oa_owner = %owner.to_hex(),
|
||||
"NIP-OA owner differs from DB owner — session will not get owner fast-path"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
conn_id = %conn_id,
|
||||
error = %e,
|
||||
"is_agent_owner check failed after set_agent_owner conflict"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
conn_id = %conn_id,
|
||||
agent = %pubkey.to_hex(),
|
||||
owner = %owner.to_hex(),
|
||||
error = %e,
|
||||
"failed to backfill agent_owner_pubkey"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful");
|
||||
|
||||
@@ -539,26 +539,41 @@ async fn handle_agent_observer_event(
|
||||
}
|
||||
};
|
||||
|
||||
// Fast path: if this connection authenticated via NIP-OA and the verified
|
||||
// owner matches the observer frame's target owner, skip the DB lookup entirely.
|
||||
let session_owner_match = {
|
||||
let auth = conn.auth_state.read().await;
|
||||
if let crate::connection::AuthState::Authenticated(ctx) = &*auth {
|
||||
ctx.agent_owner_pubkey.as_ref() == Some(&route.owner)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let agent_bytes = route.agent.serialize().to_vec();
|
||||
let owner_bytes = route.owner.serialize().to_vec();
|
||||
let cache_key = (agent_bytes.clone(), owner_bytes.clone());
|
||||
let is_owner = match state.observer_owner_cache.get(&cache_key) {
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
|
||||
match result {
|
||||
Ok(v) => {
|
||||
state.observer_owner_cache.insert(cache_key, v);
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
|
||||
conn.send(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
"error: internal server error",
|
||||
));
|
||||
return;
|
||||
let is_owner = if session_owner_match {
|
||||
true
|
||||
} else {
|
||||
match state.observer_owner_cache.get(&cache_key) {
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await;
|
||||
match result {
|
||||
Ok(v) => {
|
||||
state.observer_owner_cache.insert(cache_key, v);
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}");
|
||||
conn.send(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
"error: internal server error",
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user