lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit e06f308869d2ad92dbe84a11c0ad92b0b4091cfe
parent ae4a94f98c6be16301e9f3376fc9e8a377978a38
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 08:04:44 +0000

publish-proxy: add protocol and relay contracts

- add the shared publish proxy protocol crate and contract tier metadata
- expand relay outcome kinds for daemon publish results
- harden public relay URL validation for unsafe IP destinations
- validate raw relay events against every signed event field

Diffstat:
MCargo.lock | 8++++++++
MCargo.toml | 2++
Mcontracts/manifest.toml | 1+
Acrates/publish_proxy_protocol/Cargo.toml | 24++++++++++++++++++++++++
Acrates/publish_proxy_protocol/README | 3+++
Acrates/publish_proxy_protocol/src/lib.rs | 547+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/relay_transport/src/error.rs | 10++++++++++
Mcrates/relay_transport/src/outcome.rs | 41+++++++++++++++++++++++++++++++++++++++--
Mcrates/relay_transport/src/publish.rs | 51++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/relay_transport/src/relay.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/relay_transport/tests/transport.rs | 42++++++++++++++++++++++++++++++++++++++++++
11 files changed, 826 insertions(+), 7 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4466,6 +4466,14 @@ dependencies = [ ] [[package]] +name = "radroots_publish_proxy_protocol" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] name = "radroots_relay_transport" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/nostr_ndb", "crates/nostr_runtime", "crates/outbox", + "crates/publish_proxy_protocol", "crates/relay_transport", "crates/runtime", "crates/secret_vault", @@ -81,6 +82,7 @@ radroots_log = { path = "crates/log", version = "0.1.0-alpha.2", default-feature radroots_net = { path = "crates/net", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "0.1.0-alpha.2", default-features = false } radroots_outbox = { path = "crates/outbox", version = "0.1.0-alpha.2", default-features = false } +radroots_publish_proxy_protocol = { path = "crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false } radroots_relay_transport = { path = "crates/relay_transport", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_proto = { path = "crates/simplex_agent_proto", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_runtime = { path = "crates/simplex_agent_runtime", version = "0.1.0-alpha.2", default-features = false } diff --git a/contracts/manifest.toml b/contracts/manifest.toml @@ -44,6 +44,7 @@ deferred_publication = [ "radroots_authority", "radroots_event_store", "radroots_outbox", + "radroots_publish_proxy_protocol", "radroots_relay_transport", "radroots_net", "radroots_nostr_runtime", diff --git a/crates/publish_proxy_protocol/Cargo.toml b/crates/publish_proxy_protocol/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "radroots_publish_proxy_protocol" +publish = false +version.workspace = true +edition.workspace = true +authors = ["Tyson Lupul <tyson@radroots.org>"] +rust-version.workspace = true +license = "MIT OR Apache-2.0" +description = "Shared Radrootsd publish proxy JSON-RPC wire contracts" +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/radroots_publish_proxy_protocol" +readme = "README" + +[features] +default = ["std", "serde"] +std = [] +serde = ["dep:serde"] + +[dependencies] +serde = { workspace = true, optional = true, features = ["alloc", "derive"] } + +[dev-dependencies] +serde_json = { workspace = true, features = ["std"] } diff --git a/crates/publish_proxy_protocol/README b/crates/publish_proxy_protocol/README @@ -0,0 +1,3 @@ +# radroots_publish_proxy_protocol + +Shared Radrootsd Publish Proxy JSON-RPC wire contracts. diff --git a/crates/publish_proxy_protocol/src/lib.rs b/crates/publish_proxy_protocol/src/lib.rs @@ -0,0 +1,547 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![forbid(unsafe_code)] + +#[cfg(not(feature = "std"))] +extern crate alloc; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; +#[cfg(feature = "std")] +use std::{string::String, vec::Vec}; + +use core::fmt; + +pub const API_VERSION: &str = "radrootsd.publish_proxy.v1"; +pub const DAEMON_NAME: &str = "radrootsd"; +pub const METHOD_CAPABILITIES: &str = "publish.capabilities"; +pub const METHOD_EVENT: &str = "publish.event"; +pub const METHOD_JOB_GET: &str = "publish.job.get"; +pub const METHOD_JOB_LIST: &str = "publish.job.list"; +pub const METHOD_RELAYS_RESOLVE: &str = "publish.relays.resolve"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PublishProxyProtocolError { + InvalidHexField { + field: &'static str, + expected_len: usize, + }, + InvalidKind(u32), + EmptyTag { + index: usize, + }, + EmptyIdempotencyKey, + EmptyRelayUrl { + index: usize, + }, + RelayLimitExceeded { + max: usize, + actual: usize, + }, + InvalidQuorum, + EmptyPrincipalId, + EmptyJobId, +} + +impl fmt::Display for PublishProxyProtocolError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidHexField { + field, + expected_len, + } => write!(f, "{field} must be {expected_len} lowercase hex characters"), + Self::InvalidKind(kind) => write!(f, "event kind {kind} exceeds publish proxy range"), + Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), + Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), + Self::EmptyRelayUrl { index } => write!(f, "relay URL {index} must not be empty"), + Self::RelayLimitExceeded { max, actual } => { + write!(f, "relay count {actual} exceeds limit {max}") + } + Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), + Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), + Self::EmptyJobId => f.write_str("job id must not be empty"), + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for PublishProxyProtocolError {} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SignedNostrEventWire { + pub id: String, + pub pubkey: String, + pub created_at: u64, + pub kind: u32, + pub tags: Vec<Vec<String>>, + pub content: String, + pub sig: String, +} + +impl SignedNostrEventWire { + pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { + validate_lower_hex("id", self.id.as_str(), 64)?; + validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; + validate_lower_hex("sig", self.sig.as_str(), 128)?; + if self.kind > u16::MAX as u32 { + return Err(PublishProxyProtocolError::InvalidKind(self.kind)); + } + for (index, tag) in self.tags.iter().enumerate() { + if tag.is_empty() { + return Err(PublishProxyProtocolError::EmptyTag { index }); + } + } + Ok(()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublishRelayPolicy { + ExplicitOnly, + RequestThenAuthorWriteThenDaemonDefault, + AuthorWriteThenDaemonDefault, + DaemonDefaultOnly, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PublishDeliveryPolicy { + Any, + All, + Quorum { quorum: usize }, +} + +impl PublishDeliveryPolicy { + pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { + if matches!(self, Self::Quorum { quorum: 0 }) { + Err(PublishProxyProtocolError::InvalidQuorum) + } else { + Ok(()) + } + } + + pub fn required_ack_count(&self, relay_count: usize) -> usize { + match self { + Self::Any => usize::from(relay_count > 0), + Self::All => relay_count, + Self::Quorum { quorum } => *quorum, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishEventRequest { + pub event: SignedNostrEventWire, + #[cfg_attr(feature = "serde", serde(default))] + pub relays: Vec<String>, + pub relay_policy: PublishRelayPolicy, + pub delivery_policy: PublishDeliveryPolicy, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub idempotency_key: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub timeout_ms: Option<u64>, +} + +impl PublishEventRequest { + pub fn validate(&self, max_relays: usize) -> Result<(), PublishProxyProtocolError> { + self.event.validate()?; + self.delivery_policy.validate()?; + if self.relays.len() > max_relays { + return Err(PublishProxyProtocolError::RelayLimitExceeded { + max: max_relays, + actual: self.relays.len(), + }); + } + for (index, relay) in self.relays.iter().enumerate() { + if relay.trim().is_empty() { + return Err(PublishProxyProtocolError::EmptyRelayUrl { index }); + } + } + if self + .idempotency_key + .as_ref() + .is_some_and(|key| key.trim().is_empty()) + { + return Err(PublishProxyProtocolError::EmptyIdempotencyKey); + } + Ok(()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublishJobStatus { + Accepted, + Publishing, + DeliverySatisfied, + DeliveryUnsatisfiedRetryable, + DeliveryUnsatisfiedTerminal, + Rejected, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublishRelayOutcomeKind { + Accepted, + DuplicateAccepted, + Blocked, + RateLimited, + Invalid, + PowRequired, + Restricted, + AuthRequired, + Muted, + Unsupported, + PaymentRequired, + Error, + Timeout, + ConnectionFailed, + RelayUrlRejected, + SkippedAlreadyAccepted, + Unknown, +} + +impl PublishRelayOutcomeKind { + pub fn counts_toward_quorum(self) -> bool { + matches!( + self, + Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted + ) + } + + pub fn is_retryable(self) -> bool { + matches!( + self, + Self::RateLimited + | Self::PowRequired + | Self::AuthRequired + | Self::Error + | Self::Timeout + | Self::ConnectionFailed + | Self::Unknown + ) + } + + pub fn is_terminal_failure(self) -> bool { + matches!( + self, + Self::Blocked + | Self::Invalid + | Self::Restricted + | Self::Muted + | Self::Unsupported + | Self::PaymentRequired + | Self::RelayUrlRejected + ) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishRelayOutcome { + pub relay_url: String, + pub source: PublishRelaySource, + pub attempted: bool, + pub outcome_kind: PublishRelayOutcomeKind, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub message: Option<String>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub latency_ms: Option<u64>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublishRelaySource { + Request, + AuthorWrite, + DaemonDefault, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishJobView { + pub job_id: String, + pub status: PublishJobStatus, + pub terminal: bool, + pub delivery_satisfied: bool, + pub event_id: String, + pub pubkey: String, + pub event_kind: u32, + pub relay_policy: PublishRelayPolicy, + pub delivery_policy: PublishDeliveryPolicy, + pub relay_count: usize, + pub acknowledged_count: usize, + pub retryable_count: usize, + pub terminal_count: usize, + pub requested_at_ms: i64, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub completed_at_ms: Option<i64>, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub last_error: Option<String>, + #[cfg_attr(feature = "serde", serde(default))] + pub relays: Vec<PublishRelayOutcome>, +} + +impl PublishJobView { + pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { + if self.job_id.trim().is_empty() { + return Err(PublishProxyProtocolError::EmptyJobId); + } + validate_lower_hex("event_id", self.event_id.as_str(), 64)?; + validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; + if self.event_kind > u16::MAX as u32 { + return Err(PublishProxyProtocolError::InvalidKind(self.event_kind)); + } + self.delivery_policy.validate() + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishEventResponse { + pub deduplicated: bool, + pub job: PublishJobView, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishCapabilities { + pub daemon: String, + pub api_version: String, + pub transports: Vec<String>, + pub methods: Vec<String>, + pub auth: PublishAuthCapabilities, + pub publish: PublishSurfaceCapabilities, +} + +impl PublishCapabilities { + pub fn v1(max_event_bytes: usize, max_relays_per_request: usize) -> Self { + Self { + daemon: DAEMON_NAME.to_owned(), + api_version: API_VERSION.to_owned(), + transports: vec!["jsonrpc_http".to_owned()], + methods: vec![ + METHOD_CAPABILITIES.to_owned(), + METHOD_EVENT.to_owned(), + METHOD_JOB_GET.to_owned(), + METHOD_JOB_LIST.to_owned(), + METHOD_RELAYS_RESOLVE.to_owned(), + ], + auth: PublishAuthCapabilities { + mode: "scoped_bearer_token".to_owned(), + }, + publish: PublishSurfaceCapabilities { + signed_event_ingress: true, + server_side_user_signing: false, + max_event_bytes, + max_relays_per_request, + delivery_policies: vec![ + PublishDeliveryPolicyName::Any, + PublishDeliveryPolicyName::Quorum, + PublishDeliveryPolicyName::All, + ], + relay_policies: vec![ + PublishRelayPolicy::ExplicitOnly, + PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, + PublishRelayPolicy::AuthorWriteThenDaemonDefault, + PublishRelayPolicy::DaemonDefaultOnly, + ], + }, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishAuthCapabilities { + pub mode: String, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublishSurfaceCapabilities { + pub signed_event_ingress: bool, + pub server_side_user_signing: bool, + pub max_event_bytes: usize, + pub max_relays_per_request: usize, + pub delivery_policies: Vec<PublishDeliveryPolicyName>, + pub relay_policies: Vec<PublishRelayPolicy>, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublishDeliveryPolicyName { + Any, + Quorum, + All, +} + +fn validate_lower_hex( + field: &'static str, + value: &str, + expected_len: usize, +) -> Result<(), PublishProxyProtocolError> { + if value.len() == expected_len + && value + .as_bytes() + .iter() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + Ok(()) + } else { + Err(PublishProxyProtocolError::InvalidHexField { + field, + expected_len, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn event() -> SignedNostrEventWire { + SignedNostrEventWire { + id: "0".repeat(64), + pubkey: "1".repeat(64), + created_at: 1_700_000_000, + kind: 30_402, + tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], + content: "{\"name\":\"carrots\"}".to_owned(), + sig: "2".repeat(128), + } + } + + #[test] + fn signed_event_wire_uses_pubkey_and_rejects_author() { + let value = serde_json::to_value(event()).expect("serialize"); + assert!(value.get("pubkey").is_some()); + assert!(value.get("author").is_none()); + + let err = serde_json::from_value::<SignedNostrEventWire>(serde_json::json!({ + "id": "0".repeat(64), + "author": "1".repeat(64), + "created_at": 1_700_000_000u64, + "kind": 30402u32, + "tags": [["d", "listing-1"]], + "content": "{}", + "sig": "2".repeat(128) + })) + .expect_err("author must not be accepted"); + let message = err.to_string(); + assert!(message.contains("author")); + assert!(message.contains("pubkey")); + } + + #[test] + fn signed_event_validation_rejects_malformed_fields() { + let mut invalid_id = event(); + invalid_id.id = "A".repeat(64); + assert!(matches!( + invalid_id.validate(), + Err(PublishProxyProtocolError::InvalidHexField { field: "id", .. }) + )); + + let mut invalid_kind = event(); + invalid_kind.kind = u16::MAX as u32 + 1; + assert!(matches!( + invalid_kind.validate(), + Err(PublishProxyProtocolError::InvalidKind(_)) + )); + + let mut empty_tag = event(); + empty_tag.tags = vec![Vec::new()]; + assert!(matches!( + empty_tag.validate(), + Err(PublishProxyProtocolError::EmptyTag { index: 0 }) + )); + } + + #[test] + fn publish_request_validation_covers_policy_and_relay_limits() { + let request = PublishEventRequest { + event: event(), + relays: vec!["wss://relay.example.com".to_owned()], + relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, + delivery_policy: PublishDeliveryPolicy::Quorum { quorum: 1 }, + idempotency_key: Some("key-1".to_owned()), + timeout_ms: Some(10_000), + }; + request.validate(1).expect("valid request"); + assert_eq!(request.delivery_policy.required_ack_count(3), 1); + + let mut too_many = request.clone(); + too_many.relays.push("wss://relay-2.example.com".to_owned()); + assert!(matches!( + too_many.validate(1), + Err(PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }) + )); + + let mut invalid_quorum = request; + invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; + assert!(matches!( + invalid_quorum.validate(1), + Err(PublishProxyProtocolError::InvalidQuorum) + )); + } + + #[test] + fn capabilities_match_publish_proxy_v1_surface() { + let capabilities = PublishCapabilities::v1(65_536, 20); + let value = serde_json::to_value(&capabilities).expect("capabilities"); + assert_eq!(value["daemon"], DAEMON_NAME); + assert_eq!(value["api_version"], API_VERSION); + assert_eq!(value["auth"]["mode"], "scoped_bearer_token"); + assert_eq!(value["publish"]["server_side_user_signing"], false); + assert!( + value["methods"] + .as_array() + .expect("methods") + .iter() + .any(|method| method == METHOD_EVENT) + ); + } + + #[test] + fn outcome_kind_semantics_cover_daemon_results() { + assert!(PublishRelayOutcomeKind::SkippedAlreadyAccepted.counts_toward_quorum()); + assert!(PublishRelayOutcomeKind::AuthRequired.is_retryable()); + assert!(PublishRelayOutcomeKind::RelayUrlRejected.is_terminal_failure()); + assert!(PublishRelayOutcomeKind::Muted.is_terminal_failure()); + assert!(PublishRelayOutcomeKind::PaymentRequired.is_terminal_failure()); + } +} diff --git a/crates/relay_transport/src/error.rs b/crates/relay_transport/src/error.rs @@ -22,6 +22,16 @@ pub enum RadrootsRelayTransportError { #[error("Relay URL `{url}` must not include query or fragment")] RelayUrlQueryOrFragment { url: String }, + #[error("Relay URL `{url}` targets forbidden destination: {reason}")] + RelayUrlForbiddenDestination { url: String, reason: String }, + + #[error("Relay URL `{url}` resolved to forbidden address `{address}`: {reason}")] + RelayUrlResolvedForbiddenDestination { + url: String, + address: String, + reason: String, + }, + #[error("Relay target set must not be empty")] EmptyTargetSet, diff --git a/crates/relay_transport/src/outcome.rs b/crates/relay_transport/src/outcome.rs @@ -12,15 +12,23 @@ pub enum RadrootsRelayOutcomeKind { PowRequired, Restricted, AuthRequired, + Muted, + Unsupported, + PaymentRequired, Error, Timeout, ConnectionFailed, + RelayUrlRejected, + SkippedAlreadyAccepted, Unknown, } impl RadrootsRelayOutcomeKind { pub fn counts_toward_quorum(self) -> bool { - matches!(self, Self::Accepted | Self::DuplicateAccepted) + matches!( + self, + Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted + ) } pub fn is_retryable(self) -> bool { @@ -37,7 +45,16 @@ impl RadrootsRelayOutcomeKind { } pub fn is_terminal_failure(self) -> bool { - matches!(self, Self::Blocked | Self::Invalid | Self::Restricted) + matches!( + self, + Self::Blocked + | Self::Invalid + | Self::Restricted + | Self::Muted + | Self::Unsupported + | Self::PaymentRequired + | Self::RelayUrlRejected + ) } } @@ -76,6 +93,20 @@ impl RadrootsRelayOutcome { } } + pub fn relay_url_rejected(message: impl Into<String>) -> Self { + Self { + kind: RadrootsRelayOutcomeKind::RelayUrlRejected, + message: Some(message.into()), + } + } + + pub fn skipped_already_accepted(message: impl Into<String>) -> Self { + Self { + kind: RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, + message: Some(message.into()), + } + } + pub fn classify(message: impl AsRef<str>) -> Self { let message = message.as_ref().trim(); let lower = message.to_ascii_lowercase(); @@ -93,6 +124,12 @@ impl RadrootsRelayOutcome { RadrootsRelayOutcomeKind::Restricted } else if lower.starts_with("auth-required:") { RadrootsRelayOutcomeKind::AuthRequired + } else if lower.starts_with("mute:") { + RadrootsRelayOutcomeKind::Muted + } else if lower.starts_with("unsupported:") { + RadrootsRelayOutcomeKind::Unsupported + } else if lower.starts_with("payment-required:") { + RadrootsRelayOutcomeKind::PaymentRequired } else if lower.starts_with("error:") { RadrootsRelayOutcomeKind::Error } else if lower.starts_with("timeout:") { diff --git a/crates/relay_transport/src/publish.rs b/crates/relay_transport/src/publish.rs @@ -209,11 +209,7 @@ impl RadrootsRelayPublishAdapter for RadrootsNostrClientPublishAdapter { Box::pin(async move { let event = RadrootsNostrEvent::from_json(request.signed_event.raw_json.as_str()) .map_err(|error| RadrootsRelayTransportError::NostrEventJson(error.to_string()))?; - if event.id.to_hex() != request.signed_event.id { - return Err(RadrootsRelayTransportError::NostrEventJson( - "raw event JSON ID does not match signed event ID".to_owned(), - )); - } + ensure_raw_event_matches_signed_event(&event, &request.signed_event)?; let target_strings = request.targets.relay_strings(); for relay_url in &target_strings { self.client @@ -279,3 +275,48 @@ impl RadrootsRelayPublishAdapter for RadrootsNostrClientPublishAdapter { }) } } + +#[cfg(feature = "client")] +fn ensure_raw_event_matches_signed_event( + event: &RadrootsNostrEvent, + signed_event: &RadrootsSignedNostrEvent, +) -> Result<(), RadrootsRelayTransportError> { + let mismatches = [ + ("id", event.id.to_hex(), signed_event.id.clone()), + ("pubkey", event.pubkey.to_hex(), signed_event.pubkey.clone()), + ( + "created_at", + event.created_at.as_secs().to_string(), + signed_event.created_at.to_string(), + ), + ( + "kind", + (event.kind.as_u16() as u32).to_string(), + signed_event.kind.to_string(), + ), + ( + "content", + event.content.clone(), + signed_event.content.clone(), + ), + ("sig", event.sig.to_string(), signed_event.sig.clone()), + ]; + for (field, raw, wrapped) in mismatches { + if raw != wrapped { + return Err(RadrootsRelayTransportError::NostrEventJson(format!( + "raw event JSON {field} does not match signed event {field}" + ))); + } + } + let raw_tags = event + .tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::<Vec<_>>(); + if raw_tags != signed_event.tags { + return Err(RadrootsRelayTransportError::NostrEventJson( + "raw event JSON tags do not match signed event tags".to_owned(), + )); + } + Ok(()) +} diff --git a/crates/relay_transport/src/relay.rs b/crates/relay_transport/src/relay.rs @@ -3,6 +3,7 @@ use crate::RadrootsRelayTransportError; use serde::{Deserialize, Serialize}; use std::fmt; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use url::Url; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -42,6 +43,7 @@ impl RadrootsRelayUrl { url: original.to_owned(), }); }; + validate_host_destination(original, host, policy)?; if parsed.query().is_some() || parsed.fragment().is_some() { return Err(RadrootsRelayTransportError::RelayUrlQueryOrFragment { url: original.to_owned(), @@ -70,6 +72,27 @@ impl RadrootsRelayUrl { Ok(Self(normalized)) } + pub fn validate_public_resolved_ip_addrs<I>( + &self, + addrs: I, + ) -> Result<(), RadrootsRelayTransportError> + where + I: IntoIterator<Item = IpAddr>, + { + for address in addrs { + if let Some(reason) = forbidden_public_ip_reason(address) { + return Err( + RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { + url: self.0.clone(), + address: address.to_string(), + reason: reason.to_owned(), + }, + ); + } + } + Ok(()) + } + pub fn as_str(&self) -> &str { self.0.as_str() } @@ -79,6 +102,87 @@ impl RadrootsRelayUrl { } } +fn validate_host_destination( + original: &str, + host: &str, + policy: RadrootsRelayUrlPolicy, +) -> Result<(), RadrootsRelayTransportError> { + let host = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if matches!(policy, RadrootsRelayUrlPolicy::Public) + && let Ok(address) = host.parse::<IpAddr>() + && let Some(reason) = forbidden_public_ip_reason(address) + { + return Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { + url: original.to_owned(), + reason: reason.to_owned(), + }); + } + Ok(()) +} + +fn forbidden_public_ip_reason(address: IpAddr) -> Option<&'static str> { + match address { + IpAddr::V4(address) => forbidden_public_ipv4_reason(address), + IpAddr::V6(address) => forbidden_public_ipv6_reason(address), + } +} + +fn forbidden_public_ipv4_reason(address: Ipv4Addr) -> Option<&'static str> { + let octets = address.octets(); + if address.is_unspecified() || octets[0] == 0 { + Some("unspecified or this-network IPv4 address") + } else if address.is_loopback() { + Some("loopback IPv4 address") + } else if address.is_private() { + Some("private IPv4 address") + } else if address.is_link_local() { + Some("link-local IPv4 address") + } else if address.is_multicast() { + Some("multicast IPv4 address") + } else if address.is_broadcast() { + Some("broadcast IPv4 address") + } else if address.is_documentation() { + Some("documentation IPv4 address") + } else if octets[0] == 100 && (64..=127).contains(&octets[1]) { + Some("shared IPv4 address space") + } else if octets[0] == 192 && octets[1] == 0 && octets[2] == 0 { + Some("IETF protocol-assignment IPv4 address") + } else if octets[0] == 198 && matches!(octets[1], 18 | 19) { + Some("benchmark IPv4 address") + } else if octets[0] >= 240 { + Some("reserved IPv4 address") + } else { + None + } +} + +fn forbidden_public_ipv6_reason(address: Ipv6Addr) -> Option<&'static str> { + let segments = address.segments(); + if let Some(mapped) = address.to_ipv4_mapped() { + return forbidden_public_ipv4_reason(mapped); + } + if address.is_unspecified() { + Some("unspecified IPv6 address") + } else if address.is_loopback() { + Some("loopback IPv6 address") + } else if address.is_multicast() { + Some("multicast IPv6 address") + } else if (segments[0] & 0xfe00) == 0xfc00 { + Some("unique-local IPv6 address") + } else if (segments[0] & 0xffc0) == 0xfe80 { + Some("link-local IPv6 address") + } else if segments[0] == 0x2001 && segments[1] == 0x0db8 { + Some("documentation IPv6 address") + } else if segments[0] == 0x2001 && segments[1] < 0x0200 { + Some("IETF protocol-assignment IPv6 address") + } else { + None + } +} + impl fmt::Display for RadrootsRelayUrl { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.0.as_str()) diff --git a/crates/relay_transport/tests/transport.rs b/crates/relay_transport/tests/transport.rs @@ -19,6 +19,7 @@ use radroots_relay_transport::{ RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, publish_claimed_outbox_event, publish_signed_event, }; +use std::net::{IpAddr, Ipv4Addr}; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -150,6 +151,33 @@ fn relay_url_validation_and_target_normalization() { RadrootsRelayUrl::parse("ws://192.168.1.10:7777", RadrootsRelayUrlPolicy::Localhost) .is_err() ); + assert!(matches!( + RadrootsRelayUrl::parse("wss://127.0.0.1", RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) + )); + assert!(matches!( + RadrootsRelayUrl::parse("wss://10.1.2.3", RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) + )); + assert!(matches!( + RadrootsRelayUrl::parse("wss://[::1]", RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) + )); + assert!(matches!( + RadrootsRelayUrl::parse("wss://[fd00::1]", RadrootsRelayUrlPolicy::Public), + Err(RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. }) + )); + let public_relay = + RadrootsRelayUrl::parse("wss://relay.example.com", RadrootsRelayUrlPolicy::Public) + .expect("public relay"); + public_relay + .validate_public_resolved_ip_addrs([IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34))]) + .expect("public resolved ip"); + assert!(matches!( + public_relay + .validate_public_resolved_ip_addrs([IpAddr::V4(Ipv4Addr::new(192, 168, 1, 10))]), + Err(RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { .. }) + )); assert!( RadrootsRelayUrl::parse("https://relay.example.com", RadrootsRelayUrlPolicy::Public) @@ -268,6 +296,15 @@ fn outcome_prefix_classification_covers_required_kinds() { "auth-required: challenge", RadrootsRelayOutcomeKind::AuthRequired, ), + ("mute: pubkey muted", RadrootsRelayOutcomeKind::Muted), + ( + "unsupported: event kind", + RadrootsRelayOutcomeKind::Unsupported, + ), + ( + "payment-required: paid relay", + RadrootsRelayOutcomeKind::PaymentRequired, + ), ( "duplicate: already have it", RadrootsRelayOutcomeKind::DuplicateAccepted, @@ -283,8 +320,13 @@ fn outcome_prefix_classification_covers_required_kinds() { } assert!(RadrootsRelayOutcome::classify("duplicate: already have it").counts_toward_quorum()); + assert!( + RadrootsRelayOutcome::skipped_already_accepted("already accepted").counts_toward_quorum() + ); assert!(RadrootsRelayOutcome::classify("auth-required: challenge").is_retryable()); assert!(RadrootsRelayOutcome::classify("restricted: denied").is_terminal_failure()); + assert!(RadrootsRelayOutcome::relay_url_rejected("unsafe relay").is_terminal_failure()); + assert!(RadrootsRelayOutcome::classify("mute: pubkey muted").is_terminal_failure()); } #[tokio::test]