mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: unify NIP-OA relay membership enforcement across all ingress paths (#490)
This commit is contained in:
@@ -901,9 +901,17 @@ async fn tokio_main() -> Result<()> {
|
||||
.as_secs();
|
||||
|
||||
let pubkey_hex = config.keys.public_key().to_hex();
|
||||
let mut relay = HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;
|
||||
|
||||
// Parse SPROUT_AUTH_TAG into a nostr::Tag for NIP-OA relay membership delegation.
|
||||
let relay_auth_tag: Option<nostr::Tag> = std::env::var("SPROUT_AUTH_TAG")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| sprout_sdk::nip_oa::parse_auth_tag(&s).ok());
|
||||
|
||||
let mut relay =
|
||||
HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;
|
||||
|
||||
// Finding #22: tell the relay background task the watermark so it can use
|
||||
// `since = watermark - 5s` on the first REQ instead of `since=now`.
|
||||
|
||||
@@ -97,6 +97,8 @@ pub struct RestClient {
|
||||
pub http: reqwest::Client,
|
||||
pub base_url: String,
|
||||
pub keys: Keys,
|
||||
/// Optional NIP-OA auth tag JSON for `x-auth-tag` header (relay membership delegation).
|
||||
pub auth_tag_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether an HTTP status code is retriable (transient server/rate-limit errors).
|
||||
@@ -231,18 +233,22 @@ impl RestClient {
|
||||
) -> Result<reqwest::Response, RelayError> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let body_owned = body_bytes.to_vec();
|
||||
let auth_tag_header = self.auth_tag_json.clone();
|
||||
self.request_with_retry("POST", path, || {
|
||||
// NIP-98 is re-signed each attempt (fresh created_at).
|
||||
// sign_nip98 is infallible in practice (key is always valid).
|
||||
let auth = self
|
||||
.nip98_header("POST", &url, Some(&body_owned))
|
||||
.unwrap_or_default();
|
||||
self.http
|
||||
let mut req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body_owned.clone())
|
||||
.send()
|
||||
.header("Content-Type", "application/json");
|
||||
if let Some(ref tag) = auth_tag_header {
|
||||
req = req.header("x-auth-tag", tag);
|
||||
}
|
||||
req.body(body_owned.clone()).send()
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -412,6 +418,9 @@ pub struct HarnessRelay {
|
||||
/// Agent public key (hex) used as the `#p` filter on subscriptions.
|
||||
#[allow(dead_code)]
|
||||
agent_pubkey_hex: String,
|
||||
/// Optional NIP-OA auth tag for relay membership delegation.
|
||||
#[allow(dead_code)]
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
/// Handle to the background task (for clean shutdown).
|
||||
/// Wrapped in `Option` so `shutdown()` can take ownership without conflicting
|
||||
/// with `Drop` (which only has `&mut self`).
|
||||
@@ -440,15 +449,19 @@ impl HarnessRelay {
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Connect to relay and authenticate via NIP-42.
|
||||
///
|
||||
/// `auth_tag` is an optional NIP-OA owner attestation included in the AUTH
|
||||
/// event for relay membership delegation.
|
||||
pub async fn connect(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
agent_pubkey_hex: &str,
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
) -> Result<Self, RelayError> {
|
||||
// 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).await?;
|
||||
let (ws, handshake_buffer) = do_connect(relay_url, keys, auth_tag.as_ref()).await?;
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<Option<SproutEvent>>(event_channel_capacity());
|
||||
let (observer_control_tx, observer_control_rx) =
|
||||
@@ -458,6 +471,7 @@ impl HarnessRelay {
|
||||
let bg_keys = keys.clone();
|
||||
let bg_relay_url = relay_url.to_string();
|
||||
let bg_agent_pubkey_hex = agent_pubkey_hex.to_string();
|
||||
let bg_auth_tag = auth_tag.clone();
|
||||
|
||||
let bg_handle = tokio::spawn(async move {
|
||||
run_background_task(
|
||||
@@ -469,6 +483,7 @@ impl HarnessRelay {
|
||||
bg_keys,
|
||||
bg_relay_url,
|
||||
bg_agent_pubkey_hex,
|
||||
bg_auth_tag,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
@@ -485,6 +500,7 @@ impl HarnessRelay {
|
||||
relay_url: relay_url.to_string(),
|
||||
keys: keys.clone(),
|
||||
agent_pubkey_hex: agent_pubkey_hex.to_string(),
|
||||
auth_tag,
|
||||
bg_handle: Some(bg_handle),
|
||||
})
|
||||
}
|
||||
@@ -609,6 +625,10 @@ impl HarnessRelay {
|
||||
http: self.http.clone(),
|
||||
base_url: relay_ws_to_http(&self.relay_url),
|
||||
keys: self.keys.clone(),
|
||||
auth_tag_json: self
|
||||
.auth_tag
|
||||
.as_ref()
|
||||
.and_then(|t| serde_json::to_string(t.as_slice()).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1148,6 +1168,7 @@ async fn run_background_task(
|
||||
keys: Keys,
|
||||
relay_url: String,
|
||||
agent_pubkey_hex: String,
|
||||
auth_tag: Option<nostr::Tag>,
|
||||
) {
|
||||
let mut state = BgState::new();
|
||||
|
||||
@@ -1162,6 +1183,7 @@ async fn run_background_task(
|
||||
&keys,
|
||||
&relay_url,
|
||||
&agent_pubkey_hex,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await;
|
||||
if !handshake_ok {
|
||||
@@ -1178,6 +1200,7 @@ async fn run_background_task(
|
||||
&agent_pubkey_hex,
|
||||
&event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1202,6 +1225,7 @@ async fn run_background_task(
|
||||
&event_tx,
|
||||
&observer_control_tx,
|
||||
true,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await,
|
||||
ReconnectOutcome::Shutdown
|
||||
@@ -1241,6 +1265,7 @@ async fn run_background_task(
|
||||
&agent_pubkey_hex,
|
||||
&event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1271,6 +1296,7 @@ async fn run_background_task(
|
||||
&event_tx,
|
||||
&observer_control_tx,
|
||||
true,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await,
|
||||
ReconnectOutcome::Shutdown
|
||||
@@ -1307,6 +1333,7 @@ async fn run_background_task(
|
||||
&keys,
|
||||
&relay_url,
|
||||
&agent_pubkey_hex,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1335,6 +1362,7 @@ async fn run_background_task(
|
||||
&agent_pubkey_hex,
|
||||
&event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
)
|
||||
.await;
|
||||
match outcome {
|
||||
@@ -1355,6 +1383,7 @@ async fn run_background_task(
|
||||
wait_for_reconnect(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx, &observer_control_tx, true,
|
||||
auth_tag.as_ref(),
|
||||
).await,
|
||||
ReconnectOutcome::Shutdown
|
||||
) { return; }
|
||||
@@ -1375,6 +1404,7 @@ async fn run_background_task(
|
||||
wait_for_reconnect(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx, &observer_control_tx, true,
|
||||
auth_tag.as_ref(),
|
||||
).await,
|
||||
ReconnectOutcome::Shutdown
|
||||
) { return; }
|
||||
@@ -1409,6 +1439,7 @@ async fn run_background_task(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
).await {
|
||||
ReconnectOutcome::Shutdown => return,
|
||||
ReconnectOutcome::Ok => {
|
||||
@@ -1422,6 +1453,7 @@ async fn run_background_task(
|
||||
wait_for_reconnect(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx, &observer_control_tx, true,
|
||||
auth_tag.as_ref(),
|
||||
).await,
|
||||
ReconnectOutcome::Shutdown
|
||||
) { return; }
|
||||
@@ -1447,6 +1479,7 @@ async fn run_background_task(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
).await {
|
||||
ReconnectOutcome::Shutdown => return,
|
||||
ReconnectOutcome::Ok => {
|
||||
@@ -1460,6 +1493,7 @@ async fn run_background_task(
|
||||
wait_for_reconnect(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx, &observer_control_tx, true,
|
||||
auth_tag.as_ref(),
|
||||
).await,
|
||||
ReconnectOutcome::Shutdown
|
||||
) { return; }
|
||||
@@ -1478,6 +1512,7 @@ async fn run_background_task(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx,
|
||||
&observer_control_tx,
|
||||
auth_tag.as_ref(),
|
||||
).await {
|
||||
ReconnectOutcome::Shutdown => return,
|
||||
ReconnectOutcome::Ok => {
|
||||
@@ -1491,6 +1526,7 @@ async fn run_background_task(
|
||||
wait_for_reconnect(
|
||||
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
|
||||
&agent_pubkey_hex, &event_tx, &observer_control_tx, true,
|
||||
auth_tag.as_ref(),
|
||||
).await,
|
||||
ReconnectOutcome::Shutdown
|
||||
) { return; }
|
||||
@@ -1532,6 +1568,7 @@ async fn handle_ws_message(
|
||||
keys: &Keys,
|
||||
relay_url: &str,
|
||||
agent_pubkey_hex: &str,
|
||||
auth_tag: Option<&nostr::Tag>,
|
||||
) -> bool {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
@@ -1781,7 +1818,9 @@ async fn handle_ws_message(
|
||||
RelayMessage::Auth { challenge } => {
|
||||
// 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).await {
|
||||
if let Err(e) =
|
||||
send_auth_response(ws, &challenge, relay_url, keys, auth_tag).await
|
||||
{
|
||||
warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect");
|
||||
return false;
|
||||
}
|
||||
@@ -1833,6 +1872,7 @@ async fn process_handshake_buffer(
|
||||
keys: &Keys,
|
||||
relay_url: &str,
|
||||
agent_pubkey_hex: &str,
|
||||
auth_tag: Option<&nostr::Tag>,
|
||||
) -> bool {
|
||||
if buffer.is_empty() {
|
||||
return true;
|
||||
@@ -1875,6 +1915,7 @@ async fn process_handshake_buffer(
|
||||
keys,
|
||||
relay_url,
|
||||
agent_pubkey_hex,
|
||||
auth_tag,
|
||||
)
|
||||
.await;
|
||||
if !should_continue {
|
||||
@@ -2038,6 +2079,7 @@ async fn try_autonomous_reconnect(
|
||||
agent_pubkey_hex: &str,
|
||||
event_tx: &mpsc::Sender<Option<SproutEvent>>,
|
||||
observer_control_tx: &mpsc::Sender<Event>,
|
||||
auth_tag: Option<&nostr::Tag>,
|
||||
) -> ReconnectOutcome {
|
||||
// Finding #42: 5 attempts, up to 16s base backoff.
|
||||
let backoffs = [
|
||||
@@ -2054,7 +2096,7 @@ async fn try_autonomous_reconnect(
|
||||
attempt + 1,
|
||||
backoffs.len()
|
||||
);
|
||||
match do_connect(relay_url, keys).await {
|
||||
match do_connect(relay_url, keys, auth_tag).await {
|
||||
Ok((new_ws, handshake_buffer)) => {
|
||||
*ws = new_ws;
|
||||
info!("autonomous reconnect succeeded (attempt {})", attempt + 1);
|
||||
@@ -2068,6 +2110,7 @@ async fn try_autonomous_reconnect(
|
||||
keys,
|
||||
relay_url,
|
||||
agent_pubkey_hex,
|
||||
auth_tag,
|
||||
)
|
||||
.await;
|
||||
if !handshake_ok {
|
||||
@@ -2140,6 +2183,7 @@ async fn wait_for_reconnect(
|
||||
event_tx: &mpsc::Sender<Option<SproutEvent>>,
|
||||
observer_control_tx: &mpsc::Sender<Event>,
|
||||
skip_drain: bool,
|
||||
auth_tag: Option<&nostr::Tag>,
|
||||
) -> ReconnectOutcome {
|
||||
if !skip_drain {
|
||||
// Drain commands until we get Reconnect (or Shutdown).
|
||||
@@ -2167,7 +2211,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).await {
|
||||
match do_connect(relay_url, keys, auth_tag).await {
|
||||
Ok((new_ws, handshake_buffer)) => {
|
||||
*ws = new_ws;
|
||||
info!("relay reconnected to {relay_url}");
|
||||
@@ -2181,6 +2225,7 @@ async fn wait_for_reconnect(
|
||||
keys,
|
||||
relay_url,
|
||||
agent_pubkey_hex,
|
||||
auth_tag,
|
||||
)
|
||||
.await;
|
||||
if !handshake_ok {
|
||||
@@ -2429,17 +2474,33 @@ fn extract_h_tag_uuid(event: &nostr::Event) -> Option<Uuid> {
|
||||
}
|
||||
|
||||
/// Build and send a NIP-42 AUTH response event.
|
||||
///
|
||||
/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the
|
||||
/// AUTH event so the relay can use it for membership delegation fallback.
|
||||
async fn send_auth_response(
|
||||
ws: &mut WsStream,
|
||||
challenge: &str,
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
auth_tag: Option<&nostr::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 = EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?;
|
||||
let auth_event = if let Some(tag) = auth_tag {
|
||||
// Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags.
|
||||
let tags = vec![
|
||||
nostr::Tag::parse(&["relay", relay_url])
|
||||
.map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?,
|
||||
nostr::Tag::parse(&["challenge", challenge])
|
||||
.map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?,
|
||||
tag.clone(),
|
||||
];
|
||||
EventBuilder::new(nostr::Kind::Authentication, "", tags).sign_with_keys(keys)?
|
||||
} else {
|
||||
EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?
|
||||
};
|
||||
|
||||
let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
|
||||
ws_send_timeout(ws, Message::Text(auth_msg.into()), WS_SEND_TIMEOUT_SECS).await?;
|
||||
@@ -2576,6 +2637,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayError
|
||||
async fn do_connect(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
auth_tag: Option<&nostr::Tag>,
|
||||
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
|
||||
let parsed = relay_url
|
||||
.parse::<url::Url>()
|
||||
@@ -2594,7 +2656,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).await?;
|
||||
send_auth_response(&mut ws, &challenge, relay_url, keys, auth_tag).await?;
|
||||
|
||||
// ── Step 3: Wait for OK ───────────────────────────────────────────────
|
||||
let event_id = {
|
||||
|
||||
@@ -221,6 +221,7 @@ async fn do_connect(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<&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,18 +319,22 @@ 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 included in the
|
||||
/// AUTH event so the relay can use it for membership delegation fallback.
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn build_auth_event(
|
||||
challenge: &str,
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<&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 = vec![
|
||||
Tag::parse(&["relay", relay_url])
|
||||
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
|
||||
Tag::parse(&["challenge", challenge])
|
||||
@@ -337,6 +342,19 @@ fn build_auth_event(
|
||||
Tag::parse(&["auth_token", token])
|
||||
.map_err(|e| RelayClientError::EventBuilder(e.to_string()))?,
|
||||
];
|
||||
if let Some(t) = auth_tag {
|
||||
tags.push(t.clone());
|
||||
}
|
||||
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
|
||||
} else if let Some(t) = auth_tag {
|
||||
// Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags.
|
||||
let tags = 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()))?,
|
||||
t.clone(),
|
||||
];
|
||||
Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?)
|
||||
} else {
|
||||
Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?)
|
||||
@@ -353,9 +371,10 @@ async fn send_auth_response(
|
||||
relay_url: &str,
|
||||
keys: &Keys,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<&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 +396,7 @@ async fn handle_ws_message(
|
||||
keys: &Keys,
|
||||
relay_url: &str,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<&Tag>,
|
||||
) -> bool {
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
@@ -429,7 +449,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 +483,14 @@ async fn do_reconnect(
|
||||
keys: &Keys,
|
||||
relay_url: &str,
|
||||
api_token: Option<&str>,
|
||||
auth_tag: Option<&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 +565,7 @@ async fn run_background_task(
|
||||
keys: Keys,
|
||||
relay_url: String,
|
||||
api_token: Option<String>,
|
||||
auth_tag: Option<Tag>,
|
||||
) {
|
||||
let mut state = BgState::new();
|
||||
// Ticker for expiring timed-out pending operations (~1s granularity).
|
||||
@@ -557,14 +579,14 @@ async fn run_background_task(
|
||||
let needs_reconnect = match raw {
|
||||
Some(Ok(msg)) => {
|
||||
!handle_ws_message(
|
||||
msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(),
|
||||
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 +603,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 +633,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 +658,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;
|
||||
@@ -715,16 +737,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 {
|
||||
@@ -802,6 +825,15 @@ impl RelayClient {
|
||||
&self.keys
|
||||
}
|
||||
|
||||
/// Returns the NIP-OA auth tag JSON string for use in HTTP `x-auth-tag` headers.
|
||||
///
|
||||
/// Returns `None` if no auth tag is configured (direct-member agents).
|
||||
pub fn auth_tag_json(&self) -> Option<String> {
|
||||
self.auth_tag
|
||||
.as_ref()
|
||||
.and_then(|t| serde_json::to_string(t.as_slice()).ok())
|
||||
}
|
||||
|
||||
/// Returns the relay's server authority (host or host:port) for BUD-11 server tags.
|
||||
///
|
||||
/// Uses the same logic as the desktop client's `extract_server_authority`:
|
||||
|
||||
@@ -1017,6 +1017,7 @@ Default kind is 9 (stream message)."
|
||||
&self.client.relay_http_url(),
|
||||
self.client.server_domain().as_deref(),
|
||||
path,
|
||||
self.client.auth_tag_json().as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3113,6 +3114,7 @@ on send_message to upload and attach in one step."
|
||||
&self.client.relay_http_url(),
|
||||
self.client.server_domain().as_deref(),
|
||||
&p.file_path,
|
||||
self.client.auth_tag_json().as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -112,12 +112,16 @@ pub enum UploadError {
|
||||
///
|
||||
/// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT.
|
||||
/// Returns the relay's [`BlobDescriptor`] on success.
|
||||
///
|
||||
/// `auth_tag_json` is an optional NIP-OA auth tag (JSON-array string) sent as
|
||||
/// the `x-auth-tag` header for relay membership delegation.
|
||||
pub async fn upload_file(
|
||||
http: &reqwest::Client,
|
||||
keys: &Keys,
|
||||
relay_http_url: &str,
|
||||
server_domain: Option<&str>,
|
||||
file_path: &str,
|
||||
auth_tag_json: Option<&str>,
|
||||
) -> Result<BlobDescriptor, UploadError> {
|
||||
// 1. Validate path exists
|
||||
let metadata = std::fs::metadata(file_path).map_err(|e| {
|
||||
@@ -223,15 +227,16 @@ pub async fn upload_file(
|
||||
};
|
||||
|
||||
let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/'));
|
||||
let resp = http
|
||||
let mut req = http
|
||||
.put(&url)
|
||||
.timeout(upload_timeout)
|
||||
.header("Authorization", &auth_header)
|
||||
.header("Content-Type", mime)
|
||||
.header("X-SHA-256", &sha256)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.await?;
|
||||
.header("X-SHA-256", &sha256);
|
||||
if let Some(tag) = auth_tag_json {
|
||||
req = req.header("x-auth-tag", tag);
|
||||
}
|
||||
let resp = req.body(bytes).send().await?;
|
||||
|
||||
// 9. Handle response
|
||||
let status = resp.status();
|
||||
|
||||
@@ -137,8 +137,9 @@ pub async fn submit_event(
|
||||
check_nip98_replay(&state, event_id_bytes)?;
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
|
||||
// Enforce relay membership
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?;
|
||||
// Enforce relay membership (with NIP-OA fallback via x-auth-tag header).
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
|
||||
let event: nostr::Event = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?;
|
||||
@@ -184,7 +185,8 @@ pub async fn query_events(
|
||||
check_nip98_replay(&state, event_id_bytes)?;
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?;
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
|
||||
let filters: Vec<nostr::Filter> = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
|
||||
@@ -278,7 +280,8 @@ pub async fn count_events(
|
||||
check_nip98_replay(&state, event_id_bytes)?;
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, None).await?;
|
||||
let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok());
|
||||
super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?;
|
||||
|
||||
let filters: Vec<nostr::Filter> = serde_json::from_slice(&body)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?;
|
||||
|
||||
@@ -117,6 +117,9 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
}
|
||||
};
|
||||
|
||||
// Extract NIP-OA auth tag before verify_auth_event consumes the event.
|
||||
let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event);
|
||||
|
||||
let relay_url = state.config.relay_url.clone();
|
||||
let auth_ctx = match state
|
||||
.auth
|
||||
@@ -142,10 +145,14 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc<AppState>, channe
|
||||
let pubkey_bytes = pubkey.serialize().to_vec();
|
||||
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(), None)
|
||||
.await
|
||||
.is_err()
|
||||
// ── Relay membership gate (with NIP-OA fallback) ────────────────────────────
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
&pubkey.serialize(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied");
|
||||
let _ = ws_send
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
//! NIP-42 AUTH handler — verify challenge response, transition auth state.
|
||||
//!
|
||||
//! 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).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -8,49 +14,21 @@ use crate::connection::{AuthState, ConnectionState};
|
||||
use crate::protocol::RelayMessage;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Check relay membership for a pubkey during NIP-42 auth.
|
||||
/// Extract a NIP-OA `auth` tag from a verified AUTH event and serialize it as
|
||||
/// the JSON-array string that [`sprout_sdk::nip_oa::verify_auth_tag`] expects.
|
||||
///
|
||||
/// Returns `true` if the pubkey is a relay member (or if membership enforcement
|
||||
/// is disabled). Returns `false` and sends a rejection message if not a member.
|
||||
async fn enforce_ws_relay_membership(
|
||||
state: &AppState,
|
||||
conn: &Arc<ConnectionState>,
|
||||
conn_id: uuid::Uuid,
|
||||
pubkey: &nostr::PublicKey,
|
||||
event_id_hex: &str,
|
||||
) -> bool {
|
||||
if !state.config.require_relay_membership {
|
||||
return true;
|
||||
/// Returns `None` if no `auth` tag is present (direct-member auth path) or if
|
||||
/// more than one `auth` tag exists (per NIP-OA spec: >1 auth tag ⇒ no valid tag).
|
||||
pub fn extract_auth_tag_json(event: &nostr::Event) -> Option<String> {
|
||||
let mut iter = event
|
||||
.tags
|
||||
.iter()
|
||||
.filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth"));
|
||||
let first = iter.next()?;
|
||||
if iter.next().is_some() {
|
||||
return None; // NIP-OA spec: treat >1 auth tag as no valid auth tag
|
||||
}
|
||||
|
||||
let pubkey_hex = pubkey.to_hex();
|
||||
let is_member = match state.db.is_relay_member(&pubkey_hex).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
conn_id = %conn_id,
|
||||
pubkey = %pubkey_hex,
|
||||
error = %e,
|
||||
"relay membership check failed, denying (fail-closed)"
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !is_member {
|
||||
warn!(conn_id = %conn_id, pubkey = %pubkey_hex, "not a relay member");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
event_id_hex,
|
||||
false,
|
||||
"restricted: not a relay member",
|
||||
));
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
serde_json::to_string(first.as_slice()).ok()
|
||||
}
|
||||
|
||||
/// Handle a NIP-42 AUTH message: verify the challenge response and transition
|
||||
@@ -84,6 +62,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
}
|
||||
};
|
||||
|
||||
// Extract the NIP-OA auth tag before verification consumes the event.
|
||||
// The tag is integrity-protected by the event's Schnorr signature — if
|
||||
// tampered, NIP-42 verification will fail before we ever inspect it.
|
||||
let auth_tag_json = extract_auth_tag_json(&event);
|
||||
|
||||
let relay_url = state.config.relay_url.clone();
|
||||
let auth_svc = Arc::clone(&state.auth);
|
||||
|
||||
@@ -123,8 +106,24 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
|
||||
}
|
||||
}
|
||||
|
||||
// Relay membership gate — applies to all auth methods.
|
||||
if !enforce_ws_relay_membership(&state, &conn, conn_id, &pubkey, &event_id_hex).await {
|
||||
// Relay membership gate — uses the shared helper with NIP-OA fallback.
|
||||
if crate::api::relay_members::enforce_relay_membership(
|
||||
&state,
|
||||
&pubkey.serialize(),
|
||||
auth_tag_json.as_deref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member");
|
||||
metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member")
|
||||
.increment(1);
|
||||
*conn.auth_state.write().await = AuthState::Failed;
|
||||
conn.send(RelayMessage::ok(
|
||||
&event_id_hex,
|
||||
false,
|
||||
"restricted: not a relay member",
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user