lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit a37a06ce0dfe0c7d08e73ad324fcaddf952a173d
parent 69c3ce2ded69b08e20993620d48391be7b93c29c
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 06:41:12 +0000

transport-nostr: implement explicit NIP-42 authentication flow

- share one signer-free live relay client while disabling upstream automatic authentication
- model bounded redacted challenges and return exact host-owned NIP-42 signing inputs
- verify relay, challenge, deadline, event identity, and signature before one explicit AUTH submission
- verify challenge, wrong-relay, expiry, rejection, no-signer, package, and architecture gates

Diffstat:
MCargo.lock | 1+
Mcrates/transport_nostr/Cargo.toml | 1+
Mcrates/transport_nostr/src/auth.rs | 410++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/transport_nostr/src/client.rs | 16++++++++++++----
Mcrates/transport_nostr/src/error.rs | 40++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_nostr/src/sink.rs | 27+++++++++++++++++++++------
Mcrates/transport_nostr/src/source.rs | 26++++++++++++++++++++------
7 files changed, 504 insertions(+), 17 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -5244,6 +5244,7 @@ dependencies = [ "radroots_nostr", "radroots_protocol", "radroots_transport", + "serde_json", "url", ] diff --git a/crates/transport_nostr/Cargo.toml b/crates/transport_nostr/Cargo.toml @@ -20,6 +20,7 @@ radroots_nostr = { workspace = true, default-features = false, features = ["even radroots_protocol = { workspace = true, default-features = false } radroots_transport = { workspace = true, default-features = false } nostr-sdk = { workspace = true } +serde_json = { workspace = true, features = ["std"] } url = { workspace = true } [dev-dependencies] diff --git a/crates/transport_nostr/src/auth.rs b/crates/transport_nostr/src/auth.rs @@ -1 +1,409 @@ -//! Explicit relay authentication state. +//! Explicit NIP-42 relay authentication state. + +use crate::{Error, NostrTransport, RelayUrl}; +use nostr_sdk::prelude::{ClientMessage, Event}; +use radroots_transport::BoxFuture; +use std::collections::BTreeMap; +use std::fmt; +use std::sync::{Arc, Mutex}; + +const MAX_CHALLENGE_BYTES: usize = 1_024; +const MAX_CHALLENGE_LIFETIME_MS: u64 = 600_000; + +#[derive(Clone, Eq, PartialEq)] +struct PendingAuth { + challenge: String, + required_at_unix_ms: u64, + expires_at_unix_ms: u64, +} + +impl fmt::Debug for PendingAuth { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PendingAuth") + .field("challenge", &"[redacted]") + .field("required_at_unix_ms", &self.required_at_unix_ms) + .field("expires_at_unix_ms", &self.expires_at_unix_ms) + .finish() + } +} + +pub(crate) trait AuthClient: Send + Sync { + fn submit<'a>(&'a self, relay: RelayUrl, event: Event) -> BoxFuture<'a, Result<(), Error>>; +} + +#[derive(Clone, Debug)] +pub(crate) struct LiveAuthClient { + client: nostr_sdk::Client, +} + +impl LiveAuthClient { + pub(crate) const fn new(client: nostr_sdk::Client) -> Self { + Self { client } + } +} + +impl AuthClient for LiveAuthClient { + fn submit<'a>(&'a self, relay: RelayUrl, event: Event) -> BoxFuture<'a, Result<(), Error>> { + Box::pin(async move { + let expected = relay.as_str().trim_end_matches('/'); + let output = self + .client + .send_msg_to([relay.as_str()], ClientMessage::auth(event)) + .await + .map_err(|_| Error::AuthTransport)?; + if output + .success + .iter() + .any(|url| url.to_string().trim_end_matches('/') == expected) + { + Ok(()) + } else { + Err(Error::AuthTransport) + } + }) + } +} + +pub(crate) struct AuthFlow { + client: Arc<dyn AuthClient>, + pending: Mutex<BTreeMap<RelayUrl, PendingAuth>>, +} + +impl AuthFlow { + pub(crate) fn new(client: Arc<dyn AuthClient>) -> Self { + Self { + client, + pending: Mutex::new(BTreeMap::new()), + } + } + + #[cfg(test)] + pub(crate) fn isolated() -> Self { + let client = nostr_sdk::Client::default(); + client.automatic_authentication(false); + Self::new(Arc::new(LiveAuthClient::new(client))) + } + + fn begin( + &self, + relay: RelayUrl, + challenge: &str, + required_at_unix_ms: u64, + expires_at_unix_ms: u64, + ) -> Result<String, Error> { + validate_challenge(challenge, required_at_unix_ms, expires_at_unix_ms)?; + let pending = PendingAuth { + challenge: challenge.to_owned(), + required_at_unix_ms, + expires_at_unix_ms, + }; + let mut state = self + .pending + .lock() + .map_err(|_| Error::AuthStateUnavailable)?; + match state.get(&relay) { + Some(existing) if existing == &pending => {} + Some(_) => return Err(Error::AuthChallengeConflict), + None => { + state.insert(relay.clone(), pending); + } + } + serde_json::to_string(&serde_json::json!({ + "content": "", + "created_at_max_unix_ms": expires_at_unix_ms, + "created_at_min_unix_ms": required_at_unix_ms, + "kind": 22242, + "tags": [ + ["challenge", challenge], + ["relay", relay.as_str()], + ], + })) + .map_err(|_| Error::AuthStateUnavailable) + } + + fn pending(&self, relay: &RelayUrl, challenge: &str) -> Result<PendingAuth, Error> { + let state = self + .pending + .lock() + .map_err(|_| Error::AuthStateUnavailable)?; + let pending = state.get(relay).ok_or(Error::AuthChallengeMissing)?; + if pending.challenge != challenge { + return Err(Error::AuthResponseMismatch); + } + Ok(pending.clone()) + } + + fn remove(&self, relay: &RelayUrl, challenge: &str) -> Result<(), Error> { + let mut state = self + .pending + .lock() + .map_err(|_| Error::AuthStateUnavailable)?; + match state.get(relay) { + Some(pending) if pending.challenge == challenge => { + state.remove(relay); + Ok(()) + } + Some(_) => Err(Error::AuthResponseMismatch), + None => Err(Error::AuthChallengeMissing), + } + } +} + +impl fmt::Debug for AuthFlow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let pending_count = self + .pending + .lock() + .map(|pending| pending.len()) + .unwrap_or_default(); + formatter + .debug_struct("AuthFlow") + .field("pending_count", &pending_count) + .finish_non_exhaustive() + } +} + +impl NostrTransport { + /// Records an exact relay challenge and returns bounded signing input. + pub fn begin_authentication( + &self, + relay: &RelayUrl, + challenge: impl AsRef<str>, + required_at_unix_ms: u64, + expires_at_unix_ms: u64, + ) -> Result<String, Error> { + if !self.config().relays().contains(relay) { + return Err(Error::AuthResponseMismatch); + } + self.auth.begin( + relay.clone(), + challenge.as_ref(), + required_at_unix_ms, + expires_at_unix_ms, + ) + } + + /// Verifies and submits one host-signed NIP-42 response without retrying. + pub fn complete_authentication<'a>( + &'a self, + relay: &'a RelayUrl, + challenge: &'a str, + signed_event_json: Option<&'a str>, + now_unix_ms: u64, + ) -> BoxFuture<'a, Result<(), Error>> { + Box::pin(async move { + let pending = self.auth.pending(relay, challenge)?; + if now_unix_ms > pending.expires_at_unix_ms { + self.auth.remove(relay, challenge)?; + return Err(Error::AuthChallengeExpired); + } + let signed_event_json = signed_event_json.ok_or(Error::AuthSignerUnavailable)?; + let signed = radroots_event_codec::decode::signed_event(signed_event_json) + .map_err(|_| Error::AuthResponseInvalid)?; + if radroots_nostr::event::verify(signed.envelope()) + != radroots_nostr::event::Verification::Verified + { + return Err(Error::AuthResponseInvalid); + } + let created_at_ms = signed.created_at().saturating_mul(1_000); + if signed.kind() != 22242 + || !signed.content().is_empty() + || created_at_ms < pending.required_at_unix_ms + || created_at_ms > pending.expires_at_unix_ms + || !has_exact_tag(signed.tags_as_vec().as_slice(), "challenge", challenge) + || !has_exact_tag(signed.tags_as_vec().as_slice(), "relay", relay.as_str()) + { + return Err(Error::AuthResponseMismatch); + } + let event = radroots_nostr::event::to_nostr(signed.envelope()) + .map_err(|_| Error::AuthResponseInvalid)?; + self.auth.client.submit(relay.clone(), event).await?; + self.auth.remove(relay, challenge) + }) + } + + /// Rejects and consumes one exact pending challenge without relay access. + pub fn reject_authentication(&self, relay: &RelayUrl, challenge: &str) -> Result<(), Error> { + self.auth.remove(relay, challenge) + } + + #[cfg(test)] + fn with_auth_client(mut self, client: Arc<dyn AuthClient>) -> Self { + self.auth = Arc::new(AuthFlow::new(client)); + self + } +} + +fn validate_challenge( + challenge: &str, + required_at_unix_ms: u64, + expires_at_unix_ms: u64, +) -> Result<(), Error> { + if challenge.is_empty() + || challenge.len() > MAX_CHALLENGE_BYTES + || challenge != challenge.trim() + || challenge.chars().any(char::is_control) + || required_at_unix_ms == 0 + || expires_at_unix_ms <= required_at_unix_ms + || expires_at_unix_ms - required_at_unix_ms > MAX_CHALLENGE_LIFETIME_MS + { + return Err(Error::InvalidAuthChallenge); + } + Ok(()) +} + +fn has_exact_tag(tags: &[Vec<String>], name: &str, value: &str) -> bool { + tags.iter().any(|tag| { + tag.len() == 2 && tag.first().is_some_and(|item| item == name) && tag[1] == value + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Config, RelayUrlPolicy}; + use nostr_sdk::prelude::{ + EventBuilder, JsonUtil, Keys, RelayUrl as UpstreamRelayUrl, Timestamp, + }; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Debug)] + struct MockAuthClient(AtomicUsize); + + impl AuthClient for MockAuthClient { + fn submit<'a>( + &'a self, + _relay: RelayUrl, + _event: Event, + ) -> BoxFuture<'a, Result<(), Error>> { + self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } + } + + fn transport() -> (NostrTransport, Arc<MockAuthClient>, RelayUrl) { + let relay = + RelayUrl::parse("wss://relay.example.com", RelayUrlPolicy::Public).expect("relay"); + let config = Config::new(RelayUrlPolicy::Public, [relay.as_str()]).expect("config"); + let client = Arc::new(MockAuthClient(AtomicUsize::new(0))); + let transport = NostrTransport::new(config).with_auth_client(client.clone()); + (transport, client, relay) + } + + fn signed_response(relay: &RelayUrl, challenge: &str, created_at: u64) -> String { + EventBuilder::auth( + challenge, + relay + .as_str() + .parse::<UpstreamRelayUrl>() + .expect("upstream relay"), + ) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(&Keys::generate()) + .expect("signed auth event") + .as_json() + } + + #[test] + fn challenge_response_is_exact_bounded_and_single_use() { + let (transport, client, relay) = transport(); + let input = transport + .begin_authentication(&relay, "challenge-a", 1_000_000, 1_600_000) + .expect("challenge"); + assert!(input.contains("\"kind\":22242")); + assert!(input.contains("challenge-a")); + let response = signed_response(&relay, "challenge-a", 1_200); + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + Some(response.as_str()), + 1_300_000, + )) + .expect("complete auth"); + assert_eq!(client.0.load(Ordering::SeqCst), 1); + assert_eq!( + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + Some(response.as_str()), + 1_300_000, + )), + Err(Error::AuthChallengeMissing) + ); + } + + #[test] + fn wrong_relay_timeout_rejection_and_no_signer_fail_closed() { + let (transport, client, relay) = transport(); + transport + .begin_authentication(&relay, "challenge-a", 1_000_000, 1_600_000) + .expect("challenge"); + assert_eq!( + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + None, + 1_100_000, + )), + Err(Error::AuthSignerUnavailable) + ); + let wrong = signed_response(&relay, "challenge-b", 1_200); + assert_eq!( + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + Some(wrong.as_str()), + 1_300_000, + )), + Err(Error::AuthResponseMismatch) + ); + let wrong_relay = RelayUrl::parse("wss://other.example.com", RelayUrlPolicy::Public) + .expect("other relay"); + let wrong = signed_response(&wrong_relay, "challenge-a", 1_200); + assert_eq!( + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + Some(wrong.as_str()), + 1_300_000, + )), + Err(Error::AuthResponseMismatch) + ); + let valid = signed_response(&relay, "challenge-a", 1_200); + assert_eq!( + futures::executor::block_on(transport.complete_authentication( + &relay, + "challenge-a", + Some(valid.as_str()), + 1_700_000, + )), + Err(Error::AuthChallengeExpired) + ); + assert_eq!(client.0.load(Ordering::SeqCst), 0); + + transport + .begin_authentication(&relay, "challenge-b", 2_000_000, 2_600_000) + .expect("second challenge"); + transport + .reject_authentication(&relay, "challenge-b") + .expect("reject"); + assert_eq!( + transport.reject_authentication(&relay, "challenge-b"), + Err(Error::AuthChallengeMissing) + ); + } + + #[test] + fn challenge_debug_is_redacted_and_conflicts_are_rejected() { + let (transport, _, relay) = transport(); + transport + .begin_authentication(&relay, "secret-challenge", 1_000, 2_000) + .expect("challenge"); + assert!(!format!("{:?}", transport.auth).contains("secret-challenge")); + assert_eq!( + transport.begin_authentication(&relay, "different", 1_000, 2_000), + Err(Error::AuthChallengeConflict) + ); + } +} diff --git a/crates/transport_nostr/src/client.rs b/crates/transport_nostr/src/client.rs @@ -127,15 +127,21 @@ pub struct NostrTransport { config: Config, pub(crate) client: Arc<dyn crate::sink::RelayClient>, pub(crate) source_client: Arc<dyn crate::source::RelaySourceClient>, + pub(crate) auth: Arc<crate::auth::AuthFlow>, } impl NostrTransport { /// Creates an inert transport from validated explicit configuration. pub fn new(config: Config) -> Self { + let client = nostr_sdk::Client::default(); + client.automatic_authentication(false); Self { config, - client: Arc::new(crate::sink::LiveRelayClient), - source_client: Arc::new(crate::source::LiveRelaySourceClient), + client: Arc::new(crate::sink::LiveRelayClient::new(client.clone())), + source_client: Arc::new(crate::source::LiveRelaySourceClient::new(client.clone())), + auth: Arc::new(crate::auth::AuthFlow::new(Arc::new( + crate::auth::LiveAuthClient::new(client), + ))), } } @@ -149,7 +155,8 @@ impl NostrTransport { Self { config, client, - source_client: Arc::new(crate::source::LiveRelaySourceClient), + source_client: Arc::new(crate::source::LiveRelaySourceClient::isolated()), + auth: Arc::new(crate::auth::AuthFlow::isolated()), } } @@ -160,8 +167,9 @@ impl NostrTransport { ) -> Self { Self { config, - client: Arc::new(crate::sink::LiveRelayClient), + client: Arc::new(crate::sink::LiveRelayClient::isolated()), source_client, + auth: Arc::new(crate::auth::AuthFlow::isolated()), } } } diff --git a/crates/transport_nostr/src/error.rs b/crates/transport_nostr/src/error.rs @@ -30,6 +30,26 @@ pub enum Error { UnexpectedTransport { actual: String }, /// The generic transport target rejected the relay URL. Target(String), + /// The relay challenge is empty, malformed, or outside its time bounds. + InvalidAuthChallenge, + /// A different live challenge already exists for this relay. + AuthChallengeConflict, + /// No matching live challenge exists for this relay. + AuthChallengeMissing, + /// The matching challenge expired before response submission. + AuthChallengeExpired, + /// The host supplied no signed NIP-42 response. + AuthSignerUnavailable, + /// The signed response does not match the pending relay and challenge. + AuthResponseMismatch, + /// The signed response is malformed or cryptographically invalid. + AuthResponseInvalid, + /// The host explicitly rejected the pending challenge. + AuthRejected, + /// Internal authentication state could not be accessed safely. + AuthStateUnavailable, + /// The relay rejected or failed the explicit AUTH message. + AuthTransport, } impl fmt::Display for Error { @@ -72,6 +92,26 @@ impl fmt::Display for Error { ) } Self::Target(reason) => write!(formatter, "transport target error: {reason}"), + Self::InvalidAuthChallenge => formatter.write_str("invalid NIP-42 challenge"), + Self::AuthChallengeConflict => { + formatter.write_str("a different NIP-42 challenge is already pending") + } + Self::AuthChallengeMissing => { + formatter.write_str("matching NIP-42 challenge is not pending") + } + Self::AuthChallengeExpired => formatter.write_str("NIP-42 challenge expired"), + Self::AuthSignerUnavailable => { + formatter.write_str("NIP-42 response signer is unavailable") + } + Self::AuthResponseMismatch => { + formatter.write_str("NIP-42 response does not match its challenge") + } + Self::AuthResponseInvalid => formatter.write_str("NIP-42 response is invalid"), + Self::AuthRejected => formatter.write_str("NIP-42 challenge was rejected"), + Self::AuthStateUnavailable => { + formatter.write_str("NIP-42 authentication state is unavailable") + } + Self::AuthTransport => formatter.write_str("NIP-42 relay submission failed"), } } } diff --git a/crates/transport_nostr/src/sink.rs b/crates/transport_nostr/src/sink.rs @@ -26,8 +26,23 @@ pub(crate) trait RelayClient: Send + Sync { ) -> BoxFuture<'a, Vec<RelayPublishResult>>; } -#[derive(Debug)] -pub(crate) struct LiveRelayClient; +#[derive(Clone, Debug)] +pub(crate) struct LiveRelayClient { + client: nostr_sdk::Client, +} + +impl LiveRelayClient { + pub(crate) const fn new(client: nostr_sdk::Client) -> Self { + Self { client } + } + + #[cfg(test)] + pub(crate) fn isolated() -> Self { + let client = nostr_sdk::Client::default(); + client.automatic_authentication(false); + Self::new(client) + } +} impl RelayClient for LiveRelayClient { fn publish<'a>( @@ -37,18 +52,18 @@ impl RelayClient for LiveRelayClient { connect_timeout: Duration, ) -> BoxFuture<'a, Vec<RelayPublishResult>> { Box::pin(async move { - let client = nostr_sdk::Client::default(); let mut results = Vec::with_capacity(relays.len()); for relay in relays { let url = relay.as_str().to_owned(); - let outcome = match client.add_relay(url.as_str()).await { + let outcome = match self.client.add_relay(url.as_str()).await { Err(error) => connection_failure(error.to_string()), - Ok(_) => match client + Ok(_) => match self + .client .try_connect_relay(url.as_str(), connect_timeout) .await { Err(error) => connection_failure(error.to_string()), - Ok(()) => match client.send_event_to([url.as_str()], &event).await { + Ok(()) => match self.client.send_event_to([url.as_str()], &event).await { Err(error) => classify_failure(error.to_string()), Ok(output) => output .success diff --git a/crates/transport_nostr/src/source.rs b/crates/transport_nostr/src/source.rs @@ -33,26 +33,40 @@ pub(crate) trait RelaySourceClient: Send + Sync { fn fetch<'a>(&'a self, query: SourceQuery) -> BoxFuture<'a, Vec<RelayFetchBatch>>; } -#[derive(Debug)] -pub(crate) struct LiveRelaySourceClient; +#[derive(Clone, Debug)] +pub(crate) struct LiveRelaySourceClient { + client: nostr_sdk::Client, +} + +impl LiveRelaySourceClient { + pub(crate) const fn new(client: nostr_sdk::Client) -> Self { + Self { client } + } + + #[cfg(test)] + pub(crate) fn isolated() -> Self { + let client = nostr_sdk::Client::default(); + client.automatic_authentication(false); + Self::new(client) + } +} impl RelaySourceClient for LiveRelaySourceClient { fn fetch<'a>(&'a self, query: SourceQuery) -> BoxFuture<'a, Vec<RelayFetchBatch>> { Box::pin(async move { - let client = nostr_sdk::Client::default(); let mut batches = Vec::with_capacity(query.relays.len()); for relay in query.relays { let url = relay.as_str().to_owned(); let result = async { - client.add_relay(url.as_str()).await?; - client + self.client.add_relay(url.as_str()).await?; + self.client .try_connect_relay(url.as_str(), query.timeout) .await?; let mut filter = Filter::new().limit(UPSTREAM_FETCH_LIMIT); if let Some(until) = query.until_unix_seconds { filter = filter.until(Timestamp::from_secs(until)); } - client + self.client .fetch_events_from([url.as_str()], filter, query.timeout) .await .map(|events| events.iter().map(JsonUtil::as_json).collect())