mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): NIP-OA agent authentication for NIP-43 membership (WS + REST + git) (#471)
This commit is contained in:
Generated
+1
@@ -3960,6 +3960,7 @@ dependencies = [
|
||||
"sprout-db",
|
||||
"sprout-media",
|
||||
"sprout-pubsub",
|
||||
"sprout-sdk",
|
||||
"sprout-search",
|
||||
"sprout-workflow",
|
||||
"sqlx",
|
||||
|
||||
@@ -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<String>,
|
||||
pub keys: Keys,
|
||||
/// NIP-OA auth tag sent as `X-Auth-Tag` header for relay membership.
|
||||
pub auth_tag: Option<Tag>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<Tag>,
|
||||
/// 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<Self, RelayError> {
|
||||
// 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<Tag> = 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::<Option<SproutEvent>>(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<String>,
|
||||
auth_tag: Option<Tag>,
|
||||
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<Option<SproutEvent>>,
|
||||
observer_control_tx: &mpsc::Sender<Event>,
|
||||
@@ -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<Option<SproutEvent>>,
|
||||
observer_control_tx: &mpsc::Sender<Event>,
|
||||
@@ -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<Uuid> {
|
||||
}
|
||||
|
||||
/// Apply the appropriate auth header to a reqwest request builder.
|
||||
fn apply_auth(
|
||||
fn apply_auth_with_tag(
|
||||
builder: reqwest::RequestBuilder,
|
||||
api_token: &Option<String>,
|
||||
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<RelayMessage>), RelayError> {
|
||||
let parsed = relay_url
|
||||
.parse::<url::Url>()
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<Vec<uuid::Uuid>>,
|
||||
/// 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<nostr::PublicKey>,
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
@@ -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<WsStream, RelayClientError> {
|
||||
let parsed = relay_url
|
||||
.parse::<url::Url>()
|
||||
@@ -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<Event, RelayClientError> {
|
||||
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<String>,
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
) {
|
||||
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<nostr::Tag>,
|
||||
) -> Result<Self, RelayClientError> {
|
||||
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 <token>` (production mode).
|
||||
/// - Otherwise: `X-Pubkey: <hex>` (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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -171,7 +171,11 @@ impl axum::extract::FromRequestParts<Arc<AppState>> 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()
|
||||
{
|
||||
|
||||
@@ -87,9 +87,14 @@ impl FromRequestParts<Arc<AppState>> 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 })
|
||||
}
|
||||
|
||||
@@ -157,10 +157,35 @@ pub(crate) async fn extract_auth_context(
|
||||
state: &AppState,
|
||||
) -> Result<RestAuthContext, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<Option<&str>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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)
|
||||
|
||||
@@ -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<serde_json::Value>)> {
|
||||
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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -143,7 +143,7 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, 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()
|
||||
{
|
||||
|
||||
@@ -74,6 +74,15 @@ pub struct Config {
|
||||
/// with the `owner` role on first startup.
|
||||
pub relay_owner_pubkey: Option<String>,
|
||||
|
||||
/// 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]
|
||||
|
||||
@@ -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<ConnectionState>, 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<ConnectionState>, 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<String> = 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<ConnectionState>, 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<ConnectionState>, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Compute a NIP-OA auth tag for an agent keypair.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --example compute_auth_tag -- <owner_secret_hex> <agent_pubkey_hex> [conditions]
|
||||
//!
|
||||
//! Prints the JSON auth tag to stdout.
|
||||
|
||||
use nostr::{Keys, PublicKey};
|
||||
use sprout_sdk::nip_oa;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!(
|
||||
"Usage: {} <owner_secret_hex> <agent_pubkey_hex> [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}");
|
||||
}
|
||||
Reference in New Issue
Block a user