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 }