fix: extract NIP-OA owner on open relays for observer frame auth (#513)

This commit is contained in:
tlongwell-block
2026-05-08 16:00:23 -04:00
committed by GitHub
parent d4c21ca0eb
commit 32c3fd1340
3 changed files with 100 additions and 9 deletions

View File

@@ -40,13 +40,15 @@ pub mod relay_members {
/// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback.
///
/// - If `config.require_relay_membership` is false → always Ok (no-op).
/// - 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(Some(owner_pubkey))` when the agent is not a direct member but
/// its NIP-OA owner *is* — access is granted via delegation.
///
/// Returns `Ok(None)` for direct members, `Ok(Some(owner_pubkey))` when
/// access was granted via NIP-OA owner delegation.
/// On open relays (`require_relay_membership = false`), returns `Ok(None)`
/// immediately — no membership check is performed. Callers that need NIP-OA
/// owner extraction on open relays should call [`extract_nip_oa_owner`] directly.
///
/// Returns `Ok(None)` when the caller is a direct member (closed relay) or when
/// no NIP-OA tag is present/applicable (open relay without auth tag).
pub async fn enforce_relay_membership(
state: &AppState,
pubkey_bytes: &[u8],
@@ -107,4 +109,70 @@ pub mod relay_members {
})),
))
}
/// Extract NIP-OA owner from an auth tag without membership enforcement.
///
/// 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.
pub fn extract_nip_oa_owner(
pubkey_bytes: &[u8],
auth_tag_header: Option<&str>,
) -> Option<nostr::PublicKey> {
let tag_json = auth_tag_header?;
let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?;
match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) {
Ok(owner) => Some(owner),
Err(e) => {
debug!("extract_nip_oa_owner: invalid auth tag: {e}");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::Keys;
use sprout_sdk::nip_oa::compute_auth_tag;
/// Valid NIP-OA auth tag → returns Some(owner_pubkey).
#[test]
fn valid_nip_oa_returns_owner() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey = agent_keys.public_key();
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));
assert_eq!(result, Some(owner_keys.public_key()));
}
/// 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);
assert_eq!(result, None);
}
/// Invalid auth tag → returns None.
#[test]
fn invalid_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(), Some("not valid json"));
assert_eq!(result, None);
}
}
}

View File

@@ -80,6 +80,11 @@ pub struct Config {
/// bearing a valid NIP-OA `auth` tag can authenticate by proving their
/// owner is a relay member. The agent gets session-scoped access.
///
/// On open relays (`require_relay_membership = false`), NIP-OA owner
/// extraction for agent→owner backfill happens unconditionally (the
/// signature is cryptographically self-proving). This flag only controls
/// whether NIP-OA can grant membership access on closed relays.
///
/// Default: `false`. Set via `SPROUT_ALLOW_NIP_OA_AUTH=true`.
pub allow_nip_oa_auth: bool,

View File

@@ -2,9 +2,12 @@
//!
//! Relay membership enforcement uses the shared
//! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports
//! NIP-OA owner-delegation fallback. For WebSocket auth, the NIP-OA `auth` tag
//! is extracted from the signed AUTH event itself (the tag is integrity-protected
//! by the event signature).
//! NIP-OA owner-delegation fallback on closed relays. On open relays, the auth
//! handler calls [`crate::api::relay_members::extract_nip_oa_owner`] directly to
//! extract the owner pubkey for agent→owner backfill (observer frame auth).
//!
//! For WebSocket auth, the NIP-OA `auth` tag is extracted from the signed AUTH
//! event itself (the tag is integrity-protected by the event signature).
use std::sync::Arc;
@@ -129,6 +132,21 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
}
};
// Open relay NIP-OA backfill: extract owner for agent→owner DB mapping
// (needed for observer frame auth). Only runs on open relays — on closed
// relays, enforce_relay_membership already handles NIP-OA delegation.
// No feature flag needed: NIP-OA is cryptographically self-proving.
let nip_oa_owner = nip_oa_owner.or_else(|| {
if !state.config.require_relay_membership && auth_tag_json.is_some() {
crate::api::relay_members::extract_nip_oa_owner(
&pubkey.serialize(),
auth_tag_json.as_deref(),
)
} else {
None
}
});
// 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 {