Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 74 additions & 6 deletions crates/sprout-relay/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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);
}
}
}
5 changes: 5 additions & 0 deletions crates/sprout-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
24 changes: 21 additions & 3 deletions crates/sprout-relay/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 {
Expand Down
Loading