mirror of
https://github.com/scsibug/nostr-rs-relay.git
synced 2025-08-31 19:30:48 -04:00
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:
@@ -75,6 +75,7 @@ pub struct Limits {
|
||||
#[allow(unused)]
|
||||
pub struct Authorization {
|
||||
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)]
|
||||
@@ -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,
|
||||
|
124
src/conn.rs
124
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<String, Subscription>,
|
||||
/// 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<String, Subscription> {
|
||||
#[must_use]
|
||||
pub fn subscriptions(&self) -> &HashMap<String, Subscription> {
|
||||
&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::<String>(),
|
||||
self.get_client_prefix()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Err(Error::AuthFailure),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -30,6 +30,7 @@ pub struct SubmittedEvent {
|
||||
pub source_ip: String,
|
||||
pub origin: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub auth_pubkey: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
@@ -68,6 +68,8 @@ pub enum Error {
|
||||
AuthzError,
|
||||
#[error("Tonic GRPC error")]
|
||||
TonicError(tonic::Status),
|
||||
#[error("Invalid AUTH message")]
|
||||
AuthFailure,
|
||||
#[error("Unknown/Undocumented")]
|
||||
UnknownError,
|
||||
}
|
||||
|
20
src/event.rs
20
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<EventCmd> for Result<Event> {
|
||||
fn from(ec: EventCmd) -> Result<Event> {
|
||||
impl From<EventCmd> for Result<EventWrapper> {
|
||||
fn from(ec: EventCmd) -> Result<EventWrapper> {
|
||||
// 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<String> {
|
||||
pub fn to_canonical(&self) -> Option<String> {
|
||||
// create a JsonValue for each event element
|
||||
let mut c: Vec<Value> = vec![];
|
||||
// id must be set to 0
|
||||
|
17
src/info.rs
17
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<config::Info> for RelayInfo {
|
||||
fn from(i: config::Info) -> Self {
|
||||
impl From<Settings> 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),
|
||||
}
|
||||
|
@@ -76,6 +76,7 @@ impl EventAuthzService {
|
||||
origin: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
nip05: Option<Nip05Name>,
|
||||
auth_pubkey: Option<Vec<u8>>
|
||||
) -> Result<Box<dyn AuthzDecision>> {
|
||||
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?;
|
||||
|
@@ -16,6 +16,7 @@ pub struct EventResult {
|
||||
pub enum Notice {
|
||||
Message(String),
|
||||
EventResult(EventResult),
|
||||
AuthChallenge(String)
|
||||
}
|
||||
|
||||
impl EventResultStatus {
|
||||
|
@@ -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<Event> = Result::<Event>::from(ec);
|
||||
metrics.cmd_event.inc();
|
||||
let parsed : Result<EventWrapper> = Result::<EventWrapper>::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 => "<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) => {
|
||||
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
|
||||
|
||||
}
|
||||
|
@@ -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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
Reference in New Issue
Block a user