lib

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

auth.rs (16890B)


      1 //! Explicit NIP-42 relay authentication state.
      2 
      3 use crate::{Error, NostrTransport, RelayUrl};
      4 use nostr_sdk::prelude::{ClientMessage, Event};
      5 use radroots_transport::BoxFuture;
      6 use std::collections::BTreeMap;
      7 use std::fmt;
      8 use std::sync::{Arc, Mutex};
      9 
     10 const MAX_CHALLENGE_BYTES: usize = 1_024;
     11 const MAX_CHALLENGE_LIFETIME_MS: u64 = 600_000;
     12 
     13 #[derive(Clone, Eq, PartialEq)]
     14 struct PendingAuth {
     15     challenge: String,
     16     required_at_unix_ms: u64,
     17     expires_at_unix_ms: u64,
     18 }
     19 
     20 impl fmt::Debug for PendingAuth {
     21     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     22         formatter
     23             .debug_struct("PendingAuth")
     24             .field("challenge", &"[redacted]")
     25             .field("required_at_unix_ms", &self.required_at_unix_ms)
     26             .field("expires_at_unix_ms", &self.expires_at_unix_ms)
     27             .finish()
     28     }
     29 }
     30 
     31 pub(crate) trait AuthClient: Send + Sync {
     32     fn submit<'a>(&'a self, relay: RelayUrl, event: Event) -> BoxFuture<'a, Result<(), Error>>;
     33 }
     34 
     35 #[derive(Clone, Debug)]
     36 pub(crate) struct LiveAuthClient {
     37     client: nostr_sdk::Client,
     38 }
     39 
     40 impl LiveAuthClient {
     41     pub(crate) const fn new(client: nostr_sdk::Client) -> Self {
     42         Self { client }
     43     }
     44 }
     45 
     46 impl AuthClient for LiveAuthClient {
     47     // Relay submission is external SDK I/O; the state machine and submission
     48     // outcomes are covered through the injected AuthClient boundary.
     49     #[cfg_attr(coverage_nightly, coverage(off))]
     50     fn submit<'a>(&'a self, relay: RelayUrl, event: Event) -> BoxFuture<'a, Result<(), Error>> {
     51         Box::pin(async move {
     52             let expected = relay.as_str().trim_end_matches('/');
     53             let output = self
     54                 .client
     55                 .send_msg_to([relay.as_str()], ClientMessage::auth(event))
     56                 .await
     57                 .map_err(|_| Error::AuthTransport)?;
     58             if output
     59                 .success
     60                 .iter()
     61                 .any(|url| url.to_string().trim_end_matches('/') == expected)
     62             {
     63                 Ok(())
     64             } else {
     65                 Err(Error::AuthTransport)
     66             }
     67         })
     68     }
     69 }
     70 
     71 pub(crate) struct AuthFlow {
     72     client: Arc<dyn AuthClient>,
     73     pending: Mutex<BTreeMap<RelayUrl, PendingAuth>>,
     74 }
     75 
     76 impl AuthFlow {
     77     pub(crate) fn new(client: Arc<dyn AuthClient>) -> Self {
     78         Self {
     79             client,
     80             pending: Mutex::new(BTreeMap::new()),
     81         }
     82     }
     83 
     84     #[cfg(test)]
     85     pub(crate) fn isolated() -> Self {
     86         let client = nostr_sdk::Client::default();
     87         client.automatic_authentication(false);
     88         Self::new(Arc::new(LiveAuthClient::new(client)))
     89     }
     90 
     91     fn begin(
     92         &self,
     93         relay: RelayUrl,
     94         challenge: &str,
     95         required_at_unix_ms: u64,
     96         expires_at_unix_ms: u64,
     97     ) -> Result<String, Error> {
     98         validate_challenge(challenge, required_at_unix_ms, expires_at_unix_ms)?;
     99         let pending = PendingAuth {
    100             challenge: challenge.to_owned(),
    101             required_at_unix_ms,
    102             expires_at_unix_ms,
    103         };
    104         let mut state = self
    105             .pending
    106             .lock()
    107             .map_err(|_| Error::AuthStateUnavailable)?;
    108         match state.get(&relay) {
    109             Some(existing) if existing == &pending => {}
    110             Some(_) => return Err(Error::AuthChallengeConflict),
    111             None => {
    112                 state.insert(relay.clone(), pending);
    113             }
    114         }
    115         serde_json::to_string(&serde_json::json!({
    116             "content": "",
    117             "created_at_max_unix_ms": expires_at_unix_ms,
    118             "created_at_min_unix_ms": required_at_unix_ms,
    119             "kind": 22242,
    120             "tags": [
    121                 ["challenge", challenge],
    122                 ["relay", relay.as_str()],
    123             ],
    124         }))
    125         .map_err(|_| Error::AuthStateUnavailable)
    126     }
    127 
    128     fn pending(&self, relay: &RelayUrl, challenge: &str) -> Result<PendingAuth, Error> {
    129         let state = self
    130             .pending
    131             .lock()
    132             .map_err(|_| Error::AuthStateUnavailable)?;
    133         let pending = state.get(relay).ok_or(Error::AuthChallengeMissing)?;
    134         if pending.challenge != challenge {
    135             return Err(Error::AuthResponseMismatch);
    136         }
    137         Ok(pending.clone())
    138     }
    139 
    140     fn remove(&self, relay: &RelayUrl, challenge: &str) -> Result<(), Error> {
    141         let mut state = self
    142             .pending
    143             .lock()
    144             .map_err(|_| Error::AuthStateUnavailable)?;
    145         match state.get(relay) {
    146             Some(pending) if pending.challenge == challenge => {
    147                 state.remove(relay);
    148                 Ok(())
    149             }
    150             Some(_) => Err(Error::AuthResponseMismatch),
    151             None => Err(Error::AuthChallengeMissing),
    152         }
    153     }
    154 }
    155 
    156 impl fmt::Debug for AuthFlow {
    157     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    158         let pending_count = self
    159             .pending
    160             .lock()
    161             .map(|pending| pending.len())
    162             .unwrap_or_default();
    163         formatter
    164             .debug_struct("AuthFlow")
    165             .field("pending_count", &pending_count)
    166             .finish_non_exhaustive()
    167     }
    168 }
    169 
    170 impl NostrTransport {
    171     /// Records an exact relay challenge and returns bounded signing input.
    172     pub fn begin_authentication(
    173         &self,
    174         relay: &RelayUrl,
    175         challenge: impl AsRef<str>,
    176         required_at_unix_ms: u64,
    177         expires_at_unix_ms: u64,
    178     ) -> Result<String, Error> {
    179         if !self.config().relays().contains(relay) {
    180             return Err(Error::AuthResponseMismatch);
    181         }
    182         self.auth.begin(
    183             relay.clone(),
    184             challenge.as_ref(),
    185             required_at_unix_ms,
    186             expires_at_unix_ms,
    187         )
    188     }
    189 
    190     /// Verifies and submits one host-signed NIP-42 response without retrying.
    191     pub fn complete_authentication<'a>(
    192         &'a self,
    193         relay: &'a RelayUrl,
    194         challenge: &'a str,
    195         signed_event_json: Option<&'a str>,
    196         now_unix_ms: u64,
    197     ) -> BoxFuture<'a, Result<(), Error>> {
    198         Box::pin(async move {
    199             let pending = self.auth.pending(relay, challenge)?;
    200             if now_unix_ms > pending.expires_at_unix_ms {
    201                 self.auth.remove(relay, challenge)?;
    202                 return Err(Error::AuthChallengeExpired);
    203             }
    204             let signed_event_json = signed_event_json.ok_or(Error::AuthSignerUnavailable)?;
    205             let signed = radroots_event_codec::decode::signed_event(signed_event_json)
    206                 .map_err(|_| Error::AuthResponseInvalid)?;
    207             if radroots_nostr::event::verify(signed.envelope())
    208                 != radroots_nostr::event::Verification::Verified
    209             {
    210                 return Err(Error::AuthResponseInvalid);
    211             }
    212             let created_at_ms = signed.created_at().saturating_mul(1_000);
    213             if signed.kind() != 22242
    214                 || !signed.content().is_empty()
    215                 || created_at_ms < pending.required_at_unix_ms
    216                 || created_at_ms > pending.expires_at_unix_ms
    217                 || !has_exact_tag(signed.tags_as_vec().as_slice(), "challenge", challenge)
    218                 || !has_exact_tag(signed.tags_as_vec().as_slice(), "relay", relay.as_str())
    219             {
    220                 return Err(Error::AuthResponseMismatch);
    221             }
    222             let event = radroots_nostr::event::to_nostr(signed.envelope())
    223                 .map_err(|_| Error::AuthResponseInvalid)?;
    224             self.auth.client.submit(relay.clone(), event).await?;
    225             self.auth.remove(relay, challenge)
    226         })
    227     }
    228 
    229     /// Rejects and consumes one exact pending challenge without relay access.
    230     pub fn reject_authentication(&self, relay: &RelayUrl, challenge: &str) -> Result<(), Error> {
    231         self.auth.remove(relay, challenge)
    232     }
    233 
    234     #[cfg(test)]
    235     fn with_auth_client(mut self, client: Arc<dyn AuthClient>) -> Self {
    236         self.auth = Arc::new(AuthFlow::new(client));
    237         self
    238     }
    239 }
    240 
    241 fn validate_challenge(
    242     challenge: &str,
    243     required_at_unix_ms: u64,
    244     expires_at_unix_ms: u64,
    245 ) -> Result<(), Error> {
    246     if challenge.is_empty()
    247         || challenge.len() > MAX_CHALLENGE_BYTES
    248         || challenge != challenge.trim()
    249         || challenge.chars().any(char::is_control)
    250         || required_at_unix_ms == 0
    251         || expires_at_unix_ms <= required_at_unix_ms
    252         || expires_at_unix_ms - required_at_unix_ms > MAX_CHALLENGE_LIFETIME_MS
    253     {
    254         return Err(Error::InvalidAuthChallenge);
    255     }
    256     Ok(())
    257 }
    258 
    259 fn has_exact_tag(tags: &[Vec<String>], name: &str, value: &str) -> bool {
    260     tags.iter().any(|tag| {
    261         tag.len() == 2 && tag.first().is_some_and(|item| item == name) && tag[1] == value
    262     })
    263 }
    264 
    265 #[cfg(test)]
    266 mod tests {
    267     use super::*;
    268     use crate::{Config, RelayUrlPolicy};
    269     use nostr_sdk::prelude::{
    270         EventBuilder, JsonUtil, Keys, RelayUrl as UpstreamRelayUrl, Timestamp,
    271     };
    272     use std::sync::atomic::{AtomicUsize, Ordering};
    273 
    274     #[derive(Debug)]
    275     struct MockAuthClient(AtomicUsize);
    276 
    277     impl AuthClient for MockAuthClient {
    278         fn submit<'a>(
    279             &'a self,
    280             _relay: RelayUrl,
    281             _event: Event,
    282         ) -> BoxFuture<'a, Result<(), Error>> {
    283             self.0.fetch_add(1, Ordering::SeqCst);
    284             Box::pin(async { Ok(()) })
    285         }
    286     }
    287 
    288     fn transport() -> (NostrTransport, Arc<MockAuthClient>, RelayUrl) {
    289         let relay =
    290             RelayUrl::parse("wss://relay.example.com", RelayUrlPolicy::Public).expect("relay");
    291         let config = Config::from_profile(
    292             crate::profile::test_profile(
    293                 crate::RelayProfileKind::Public,
    294                 RelayUrlPolicy::Public,
    295                 [relay.as_str()],
    296             )
    297             .expect("profile"),
    298         );
    299         let client = Arc::new(MockAuthClient(AtomicUsize::new(0)));
    300         let transport = NostrTransport::new(config).with_auth_client(client.clone());
    301         (transport, client, relay)
    302     }
    303 
    304     fn signed_response(relay: &RelayUrl, challenge: &str, created_at: u64) -> String {
    305         EventBuilder::auth(
    306             challenge,
    307             relay
    308                 .as_str()
    309                 .parse::<UpstreamRelayUrl>()
    310                 .expect("upstream relay"),
    311         )
    312         .custom_created_at(Timestamp::from_secs(created_at))
    313         .sign_with_keys(&Keys::generate())
    314         .expect("signed auth event")
    315         .as_json()
    316     }
    317 
    318     #[test]
    319     fn challenge_response_is_exact_bounded_and_single_use() {
    320         let (transport, client, relay) = transport();
    321         let input = transport
    322             .begin_authentication(&relay, "challenge-a", 1_000_000, 1_600_000)
    323             .expect("challenge");
    324         assert!(input.contains("\"kind\":22242"));
    325         assert!(input.contains("challenge-a"));
    326         let response = signed_response(&relay, "challenge-a", 1_200);
    327         futures::executor::block_on(transport.complete_authentication(
    328             &relay,
    329             "challenge-a",
    330             Some(response.as_str()),
    331             1_300_000,
    332         ))
    333         .expect("complete auth");
    334         assert_eq!(client.0.load(Ordering::SeqCst), 1);
    335         assert_eq!(
    336             futures::executor::block_on(transport.complete_authentication(
    337                 &relay,
    338                 "challenge-a",
    339                 Some(response.as_str()),
    340                 1_300_000,
    341             )),
    342             Err(Error::AuthChallengeMissing)
    343         );
    344     }
    345 
    346     #[test]
    347     fn wrong_relay_timeout_rejection_and_no_signer_fail_closed() {
    348         let (transport, client, relay) = transport();
    349         transport
    350             .begin_authentication(&relay, "challenge-a", 1_000_000, 1_600_000)
    351             .expect("challenge");
    352         assert_eq!(
    353             futures::executor::block_on(transport.complete_authentication(
    354                 &relay,
    355                 "challenge-a",
    356                 None,
    357                 1_100_000,
    358             )),
    359             Err(Error::AuthSignerUnavailable)
    360         );
    361         let wrong = signed_response(&relay, "challenge-b", 1_200);
    362         assert_eq!(
    363             futures::executor::block_on(transport.complete_authentication(
    364                 &relay,
    365                 "challenge-a",
    366                 Some(wrong.as_str()),
    367                 1_300_000,
    368             )),
    369             Err(Error::AuthResponseMismatch)
    370         );
    371         let wrong_relay = RelayUrl::parse("wss://other.example.com", RelayUrlPolicy::Public)
    372             .expect("other relay");
    373         let wrong = signed_response(&wrong_relay, "challenge-a", 1_200);
    374         assert_eq!(
    375             futures::executor::block_on(transport.complete_authentication(
    376                 &relay,
    377                 "challenge-a",
    378                 Some(wrong.as_str()),
    379                 1_300_000,
    380             )),
    381             Err(Error::AuthResponseMismatch)
    382         );
    383         let valid = signed_response(&relay, "challenge-a", 1_200);
    384         assert_eq!(
    385             futures::executor::block_on(transport.complete_authentication(
    386                 &relay,
    387                 "challenge-a",
    388                 Some(valid.as_str()),
    389                 1_700_000,
    390             )),
    391             Err(Error::AuthChallengeExpired)
    392         );
    393         assert_eq!(client.0.load(Ordering::SeqCst), 0);
    394 
    395         transport
    396             .begin_authentication(&relay, "challenge-b", 2_000_000, 2_600_000)
    397             .expect("second challenge");
    398         transport
    399             .reject_authentication(&relay, "challenge-b")
    400             .expect("reject");
    401         assert_eq!(
    402             transport.reject_authentication(&relay, "challenge-b"),
    403             Err(Error::AuthChallengeMissing)
    404         );
    405     }
    406 
    407     #[test]
    408     fn challenge_debug_is_redacted_and_conflicts_are_rejected() {
    409         let (transport, _, relay) = transport();
    410         transport
    411             .begin_authentication(&relay, "secret-challenge", 1_000, 2_000)
    412             .expect("challenge");
    413         assert!(!format!("{:?}", transport.auth).contains("secret-challenge"));
    414         assert_eq!(
    415             transport.begin_authentication(&relay, "different", 1_000, 2_000),
    416             Err(Error::AuthChallengeConflict)
    417         );
    418         assert_eq!(
    419             transport.reject_authentication(&relay, "different"),
    420             Err(Error::AuthResponseMismatch)
    421         );
    422         transport
    423             .begin_authentication(&relay, "secret-challenge", 1_000, 2_000)
    424             .expect("idempotent challenge");
    425     }
    426 
    427     #[test]
    428     fn challenge_validation_rejects_each_invalid_boundary() {
    429         for (challenge, required, expires) in [
    430             ("", 1, 2),
    431             (&"a".repeat(MAX_CHALLENGE_BYTES + 1), 1, 2),
    432             (" challenge", 1, 2),
    433             ("challenge ", 1, 2),
    434             ("chall\nenge", 1, 2),
    435             ("challenge", 0, 2),
    436             ("challenge", 2, 2),
    437             ("challenge", 2, 1),
    438             ("challenge", 1, MAX_CHALLENGE_LIFETIME_MS + 2),
    439         ] {
    440             assert_eq!(
    441                 validate_challenge(challenge, required, expires),
    442                 Err(Error::InvalidAuthChallenge)
    443             );
    444         }
    445         assert!(validate_challenge("challenge", 1, MAX_CHALLENGE_LIFETIME_MS + 1).is_ok());
    446 
    447         assert!(has_exact_tag(
    448             &[vec!["challenge".into(), "value".into()]],
    449             "challenge",
    450             "value"
    451         ));
    452         assert!(!has_exact_tag(&[], "challenge", "value"));
    453         assert!(!has_exact_tag(
    454             &[vec!["challenge".into()]],
    455             "challenge",
    456             "value"
    457         ));
    458         assert!(!has_exact_tag(
    459             &[vec!["other".into(), "value".into()]],
    460             "challenge",
    461             "value"
    462         ));
    463         assert!(!has_exact_tag(
    464             &[vec!["challenge".into(), "other".into()]],
    465             "challenge",
    466             "value"
    467         ));
    468     }
    469 
    470     #[test]
    471     fn authentication_rejects_unconfigured_and_malformed_responses() {
    472         let (transport, _, relay) = transport();
    473         let other =
    474             RelayUrl::parse("wss://other.example.com", RelayUrlPolicy::Public).expect("other");
    475         assert_eq!(
    476             transport.begin_authentication(&other, "challenge", 1, 2),
    477             Err(Error::AuthResponseMismatch)
    478         );
    479         transport
    480             .begin_authentication(&relay, "challenge", 1_000, 2_000)
    481             .expect("begin");
    482         assert_eq!(
    483             futures::executor::block_on(transport.complete_authentication(
    484                 &relay,
    485                 "wrong",
    486                 Some("{}"),
    487                 1_500
    488             )),
    489             Err(Error::AuthResponseMismatch)
    490         );
    491         assert_eq!(
    492             futures::executor::block_on(transport.complete_authentication(
    493                 &relay,
    494                 "challenge",
    495                 Some("{}"),
    496                 1_500
    497             )),
    498             Err(Error::AuthResponseInvalid)
    499         );
    500     }
    501 }