From df33818b5b8693ecf4c483e562786e6d26c108ea Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 4 May 2026 12:12:22 -0400 Subject: [PATCH] feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git) (#471) --- Cargo.lock | 1 + crates/sprout-acp/src/relay.rs | 123 ++++++++++++++---- crates/sprout-auth/src/lib.rs | 13 ++ crates/sprout-mcp/src/relay_client.rs | 66 +++++++--- crates/sprout-relay/Cargo.toml | 1 + crates/sprout-relay/src/api/git/transport.rs | 6 +- crates/sprout-relay/src/api/media.rs | 11 +- crates/sprout-relay/src/api/mod.rs | 27 +++- crates/sprout-relay/src/api/relay_members.rs | 68 ++++++++-- crates/sprout-relay/src/api/tokens.rs | 4 +- crates/sprout-relay/src/audio/handler.rs | 2 +- crates/sprout-relay/src/config.rs | 18 +++ crates/sprout-relay/src/handlers/auth.rs | 118 ++++++++++++++++- .../sprout-sdk/examples/compute_auth_tag.rs | 29 +++++ 14 files changed, 419 insertions(+), 68 deletions(-) create mode 100644 crates/sprout-sdk/examples/compute_auth_tag.rs diff --git a/Cargo.lock b/Cargo.lock index 6e15c742a..93435bd27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,6 +3960,7 @@ dependencies = [ "sprout-db", "sprout-media", "sprout-pubsub", + "sprout-sdk", "sprout-search", "sprout-workflow", "sqlx", diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index b8e867fb3..8f2afd4d1 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -62,7 +62,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); use std::time::Instant; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl}; +use nostr::{Event, EventBuilder, Keys, Kind, Tag}; use serde_json::{json, Value}; use sprout_core::kind::{ KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, @@ -95,6 +95,8 @@ pub struct RestClient { pub base_url: String, pub api_token: Option, pub keys: Keys, + /// NIP-OA auth tag sent as `X-Auth-Tag` header for relay membership. + pub auth_tag: Option, } /// Whether an HTTP status code is retriable (transient server/rate-limit errors). @@ -179,7 +181,12 @@ impl RestClient { let url = format!("{}{}", self.base_url, path); let resp = self .request_with_retry("GET", path, || { - let builder = apply_auth(self.http.get(&url), &self.api_token, &self.keys); + let builder = apply_auth_with_tag( + self.http.get(&url), + &self.api_token, + &self.keys, + self.auth_tag.as_ref(), + ); builder.send() }) .await?; @@ -196,8 +203,12 @@ impl RestClient { let body = body.clone(); let resp = self .request_with_retry("PUT", path, || { - let builder = - apply_auth(self.http.put(&url).json(&body), &self.api_token, &self.keys); + let builder = apply_auth_with_tag( + self.http.put(&url).json(&body), + &self.api_token, + &self.keys, + self.auth_tag.as_ref(), + ); builder.send() }) .await?; @@ -219,10 +230,11 @@ impl RestClient { let body = body.clone(); let resp = self .request_with_retry("POST", path, || { - let builder = apply_auth( + let builder = apply_auth_with_tag( self.http.post(&url).json(&body), &self.api_token, &self.keys, + self.auth_tag.as_ref(), ); builder.send() }) @@ -242,7 +254,12 @@ impl RestClient { pub async fn delete(&self, path: &str) -> Result<(), RelayError> { let url = format!("{}{}", self.base_url, path); self.request_with_retry("DELETE", path, || { - let builder = apply_auth(self.http.delete(&url), &self.api_token, &self.keys); + let builder = apply_auth_with_tag( + self.http.delete(&url), + &self.api_token, + &self.keys, + self.auth_tag.as_ref(), + ); builder.send() }) .await?; @@ -383,6 +400,9 @@ pub struct HarnessRelay { api_token: Option, /// Keys used for NIP-42 signing. keys: Keys, + /// NIP-OA owner-attestation tag threaded into NIP-42 AUTH events. + #[allow(dead_code)] + auth_tag: Option, /// Agent public key (hex) used as the `#p` filter on subscriptions. #[allow(dead_code)] agent_pubkey_hex: String, @@ -420,10 +440,22 @@ impl HarnessRelay { api_token: Option<&str>, agent_pubkey_hex: &str, ) -> Result { + // Parse NIP-OA owner-attestation tag from env before connecting so it + // can be included in the initial NIP-42 AUTH event. + let auth_tag: Option = std::env::var("SPROUT_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| { + sprout_sdk::nip_oa::parse_auth_tag(&s) + .map_err(|e| tracing::warn!("invalid SPROUT_AUTH_TAG: {e}")) + .ok() + }); + // Perform the initial connection and auth handshake. // Finding #8: capture the handshake buffer and pass it to the background // task so buffered messages aren't silently discarded. - let (ws, handshake_buffer) = do_connect(relay_url, keys, api_token).await?; + let (ws, handshake_buffer) = + do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?; let (event_tx, event_rx) = mpsc::channel::>(event_channel_capacity()); let (observer_control_tx, observer_control_rx) = @@ -433,6 +465,7 @@ impl HarnessRelay { let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); let bg_api_token = api_token.map(|t| t.to_string()); + let bg_auth_tag = auth_tag.clone(); let bg_agent_pubkey_hex = agent_pubkey_hex.to_string(); let bg_handle = tokio::spawn(async move { @@ -445,6 +478,7 @@ impl HarnessRelay { bg_keys, bg_relay_url, bg_api_token, + bg_auth_tag, bg_agent_pubkey_hex, ) .await; @@ -462,6 +496,7 @@ impl HarnessRelay { relay_url: relay_url.to_string(), api_token: api_token.map(|t| t.to_string()), keys: keys.clone(), + auth_tag, agent_pubkey_hex: agent_pubkey_hex.to_string(), bg_handle: Some(bg_handle), }) @@ -517,6 +552,7 @@ impl HarnessRelay { base_url: relay_ws_to_http(&self.relay_url), api_token: self.api_token.clone(), keys: self.keys.clone(), + auth_tag: self.auth_tag.clone(), } } @@ -1056,6 +1092,7 @@ async fn run_background_task( keys: Keys, relay_url: String, api_token: Option, + auth_tag: Option, agent_pubkey_hex: String, ) { let mut state = BgState::new(); @@ -1071,6 +1108,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, ) .await; @@ -1086,6 +1124,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, @@ -1110,6 +1149,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, @@ -1151,6 +1191,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, @@ -1181,6 +1222,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, @@ -1221,6 +1263,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, ) .await @@ -1248,6 +1291,7 @@ async fn run_background_task( &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, @@ -1270,7 +1314,7 @@ async fn run_background_task( if matches!( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1290,7 +1334,7 @@ async fn run_background_task( if matches!( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1323,7 +1367,7 @@ async fn run_background_task( let _ = event_tx.try_send(None); match try_autonomous_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, ).await { ReconnectOutcome::Shutdown => return, @@ -1337,7 +1381,7 @@ async fn run_background_task( if matches!( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1361,7 +1405,7 @@ async fn run_background_task( let _ = event_tx.try_send(None); match try_autonomous_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, ).await { ReconnectOutcome::Shutdown => return, @@ -1375,7 +1419,7 @@ async fn run_background_task( if matches!( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1392,7 +1436,7 @@ async fn run_background_task( let _ = event_tx.try_send(None); match try_autonomous_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, ).await { ReconnectOutcome::Shutdown => return, @@ -1406,7 +1450,7 @@ async fn run_background_task( if matches!( wait_for_reconnect( &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + api_token.as_deref(), auth_tag.as_ref(), &agent_pubkey_hex, &event_tx, &observer_control_tx, true, ).await, ReconnectOutcome::Shutdown ) { return; } @@ -1448,6 +1492,7 @@ async fn handle_ws_message( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, agent_pubkey_hex: &str, ) -> bool { match msg { @@ -1699,7 +1744,8 @@ async fn handle_ws_message( // Finding #18: AUTH send failure must trigger reconnect. debug!("received mid-session AUTH challenge — re-authenticating"); if let Err(e) = - send_auth_response(ws, &challenge, relay_url, keys, api_token).await + send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag) + .await { warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect"); return false; @@ -1752,6 +1798,7 @@ async fn process_handshake_buffer( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, agent_pubkey_hex: &str, ) -> bool { if buffer.is_empty() { @@ -1795,6 +1842,7 @@ async fn process_handshake_buffer( keys, relay_url, api_token, + auth_tag, agent_pubkey_hex, ) .await; @@ -1957,6 +2005,7 @@ async fn try_autonomous_reconnect( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, agent_pubkey_hex: &str, event_tx: &mpsc::Sender>, observer_control_tx: &mpsc::Sender, @@ -1976,7 +2025,7 @@ async fn try_autonomous_reconnect( attempt + 1, backoffs.len() ); - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, api_token, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("autonomous reconnect succeeded (attempt {})", attempt + 1); @@ -1990,6 +2039,7 @@ async fn try_autonomous_reconnect( keys, relay_url, api_token, + auth_tag, agent_pubkey_hex, ) .await; @@ -2060,6 +2110,7 @@ async fn wait_for_reconnect( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, agent_pubkey_hex: &str, event_tx: &mpsc::Sender>, observer_control_tx: &mpsc::Sender, @@ -2091,7 +2142,7 @@ async fn wait_for_reconnect( let mut delay = Duration::from_secs(1); loop { info!("attempting relay reconnect to {relay_url}…"); - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, api_token, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("relay reconnected to {relay_url}"); @@ -2105,6 +2156,7 @@ async fn wait_for_reconnect( keys, relay_url, api_token, + auth_tag, agent_pubkey_hex, ) .await; @@ -2360,22 +2412,30 @@ async fn send_auth_response( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result<(), RelayError> { - let relay_nostr_url: NostrUrl = relay_url - .parse() - .map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?; - let auth_event = if let Some(token) = api_token { - let tags = vec![ + let mut tags = vec![ Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?, Tag::parse(&["challenge", challenge]) .map_err(|e| RelayError::AuthFailed(e.to_string()))?, Tag::parse(&["auth_token", token]) .map_err(|e| RelayError::AuthFailed(e.to_string()))?, ]; + if let Some(tag) = auth_tag { + tags.push(tag.clone()); + } EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)? } else { - EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)? + let mut tags = vec![ + Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?, + Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayError::AuthFailed(e.to_string()))?, + ]; + if let Some(tag) = auth_tag { + tags.push(tag.clone()); + } + EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)? }; let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; @@ -2412,15 +2472,23 @@ fn channel_id_from_sub_id(sub_id: &str) -> Option { } /// Apply the appropriate auth header to a reqwest request builder. -fn apply_auth( +fn apply_auth_with_tag( builder: reqwest::RequestBuilder, api_token: &Option, keys: &Keys, + auth_tag: Option<&Tag>, ) -> reqwest::RequestBuilder { - if let Some(ref token) = api_token { + let builder = if let Some(ref token) = api_token { builder.header("Authorization", format!("Bearer {token}")) } else { builder.header("X-Pubkey", keys.public_key().to_hex()) + }; + if let Some(tag) = auth_tag { + let slice = tag.as_slice(); + let json = serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string(); + builder.header("X-Auth-Tag", json) + } else { + builder } } @@ -2526,6 +2594,7 @@ async fn do_connect( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result<(WsStream, VecDeque), RelayError> { let parsed = relay_url .parse::() @@ -2544,7 +2613,7 @@ async fn do_connect( let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; // ── Step 2: Build and send kind:22242 auth event ────────────────────── - send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?; + send_auth_response(&mut ws, &challenge, relay_url, keys, api_token, auth_tag).await?; // ── Step 3: Wait for OK ─────────────────────────────────────────────── let event_id = { diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index e0912fee9..45dec4eab 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -59,6 +59,11 @@ pub enum AuthMethod { Nip42Okta, /// NIP-42 with a `sprout_` API token in the `auth_token` tag. Nip42ApiToken, + /// NIP-42 with a NIP-OA owner attestation `auth` tag. + /// + /// The agent key signed the AUTH event; the `auth` tag proves an owner + /// key authorized it. Relay membership is checked against the owner. + Nip42OwnerAttestation, } /// The result of a successful authentication, bound to a WebSocket connection. @@ -74,6 +79,12 @@ pub struct AuthContext { pub channel_ids: Option>, /// How the connection was authenticated. pub auth_method: AuthMethod, + /// The owner pubkey from a NIP-OA `auth` tag, if present. + /// + /// When an agent authenticates via owner attestation, this holds the + /// owner's pubkey. Relay membership is checked against this key. + /// `None` for all other auth methods. + pub owner_pubkey: Option, } impl AuthContext { @@ -193,6 +204,7 @@ impl AuthService { scopes, channel_ids: None, auth_method, + owner_pubkey: None, }) } @@ -424,6 +436,7 @@ mod tests { scopes: vec![Scope::MessagesRead, Scope::ChannelsRead], channel_ids: None, auth_method: AuthMethod::Nip42PubkeyOnly, + owner_pubkey: None, }; assert!(ctx.has_scope(&Scope::MessagesRead)); assert!(!ctx.has_scope(&Scope::MessagesWrite)); diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index d895800b7..d63a25b63 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::time::Duration; use futures_util::{SinkExt, StreamExt}; -use nostr::{Event, EventBuilder, Filter, Keys, Kind, Tag, Url}; +use nostr::{Event, EventBuilder, Filter, Keys, Kind, Tag}; use serde_json::{json, Value}; use thiserror::Error; use tokio::sync::{mpsc, oneshot}; @@ -221,6 +221,7 @@ async fn do_connect( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) -> Result { let parsed = relay_url .parse::() @@ -236,7 +237,7 @@ async fn do_connect( // Wait for AUTH challenge (5s timeout). let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?; - let auth_event = build_auth_event(&challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?; let event_id = auth_event.id.to_hex(); debug!("sending AUTH event {event_id}"); let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; @@ -318,29 +319,38 @@ async fn wait_for_ok( } /// Build a NIP-42 AUTH event for the given challenge. +/// +/// If `auth_tag` is provided (NIP-OA owner attestation), it is appended to the +/// event's tags so the relay can verify owner attestation at connection time. #[allow(clippy::result_large_err)] fn build_auth_event( challenge: &str, relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) -> Result { - let relay_nostr_url: Url = relay_url - .parse() - .map_err(|e: url::ParseError| RelayClientError::Url(e.to_string()))?; - if let Some(token) = api_token { - let tags = vec![ + let mut tags = if let Some(token) = api_token { + vec![ Tag::parse(&["relay", relay_url]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, Tag::parse(&["challenge", challenge]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, Tag::parse(&["auth_token", token]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, - ]; - Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) + ] } else { - Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?) + vec![ + Tag::parse(&["relay", relay_url]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + ] + }; + if let Some(tag) = auth_tag { + tags.push(tag.clone()); } + Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) } /// Send a NIP-42 AUTH response for a mid-session challenge. @@ -353,9 +363,10 @@ async fn send_auth_response( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) { let result: Result<(), RelayClientError> = async { - let auth_event = build_auth_event(challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?; let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; ws.send(Message::Text(msg.into())).await?; debug!("sent AUTH response for mid-session challenge"); @@ -377,6 +388,7 @@ async fn handle_ws_message( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) -> bool { match msg { Message::Text(text) => { @@ -429,7 +441,7 @@ async fn handle_ws_message( } RelayMessage::Auth { challenge } => { debug!("received mid-session AUTH challenge — re-authenticating"); - send_auth_response(ws, &challenge, relay_url, keys, api_token).await; + send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await; } } true @@ -463,13 +475,14 @@ async fn do_reconnect( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) -> bool { warn!("relay connection lost — reconnecting…"); state.cancel_pending(); let mut delay = Duration::from_secs(1); loop { - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, api_token, auth_tag).await { Ok(new_ws) => { tracing::info!("reconnected to relay at {relay_url}"); *ws = new_ws; @@ -544,6 +557,7 @@ async fn run_background_task( keys: Keys, relay_url: String, api_token: Option, + auth_tag: Option, ) { let mut state = BgState::new(); // Ticker for expiring timed-out pending operations (~1s granularity). @@ -558,13 +572,14 @@ async fn run_background_task( Some(Ok(msg)) => { !handle_ws_message( msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), + auth_tag.as_ref(), ).await } Some(Err(e)) => { warn!("WebSocket error: {e}"); true } None => { debug!("WebSocket stream ended"); true } }; if needs_reconnect - && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await + && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; // Shutdown received during reconnect } @@ -581,7 +596,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -611,7 +626,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(text.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -636,7 +651,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -717,16 +732,17 @@ impl RelayClient { api_token: Option<&str>, auth_tag: Option, ) -> Result { - let ws = do_connect(relay_url, keys, api_token).await?; + let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?; let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); let bg_api_token = api_token.map(|t| t.to_string()); + let bg_auth_tag = auth_tag.clone(); let handle = tokio::spawn(async move { - run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token).await; + run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await; }); Ok(Self { @@ -833,15 +849,23 @@ impl RelayClient { } } - /// Returns the appropriate auth header for REST requests. + /// Returns the appropriate auth headers for REST requests. /// /// - If an API token is present: `Authorization: Bearer ` (production mode). /// - Otherwise: `X-Pubkey: ` (dev mode, relay has `require_auth_token=false`). + /// - If a NIP-OA auth tag is configured: adds `X-Auth-Tag` for relay membership. fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if let Some(ref token) = self.api_token { + let builder = if let Some(ref token) = self.api_token { builder.header("Authorization", format!("Bearer {}", token)) } else { builder.header("X-Pubkey", self.pubkey_hex()) + }; + if let Some(ref tag) = self.auth_tag { + let slice = tag.as_slice(); + let json = serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string(); + builder.header("X-Auth-Tag", json) + } else { + builder } } diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index 4c3cdaa2e..6c9305ce4 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -38,6 +38,7 @@ deadpool-redis = { workspace = true } redis = { workspace = true } sqlx = { workspace = true } base64 = "0.22" +sprout-sdk = { workspace = true } sprout-workflow = { workspace = true, features = ["reqwest"] } sprout-media = { workspace = true } bytes = "1" diff --git a/crates/sprout-relay/src/api/git/transport.rs b/crates/sprout-relay/src/api/git/transport.rs index 0b85446e1..7c00900c5 100644 --- a/crates/sprout-relay/src/api/git/transport.rs +++ b/crates/sprout-relay/src/api/git/transport.rs @@ -171,7 +171,11 @@ impl axum::extract::FromRequestParts> for GitAuth { // replay protection for v1. Per-request signing requires protocol changes. // Relay membership gate (NIP-43). - if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize()) + let auth_tag = parts + .headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()); + if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize(), auth_tag) .await .is_err() { diff --git a/crates/sprout-relay/src/api/media.rs b/crates/sprout-relay/src/api/media.rs index 5271b1fe4..2153845e1 100644 --- a/crates/sprout-relay/src/api/media.rs +++ b/crates/sprout-relay/src/api/media.rs @@ -87,9 +87,14 @@ impl FromRequestParts> for AuthenticatedUpload { .map_err(|_| MediaError::InsufficientScope)?; // 5. Relay membership gate (NIP-43). - crate::api::relay_members::enforce_relay_membership(state, &auth_event.pubkey.serialize()) - .await - .map_err(|_| MediaError::RelayMembershipRequired)?; + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + crate::api::relay_members::enforce_relay_membership( + state, + &auth_event.pubkey.serialize(), + auth_tag, + ) + .await + .map_err(|_| MediaError::RelayMembershipRequired)?; Ok(AuthenticatedUpload { auth_event, scopes }) } diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 0652e0c84..bf2c672fb 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -157,10 +157,35 @@ pub(crate) async fn extract_auth_context( state: &AppState, ) -> Result)> { let ctx = extract_auth_context_inner(headers, state).await?; - relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes).await?; + let auth_tag = extract_single_auth_tag(headers)?; + relay_members::enforce_relay_membership(state, &ctx.pubkey_bytes, auth_tag).await?; Ok(ctx) } +/// Extract the `X-Auth-Tag` header, rejecting requests with multiple values. +/// +/// Mirrors the WS path's "exactly 0 or 1 auth tags" rule — duplicates are +/// rejected rather than silently using the first value. +pub(crate) fn extract_single_auth_tag( + headers: &HeaderMap, +) -> Result, (StatusCode, Json)> { + let mut iter = headers.get_all("x-auth-tag").iter(); + let first = match iter.next() { + Some(v) => v, + None => return Ok(None), + }; + if iter.next().is_some() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "invalid_auth_tag", + "message": "multiple X-Auth-Tag headers not allowed" + })), + )); + } + Ok(first.to_str().ok()) +} + /// Inner auth extraction — no relay membership check. /// /// Used by `extract_auth_context` (which layers the membership gate on top) diff --git a/crates/sprout-relay/src/api/relay_members.rs b/crates/sprout-relay/src/api/relay_members.rs index 66ea4c8f2..7147db22c 100644 --- a/crates/sprout-relay/src/api/relay_members.rs +++ b/crates/sprout-relay/src/api/relay_members.rs @@ -4,6 +4,11 @@ //! [`enforce_relay_membership`] is the single gate — called at every authenticated //! entry point. When `require_relay_membership` is disabled, it's a no-op. //! +//! When `allow_nip_oa_auth` is enabled and the agent is not a direct member, +//! the `X-Auth-Tag` header is checked: if it contains a valid NIP-OA owner +//! attestation for the agent's pubkey, and the attesting owner IS a relay +//! member, access is granted. +//! //! ## Routes //! - `GET /api/relay/members` — list all relay members (any authenticated member) //! - `GET /api/relay/members/me` — get own membership record (or 404) @@ -17,22 +22,28 @@ use axum::{ }; use sprout_auth::Scope; +use sprout_sdk::nip_oa; +use tracing::debug; use super::{extract_auth_context, extract_auth_context_inner, internal_error}; use crate::state::AppState; // ── Enforcement ─────────────────────────────────────────────────────────────── -/// Enforce relay membership for a pubkey. +/// Enforce relay membership for a pubkey, with optional NIP-OA fallback. /// /// - If `config.require_relay_membership` is false → always Ok (no-op). -/// - If enabled → checks `relay_members` table. Returns 403 if not a member. +/// - If the pubkey is a direct relay member → Ok. +/// - If `config.allow_nip_oa_auth` is true and `auth_tag_header` contains a +/// valid NIP-OA tag proving an owner who IS a member → Ok. +/// - Otherwise → 403. /// /// `pubkey_bytes` is the 32-byte compressed pubkey; it is hex-encoded before /// the DB lookup (the `relay_members` table stores 64-char hex strings). pub async fn enforce_relay_membership( state: &AppState, pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, ) -> Result<(), (StatusCode, Json)> { if !state.config.require_relay_membership { return Ok(()); @@ -46,16 +57,51 @@ pub async fn enforce_relay_membership( .map_err(|e| internal_error(&format!("relay membership check failed: {e}")))?; if is_member { - Ok(()) - } else { - Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "relay_membership_required", - "message": "You must be a relay member to access this relay" - })), - )) + return Ok(()); } + + // ── NIP-OA fallback: check owner attestation ────────────────────────── + if state.config.allow_nip_oa_auth { + if let Some(tag_json) = auth_tag_header { + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| { + internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}")) + })?; + + match nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + Ok(owner_pubkey) => { + let owner_hex = owner_pubkey.to_hex(); + let owner_is_member = + state.db.is_relay_member(&owner_hex).await.map_err(|e| { + internal_error(&format!("relay membership check (owner) failed: {e}")) + })?; + + if owner_is_member { + debug!( + agent = %pubkey_hex, + owner = %owner_hex, + "REST NIP-OA membership granted via owner" + ); + return Ok(()); + } + } + Err(e) => { + debug!( + agent = %pubkey_hex, + error = %e, + "REST NIP-OA auth tag verification failed" + ); + } + } + } + } + + Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + )) } // ── REST read handlers ──────────────────────────────────────────────────────── diff --git a/crates/sprout-relay/src/api/tokens.rs b/crates/sprout-relay/src/api/tokens.rs index b7ea96287..f334d5ed6 100644 --- a/crates/sprout-relay/src/api/tokens.rs +++ b/crates/sprout-relay/src/api/tokens.rs @@ -292,7 +292,9 @@ pub async fn post_tokens( } // NIP-98 path builds auth context directly — enforce membership here. // Bearer/JWT paths go through extract_auth_context which already checks. - super::relay_members::enforce_relay_membership(&state, &pubkey_bytes).await?; + let auth_tag = super::extract_single_auth_tag(&headers)?; + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag) + .await?; super::RestAuthContext { pubkey, pubkey_bytes, diff --git a/crates/sprout-relay/src/audio/handler.rs b/crates/sprout-relay/src/audio/handler.rs index 4df751938..599b38920 100644 --- a/crates/sprout-relay/src/audio/handler.rs +++ b/crates/sprout-relay/src/audio/handler.rs @@ -143,7 +143,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc, channe let parent_channel_id = auth_msg.parent_channel_id; // ── Relay membership gate (NIP-43) ──────────────────────────────────────── - if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize()) + if crate::api::relay_members::enforce_relay_membership(&state, &pubkey.serialize(), None) .await .is_err() { diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 677c0d06c..743af53f3 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -74,6 +74,15 @@ pub struct Config { /// with the `owner` role on first startup. pub relay_owner_pubkey: Option, + /// Allow NIP-OA owner attestation for relay membership. + /// + /// When `true` and `require_relay_membership` is also `true`, agents + /// bearing a valid NIP-OA `auth` tag can authenticate by proving their + /// owner is a relay member. The agent gets session-scoped access. + /// + /// Default: `false`. Set via `SPROUT_ALLOW_NIP_OA_AUTH=true`. + pub allow_nip_oa_auth: bool, + /// Media storage configuration (S3/MinIO). pub media: sprout_media::MediaConfig, @@ -149,6 +158,10 @@ impl Config { .map(|v| v == "true" || v == "1") .unwrap_or(false); + let allow_nip_oa_auth = std::env::var("SPROUT_ALLOW_NIP_OA_AUTH") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + // Note: intentionally not prefixed with SPROUT_ — this is a relay-identity // config that may be shared across multiple services (e.g., ACP agent). let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY") @@ -322,6 +335,7 @@ impl Config { pubkey_allowlist_enabled, require_relay_membership, relay_owner_pubkey, + allow_nip_oa_auth, media, ephemeral_ttl_override, git_repo_path, @@ -363,6 +377,10 @@ mod tests { config.relay_owner_pubkey.is_none(), "relay_owner_pubkey should default to None" ); + assert!( + !config.allow_nip_oa_auth, + "allow_nip_oa_auth should default to false" + ); } #[test] diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index dd47eb701..467bbe0ac 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use sha2::{Digest, Sha256}; +use sprout_sdk::nip_oa; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; @@ -212,6 +213,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: scopes, channel_ids: record.channel_ids, auth_method: sprout_auth::AuthMethod::Nip42ApiToken, + owner_pubkey: None, }; // API token users have already proven authorization via their token — // the pubkey allowlist does not apply here. @@ -246,6 +248,48 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // ── Okta JWT / pubkey-only path ───────────────────────────────────────── // Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here. + + // ── NIP-OA: extract auth tag before event is consumed ──────────────── + // Only when the relay operator has opted in via SPROUT_ALLOW_NIP_OA_AUTH=true. + // NIP-OA spec: exactly 0 or 1 `auth` tags per event; 2+ is invalid. + let nip_oa_auth_tag: Option = if state.config.allow_nip_oa_auth { + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|tag| { + let s = tag.as_slice(); + s.len() == 4 && s[0] == "auth" + }) + .collect(); + + match auth_tags.len() { + 0 => None, + 1 => { + let slice = auth_tags[0].as_slice(); + Some(serde_json::json!([slice[0], slice[1], slice[2], slice[3]]).to_string()) + } + n => { + warn!( + conn_id = %conn.conn_id, + count = n, + "AUTH event contains multiple auth tags, rejecting" + ); + metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_multiple_tags") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event.id.to_hex(), + false, + "auth-required: multiple auth tags not allowed", + )); + return; + } + } + } else { + None + }; + let agent_pubkey = event.pubkey; + match auth_svc .verify_auth_event(event, &challenge, &relay_url) .await @@ -253,6 +297,10 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Ok(auth_ctx) => { let pubkey = auth_ctx.pubkey; // Pubkey allowlist gate — only for pubkey-only auth (no JWT/token). + // NOTE: The allowlist gates which keys may *connect*. For NIP-OA, + // the agent is the connecting party, so the allowlist correctly + // checks the agent's pubkey. The NIP-43 membership check (below) + // separately verifies the owner is a relay member. // Users with valid API tokens or Okta JWTs bypass the allowlist. if state.config.pubkey_allowlist_enabled && auth_ctx.auth_method == sprout_auth::AuthMethod::Nip42PubkeyOnly @@ -278,8 +326,74 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } } - // Relay membership gate (NIP-43) — applies to ALL auth methods. - if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await { + + // ── NIP-OA: verify owner attestation if present ────────────────── + let (auth_ctx, membership_pubkey) = if let Some(ref tag_json) = nip_oa_auth_tag { + // Defense-in-depth: verify_auth_event already checks pubkey match, + // but NIP-OA verification depends on the agent pubkey being the + // event signer, so assert the invariant explicitly. + if agent_pubkey != auth_ctx.pubkey { + warn!( + conn_id = %conn_id, + agent = %agent_pubkey.to_hex(), + authenticated = %auth_ctx.pubkey.to_hex(), + "NIP-OA: agent pubkey does not match authenticated pubkey" + ); + metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_pubkey_mismatch") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: pubkey mismatch", + )); + return; + } + match nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + Ok(owner_pubkey) => { + info!( + conn_id = %conn_id, + agent = %agent_pubkey.to_hex(), + owner = %owner_pubkey.to_hex(), + "NIP-OA owner attestation verified" + ); + let mut ctx = auth_ctx; + ctx.auth_method = sprout_auth::AuthMethod::Nip42OwnerAttestation; + ctx.owner_pubkey = Some(owner_pubkey); + (ctx, owner_pubkey) + } + Err(e) => { + warn!( + conn_id = %conn_id, + agent = %agent_pubkey.to_hex(), + error = %e, + "NIP-OA auth tag verification failed" + ); + metrics::counter!("sprout_auth_failures_total", "reason" => "nip_oa_invalid") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "auth-required: owner attestation verification failed", + )); + return; + } + } + } else { + (auth_ctx, pubkey) + }; + + // Relay membership gate (NIP-43) — check owner for NIP-OA, agent otherwise. + if !enforce_ws_relay_membership( + &state, + &conn, + conn_id, + &membership_pubkey, + &event_id_hex, + ) + .await + { return; } diff --git a/crates/sprout-sdk/examples/compute_auth_tag.rs b/crates/sprout-sdk/examples/compute_auth_tag.rs new file mode 100644 index 000000000..e8c268dd3 --- /dev/null +++ b/crates/sprout-sdk/examples/compute_auth_tag.rs @@ -0,0 +1,29 @@ +//! Compute a NIP-OA auth tag for an agent keypair. +//! +//! Usage: +//! cargo run --release --example compute_auth_tag -- [conditions] +//! +//! Prints the JSON auth tag to stdout. + +use nostr::{Keys, PublicKey}; +use sprout_sdk::nip_oa; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!( + "Usage: {} [conditions]", + args[0] + ); + std::process::exit(1); + } + + let owner_keys = Keys::parse(&args[1]).expect("invalid owner secret key"); + let agent_pubkey = PublicKey::from_hex(&args[2]).expect("invalid agent pubkey hex"); + let conditions = args.get(3).map(|s| s.as_str()).unwrap_or(""); + + let tag_json = nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions) + .expect("failed to compute auth tag"); + + println!("{tag_json}"); +}