lib

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

commit 4d31f327c9659240d93ab01e9343b8c0171249ea
parent fce00896ca3f87a14ea57f46e628c429167d4f2c
Author: triesap <tyson@radroots.org>
Date:   Tue, 25 Aug 2026 20:25:31 +0000

- add an explicit typed private-device transport policy
- enforce literal RFC1918 and ULA parity for relay and Blossom endpoints
- remove network material from public Nostr transport errors
- bind behavior with conformance vectors, package guards, and API baselines

Diffstat:
Mcontracts/api_baselines/radroots_transport.txt | 8++++++++
Mcontracts/api_baselines/radroots_transport_nostr.txt | 12+-----------
Acontracts/conformance/vectors/transport/device_network_policy.v1.json | 24++++++++++++++++++++++++
Mcontracts/conformance/vectors/transport/target_uri.v1.json | 20++++++++++++++++++++
Mcrates/blossom/README.md | 3++-
Mcrates/blossom/src/authorization.rs | 23+++++++++++++++++++++--
Mcrates/sdk/src/transport.rs | 69++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/transport/README.md | 13++++++++++---
Mcrates/transport/src/lib.rs | 2+-
Mcrates/transport/src/target.rs | 134++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/transport/tests/fixtures/target_uri.v1.json | 20++++++++++++++++++++
Mcrates/transport/tests/package_boundary.rs | 9+++++++--
Mcrates/transport/tests/target_contract.rs | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/transport_nostr/README.md | 21++++++++++++---------
Mcrates/transport_nostr/src/error.rs | 86++++++++++++++++++++++++++-----------------------------------------------------
Mcrates/transport_nostr/src/profile.rs | 6++----
Mcrates/transport_nostr/src/relay.rs | 133++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcrates/transport_nostr/tests/package_boundary.rs | 28++++++++++++++++++++++++++++
18 files changed, 514 insertions(+), 176 deletions(-)

