diff --git a/CHANGELOG.md b/CHANGELOG.md index 33df58382..b49fea3b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ * nostr: add `Tags::challenge` method ([Yuki Kishimoto]) * database: impl PartialEq and Eq for `Events` ([Yuki Kishimoto]) * sdk: automatically resend event after NIP-42 authentication ([Yuki Kishimoto]) +* relay-builder: add NIP42 support ([Yuki Kishimoto]) ### Fixed diff --git a/crates/nostr-relay-builder/src/builder.rs b/crates/nostr-relay-builder/src/builder.rs index 6aea3aca5..e4df3519b 100644 --- a/crates/nostr-relay-builder/src/builder.rs +++ b/crates/nostr-relay-builder/src/builder.rs @@ -100,6 +100,41 @@ pub struct RelayTestOptions { pub unresponsive_connection: Option, } +/// NIP42 mode +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum RelayBuilderNip42Mode { + /// Require authentication for writing + Write, + /// Require authentication for reading + Read, + /// Always require authentication + #[default] + Both, +} + +impl RelayBuilderNip42Mode { + /// Check if is [`RelayBuilderNip42Mode::Read`] or [`RelayBuilderNip42Mode::Both`] + #[inline] + pub fn is_read(&self) -> bool { + matches!(self, Self::Read | Self::Both) + } + + /// Check if is [`RelayBuilderNip42Mode::Write`] or [`RelayBuilderNip42Mode::Both`] + #[inline] + pub fn is_write(&self) -> bool { + matches!(self, Self::Write | Self::Both) + } +} + +/// NIP42 options +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RelayBuilderNip42 { + /// Mode + pub mode: RelayBuilderNip42Mode, + // /// Allowed public keys + // pub allowed: HashSet, +} + /// Relay builder #[derive(Debug)] pub struct RelayBuilder { @@ -113,6 +148,8 @@ pub struct RelayBuilder { pub(crate) mode: RelayBuilderMode, /// Rate limit pub(crate) rate_limit: RateLimit, + /// NIP42 options + pub(crate) nip42: Option, /// Tor hidden service #[cfg(feature = "tor")] pub(crate) tor: Option, @@ -135,6 +172,7 @@ impl Default for RelayBuilder { })), mode: RelayBuilderMode::default(), rate_limit: RateLimit::default(), + nip42: None, #[cfg(feature = "tor")] tor: None, max_connections: None, @@ -183,6 +221,13 @@ impl RelayBuilder { self } + /// Require NIP42 authentication + #[inline] + pub fn nip42(mut self, opts: RelayBuilderNip42) -> Self { + self.nip42 = Some(opts); + self + } + /// Set tor options #[inline] #[cfg(feature = "tor")] diff --git a/crates/nostr-relay-builder/src/local/inner.rs b/crates/nostr-relay-builder/src/local/inner.rs index 2013d09b0..0e8681092 100644 --- a/crates/nostr-relay-builder/src/local/inner.rs +++ b/crates/nostr-relay-builder/src/local/inner.rs @@ -14,9 +14,11 @@ use nostr_database::prelude::*; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, Semaphore}; -use super::session::{RateLimiterResponse, Session, Tokens}; +use super::session::{Nip42Session, RateLimiterResponse, Session, Tokens}; use super::util; -use crate::builder::{RateLimit, RelayBuilder, RelayBuilderMode, RelayTestOptions}; +use crate::builder::{ + RateLimit, RelayBuilder, RelayBuilderMode, RelayBuilderNip42, RelayTestOptions, +}; use crate::error::Error; type WsTx = SplitSink, Message>; @@ -36,6 +38,7 @@ pub(super) struct InnerLocalRelay { min_pow: Option, // TODO: use AtomicU8 to allow to change it? #[cfg(feature = "tor")] hidden_service: Option, + nip42: Option, test: RelayTestOptions, } @@ -98,6 +101,7 @@ impl InnerLocalRelay { min_pow: builder.min_pow, #[cfg(feature = "tor")] hidden_service, + nip42: builder.nip42, test: builder.test, }; @@ -169,6 +173,7 @@ impl InnerLocalRelay { let mut session: Session = Session { subscriptions: HashMap::new(), + nip42: Nip42Session::default(), tokens: Tokens::new(self.rate_limit.notes_per_minute), }; @@ -272,6 +277,38 @@ impl InnerLocalRelay { } } + // Check NIP42 + if let Some(nip42) = &self.nip42 { + // TODO: check if public key allowed + + // Check mode and if it's authenticated + if nip42.mode.is_write() && !session.nip42.is_authenticated() { + // Generate and send AUTH challenge + self.send_msg( + ws_tx, + RelayMessage::Auth { + challenge: session.nip42.generate_challenge(), + }, + ) + .await?; + + // Return error + return self + .send_msg( + ws_tx, + RelayMessage::Ok { + event_id: event.id, + status: false, + message: format!( + "{}: you must auth", + MachineReadablePrefix::AuthRequired + ), + }, + ) + .await; + } + } + // Check if event already exists let event_status = self.database.check_id(&event.id).await?; match event_status { @@ -437,6 +474,37 @@ impl InnerLocalRelay { .await; } + // Check NIP42 + if let Some(nip42) = &self.nip42 { + // TODO: check if public key allowed + + // Check mode and if it's authenticated + if nip42.mode.is_read() && !session.nip42.is_authenticated() { + // Generate and send AUTH challenge + self.send_msg( + ws_tx, + RelayMessage::Auth { + challenge: session.nip42.generate_challenge(), + }, + ) + .await?; + + // Return error + return self + .send_msg( + ws_tx, + RelayMessage::Closed { + subscription_id, + message: format!( + "{}: you must auth", + MachineReadablePrefix::AuthRequired + ), + }, + ) + .await; + } + } + // Update session subscriptions session .subscriptions @@ -474,10 +542,30 @@ impl InnerLocalRelay { session.subscriptions.remove(&subscription_id); Ok(()) } - ClientMessage::Auth(_event) => { - // TODO - Ok(()) - } + ClientMessage::Auth(event) => match session.nip42.check_challenge(&event) { + Ok(()) => { + self.send_msg( + ws_tx, + RelayMessage::Ok { + event_id: event.id, + status: true, + message: String::new(), + }, + ) + .await + } + Err(e) => { + self.send_msg( + ws_tx, + RelayMessage::Ok { + event_id: event.id, + status: false, + message: format!("{}: {e}", MachineReadablePrefix::AuthRequired), + }, + ) + .await + } + }, ClientMessage::NegOpen { .. } | ClientMessage::NegMsg { .. } | ClientMessage::NegClose { .. } => Ok(()), diff --git a/crates/nostr-relay-builder/src/local/session.rs b/crates/nostr-relay-builder/src/local/session.rs index ec2876a51..bbde1622f 100644 --- a/crates/nostr-relay-builder/src/local/session.rs +++ b/crates/nostr-relay-builder/src/local/session.rs @@ -2,18 +2,78 @@ // Copyright (c) 2023-2024 Rust Nostr Developers // Distributed under the MIT software license -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -use nostr::{Filter, SubscriptionId}; +use nostr::{Event, Filter, PublicKey, Result, SubscriptionId, Timestamp}; pub(super) enum RateLimiterResponse { Allowed, Limited, } +#[derive(Default)] +pub(super) struct Nip42Session { + /// Is authenticated + pub public_key: Option, + /// Challenges + pub challenges: HashSet, +} + +impl Nip42Session { + /// Get or generate challenge + pub fn generate_challenge(&mut self) -> String { + // TODO: alternatives? + + // Too many challenges without reply + if self.challenges.len() > 20 { + // Clean to avoid possible attack where client never complete auth + self.challenges.clear(); + } + + let challenge: String = SubscriptionId::generate().to_string(); + self.challenges.insert(challenge.clone()); + challenge + } + + #[inline] + pub fn is_authenticated(&self) -> bool { + self.public_key.is_some() + } + + pub fn check_challenge(&mut self, event: &Event) -> Result<(), String> { + match event.tags.challenge() { + Some(challenge) => { + // Tried to remove challenge but wasn't in the set: return false. + if !self.challenges.remove(challenge) { + return Err(String::from("received invalid challenge")); + } + + // Check created_at + let now = Timestamp::now(); + let diff: u64 = now.as_u64().abs_diff(event.created_at.as_u64()); + if diff > 120 { + return Err(String::from("challenge is too old (max allowed 2 min)")); + } + + // Verify event + event.verify().map_err(|e| e.to_string())?; + + // TODO: check `relay` tag + + // Mark as authenticated + self.public_key = Some(event.pubkey); + + Ok(()) + } + None => Err(String::from("challenge not found")), + } + } +} + pub(super) struct Session { pub subscriptions: HashMap>, + pub nip42: Nip42Session, pub tokens: Tokens, }