feat(NIP-42): pubkey authentication

Configurable in `config.toml`.  Limited functionality, but this does
send metadata to gRPC for event authorization.

fixes: https://todo.sr.ht/~gheartsfield/nostr-rs-relay/66
This commit is contained in:
rorp 2023-02-14 19:17:48 -08:00 committed by Greg Heartsfield
parent d0f57aea21
commit 5cecfba319
15 changed files with 579 additions and 23 deletions

1
Cargo.lock generated
View File

@ -1592,6 +1592,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber 0.2.25", "tracing-subscriber 0.2.25",
"tungstenite", "tungstenite",
"url",
"uuid", "uuid",
] ]

View File

@ -51,6 +51,7 @@ chrono = "0.4.23"
prometheus = "0.13.3" prometheus = "0.13.3"
indicatif = "0.17.3" indicatif = "0.17.3"
bech32 = "0.9.1" bech32 = "0.9.1"
url = "2.3.1"
[dev-dependencies] [dev-dependencies]
anyhow = "1" anyhow = "1"

View File

@ -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_) - [ ] 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-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-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 ## Quick Start

View File

@ -136,6 +136,8 @@ reject_future_seconds = 1800
# "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f", # "35d26e4690cbe1a898af61cc3515661eb5fa763b57bd0b42e45099c8b32fd50f",
# "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072", # "887645fef0ce0c3c1218d2f5d8e6132a19304cdc57cd20281d082f38cfea0072",
#] #]
# Enable NIP-42 authentication
#nip42_auth = false
[verified_users] [verified_users]
# NIP-05 verification of users. Can be "enabled" to require NIP-05 # NIP-05 verification of users. Can be "enabled" to require NIP-05

View File

@ -75,6 +75,7 @@ pub struct Limits {
#[allow(unused)] #[allow(unused)]
pub struct Authorization { pub struct Authorization {
pub pubkey_whitelist: Option<Vec<String>>, // If present, only allow these pubkeys to publish events pub pubkey_whitelist: Option<Vec<String>>, // If present, only allow these pubkeys to publish events
pub nip42_auth: bool, // if true enables NIP-42 authentication
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -250,6 +251,7 @@ impl Default for Settings {
}, },
authorization: Authorization { authorization: Authorization {
pubkey_whitelist: None, // Allow any address to publish pubkey_whitelist: None, // Allow any address to publish
nip42_auth: false, // Disable NIP-42 authentication
}, },
verified_users: VerifiedUsers { verified_users: VerifiedUsers {
mode: VerifiedUsersMode::Disabled, mode: VerifiedUsersMode::Disabled,

View File

@ -1,16 +1,30 @@
//! Client connection state //! Client connection state
use crate::close::Close;
use crate::error::Error;
use crate::error::Result;
use crate::subscription::Subscription;
use std::collections::HashMap; use std::collections::HashMap;
use tracing::{debug, trace}; use tracing::{debug, trace};
use uuid::Uuid; 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 /// A subscription identifier has a maximum length
const MAX_SUBSCRIPTION_ID_LEN: usize = 256; 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 /// State for a client connection
pub struct ClientConn { pub struct ClientConn {
/// Client IP (either from socket, or configured proxy header /// Client IP (either from socket, or configured proxy header
@ -21,6 +35,8 @@ pub struct ClientConn {
subscriptions: HashMap<String, Subscription>, subscriptions: HashMap<String, Subscription>,
/// Per-connection maximum concurrent subscriptions /// Per-connection maximum concurrent subscriptions
max_subs: usize, max_subs: usize,
/// NIP-42 AUTH
auth: Nip42AuthState,
} }
impl Default for ClientConn { impl Default for ClientConn {
@ -39,15 +55,18 @@ impl ClientConn {
client_id, client_id,
subscriptions: HashMap::new(), subscriptions: HashMap::new(),
max_subs: 32, max_subs: 32,
auth: NoAuth,
} }
} }
#[must_use] pub fn subscriptions(&self) -> &HashMap<String, Subscription> { #[must_use]
pub fn subscriptions(&self) -> &HashMap<String, Subscription> {
&self.subscriptions &self.subscriptions
} }
/// Check if the given subscription already exists /// 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) self.subscriptions.values().any(|x| x == sub)
} }
@ -63,6 +82,22 @@ impl ClientConn {
&self.client_ip_addr &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. /// Add a new subscription for this connection.
/// # Errors /// # Errors
/// ///
@ -116,4 +151,79 @@ impl ClientConn {
self.get_client_prefix(), 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::<String>(),
self.get_client_prefix()
);
Ok(())
}
Err(_) => Err(Error::AuthFailure),
}
}
} }

