diff --git a/Cargo.lock b/Cargo.lock index 928ad1d..15d628a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1592,6 +1592,7 @@ dependencies = [ "tracing", "tracing-subscriber 0.2.25", "tungstenite", + "url", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 6b0a37b..acf51cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ chrono = "0.4.23" prometheus = "0.13.3" indicatif = "0.17.3" bech32 = "0.9.1" +url = "2.3.1" [dev-dependencies] anyhow = "1" diff --git a/README.md b/README.md index e394c81..6b4b8a9 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ mirrored on [GitHub](https://github.com/scsibug/nostr-rs-relay). - [ ] NIP-26: [Event Delegation](https://github.com/nostr-protocol/nips/blob/master/26.md) (_implemented, but currently disabled_) - [x] NIP-28: [Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) - [x] NIP-33: [Parameterized Replaceable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) +- [x] NIP-42: [Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md) ## Quick Start diff --git a/config.toml b/config.toml index 25325db..f49290a 100644 --- a/config.toml +++ b/config.toml @@ -136,6 +136,8 @@ reject_future_seconds = 1800 # "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f", # "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", #] +# Enable NIP-42 authentication +#nip42_auth = false [verified_users] # NIP-05 verification of users. Can be "enabled" to require NIP-05 diff --git a/src/config.rs b/src/config.rs index eff1f7b..e41305c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,6 +75,7 @@ pub struct Limits { #[allow(unused)] pub struct Authorization { pub pubkey_whitelist: Option>, // If present, only allow these pubkeys to publish events + pub nip42_auth: bool, // if true enables NIP-42 authentication } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -250,6 +251,7 @@ impl Default for Settings { }, authorization: Authorization { pubkey_whitelist: None, // Allow any address to publish + nip42_auth: false, // Disable NIP-42 authentication }, verified_users: VerifiedUsers { mode: VerifiedUsersMode::Disabled, diff --git a/src/conn.rs b/src/conn.rs index dcd6ffb..30ebeee 100644 --- a/src/conn.rs +++ b/src/conn.rs @@ -1,16 +1,30 @@ //! Client connection state -use crate::close::Close; -use crate::error::Error; -use crate::error::Result; - -use crate::subscription::Subscription; use std::collections::HashMap; + use tracing::{debug, trace}; use uuid::Uuid; +use crate::close::Close; +use crate::conn::Nip42AuthState::{AuthPubkey, Challenge, NoAuth}; +use crate::error::Error; +use crate::error::Result; +use crate::event::Event; +use crate::subscription::Subscription; +use crate::utils::{host_str, unix_time}; + /// A subscription identifier has a maximum length const MAX_SUBSCRIPTION_ID_LEN: usize = 256; +/// NIP-42 authentication state +pub enum Nip42AuthState { + /// The client is not authenticated yet + NoAuth, + /// The AUTH challenge sent + Challenge(String), + /// The client is authenticated + AuthPubkey(String), +} + /// State for a client connection pub struct ClientConn { /// Client IP (either from socket, or configured proxy header @@ -21,6 +35,8 @@ pub struct ClientConn { subscriptions: HashMap, /// Per-connection maximum concurrent subscriptions max_subs: usize, + /// NIP-42 AUTH + auth: Nip42AuthState, } impl Default for ClientConn { @@ -39,15 +55,18 @@ impl ClientConn { client_id, subscriptions: HashMap::new(), max_subs: 32, + auth: NoAuth, } } - #[must_use] pub fn subscriptions(&self) -> &HashMap { + #[must_use] + pub fn subscriptions(&self) -> &HashMap { &self.subscriptions } /// Check if the given subscription already exists - #[must_use] pub fn has_subscription(&self, sub: &Subscription) -> bool { + #[must_use] + pub fn has_subscription(&self, sub: &Subscription) -> bool { self.subscriptions.values().any(|x| x == sub) } @@ -63,6 +82,22 @@ impl ClientConn { &self.client_ip_addr } + #[must_use] + pub fn auth_pubkey(&self) -> Option<&String> { + match &self.auth { + AuthPubkey(pubkey) => Some(pubkey), + _ => None, + } + } + + #[must_use] + pub fn auth_challenge(&self) -> Option<&String> { + match &self.auth { + Challenge(pubkey) => Some(pubkey), + _ => None, + } + } + /// Add a new subscription for this connection. /// # Errors /// @@ -116,4 +151,79 @@ impl ClientConn { self.get_client_prefix(), ); } + + pub fn generate_auth_challenge(&mut self) { + self.auth = Challenge(Uuid::new_v4().to_string()); + } + + pub fn authenticate(&mut self, event: &Event, relay_url: &String) -> Result<()> { + match &self.auth { + Challenge(_) => (), + AuthPubkey(_) => { + // already authenticated + return Ok(()) + }, + NoAuth => { + // unexpected AUTH request + return Err(Error::AuthFailure); + }, + } + match event.validate() { + Ok(_) => { + if event.kind != 22242 { + return Err(Error::AuthFailure); + } + + let curr_time = unix_time(); + let past_cutoff = curr_time - 600; // 10 minutes + let future_cutoff = curr_time + 600; // 10 minutes + if event.created_at < past_cutoff || event.created_at > future_cutoff { + return Err(Error::AuthFailure); + } + + let mut challenge: Option<&String> = None; + let mut relay: Option<&String> = None; + + for tag in &event.tags { + if tag.len() == 2 && tag.get(0) == Some(&"challenge".into()) { + challenge = tag.get(1); + } + if tag.len() == 2 && tag.get(0) == Some(&"relay".into()) { + relay = tag.get(1); + } + } + + match (challenge, &self.auth) { + (Some(received_challenge), Challenge(sent_challenge)) => { + if received_challenge != sent_challenge { + return Err(Error::AuthFailure); + } + } + (_, _) => { + return Err(Error::AuthFailure); + } + } + + match (relay.and_then(|url| host_str(url)), host_str(relay_url)) { + (Some(received_relay), Some(our_relay)) => { + if received_relay != our_relay { + return Err(Error::AuthFailure); + } + } + (_, _) => { + return Err(Error::AuthFailure); + } + } + + self.auth = AuthPubkey(event.pubkey.clone()); + trace!( + "authenticated pubkey {} (cid: {})", + event.pubkey.chars().take(8).collect::(), + self.get_client_prefix() + ); + Ok(()) + } + Err(_) => Err(Error::AuthFailure), + } + } } diff --git a/src/db.rs b/src/db.rs index f2806ce..7df8935 100644 --- a/src/db.rs +++ b/src/db.rs @@ -30,6 +30,7 @@ pub struct SubmittedEvent { pub source_ip: String, pub origin: Option, pub user_agent: Option, + pub auth_pubkey: Option>, } /// Database file @@ -240,7 +241,7 @@ pub async fn db_writer( if let Some(ref mut c) = grpc_client { trace!("checking if grpc permits"); let grpc_start = Instant::now(); - let decision_res = c.admit_event(&event, &subm_event.source_ip, subm_event.origin, subm_event.user_agent, nip05_address).await; + let decision_res = c.admit_event(&event, &subm_event.source_ip, subm_event.origin, subm_event.user_agent, nip05_address, subm_event.auth_pubkey).await; match decision_res { Ok(decision) => { if !decision.permitted() { diff --git a/src/error.rs b/src/error.rs index 5114fb5..ebefbd9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -68,6 +68,8 @@ pub enum Error { AuthzError, #[error("Tonic GRPC error")] TonicError(tonic::Status), + #[error("Invalid AUTH message")] + AuthFailure, #[error("Unknown/Undocumented")] UnknownError, } diff --git a/src/event.rs b/src/event.rs index 83640f8..772b31d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -14,6 +14,8 @@ use std::collections::HashMap; use std::collections::HashSet; use std::str::FromStr; use tracing::{debug, info}; +use crate::event::EventWrapper::WrappedEvent; +use crate::event::EventWrapper::WrappedAuth; lazy_static! { /// Secp256k1 verification instance. @@ -83,17 +85,27 @@ where } } + +pub enum EventWrapper { + WrappedEvent(Event), + WrappedAuth(Event) +} + /// Convert network event to parsed/validated event. -impl From for Result { - fn from(ec: EventCmd) -> Result { +impl From for Result { + fn from(ec: EventCmd) -> Result { // ensure command is correct if ec.cmd == "EVENT" { ec.event.validate().map(|_| { let mut e = ec.event; e.build_index(); e.update_delegation(); - e + WrappedEvent(e) }) + } else if ec.cmd == "AUTH" { + // we don't want to validate the event here, because NIP-42 can be disabled + // it will be validated later during the authentication process + Ok(WrappedAuth(ec.event)) } else { Err(CommandUnknownError) } @@ -326,7 +338,7 @@ impl Event { } /// Convert event to canonical representation for signing. - fn to_canonical(&self) -> Option { + pub fn to_canonical(&self) -> Option { // create a JsonValue for each event element let mut c: Vec = vec![]; // id must be set to 0 diff --git a/src/info.rs b/src/info.rs index 1c89a8e..4ce4b72 100644 --- a/src/info.rs +++ b/src/info.rs @@ -1,6 +1,6 @@ //! Relay metadata using NIP-11 /// Relay Info -use crate::config; +use crate::config::Settings; use serde::{Deserialize, Serialize}; pub const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); @@ -27,15 +27,24 @@ pub struct RelayInfo { } /// Convert an Info configuration into public Relay Info -impl From for RelayInfo { - fn from(i: config::Info) -> Self { +impl From for RelayInfo { + fn from(c: Settings) -> Self { + let mut supported_nips = vec![1, 2, 9, 11, 12, 15, 16, 20, 22, 33]; + + if c.authorization.nip42_auth { + supported_nips.push(42); + supported_nips.sort(); + } + + let i = c.info; + RelayInfo { id: i.relay_url, name: i.name, description: i.description, pubkey: i.pubkey, contact: i.contact, - supported_nips: Some(vec![1, 2, 9, 11, 12, 15, 16, 20, 22, 33]), + supported_nips: Some(supported_nips), software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()), version: CARGO_PKG_VERSION.map(std::borrow::ToOwned::to_owned), } diff --git a/src/nauthz.rs b/src/nauthz.rs index b79ed3e..da57175 100644 --- a/src/nauthz.rs +++ b/src/nauthz.rs @@ -76,6 +76,7 @@ impl EventAuthzService { origin: Option, user_agent: Option, nip05: Option, + auth_pubkey: Option> ) -> Result> { self.ready_connection().await; let id_blob = hex::decode(&event.id)?; @@ -97,7 +98,7 @@ impl EventAuthzService { ip_addr: Some(ip.to_string()), origin, user_agent, - auth_pubkey: None, + auth_pubkey, nip05: nip05.map(|x| nauthz_grpc::event_request::Nip05Name::from(x)), }) .await?; diff --git a/src/notice.rs b/src/notice.rs index f780683..0dd8202 100644 --- a/src/notice.rs +++ b/src/notice.rs @@ -16,6 +16,7 @@ pub struct EventResult { pub enum Notice { Message(String), EventResult(EventResult), + AuthChallenge(String) } impl EventResultStatus { diff --git a/src/server.rs b/src/server.rs index b0026a0..c3ad8ed 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,6 +7,8 @@ use crate::repo::NostrRepo; use crate::db; use crate::db::SubmittedEvent; use crate::error::{Error, Result}; +use crate::event::EventWrapper; +use crate::server::EventWrapper::{WrappedAuth, WrappedEvent}; use crate::event::Event; use crate::event::EventCmd; use crate::info::RelayInfo; @@ -48,6 +50,7 @@ use tungstenite::error::Error as WsError; use tungstenite::handshake; use tungstenite::protocol::Message; use tungstenite::protocol::WebSocketConfig; +use crate::server::Error::CommandUnknownError; /// Handle arbitrary HTTP requests, including for `WebSocket` upgrades. #[allow(clippy::too_many_arguments)] @@ -157,7 +160,7 @@ async fn handle_web_request( if mt_str.contains("application/nostr+json") { // build a relay info response debug!("Responding to server info request"); - let rinfo = RelayInfo::from(settings.info); + let rinfo = RelayInfo::from(settings); let b = Body::from(serde_json::to_string_pretty(&rinfo).unwrap()); return Ok(Response::builder() .status(200) @@ -268,6 +271,10 @@ fn create_metrics() -> (Registry, NostrMetrics) { "nostr_cmd_close_total", "CLOSE commands", )).unwrap(); + let cmd_auth = IntCounter::with_opts(Opts::new( + "nostr_cmd_auth_total", + "AUTH commands", + )).unwrap(); let disconnects = IntCounterVec::new( Opts::new("nostr_disconnects_total", "Client disconnects"), vec!["reason"].as_slice(), @@ -282,6 +289,7 @@ fn create_metrics() -> (Registry, NostrMetrics) { registry.register(Box::new(cmd_req.clone())).unwrap(); registry.register(Box::new(cmd_event.clone())).unwrap(); registry.register(Box::new(cmd_close.clone())).unwrap(); + registry.register(Box::new(cmd_auth.clone())).unwrap(); registry.register(Box::new(disconnects.clone())).unwrap(); let metrics = NostrMetrics { query_sub, @@ -295,6 +303,7 @@ fn create_metrics() -> (Registry, NostrMetrics) { cmd_req, cmd_event, cmd_close, + cmd_auth, }; (registry,metrics) } @@ -488,7 +497,7 @@ pub fn start_server(settings: &Settings, shutdown_rx: MpscReceiver<()>) -> Resul #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)] #[serde(untagged)] pub enum NostrMessage { - /// An `EVENT` message + /// `EVENT` and `AUTH` messages EventMsg(EventCmd), /// A `REQ` message SubMsg(Subscription), @@ -528,6 +537,7 @@ fn make_notice_message(notice: &Notice) -> Message { let json = match notice { Notice::Message(ref msg) => json!(["NOTICE", msg]), Notice::EventResult(ref res) => json!(["OK", res.id, res.status.to_bool(), res.msg]), + Notice::AuthChallenge(ref challenge) => json!(["AUTH", challenge]), }; Message::text(json.to_string()) @@ -616,6 +626,14 @@ async fn nostr_server( // Measure connections metrics.connections.inc(); + if settings.authorization.nip42_auth { + conn.generate_auth_challenge(); + if let Some(challenge) = conn.auth_challenge() { + ws_stream.send( + make_notice_message(&Notice::AuthChallenge(challenge.to_string()))).await.ok(); + } + } + loop { tokio::select! { _ = shutdown.recv() => { @@ -729,16 +747,17 @@ async fn nostr_server( // An EventCmd needs to be validated to be converted into an Event // handle each type of message let evid = ec.event_id().to_owned(); - let parsed : Result = Result::::from(ec); - metrics.cmd_event.inc(); + let parsed : Result = Result::::from(ec); match parsed { - Ok(e) => { + Ok(WrappedEvent(e)) => { + metrics.cmd_event.inc(); let id_prefix:String = e.id.chars().take(8).collect(); debug!("successfully parsed/validated event: {:?} (cid: {}, kind: {})", id_prefix, cid, e.kind); // check if the event is too far in the future. if e.is_valid_timestamp(settings.options.reject_future_seconds) { // Write this to the database. - let submit_event = SubmittedEvent { event: e.clone(), notice_tx: notice_tx.clone(), source_ip: conn.ip().to_string(), origin: client_info.origin.clone(), user_agent: client_info.user_agent.clone()}; + let auth_pubkey = conn.auth_pubkey().and_then(|pubkey| hex::decode(&pubkey).ok()); + let submit_event = SubmittedEvent { event: e.clone(), notice_tx: notice_tx.clone(), source_ip: conn.ip().to_string(), origin: client_info.origin.clone(), user_agent: client_info.user_agent.clone(), auth_pubkey }; event_tx.send(submit_event).await.ok(); client_published_event_count += 1; } else { @@ -750,7 +769,39 @@ async fn nostr_server( } } }, + Ok(WrappedAuth(event)) => { + metrics.cmd_auth.inc(); + if settings.authorization.nip42_auth { + let id_prefix:String = event.id.chars().take(8).collect(); + debug!("successfully parsed auth: {:?} (cid: {})", id_prefix, cid); + match &settings.info.relay_url { + None => { + error!("AUTH command received, but relay_url is not set in the config file (cid: {})", cid); + }, + Some(relay) => { + match conn.authenticate(&event, &relay) { + Ok(_) => { + let pubkey = match conn.auth_pubkey() { + Some(k) => k.chars().take(8).collect(), + None => "".to_string(), + }; + info!("client is authenticated: (cid: {}, pubkey: {:?})", cid, pubkey); + }, + Err(e) => { + info!("authentication error: {} (cid: {})", e, cid); + ws_stream.send(make_notice_message(&Notice::message(format!("Authentication error: {e}")))).await.ok(); + }, + } + } + } + } else { + let e = CommandUnknownError; + info!("client sent an invalid event (cid: {})", cid); + ws_stream.send(make_notice_message(&Notice::invalid(evid, &format!("{e}")))).await.ok(); + } + }, Err(e) => { + metrics.cmd_event.inc(); info!("client sent an invalid event (cid: {})", cid); ws_stream.send(make_notice_message(&Notice::invalid(evid, &format!("{e}")))).await.ok(); } @@ -855,5 +906,6 @@ pub struct NostrMetrics { pub cmd_req: IntCounter, // count of REQ commands received pub cmd_event: IntCounter, // count of EVENT commands received pub cmd_close: IntCounter, // count of CLOSE commands received + pub cmd_auth: IntCounter, // count of AUTH commands received } diff --git a/src/utils.rs b/src/utils.rs index 92ca8d0..3cceae7 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,7 @@ //! Common utility functions use bech32::FromBase32; use std::time::SystemTime; +use url::Url; /// Seconds since 1970. #[must_use] pub fn unix_time() -> u64 { @@ -33,6 +34,10 @@ pub fn nip19_to_hex(s: &str) -> Result { }) } +pub fn host_str(url: &String) -> Option { + Url::parse(url).ok().and_then(|u| u.host_str().map(|s| s.to_string())) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/conn.rs b/tests/conn.rs new file mode 100644 index 0000000..9a12859 --- /dev/null +++ b/tests/conn.rs @@ -0,0 +1,356 @@ +#[cfg(test)] +mod tests { + use bitcoin_hashes::hex::ToHex; + use bitcoin_hashes::sha256; + use bitcoin_hashes::Hash; + use secp256k1::rand; + use secp256k1::{KeyPair, Secp256k1, XOnlyPublicKey}; + + use nostr_rs_relay::conn::ClientConn; + use nostr_rs_relay::error::Error; + use nostr_rs_relay::event::Event; + use nostr_rs_relay::utils::unix_time; + + const RELAY: &str = "wss://nostr.example.com/"; + + #[test] + fn test_generate_auth_challenge() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let last_auth_challenge = client_conn.auth_challenge().cloned(); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_ne!( + client_conn.auth_challenge().unwrap(), + &last_auth_challenge.unwrap() + ); + assert_eq!(client_conn.auth_pubkey(), None); + } + + #[test] + fn test_authenticate_with_valid_event() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event(challenge); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Ok(()))); + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey)); + } + + #[test] + fn test_fail_to_authenticate_in_invalid_state() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let event = auth_event(&"challenge".into()); + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_authenticate_when_already_authenticated() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap().clone(); + + let event = auth_event(&challenge); + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Ok(()))); + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey)); + + let event1 = auth_event(&challenge); + let result1 = client_conn.authenticate(&event1, &RELAY.into()); + + assert!(matches!(result1, Ok(()))); + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), Some(&event.pubkey)); + assert_ne!(client_conn.auth_pubkey(), Some(&event1.pubkey)); + } + + #[test] + fn test_fail_to_authenticate_with_invalid_event() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let mut event = auth_event(challenge); + event.sig = event.sig.chars().rev().collect::(); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_with_invalid_event_kind() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event_with_kind(challenge, 9999999999999999); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_with_expired_timestamp() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event_with_created_at(challenge, unix_time() - 1200); // 20 minutes + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_with_future_timestamp() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event_with_created_at(challenge, unix_time() + 1200); // 20 minutes + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_without_tags() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let event = auth_event_without_tags(); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_without_challenge() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let event = auth_event_without_challenge(); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_without_relay() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event_without_relay(challenge); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_with_invalid_challenge() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let event = auth_event(&"invalid challenge".into()); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + #[test] + fn test_fail_to_authenticate_with_invalid_relay() { + let mut client_conn = ClientConn::new("127.0.0.1".into()); + + assert_eq!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + client_conn.generate_auth_challenge(); + + assert_ne!(client_conn.auth_challenge(), None); + assert_eq!(client_conn.auth_pubkey(), None); + + let challenge = client_conn.auth_challenge().unwrap(); + let event = auth_event_with_relay(challenge, &"xyz".into()); + + let result = client_conn.authenticate(&event, &RELAY.into()); + + assert!(matches!(result, Err(Error::AuthFailure))); + } + + fn auth_event(challenge: &String) -> Event { + create_auth_event(Some(challenge), Some(&RELAY.into()), 22242, unix_time()) + } + + fn auth_event_with_kind(challenge: &String, kind: u64) -> Event { + create_auth_event(Some(challenge), Some(&RELAY.into()), kind, unix_time()) + } + + fn auth_event_with_created_at(challenge: &String, created_at: u64) -> Event { + create_auth_event(Some(challenge), Some(&RELAY.into()), 22242, created_at) + } + + fn auth_event_without_challenge() -> Event { + create_auth_event(None, Some(&RELAY.into()), 22242, unix_time()) + } + + fn auth_event_without_relay(challenge: &String) -> Event { + create_auth_event(Some(challenge), None, 22242, unix_time()) + } + + fn auth_event_without_tags() -> Event { + create_auth_event(None, None, 22242, unix_time()) + } + + fn auth_event_with_relay(challenge: &String, relay: &String) -> Event { + create_auth_event(Some(challenge), Some(relay), 22242, unix_time()) + } + + fn create_auth_event( + challenge: Option<&String>, + relay: Option<&String>, + kind: u64, + created_at: u64, + ) -> Event { + let secp = Secp256k1::new(); + let key_pair = KeyPair::new(&secp, &mut rand::thread_rng()); + let public_key = XOnlyPublicKey::from_keypair(&key_pair); + + let mut tags: Vec> = vec![]; + + if let Some(c) = challenge { + let tag = vec!["challenge".into(), c.into()]; + tags.push(tag); + } + + if let Some(r) = relay { + let tag = vec!["relay".into(), r.into()]; + tags.push(tag); + } + + let mut event = Event { + id: "0".to_owned(), + pubkey: public_key.to_hex(), + delegated_by: None, + created_at: created_at, + kind: kind, + tags: tags, + content: "".to_owned(), + sig: "0".to_owned(), + tagidx: None, + }; + + let c = event.to_canonical().unwrap(); + let digest: sha256::Hash = sha256::Hash::hash(c.as_bytes()); + + let msg = secp256k1::Message::from_slice(digest.as_ref()).unwrap(); + let sig = secp.sign_schnorr(&msg, &key_pair); + + event.id = format!("{digest:x}"); + event.sig = sig.to_hex(); + + event + } +}