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:
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]