View File

@ -30,6 +30,7 @@ pub struct SubmittedEvent {
pub source_ip: String, pub source_ip: String,
pub origin: Option<String>, pub origin: Option<String>,
pub user_agent: Option<String>, pub user_agent: Option<String>,
pub auth_pubkey: Option<Vec<u8>>,
} }
/// Database file /// Database file
@ -240,7 +241,7 @@ pub async fn db_writer(
if let Some(ref mut c) = grpc_client { if let Some(ref mut c) = grpc_client {
trace!("checking if grpc permits"); trace!("checking if grpc permits");
let grpc_start = Instant::now(); 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 { match decision_res {
Ok(decision) => { Ok(decision) => {
if !decision.permitted() { if !decision.permitted() {

View File

@ -68,6 +68,8 @@ pub enum Error {
AuthzError, AuthzError,
#[error("Tonic GRPC error")] #[error("Tonic GRPC error")]
TonicError(tonic::Status), TonicError(tonic::Status),
#[error("Invalid AUTH message")]
AuthFailure,
#[error("Unknown/Undocumented")] #[error("Unknown/Undocumented")]
UnknownError, UnknownError,
} }

View File

@ -14,6 +14,8 @@ use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::str::FromStr; use std::str::FromStr;
use tracing::{debug, info}; use tracing::{debug, info};
use crate::event::EventWrapper::WrappedEvent;
use crate::event::EventWrapper::WrappedAuth;
lazy_static! { lazy_static! {
/// Secp256k1 verification instance. /// Secp256k1 verification instance.
@ -83,17 +85,27 @@ where
} }
} }
pub enum EventWrapper {
WrappedEvent(Event),
WrappedAuth(Event)
}
/// Convert network event to parsed/validated event. /// Convert network event to parsed/validated event.
impl From<EventCmd> for Result<Event> { impl From<EventCmd> for Result<EventWrapper> {
fn from(ec: EventCmd) -> Result<Event> { fn from(ec: EventCmd) -> Result<EventWrapper> {
// ensure command is correct // ensure command is correct
if ec.cmd == "EVENT" { if ec.cmd == "EVENT" {
ec.event.validate().map(|_| { ec.event.validate().map(|_| {
let mut e = ec.event; let mut e = ec.event;
e.build_index(); e.build_index();
e.update_delegation(); 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 { } else {
Err(CommandUnknownError) Err(CommandUnknownError)
} }
@ -326,7 +338,7 @@ impl Event {
} }
/// Convert event to canonical representation for signing. /// Convert event to canonical representation for signing.
fn to_canonical(&self) -> Option<String> { pub fn to_canonical(&self) -> Option<String> {
// create a JsonValue for each event element // create a JsonValue for each event element
let mut c: Vec<Value> = vec![]; let mut c: Vec<Value> = vec![];
// id must be set to 0 // id must be set to 0

View File

@ -1,6 +1,6 @@
//! Relay metadata using NIP-11 //! Relay metadata using NIP-11
/// Relay Info /// Relay Info
use crate::config; use crate::config::Settings;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); 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 /// Convert an Info configuration into public Relay Info
impl From<config::Info> for RelayInfo { impl From<Settings> for RelayInfo {
fn from(i: config::Info) -> Self { 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 { RelayInfo {
id: i.relay_url, id: i.relay_url,
name: i.name, name: i.name,
description: i.description, description: i.description,
pubkey: i.pubkey, pubkey: i.pubkey,
contact: i.contact, 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()), software: Some("https://git.sr.ht/~gheartsfield/nostr-rs-relay".to_owned()),
version: CARGO_PKG_VERSION.map(std::borrow::ToOwned::to_owned), version: CARGO_PKG_VERSION.map(std::borrow::ToOwned::to_owned),
} }

View File

@ -76,6 +76,7 @@ impl EventAuthzService {
origin: Option<String>, origin: Option<String>,
user_agent: Option<String>, user_agent: Option<String>,
nip05: Option<Nip05Name>, nip05: Option<Nip05Name>,
auth_pubkey: Option<Vec<u8>>
) -> Result<Box<dyn AuthzDecision>> { ) -> Result<Box<dyn AuthzDecision>> {
self.ready_connection().await; self.ready_connection().await;
let id_blob = hex::decode(&event.id)?; let id_blob = hex::decode(&event.id)?;
@ -97,7 +98,7 @@ impl EventAuthzService {
ip_addr: Some(ip.to_string()), ip_addr: Some(ip.to_string()),
origin, origin,
user_agent, user_agent,
auth_pubkey: None, auth_pubkey,
nip05: nip05.map(|x| nauthz_grpc::event_request::Nip05Name::from(x)), nip05: nip05.map(|x| nauthz_grpc::event_request::Nip05Name::from(x)),
}) })
.await?; .await?;

View File

@ -16,6 +16,7 @@ pub struct EventResult {
pub enum Notice { pub enum Notice {
Message(String), Message(String),
EventResult(EventResult), EventResult(EventResult),
AuthChallenge(String)
} }
impl EventResultStatus { impl EventResultStatus {

View File

@ -7,6 +7,8 @@ use crate::repo::NostrRepo;
use crate::db; use crate::db;
use crate::db::SubmittedEvent; use crate::db::SubmittedEvent;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::event::EventWrapper;
use crate::server::EventWrapper::{WrappedAuth, WrappedEvent};
use crate::event::Event; use crate::event::Event;
use crate::event::EventCmd; use crate::event::EventCmd;
use crate::info::RelayInfo; use crate::info::RelayInfo;
@ -48,6 +50,7 @@ use tungstenite::error::Error as WsError;
use tungstenite::handshake; use tungstenite::handshake;
use tungstenite::protocol::Message; use tungstenite::protocol::Message;
use tungstenite::protocol::WebSocketConfig; use tungstenite::protocol::WebSocketConfig;
use crate::server::Error::CommandUnknownError;
/// Handle arbitrary HTTP requests, including for `WebSocket` upgrades. /// Handle arbitrary HTTP requests, including for `WebSocket` upgrades.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@ -157,7 +160,7 @@ async fn handle_web_request(
if mt_str.contains("application/nostr+json") { if mt_str.contains("application/nostr+json") {
// build a relay info response // build a relay info response
debug!("Responding to server info request"); 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()); let b = Body::from(serde_json::to_string_pretty(&rinfo).unwrap());
return Ok(Response::builder() return Ok(Response::builder()
.status(200) .status(200)
@ -268,6 +271,10 @@ fn create_metrics() -> (Registry, NostrMetrics) {
"nostr_cmd_close_total", "nostr_cmd_close_total",
"CLOSE commands", "CLOSE commands",
)).unwrap(); )).unwrap();
let cmd_auth = IntCounter::with_opts(Opts::new(
"nostr_cmd_auth_total",
"AUTH commands",
)).unwrap();
let disconnects = IntCounterVec::new( let disconnects = IntCounterVec::new(
Opts::new("nostr_disconnects_total", "Client disconnects"), Opts::new("nostr_disconnects_total", "Client disconnects"),
vec!["reason"].as_slice(), 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_req.clone())).unwrap();
registry.register(Box::new(cmd_event.clone())).unwrap(); registry.register(Box::new(cmd_event.clone())).unwrap();
registry.register(Box::new(cmd_close.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(); registry.register(Box::new(disconnects.clone())).unwrap();
let metrics = NostrMetrics { let metrics = NostrMetrics {
query_sub, query_sub,
@ -295,6 +303,7 @@ fn create_metrics() -> (Registry, NostrMetrics) {
cmd_req, cmd_req,
cmd_event, cmd_event,
cmd_close, cmd_close,
cmd_auth,
}; };
(registry,metrics) (registry,metrics)
} }
@ -488,7 +497,7 @@ pub fn start_server(settings: &Settings, shutdown_rx: MpscReceiver<()>) -> Resul
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)] #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug)]
#[serde(untagged)] #[serde(untagged)]
pub enum NostrMessage { pub enum NostrMessage {
/// An `EVENT` message /// `EVENT` and `AUTH` messages
EventMsg(EventCmd), EventMsg(EventCmd),
/// A `REQ` message /// A `REQ` message
SubMsg(Subscription), SubMsg(Subscription),
@ -528,6 +537,7 @@ fn make_notice_message(notice: &Notice) -> Message {
let json = match notice { let json = match notice {
Notice::Message(ref msg) => json!(["NOTICE", msg]), Notice::Message(ref msg) => json!(["NOTICE", msg]),
Notice::EventResult(ref res) => json!(["OK", res.id, res.status.to_bool(), res.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()) Message::text(json.to_string())
@ -616,6 +626,14 @@ async fn nostr_server(
// Measure connections // Measure connections
metrics.connections.inc(); 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 { loop {
tokio::select! { tokio::select! {
_ = shutdown.recv() => { _ = shutdown.recv() => {
@ -729,16 +747,17 @@ async fn nostr_server(
// An EventCmd needs to be validated to be converted into an Event // An EventCmd needs to be validated to be converted into an Event
// handle each type of message // handle each type of message
let evid = ec.event_id().to_owned(); let evid = ec.event_id().to_owned();
let parsed : Result<Event> = Result::<Event>::from(ec); let parsed : Result<EventWrapper> = Result::<EventWrapper>::from(ec);
metrics.cmd_event.inc();
match parsed { match parsed {
Ok(e) => { Ok(WrappedEvent(e)) => {
metrics.cmd_event.inc();
let id_prefix:String = e.id.chars().take(8).collect(); let id_prefix:String = e.id.chars().take(8).collect();
debug!("successfully parsed/validated event: {:?} (cid: {}, kind: {})", id_prefix, cid, e.kind); debug!("successfully parsed/validated event: {:?} (cid: {}, kind: {})", id_prefix, cid, e.kind);
// check if the event is too far in the future. // check if the event is too far in the future.
if e.is_valid_timestamp(settings.options.reject_future_seconds) { if e.is_valid_timestamp(settings.options.reject_future_seconds) {
// Write this to the database. // 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(); event_tx.send(submit_event).await.ok();
client_published_event_count += 1; client_published_event_count += 1;
} else { } 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 => "<unspecified>".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) => { Err(e) => {
metrics.cmd_event.inc();
info!("client sent an invalid event (cid: {})", cid); info!("client sent an invalid event (cid: {})", cid);
ws_stream.send(make_notice_message(&Notice::invalid(evid, &format!("{e}")))).await.ok(); 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_req: IntCounter, // count of REQ commands received
pub cmd_event: IntCounter, // count of EVENT commands received pub cmd_event: IntCounter, // count of EVENT commands received
pub cmd_close: IntCounter, // count of CLOSE commands received pub cmd_close: IntCounter, // count of CLOSE commands received
pub cmd_auth: IntCounter, // count of AUTH commands received
} }

View File

@ -1,6 +1,7 @@
//! Common utility functions //! Common utility functions
use bech32::FromBase32; use bech32::FromBase32;
use std::time::SystemTime; use std::time::SystemTime;
use url::Url;
/// Seconds since 1970. /// Seconds since 1970.
#[must_use] pub fn unix_time() -> u64 { #[must_use] pub fn unix_time() -> u64 {
@ -33,6 +34,10 @@ pub fn nip19_to_hex(s: &str) -> Result<String, bech32::Error> {
}) })
} }
pub fn host_str(url: &String) -> Option<String> {
Url::parse(url).ok().and_then(|u| u.host_str().map(|s| s.to_string()))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

356
tests/conn.rs Normal file
View File

@ -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::<String>();
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<String>> = 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
}
}