diff --git a/contracts/api_baselines/radroots_transport.txt b/contracts/api_baselines/radroots_transport.txt @@ -486,6 +486,9 @@ pub fn radroots_transport::source::EventSubscription::request(&self) -> &radroot pub type radroots_transport::source::BoxFuture<'a, T> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = T> + core::marker::Send + 'a)>> pub type radroots_transport::source::BoxSubscription = alloc::boxed::Box<dyn radroots_transport::source::EventSubscription> pub mod radroots_transport::target +#[non_exhaustive] pub enum radroots_transport::target::TargetNetworkPolicy +pub radroots_transport::target::TargetNetworkPolicy::PrivateDevice +pub radroots_transport::target::TargetNetworkPolicy::TlsOrLoopback pub struct radroots_transport::target::EndpointUri(_) impl radroots_transport::target::EndpointUri pub fn radroots_transport::target::EndpointUri::as_str(&self) -> &str @@ -513,6 +516,7 @@ pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay_with_policy(impl core::convert::AsRef<str>, radroots_transport::target::TargetNetworkPolicy) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::scope(&self) -> core::option::Option<&radroots_transport::target::TargetScope> pub fn radroots_transport::target::Target::uri(&self) -> &radroots_transport::target::EndpointUri impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::Target @@ -658,6 +662,9 @@ pub radroots_transport::SubscriptionEndReason::SourceClosed pub enum radroots_transport::SubscriptionNext pub radroots_transport::SubscriptionNext::End(radroots_transport::source::SubscriptionEnd) pub radroots_transport::SubscriptionNext::Event(alloc::boxed::Box<radroots_transport::source::SubscriptionEvent>) +#[non_exhaustive] pub enum radroots_transport::TargetNetworkPolicy +pub radroots_transport::TargetNetworkPolicy::PrivateDevice +pub radroots_transport::TargetNetworkPolicy::TlsOrLoopback pub struct radroots_transport::DeliveryReceipt impl radroots_transport::sink::DeliveryReceipt pub fn radroots_transport::sink::DeliveryReceipt::for_request(&radroots_transport::sink::DeliveryRequest, alloc::vec::Vec<radroots_transport::sink::DeliveryTargetReceipt>) -> core::result::Result<Self, radroots_transport::error::Error> @@ -776,6 +783,7 @@ pub fn radroots_transport::target::Target::new(radroots_transport::TransportId, pub fn radroots_transport::target::Target::new_with_metadata(radroots_transport::TransportId, impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::nostr_relay(impl core::convert::AsRef<str>) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::nostr_relay_with_metadata(impl core::convert::AsRef<str>, core::option::Option<radroots_transport::target::TargetScope>, core::option::Option<radroots_transport::target::TargetLabel>) -> core::result::Result<Self, radroots_transport::error::Error> +pub fn radroots_transport::target::Target::nostr_relay_with_policy(impl core::convert::AsRef<str>, radroots_transport::target::TargetNetworkPolicy) -> core::result::Result<Self, radroots_transport::error::Error> pub fn radroots_transport::target::Target::scope(&self) -> core::option::Option<&radroots_transport::target::TargetScope> pub fn radroots_transport::target::Target::uri(&self) -> &radroots_transport::target::EndpointUri impl<'de> serde_core::de::Deserialize<'de> for radroots_transport::target::Target diff --git a/contracts/api_baselines/radroots_transport_nostr.txt b/contracts/api_baselines/radroots_transport_nostr.txt @@ -10,10 +10,8 @@ pub radroots_transport_nostr::Error::AuthSignerUnavailable pub radroots_transport_nostr::Error::AuthStateUnavailable pub radroots_transport_nostr::Error::AuthTransport pub radroots_transport_nostr::Error::DuplicateRelayUrl -pub radroots_transport_nostr::Error::DuplicateRelayUrl::url: alloc::string::String pub radroots_transport_nostr::Error::EmptyRelaySet pub radroots_transport_nostr::Error::EmptyResolution -pub radroots_transport_nostr::Error::EmptyResolution::url: alloc::string::String pub radroots_transport_nostr::Error::InvalidAuthChallenge pub radroots_transport_nostr::Error::InvalidConnectionLimit pub radroots_transport_nostr::Error::InvalidConnectionLimit::value: usize @@ -22,26 +20,18 @@ pub radroots_transport_nostr::Error::InvalidReconnectBackoff::initial_delay_ms: pub radroots_transport_nostr::Error::InvalidReconnectBackoff::max_delay_ms: u64 pub radroots_transport_nostr::Error::InvalidRelayCursor pub radroots_transport_nostr::Error::InvalidRelayUrl -pub radroots_transport_nostr::Error::InvalidRelayUrl::reason: alloc::string::String -pub radroots_transport_nostr::Error::InvalidRelayUrl::url: alloc::string::String pub radroots_transport_nostr::Error::InvalidTimeout pub radroots_transport_nostr::Error::InvalidTimeout::field: &'static str pub radroots_transport_nostr::Error::InvalidTimeout::value_ms: u64 pub radroots_transport_nostr::Error::RelayDestinationDenied -pub radroots_transport_nostr::Error::RelayDestinationDenied::reason: &'static str -pub radroots_transport_nostr::Error::RelayDestinationDenied::url: alloc::string::String pub radroots_transport_nostr::Error::RelayProfilePolicyMismatch pub radroots_transport_nostr::Error::RelaySchemeDenied -pub radroots_transport_nostr::Error::RelaySchemeDenied::url: alloc::string::String pub radroots_transport_nostr::Error::ResolvedAddressDenied -pub radroots_transport_nostr::Error::ResolvedAddressDenied::address: alloc::string::String -pub radroots_transport_nostr::Error::ResolvedAddressDenied::url: alloc::string::String -pub radroots_transport_nostr::Error::Target(alloc::string::String) +pub radroots_transport_nostr::Error::Target pub radroots_transport_nostr::Error::TooManyRelays pub radroots_transport_nostr::Error::TooManyRelays::actual: usize pub radroots_transport_nostr::Error::TooManyRelays::max: usize pub radroots_transport_nostr::Error::UnexpectedTransport -pub radroots_transport_nostr::Error::UnexpectedTransport::actual: alloc::string::String impl core::error::Error for radroots_transport_nostr::Error impl core::fmt::Display for radroots_transport_nostr::Error pub fn radroots_transport_nostr::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result diff --git a/contracts/conformance/vectors/transport/device_network_policy.v1.json b/contracts/conformance/vectors/transport/device_network_policy.v1.json @@ -0,0 +1,24 @@ +{ + "suite": "transport-device-network-policy", + "contract_version": "1.0.0", + "vectors": [ + { "id": "relay_public_tls_name", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "public", "endpoint": "wss://relay.example" }, "expected": { "accepted": true } }, + { "id": "relay_public_plaintext_name", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "public", "endpoint": "ws://relay.example" }, "expected": { "accepted": false } }, + { "id": "relay_loopback_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "loopback", "endpoint": "ws://127.0.0.1:7447" }, "expected": { "accepted": true } }, + { "id": "relay_device_rfc1918_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "ws://10.0.0.5:7447" }, "expected": { "accepted": true } }, + { "id": "relay_device_ula_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "ws://[fd00::5]:7447" }, "expected": { "accepted": true } }, + { "id": "relay_device_named_tls", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "wss://relay.internal" }, "expected": { "accepted": false } }, + { "id": "relay_device_public_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "ws://8.8.8.8:7447" }, "expected": { "accepted": false } }, + { "id": "relay_device_loopback_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "ws://127.0.0.1:7447" }, "expected": { "accepted": false } }, + { "id": "relay_device_link_local_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "relay", "policy": "private_device", "endpoint": "ws://169.254.1.1:7447" }, "expected": { "accepted": false } }, + { "id": "blossom_public_tls_name", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "public", "endpoint": "https://media.example" }, "expected": { "accepted": true } }, + { "id": "blossom_public_plaintext_name", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "public", "endpoint": "http://media.example" }, "expected": { "accepted": false } }, + { "id": "blossom_loopback_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "loopback", "endpoint": "http://127.0.0.1:3100" }, "expected": { "accepted": true } }, + { "id": "blossom_device_rfc1918_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "http://192.168.1.5:3100" }, "expected": { "accepted": true } }, + { "id": "blossom_device_ula_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "http://[fd00::5]:3100" }, "expected": { "accepted": true } }, + { "id": "blossom_device_named_tls", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "https://media.internal" }, "expected": { "accepted": false } }, + { "id": "blossom_device_public_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "http://8.8.8.8:3100" }, "expected": { "accepted": false } }, + { "id": "blossom_device_loopback_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "http://127.0.0.1:3100" }, "expected": { "accepted": false } }, + { "id": "blossom_device_link_local_plaintext", "kind": "transport.device_network_policy", "input": { "surface": "blossom", "policy": "private_device", "endpoint": "http://[fe80::1]:3100" }, "expected": { "accepted": false } } + ] +} diff --git a/contracts/conformance/vectors/transport/target_uri.v1.json b/contracts/conformance/vectors/transport/target_uri.v1.json @@ -71,6 +71,26 @@ "expected": { "error": "invalid_target_uri" } + }, + { + "id": "transport_nostr_relay_private_device_008", + "kind": "transport.nostr_relay_private_device.valid", + "input": { + "uri": "WS://192.168.1.2:7447/" + }, + "expected": { + "canonical_uri": "ws://192.168.1.2:7447" + } + }, + { + "id": "transport_nostr_relay_named_device_009", + "kind": "transport.nostr_relay_private_device.invalid", + "input": { + "uri": "ws://relay.internal" + }, + "expected": { + "error": "invalid_target_uri" + } } ] } diff --git a/crates/blossom/README.md b/crates/blossom/README.md @@ -118,7 +118,8 @@ HTTP authorization material and must not be published to relays. `AuthoredUploadClaim` emits checked event wire parts; it does not sign them or send a request. Authorization content is bounded to 4,096 bytes, server domains -use lowercase ASCII DNS or canonical IPv4 forms, timestamps are explicit +use lowercase ASCII DNS, canonical IPv4, or bracketed canonical IPv6 forms, +timestamps are explicit caller inputs, and authored lifetimes are limited to 300 seconds. The crate forbids unsafe Rust. It does not provide confidentiality, diff --git a/crates/blossom/src/authorization.rs b/crates/blossom/src/authorization.rs @@ -8,11 +8,12 @@ //! authentication and request-commit responsibility. use alloc::{ + format, string::{String, ToString}, vec, vec::Vec, }; -use core::{fmt, str::FromStr}; +use core::{fmt, net::Ipv6Addr, str::FromStr}; use crate::{Error, Sha256}; @@ -80,6 +81,19 @@ impl ServerDomain { return Err(Error::InvalidAuthorizationServerDomain); } + if let Some(address) = value + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + { + let address = address + .parse::<Ipv6Addr>() + .map_err(|_| Error::InvalidAuthorizationServerDomain)?; + if value != format!("[{address}]") { + return Err(Error::InvalidAuthorizationServerDomain); + } + return Ok(Self(value.to_string())); + } + let mut all_labels_are_numeric = true; for label in value.split('.') { if label.is_empty() @@ -686,13 +700,15 @@ mod tests { } #[test] - fn domains_accept_lowercase_ldh_dns_localhost_and_canonical_ipv4() { + fn domains_accept_lowercase_ldh_dns_localhost_and_canonical_ip_literals() { for value in [ "localhost", "media.example.com", "xn--bcher-kva.example", "127.0.0.1", "0.0.0.0", + "[fd00::1]", + "[2001:db8::1]", ] { let domain = ServerDomain::parse(value).unwrap(); assert_eq!(domain.as_str(), value); @@ -717,6 +733,9 @@ mod tests { "127.00.0.1", "256.0.0.1", "2130706433", + "fd00::1", + "[FD00::1]", + "[fd00:0:0:0:0:0:0:1]", &long_label, &long_domain, ] { diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -76,7 +76,7 @@ pub enum BlossomEndpointAuthority { PublicWebPki, /// Development-only HTTP or HTTPS resolving exclusively to loopback. LoopbackDevelopment, - /// Development-only HTTPS resolving to a non-loopback trusted network. + /// Development-only HTTP or HTTPS using an exact RFC1918 or ULA address. PrivateNetworkDevelopment, } @@ -648,7 +648,7 @@ pub enum BlossomTransportSecurity { PublicWebPki, /// Development HTTPS without a public-origin availability claim. DevelopmentTls, - /// Simulator-only cleartext loopback HTTP. + /// Development-only cleartext loopback or exact-private-network HTTP. DevelopmentCleartext, } @@ -1595,7 +1595,12 @@ impl std::fmt::Debug for BlossomSlot { #[cfg(feature = "blossom")] fn endpoint_scheme_is_allowed(scheme: &str, authority: BlossomEndpointAuthority) -> bool { scheme == "https" - || scheme == "http" && authority == BlossomEndpointAuthority::LoopbackDevelopment + || scheme == "http" + && matches!( + authority, + BlossomEndpointAuthority::LoopbackDevelopment + | BlossomEndpointAuthority::PrivateNetworkDevelopment + ) } #[cfg(feature = "blossom")] @@ -1624,7 +1629,7 @@ fn validate_blossom_host( host: &str, authority: BlossomEndpointAuthority, ) -> Result<(), BlossomError> { - let address = host.parse::<IpAddr>().ok(); + let address = host.trim_matches(['[', ']']).parse::<IpAddr>().ok(); let accepted = match (authority, address) { (BlossomEndpointAuthority::PublicWebPki, Some(address)) => public_blossom_address(address), (BlossomEndpointAuthority::PublicWebPki, None) => public_blossom_hostname(host), @@ -1633,7 +1638,7 @@ fn validate_blossom_host( (BlossomEndpointAuthority::PrivateNetworkDevelopment, Some(address)) => { trusted_blossom_address(address) } - (BlossomEndpointAuthority::PrivateNetworkDevelopment, None) => host != "localhost", + (BlossomEndpointAuthority::PrivateNetworkDevelopment, None) => false, }; if accepted { Ok(()) @@ -1691,15 +1696,8 @@ fn public_blossom_address(address: IpAddr) -> bool { #[cfg(feature = "blossom")] fn trusted_blossom_address(address: IpAddr) -> bool { match address { - IpAddr::V4(address) => { - !address.is_unspecified() - && !address.is_loopback() - && !address.is_multicast() - && !address.is_broadcast() - } - IpAddr::V6(address) => { - !address.is_unspecified() && !address.is_loopback() && !address.is_multicast() - } + IpAddr::V4(address) => address.is_private(), + IpAddr::V6(address) => address.segments()[0] & 0xfe00 == 0xfc00, } } @@ -2363,7 +2361,9 @@ mod tests { assert!(simulator_blossom_profile("http://localhost:3000").is_ok()); assert!(simulator_blossom_profile("http://media.example").is_err()); assert!(device_blossom_profile("https://10.0.0.10:8443").is_ok()); - assert!(device_blossom_profile("http://10.0.0.10:8443").is_err()); + assert!(device_blossom_profile("http://10.0.0.10:8443").is_ok()); + assert!(device_blossom_profile("http://8.8.8.8:8443").is_err()); + assert!(device_blossom_profile("https://device.example:8443").is_err()); assert!(device_blossom_profile("https://127.0.0.1:8443").is_err()); } @@ -2486,6 +2486,13 @@ mod tests { cleartext.transport_security(), BlossomTransportSecurity::DevelopmentCleartext ); + let device_cleartext = BlossomEndpointEvidence::configured(&BlossomConfig::from_profile( + device_blossom_profile("http://10.0.0.10:3000").expect("device profile"), + )); + assert_eq!( + device_cleartext.transport_security(), + BlossomTransportSecurity::DevelopmentCleartext + ); slot.clear(); assert!(slot.evidence().is_none()); @@ -2592,7 +2599,7 @@ mod tests { BlossomProfile::new( BlossomHostKind::PhysicalDevice, BlossomEndpointAuthority::PrivateNetworkDevelopment, - "https://device.example", + "https://10.0.0.10", std::iter::empty::<&str>(), ) .unwrap() @@ -3039,7 +3046,7 @@ mod tests { .validate_resolved_addresses([IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))]) .is_ok() ); - let device = device_blossom_profile("https://device.example").unwrap(); + let device = device_blossom_profile("https://10.0.0.10").unwrap(); assert!( device .primary() @@ -3079,6 +3086,34 @@ mod tests { } #[cfg(feature = "blossom")] + #[test] + fn blossom_device_policy_matches_the_shared_conformance_vectors() { + let document: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/transport/device_network_policy.v1.json" + )) + .expect("device network policy vectors"); + for vector in document["vectors"].as_array().expect("vectors") { + let input = &vector["input"]; + if input["surface"] != "blossom" { + continue; + } + let endpoint = input["endpoint"].as_str().expect("endpoint"); + let profile = match input["policy"].as_str().expect("policy") { + "public" => public_blossom_profile(endpoint), + "loopback" => simulator_blossom_profile(endpoint), + "private_device" => device_blossom_profile(endpoint), + other => panic!("unknown Blossom policy {other}"), + }; + assert_eq!( + profile.is_ok(), + vector["expected"]["accepted"].as_bool().expect("accepted"), + "{}", + vector["id"].as_str().expect("id") + ); + } + } + + #[cfg(feature = "blossom")] #[tokio::test] async fn blossom_cancellation_slot_claim_and_receipt_state_are_exact() { let cancellation = BlossomCancellation::default(); diff --git a/crates/transport/README.md b/crates/transport/README.md @@ -90,9 +90,16 @@ does not participate in identity. The transport ID, endpoint, and scope do. Target-set construction preserves caller order and rejects duplicate fingerprints instead of silently deduplicating them. -Adapter-specific endpoint policy belongs in the adapter. This generic package -does not export relay URL types, Reticulum constants, network clients, or -private-network exceptions. +The ordinary Nostr target constructor admits TLS endpoints and exact loopback +cleartext only. Physical-device callers must select +[`TargetNetworkPolicy::PrivateDevice`] explicitly; that policy admits only +literal RFC1918 IPv4 or ULA IPv6 endpoints. This is a passive construction +boundary, not connection authority. Concrete adapters must retain the selected +policy, revalidate every resolved address, and pin the validated destination +when opening a socket. This generic package does not resolve names, open +connections, export relay URL types, or own network fallback. + +[`TargetNetworkPolicy::PrivateDevice`]: crate::TargetNetworkPolicy::PrivateDevice ## Bounds, deadlines, cancellation, and commit points diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -24,7 +24,7 @@ pub use source::{ FetchRequest, SourceStatus, SubscriptionEnd, SubscriptionEndReason, SubscriptionEvent, SubscriptionNext, SubscriptionRequest, }; -pub use target::{TARGET_SET_MAX_ITEMS, Target, TargetSet}; +pub use target::{TARGET_SET_MAX_ITEMS, Target, TargetNetworkPolicy, TargetSet}; #[cfg(test)] extern crate self as radroots_transport; diff --git a/crates/transport/src/target.rs b/crates/transport/src/target.rs @@ -8,13 +8,27 @@ use alloc::collections::BTreeSet; use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use core::net::Ipv6Addr; +use core::net::{IpAddr, Ipv6Addr}; use core::str::FromStr; use sha2::{Digest, Sha256}; /// Maximum number of targets in one operation. pub const TARGET_SET_MAX_ITEMS: usize = 64; +/// Construction policy for canonical Nostr relay targets. +/// +/// This policy controls only whether a cleartext relay identifier may be +/// represented. A concrete adapter must independently retain and enforce its +/// network policy before and after address resolution. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum TargetNetworkPolicy { + /// TLS relay targets plus exact loopback cleartext targets. + TlsOrLoopback, + /// Exact RFC1918 IPv4 or ULA IPv6 device targets, with or without TLS. + PrivateDevice, +} + #[cfg_attr(feature = "serde", derive(serde::Serialize))] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] /// Canonical transport endpoint URI. @@ -26,8 +40,11 @@ impl EndpointUri { Ok(Self(canonical)) } - fn parse_nostr_relay(raw: impl AsRef<str>) -> Result<Self, TransportError> { - let canonical = canonicalize_nostr_relay_uri(raw.as_ref())?; + fn parse_nostr_relay( + raw: impl AsRef<str>, + policy: TargetNetworkPolicy, + ) -> Result<Self, TransportError> { + let canonical = canonicalize_nostr_relay_uri(raw.as_ref(), policy)?; Ok(Self(canonical)) } @@ -311,6 +328,11 @@ pub struct Target { scope: Option<TargetScope>, label: Option<TargetLabel>, fingerprint: TargetFingerprint, + #[cfg_attr( + feature = "serde", + serde(skip_serializing_if = "private_device_cleartext_is_false") + )] + private_device_cleartext: bool, } impl Target { @@ -322,12 +344,29 @@ impl Target { Self::nostr_relay_with_metadata(uri, None, None) } + /// Creates a Nostr relay target under an explicit construction policy. + /// + /// The private-device policy accepts only literal RFC1918 IPv4 or ULA IPv6 + /// destinations. Named, public, loopback, link-local, unspecified, and + /// multicast destinations remain denied. + pub fn nostr_relay_with_policy( + uri: impl AsRef<str>, + policy: TargetNetworkPolicy, + ) -> Result<Self, TransportError> { + Self::new_with_metadata_and_nostr_policy(uri, None, None, policy) + } + pub fn nostr_relay_with_metadata( uri: impl AsRef<str>, scope: Option<TargetScope>, label: Option<TargetLabel>, ) -> Result<Self, TransportError> { - Self::new_with_metadata(TransportId::NOSTR, uri, scope, label) + Self::new_with_metadata_and_nostr_policy( + uri, + scope, + label, + TargetNetworkPolicy::TlsOrLoopback, + ) } pub fn local(uri: impl AsRef<str>) -> Result<Self, TransportError> { @@ -348,11 +387,15 @@ impl Target { scope: Option<TargetScope>, label: Option<TargetLabel>, ) -> Result<Self, TransportError> { - let raw_uri = uri.as_ref(); - let uri = match kind { - TransportId::NOSTR => EndpointUri::parse_nostr_relay(raw_uri)?, - _ => EndpointUri::parse(raw_uri)?, - }; + if kind == TransportId::NOSTR { + return Self::new_with_metadata_and_nostr_policy( + uri, + scope, + label, + TargetNetworkPolicy::TlsOrLoopback, + ); + } + let uri = EndpointUri::parse(uri)?; let fingerprint = TargetFingerprint::from_target(&kind, &uri, scope.as_ref()); Ok(Self { kind, @@ -360,6 +403,26 @@ impl Target { scope, label, fingerprint, + private_device_cleartext: false, + }) + } + + fn new_with_metadata_and_nostr_policy( + uri: impl AsRef<str>, + scope: Option<TargetScope>, + label: Option<TargetLabel>, + policy: TargetNetworkPolicy, + ) -> Result<Self, TransportError> { + let uri = EndpointUri::parse_nostr_relay(uri, policy)?; + let fingerprint = TargetFingerprint::from_target(&TransportId::NOSTR, &uri, scope.as_ref()); + Ok(Self { + kind: TransportId::NOSTR, + private_device_cleartext: policy == TargetNetworkPolicy::PrivateDevice + && uri.as_str().starts_with("ws://"), + uri, + scope, + label, + fingerprint, }) } @@ -393,6 +456,7 @@ struct TargetWire { scope: Option<String>, label: Option<String>, fingerprint: String, + private_device_cleartext: Option<bool>, } #[cfg(feature = "serde")] @@ -425,9 +489,23 @@ impl<'de> serde::Deserialize<'de> for Target { "transport target fingerprint is not canonical", )); } + if wire.private_device_cleartext == Some(false) { + return Err(serde::de::Error::custom( + "transport target private-device marker is not canonical", + )); + } let target = - Self::new_with_metadata(wire.kind, wire.uri.as_str(), scope.clone(), label.clone()) - .map_err(serde::de::Error::custom)?; + if wire.kind == TransportId::NOSTR && wire.private_device_cleartext == Some(true) { + Self::new_with_metadata_and_nostr_policy( + wire.uri.as_str(), + scope.clone(), + label.clone(), + TargetNetworkPolicy::PrivateDevice, + ) + } else { + Self::new_with_metadata(wire.kind, wire.uri.as_str(), scope.clone(), label.clone()) + } + .map_err(serde::de::Error::custom)?; if target.uri.as_str() != wire.uri || target.scope != scope || target.label != label @@ -550,7 +628,10 @@ fn is_valid_scheme(value: &str) -> bool { && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.')) } -fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, TransportError> { +fn canonicalize_nostr_relay_uri( + raw: &str, + policy: TargetNetworkPolicy, +) -> Result<String, TransportError> { let trimmed = raw.trim(); if trimmed.is_empty() { return Err(TransportError::EmptyTargetUri); @@ -581,7 +662,7 @@ fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, TransportError> { let authority_end = endpoint.find('/').unwrap_or(endpoint.len()); let authority = &endpoint[..authority_end]; let path = &endpoint[authority_end..]; - let authority = canonicalize_nostr_relay_authority(authority, scheme.as_str())?; + let authority = canonicalize_nostr_relay_authority(authority, scheme.as_str(), policy)?; validate_nostr_relay_path(path)?; if path == "/" { return Ok(format!("{scheme}://{authority}")); @@ -592,6 +673,7 @@ fn canonicalize_nostr_relay_uri(raw: &str) -> Result<String, TransportError> { fn canonicalize_nostr_relay_authority( authority: &str, scheme: &str, + policy: TargetNetworkPolicy, ) -> Result<String, TransportError> { if authority.is_empty() || authority.contains('@') { return Err(TransportError::InvalidTargetUri); @@ -626,8 +708,17 @@ fn canonicalize_nostr_relay_authority( .transpose()?; (canonicalize_nostr_relay_host(host)?, port) }; - if scheme == "ws" && !is_local_ws_relay_host(host.as_str()) { - return Err(TransportError::InvalidTargetUri); + match policy { + TargetNetworkPolicy::TlsOrLoopback => { + if scheme == "ws" && !is_local_ws_relay_host(host.as_str()) { + return Err(TransportError::InvalidTargetUri); + } + } + TargetNetworkPolicy::PrivateDevice => { + if !is_private_device_relay_host(host.as_str()) { + return Err(TransportError::InvalidTargetUri); + } + } } let port = port.filter(|port| !matches!((scheme, port.as_str()), ("wss", "443") | ("ws", "80"))); @@ -822,3 +913,16 @@ fn upper_hex_digit(byte: u8) -> bool { fn is_local_ws_relay_host(host: &str) -> bool { matches!(host, "localhost" | "127.0.0.1" | "[::1]") } + +fn is_private_device_relay_host(host: &str) -> bool { + match host.trim_matches(['[', ']']).parse::<IpAddr>() { + Ok(IpAddr::V4(address)) => address.is_private(), + Ok(IpAddr::V6(address)) => address.segments()[0] & 0xfe00 == 0xfc00, + Err(_) => false, + } +} + +#[cfg(feature = "serde")] +const fn private_device_cleartext_is_false(value: &bool) -> bool { + !*value +} diff --git a/crates/transport/tests/fixtures/target_uri.v1.json b/crates/transport/tests/fixtures/target_uri.v1.json @@ -71,6 +71,26 @@ "expected": { "error": "invalid_target_uri" } + }, + { + "id": "transport_nostr_relay_private_device_008", + "kind": "transport.nostr_relay_private_device.valid", + "input": { + "uri": "WS://192.168.1.2:7447/" + }, + "expected": { + "canonical_uri": "ws://192.168.1.2:7447" + } + }, + { + "id": "transport_nostr_relay_named_device_009", + "kind": "transport.nostr_relay_private_device.invalid", + "input": { + "uri": "ws://relay.internal" + }, + "expected": { + "error": "invalid_target_uri" + } } ] } diff --git a/crates/transport/tests/package_boundary.rs b/crates/transport/tests/package_boundary.rs @@ -5,8 +5,8 @@ use radroots_transport::{ DeliveryReceipt as _, DeliveryRequest as _, Error as _, EventSink as _, EventSource as _, EventSubscriber as _, EventSubscription as _, FetchPage as _, FetchRequest as _, SubscriptionEnd as _, SubscriptionEvent as _, SubscriptionRequest as _, Target as _, - TargetSet as _, TransportId as _, capability as _, endpoint as _, error as _, outcome as _, - policy as _, sink as _, source as _, target as _, + TargetNetworkPolicy as _, TargetSet as _, TransportId as _, capability as _, endpoint as _, + error as _, outcome as _, policy as _, sink as _, source as _, target as _, }; const MANIFEST: &str = include_str!("../Cargo.toml"); @@ -85,6 +85,9 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "radroots_crates_release_v1.toml", "examples/host_transport.rs", "indexed single-letter tag values", + "TargetNetworkPolicy::PrivateDevice", + "literal RFC1918 IPv4 or ULA IPv6 endpoints", + "not connection authority", ] { assert!(README.contains(required), "README is missing {required}"); } @@ -127,6 +130,8 @@ fn package_documentation_and_reviewed_api_baseline_are_complete() { "pub fn radroots_transport::source::FetchSelector::exact_tag_filters", "pub fn radroots_transport::target::Target::new(radroots_transport::TransportId", "pub fn radroots_transport::target::Target::kind(&self) -> &radroots_transport::TransportId", + "pub enum radroots_transport::target::TargetNetworkPolicy", + "pub fn radroots_transport::target::Target::nostr_relay_with_policy", ] { assert!( PUBLIC_API.contains(required), diff --git a/crates/transport/tests/target_contract.rs b/crates/transport/tests/target_contract.rs @@ -1,7 +1,7 @@ use core::str::FromStr; use radroots_transport::{ - Error, TARGET_SET_MAX_ITEMS, Target, TargetSet, TransportId, + Error, TARGET_SET_MAX_ITEMS, Target, TargetNetworkPolicy, TargetSet, TransportId, endpoint::{ ENDPOINT_URI_MAX_BYTES, EndpointUri, TARGET_LABEL_MAX_BYTES, TARGET_SCOPE_MAX_BYTES, TargetLabel, TargetScope, @@ -102,6 +102,18 @@ fn transport_target_uri_vectors_match_the_canonical_parser() { "transport.nostr_relay_target.invalid" => { assert!(Target::nostr_relay(raw).is_err()) } + "transport.nostr_relay_private_device.valid" => assert_eq!( + Target::nostr_relay_with_policy(raw, TargetNetworkPolicy::PrivateDevice) + .expect("valid private-device relay") + .uri() + .as_str(), + vector["expected"]["canonical_uri"] + .as_str() + .expect("canonical URI") + ), + "transport.nostr_relay_private_device.invalid" => assert!( + Target::nostr_relay_with_policy(raw, TargetNetworkPolicy::PrivateDevice).is_err() + ), other => panic!("unknown vector kind {other}"), } } @@ -250,6 +262,45 @@ fn nostr_targets_reject_ambiguous_authorities_hosts_ports_and_paths() { } #[test] +fn private_device_relay_targets_require_an_explicit_literal_policy() { + for (raw, canonical, requires_explicit_policy) in [ + ("ws://10.0.0.1:7447", "ws://10.0.0.1:7447", true), + ("ws://172.16.0.1", "ws://172.16.0.1", true), + ("wss://192.168.1.2", "wss://192.168.1.2", false), + ("ws://[FD00::1]:7447", "ws://[fd00::1]:7447", true), + ] { + assert_eq!( + Target::nostr_relay(raw).is_err(), + requires_explicit_policy, + "{raw}" + ); + assert_eq!( + Target::nostr_relay_with_policy(raw, TargetNetworkPolicy::PrivateDevice) + .expect("private-device target") + .uri() + .as_str(), + canonical + ); + } + + for denied in [ + "ws://relay.example", + "ws://8.8.8.8", + "ws://127.0.0.1", + "ws://169.254.1.1", + "ws://224.0.0.1", + "ws://[::1]", + "ws://[fe80::1]", + "ws://[ff02::1]", + ] { + assert!( + Target::nostr_relay_with_policy(denied, TargetNetworkPolicy::PrivateDevice).is_err(), + "{denied}" + ); + } +} + +#[test] fn target_metadata_identity_and_collection_accessors_are_explicit() { let scope = TargetScope::parse("local").expect("scope"); let label = TargetLabel::parse(" Local node ").expect("label"); @@ -353,3 +404,29 @@ fn target_deserialization_revalidates_every_canonical_identity_field() { duplicate["targets"].as_array_mut().unwrap().push(first); assert!(serde_json::from_value::<TargetSet>(duplicate).is_err()); } + +#[test] +#[cfg(feature = "serde")] +fn private_device_cleartext_target_round_trip_remains_explicit_and_validated() { + let target = + Target::nostr_relay_with_policy("ws://[fd00::5]:7447", TargetNetworkPolicy::PrivateDevice) + .expect("private-device target"); + let value = serde_json::to_value(&target).expect("private-device target JSON"); + assert_eq!(value["private_device_cleartext"], Value::Bool(true)); + assert_eq!( + serde_json::from_value::<Target>(value.clone()).expect("private-device round trip"), + target + ); + + let mut false_marker = value.clone(); + false_marker["private_device_cleartext"] = Value::Bool(false); + assert!(serde_json::from_value::<Target>(false_marker).is_err()); + + let mut public_address = value.clone(); + public_address["uri"] = Value::String("ws://8.8.8.8:7447".into()); + assert!(serde_json::from_value::<Target>(public_address).is_err()); + + let mut named = value; + named["uri"] = Value::String("ws://relay.internal:7447".into()); + assert!(serde_json::from_value::<Target>(named).is_err()); +} diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -89,7 +89,7 @@ let _forged = PreparedDelivery { - [`RelayUrl`] is a canonical Nostr relay URL that converts to and from the generic `radroots_transport::Target` model. - [`RelayUrlPolicy`] selects public-Internet, exact-loopback, or explicitly - trusted private-network destination rules. + typed private-device destination rules. - [`RelayCursor`] provides the equal-timestamp-safe event ordering primitive used by scoped fetch continuation cursors. - [`NostrTransport`] implements all three transport SPIs, exposes passive typed @@ -106,16 +106,18 @@ relay pool, Tokio handle, signer, storage handle, or retry worker. ## Relay and network security Profiles never inject a relay or infer destination policy. The caller supplies -every endpoint together with its public-Internet, exact-loopback, or trusted -private-network policy and independent read-only/read-write authority. Public +every endpoint together with its public-Internet, exact-loopback, or typed +private-device policy and independent read-only/read-write authority. Public profiles admit only public endpoints, simulator profiles admit only exact -loopback endpoints, and physical-device profiles admit public or explicitly -trusted private-network TLS endpoints. +loopback endpoints, and physical-device profiles admit public endpoints or +literal RFC1918 IPv4 and ULA IPv6 endpoints. `RelayUrlPolicy::Public` accepts TLS WebSocket URLs with public hostnames or global addresses. `Local` accepts exact loopback destinations and permits -plaintext WebSocket only for that class. `PrivateNetwork` accepts explicit -trusted private or public destinations but still requires TLS. +plaintext WebSocket only for that class. `PrivateNetwork` accepts only literal RFC1918 IPv4 or ULA IPv6 destinations +and permits plaintext WebSocket for that explicit device-network class. It +rejects names, public, loopback, link-local, +unspecified, and multicast destinations before DNS or socket I/O. Before opening a socket, the live connector resolves at most 32 addresses, validates the entire answer set against the selected policy, and connects to a @@ -206,8 +208,9 @@ Generic requests, pages, receipts, targets, and status values follow the serialization contract of `radroots_transport`. Public diagnostics are bounded and secret-safe. Raw upstream client errors, -relay challenge payloads, signed authentication events, credentials, and -transport internals are not retained in public status or normalized outcomes. +relay URLs and resolved addresses, relay challenge payloads, signed +authentication events, credentials, and transport internals are not retained +in public errors, status, or normalized outcomes. Applications should still avoid logging relay authentication inputs or signed event JSON. diff --git a/crates/transport_nostr/src/error.rs b/crates/transport_nostr/src/error.rs @@ -11,17 +11,17 @@ pub enum Error { /// The configured relay count exceeds the adapter bound. TooManyRelays { max: usize, actual: usize }, /// A canonical relay URL occurs more than once. - DuplicateRelayUrl { url: String }, + DuplicateRelayUrl, /// The URL is not a valid canonical Nostr relay target. - InvalidRelayUrl { url: String, reason: String }, + InvalidRelayUrl, /// The URL scheme is not permitted by the selected policy. - RelaySchemeDenied { url: String }, + RelaySchemeDenied, /// The URL destination is not permitted by the selected policy. - RelayDestinationDenied { url: String, reason: &'static str }, + RelayDestinationDenied, /// DNS resolution produced no addresses. - EmptyResolution { url: String }, + EmptyResolution, /// A resolved address violates the selected policy. - ResolvedAddressDenied { url: String, address: String }, + ResolvedAddressDenied, /// An endpoint policy is incompatible with its host profile kind. RelayProfilePolicyMismatch, /// A connection or request timeout is outside its governed bounds. @@ -34,11 +34,11 @@ pub enum Error { max_delay_ms: u64, }, /// A transport-neutral target is not a Nostr target. - UnexpectedTransport { actual: String }, + UnexpectedTransport, /// A relay cursor contains a noncanonical event position. InvalidRelayCursor, /// The generic transport target rejected the relay URL. - Target(String), + Target, /// The relay challenge is empty, malformed, or outside its time bounds. InvalidAuthChallenge, /// A different live challenge already exists for this relay. @@ -68,26 +68,16 @@ impl fmt::Display for Error { Self::TooManyRelays { max, actual } => { write!(formatter, "relay count {actual} exceeds maximum {max}") } - Self::DuplicateRelayUrl { url } => write!(formatter, "duplicate relay URL `{url}`"), - Self::InvalidRelayUrl { url, reason } => { - write!(formatter, "invalid relay URL `{url}`: {reason}") + Self::DuplicateRelayUrl => formatter.write_str("duplicate relay URL"), + Self::InvalidRelayUrl => formatter.write_str("invalid relay URL"), + Self::RelaySchemeDenied => formatter.write_str("relay URL scheme is denied by policy"), + Self::RelayDestinationDenied => { + formatter.write_str("relay URL destination is denied by policy") } - Self::RelaySchemeDenied { url } => { - write!(formatter, "relay URL scheme is denied by policy: `{url}`") + Self::EmptyResolution => formatter.write_str("relay URL resolved to no addresses"), + Self::ResolvedAddressDenied => { + formatter.write_str("relay URL resolved to a denied address") } - Self::RelayDestinationDenied { url, reason } => { - write!( - formatter, - "relay URL destination is denied: `{url}` ({reason})" - ) - } - Self::EmptyResolution { url } => { - write!(formatter, "relay URL resolved to no addresses: `{url}`") - } - Self::ResolvedAddressDenied { url, address } => write!( - formatter, - "relay URL `{url}` resolved to denied address `{address}`" - ), Self::RelayProfilePolicyMismatch => { formatter.write_str("relay endpoint policy does not match its profile kind") } @@ -104,14 +94,9 @@ impl fmt::Display for Error { formatter, "invalid reconnect backoff: initial={initial_delay_ms}ms max={max_delay_ms}ms" ), - Self::UnexpectedTransport { actual } => { - write!( - formatter, - "expected Nostr transport target, received `{actual}`" - ) - } + Self::UnexpectedTransport => formatter.write_str("expected Nostr transport target"), Self::InvalidRelayCursor => formatter.write_str("invalid relay cursor"), - Self::Target(reason) => write!(formatter, "transport target error: {reason}"), + Self::Target => formatter.write_str("transport target error"), Self::InvalidAuthChallenge => formatter.write_str("invalid NIP-42 challenge"), Self::AuthChallengeConflict => { formatter.write_str("a different NIP-42 challenge is already pending") @@ -141,33 +126,19 @@ impl std::error::Error for Error {} #[cfg(test)] mod tests { use super::*; + use std::error::Error as _; #[test] fn every_error_has_a_stable_nonempty_message() { let errors = [ Error::EmptyRelaySet, Error::TooManyRelays { max: 1, actual: 2 }, - Error::DuplicateRelayUrl { - url: "wss://relay.example".into(), - }, - Error::InvalidRelayUrl { - url: "bad".into(), - reason: "invalid".into(), - }, - Error::RelaySchemeDenied { - url: "ws://relay.example".into(), - }, - Error::RelayDestinationDenied { - url: "wss://localhost".into(), - reason: "denied", - }, - Error::EmptyResolution { - url: "wss://relay.example".into(), - }, - Error::ResolvedAddressDenied { - url: "wss://relay.example".into(), - address: "127.0.0.1".into(), - }, + Error::DuplicateRelayUrl, + Error::InvalidRelayUrl, + Error::RelaySchemeDenied, + Error::RelayDestinationDenied, + Error::EmptyResolution, + Error::ResolvedAddressDenied, Error::RelayProfilePolicyMismatch, Error::InvalidTimeout { field: "request", @@ -178,11 +149,9 @@ mod tests { initial_delay_ms: 0, max_delay_ms: 1, }, - Error::UnexpectedTransport { - actual: "local".into(), - }, + Error::UnexpectedTransport, Error::InvalidRelayCursor, - Error::Target("invalid".into()), + Error::Target, Error::InvalidAuthChallenge, Error::AuthChallengeConflict, Error::AuthChallengeMissing, @@ -196,6 +165,7 @@ mod tests { ]; for error in errors { assert!(!error.to_string().is_empty()); + assert!(error.source().is_none()); } } } diff --git a/crates/transport_nostr/src/profile.rs b/crates/transport_nostr/src/profile.rs @@ -35,7 +35,7 @@ pub enum RelayProfileKind { Public, /// Development-only profile restricted to exact loopback destinations. Simulator, - /// Physical-device profile using explicit TLS endpoints on trusted networks. + /// Physical-device profile using public TLS or literal private-network endpoints. Device, } @@ -127,9 +127,7 @@ impl RelayProfile { return Err(Error::RelayProfilePolicyMismatch); } if !seen.insert(endpoint.url.clone()) { - return Err(Error::DuplicateRelayUrl { - url: endpoint.url.to_string(), - }); + return Err(Error::DuplicateRelayUrl); } } Ok(Self { kind, endpoints }) diff --git a/crates/transport_nostr/src/relay.rs b/crates/transport_nostr/src/relay.rs @@ -9,7 +9,7 @@ use core::pin::Pin; use nostr_relay_pool::ConnectionMode; use nostr_relay_pool::transport::error::TransportError; use nostr_relay_pool::transport::websocket::{WebSocketSink, WebSocketStream, WebSocketTransport}; -use radroots_transport::{BoxFuture, Target, TransportId}; +use radroots_transport::{BoxFuture, Target, TargetNetworkPolicy, TransportId}; use std::collections::BTreeMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; @@ -28,19 +28,16 @@ impl RelayUrl { /// Parses, canonicalizes, and applies an explicit destination policy. pub fn parse(value: impl AsRef<str>, policy: RelayUrlPolicy) -> Result<Self, Error> { let original = value.as_ref(); - let target = Target::nostr_relay(original).map_err(|error| Error::InvalidRelayUrl { - url: original.to_owned(), - reason: error.to_string(), - })?; + let target = match policy { + RelayUrlPolicy::PrivateNetwork => { + Target::nostr_relay_with_policy(original, TargetNetworkPolicy::PrivateDevice) + } + RelayUrlPolicy::Public | RelayUrlPolicy::Local => Target::nostr_relay(original), + } + .map_err(|_| Error::InvalidRelayUrl)?; let canonical = target.uri().as_str(); - let parsed = Url::parse(canonical).map_err(|error| Error::InvalidRelayUrl { - url: original.to_owned(), - reason: error.to_string(), - })?; - let host = parsed.host_str().ok_or_else(|| Error::InvalidRelayUrl { - url: original.to_owned(), - reason: "host is required".to_owned(), - })?; + let parsed = Url::parse(canonical).map_err(|_| Error::InvalidRelayUrl)?; + let host = parsed.host_str().ok_or(Error::InvalidRelayUrl)?; validate_scheme(canonical, parsed.scheme(), policy)?; validate_host(canonical, host, policy)?; Ok(Self(canonical.to_owned())) @@ -48,15 +45,17 @@ impl RelayUrl { /// Converts a validated relay URL into the generic Nostr target model. pub fn to_target(&self) -> Result<Target, Error> { - Target::nostr_relay(self.as_str()).map_err(|error| Error::Target(error.to_string())) + Target::nostr_relay(self.as_str()) + .or_else(|_| { + Target::nostr_relay_with_policy(self.as_str(), TargetNetworkPolicy::PrivateDevice) + }) + .map_err(|_| Error::Target) } /// Validates and converts a generic target under the selected policy. pub fn from_target(target: &Target, policy: RelayUrlPolicy) -> Result<Self, Error> { if *target.kind() != TransportId::NOSTR { - return Err(Error::UnexpectedTransport { - actual: target.kind().to_string(), - }); + return Err(Error::UnexpectedTransport); } Self::parse(target.uri().as_str(), policy) } @@ -71,16 +70,11 @@ impl RelayUrl { for address in addresses { resolved = true; if !policy.accepts_address(address) { - return Err(Error::ResolvedAddressDenied { - url: self.0.clone(), - address: address.to_string(), - }); + return Err(Error::ResolvedAddressDenied); } } if !resolved { - return Err(Error::EmptyResolution { - url: self.0.clone(), - }); + return Err(Error::EmptyResolution); } Ok(()) } @@ -137,14 +131,12 @@ impl WebSocketTransport for HardenedWebsocketTransport { "proxy and Tor connection modes are not configured", )); } - let target = Target::nostr_relay(url.as_str()) - .map_err(|_| policy_error("relay URL is invalid"))?; let policy = self .policies - .get(target.uri().as_str()) + .get(configured_policy_key(url)) .copied() .ok_or_else(|| policy_error("relay URL is not configured"))?; - let relay = RelayUrl::parse(target.uri().as_str(), policy) + let relay = RelayUrl::parse(url.as_str(), policy) .map_err(|_| policy_error("relay URL is denied by network policy"))?; let parsed = Url::parse(relay.as_str()).map_err(|_| policy_error("relay URL is invalid"))?; @@ -179,6 +171,14 @@ impl WebSocketTransport for HardenedWebsocketTransport { } } +fn configured_policy_key(url: &Url) -> &str { + if url.path() == "/" && url.query().is_none() && url.fragment().is_none() { + url.as_str().strip_suffix('/').unwrap_or(url.as_str()) + } else { + url.as_str() + } +} + #[cfg_attr(coverage_nightly, coverage(off))] async fn resolve_bounded(host: &str, port: u16) -> Result<Vec<SocketAddr>, TransportError> { let mut addresses = tokio::net::lookup_host((host, port)) @@ -275,7 +275,7 @@ pub enum RelayUrlPolicy { Public, /// Exact loopback endpoints; plaintext WebSocket is allowed. Local, - /// TLS-only endpoints on explicitly trusted private or public networks. + /// Exact RFC1918 IPv4 or ULA IPv6 device endpoints. PrivateNetwork, } @@ -289,32 +289,33 @@ impl RelayUrlPolicy { } } -fn validate_scheme(url: &str, scheme: &str, policy: RelayUrlPolicy) -> Result<(), Error> { - if scheme == "wss" || scheme == "ws" && matches!(policy, RelayUrlPolicy::Local) { +fn validate_scheme(_url: &str, scheme: &str, policy: RelayUrlPolicy) -> Result<(), Error> { + if scheme == "wss" + || scheme == "ws" + && matches!( + policy, + RelayUrlPolicy::Local | RelayUrlPolicy::PrivateNetwork + ) + { return Ok(()); } - Err(Error::RelaySchemeDenied { - url: url.to_owned(), - }) + Err(Error::RelaySchemeDenied) } -fn validate_host(url: &str, host: &str, policy: RelayUrlPolicy) -> Result<(), Error> { - let address = host.parse::<IpAddr>().ok(); +fn validate_host(_url: &str, host: &str, policy: RelayUrlPolicy) -> Result<(), Error> { + let address = host.trim_matches(['[', ']']).parse::<IpAddr>().ok(); let accepted = match (policy, address) { (RelayUrlPolicy::Public, Some(address)) => public_address(address), (RelayUrlPolicy::Public, None) => public_hostname(host), (RelayUrlPolicy::Local, Some(address)) => address.is_loopback(), (RelayUrlPolicy::Local, None) => host.eq_ignore_ascii_case("localhost"), (RelayUrlPolicy::PrivateNetwork, Some(address)) => trusted_network_address(address), - (RelayUrlPolicy::PrivateNetwork, None) => !host.eq_ignore_ascii_case("localhost"), + (RelayUrlPolicy::PrivateNetwork, None) => false, }; if accepted { Ok(()) } else { - Err(Error::RelayDestinationDenied { - url: url.to_owned(), - reason: "destination class does not match relay policy", - }) + Err(Error::RelayDestinationDenied) } } @@ -336,15 +337,8 @@ fn public_address(address: IpAddr) -> bool { fn trusted_network_address(address: IpAddr) -> bool { match address { - IpAddr::V4(address) => { - !address.is_unspecified() - && !address.is_loopback() - && !address.is_multicast() - && !address.is_broadcast() - } - IpAddr::V6(address) => { - !address.is_unspecified() && !address.is_loopback() && !address.is_multicast() - } + IpAddr::V4(address) => address.is_private(), + IpAddr::V6(address) => address.segments()[0] & 0xfe00 == 0xfc00, } } @@ -393,8 +387,10 @@ mod tests { assert!(!public_hostname("host.localhost")); assert!(RelayUrl::parse("wss://host.local", RelayUrlPolicy::Public).is_err()); assert!(RelayUrl::parse("wss://host.home.arpa", RelayUrlPolicy::Public).is_err()); - assert!(RelayUrl::parse("wss://private.example", RelayUrlPolicy::PrivateNetwork).is_ok()); + assert!(RelayUrl::parse("wss://private.example", RelayUrlPolicy::PrivateNetwork).is_err()); assert!(RelayUrl::parse("wss://localhost", RelayUrlPolicy::PrivateNetwork).is_err()); + assert!(RelayUrl::parse("ws://10.0.0.1", RelayUrlPolicy::PrivateNetwork).is_ok()); + assert!(RelayUrl::parse("ws://8.8.8.8", RelayUrlPolicy::PrivateNetwork).is_err()); let relay = RelayUrl::parse("wss://relay.example.com", RelayUrlPolicy::Public).expect("relay"); @@ -406,7 +402,7 @@ mod tests { let local = Target::local("local:device").expect("local target"); assert!(matches!( RelayUrl::from_target(&local, RelayUrlPolicy::Public), - Err(Error::UnexpectedTransport { .. }) + Err(Error::UnexpectedTransport) )); let profile = crate::profile::test_profile( crate::RelayProfileKind::Public, @@ -416,6 +412,39 @@ mod tests { .expect("profile"); assert!(HardenedWebsocketTransport::new(profile.endpoints()).support_ping()); assert!(!policy_error("denied").to_string().is_empty()); + + let root = Url::parse("wss://relay.example/").expect("root URL"); + let path = Url::parse("wss://relay.example/path/").expect("path URL"); + assert_eq!(configured_policy_key(&root), "wss://relay.example"); + assert_eq!(configured_policy_key(&path), "wss://relay.example/path/"); + } + + #[test] + fn device_network_policy_matches_the_shared_conformance_vectors() { + let document: serde_json::Value = serde_json::from_str(include_str!( + "../../../contracts/conformance/vectors/transport/device_network_policy.v1.json" + )) + .expect("device network policy vectors"); + for vector in document["vectors"].as_array().expect("vectors") { + let input = &vector["input"]; + if input["surface"] != "relay" { + continue; + } + let policy = match input["policy"].as_str().expect("policy") { + "public" => RelayUrlPolicy::Public, + "loopback" => RelayUrlPolicy::Local, + "private_device" => RelayUrlPolicy::PrivateNetwork, + other => panic!("unknown relay policy {other}"), + }; + let accepted = + RelayUrl::parse(input["endpoint"].as_str().expect("endpoint"), policy).is_ok(); + assert_eq!( + accepted, + vector["expected"]["accepted"].as_bool().expect("accepted"), + "{}", + vector["id"].as_str().expect("id") + ); + } } #[test] diff --git a/crates/transport_nostr/tests/package_boundary.rs b/crates/transport_nostr/tests/package_boundary.rs @@ -8,6 +8,7 @@ const EXAMPLE: &str = include_str!("../examples/configure_transport.rs"); const PUBLIC_API: &str = include_str!("../../../contracts/api_baselines/radroots_transport_nostr.txt"); const ROOT: &str = include_str!("../src/lib.rs"); +const ERROR: &str = include_str!("../src/error.rs"); const PROFILE: &str = include_str!("../src/profile.rs"); const SINK: &str = include_str!("../src/sink.rs"); const SUBSCRIPTION: &str = include_str!("../src/subscription.rs"); @@ -93,6 +94,8 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "let _forged = PreparedDelivery {", "request: panic!(),", "skipped: panic!(),", + "literal RFC1918 IPv4 or ULA IPv6 destinations", + "relay URLs and resolved addresses", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -157,6 +160,31 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { } #[test] +fn public_errors_retain_no_network_or_dependency_owned_material() { + let declaration = ERROR + .split_once("pub enum Error {") + .expect("error declaration") + .1 + .split_once("\n}") + .expect("error declaration end") + .0; + for forbidden in [ + "url:", + "address:", + "reason:", + "String", + "url::", + "nostr_sdk::", + "nostr_relay_pool::", + ] { + assert!( + !declaration.contains(forbidden), + "public error retains forbidden network material `{forbidden}`" + ); + } +} + +#[test] fn preparation_is_sealed_and_separated_from_execution_io() { let prepare = SINK .split_once("pub fn prepare_delivery(")