From fd780b847ebf776d9b39a476686a6505f1884c14 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Wed, 8 Jul 2026 22:43:42 +0800 Subject: [PATCH 1/7] Add self-authenticating .rings names --- crates/node/bin/rings.rs | 76 +++ crates/node/src/error.rs | 2 + crates/node/src/lib.rs | 1 + crates/node/src/native/cli.rs | 53 ++ crates/node/src/prelude.rs | 5 + crates/node/src/processor/mod.rs | 89 +++ .../node/src/processor/tests/test_registry.rs | 109 ++++ crates/node/src/provider/browser/provider.rs | 55 ++ crates/node/src/rings_name.rs | 518 ++++++++++++++++++ crates/node/src/rpc_dto.rs | 32 +- crates/node/src/rpc_impl.rs | 31 ++ crates/rpc/src/jsonrpc.rs | 16 + crates/rpc/src/method.rs | 8 + crates/rpc/src/protos/rings_node.rs | 53 ++ crates/rpc/src/protos/rings_node_handler.rs | 14 + 15 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 crates/node/src/rings_name.rs diff --git a/crates/node/bin/rings.rs b/crates/node/bin/rings.rs index 8757885ac..4acf93855 100644 --- a/crates/node/bin/rings.rs +++ b/crates/node/bin/rings.rs @@ -37,6 +37,7 @@ use rings_node::processor::ProcessorConfig; use rings_node::provider::Provider; use rings_node::util::ensure_parent_dir; use rings_node::util::expand_home; +use rings_rpc::protos::rings_node::OnionExitTransportInfo; use tokio::io; use tokio::io::AsyncBufReadExt; @@ -120,6 +121,19 @@ fn parse_onion_service_name(raw: &str) -> Result { OnionServiceName::parse(raw).map_err(|error| error.to_string()) } +fn parse_onion_exit_transport_info(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "tcp" => Ok(OnionExitTransportInfo::Tcp), + "udp" => Ok(OnionExitTransportInfo::Udp), + "webtransport" | "web-transport" => Ok(OnionExitTransportInfo::WebTransport), + "requestresponse" | "request-response" => Ok(OnionExitTransportInfo::RequestResponse), + "https" => Ok(OnionExitTransportInfo::Https), + other => Err(format!( + "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" + )), + } +} + fn validate_native_onion_exit_services(services: &[OnionExitService]) -> anyhow::Result<()> { for service in services { if service.transport != OnionExitTransport::Tcp { @@ -573,6 +587,8 @@ struct SendMessageCommand { enum ServiceCommand { Register(ServiceRegisterCommand), Lookup(ServiceLookupCommand), + PublishRingsName(PublishRingsNameCommand), + ResolveRingsName(ResolveRingsNameCommand), } #[derive(Args, Debug)] @@ -591,6 +607,42 @@ struct ServiceLookupCommand { name: String, } +#[derive(Args, Debug)] +struct PublishRingsNameCommand { + #[command(flatten)] + client_args: ClientArgs, + + #[arg(long, default_value = "", help = "Optional .rings name to validate")] + name: String, + + #[arg(long, default_value = "web", help = "Application service name")] + service: String, + + #[arg(long, default_value = "tcp", value_parser = parse_onion_exit_transport_info)] + transport: OnionExitTransportInfo, + + #[arg( + long, + default_value_t = 0, + help = "Record TTL in milliseconds; 0 uses node default" + )] + ttl_ms: u64, + + #[arg(long, default_value_t = 1, help = "Monotonic record sequence")] + seq: u64, +} + +#[derive(Args, Debug)] +struct ResolveRingsNameCommand { + #[command(flatten)] + client_args: ClientArgs, + + name: String, + + #[arg(long, default_value_t = false)] + include_expired: bool, +} + #[derive(Args, Debug)] struct InspectCommand { #[command(flatten)] @@ -896,6 +948,30 @@ async fn run(cli: Cli) -> anyhow::Result<()> { .display(); Ok(()) } + Command::Service(ServiceCommand::PublishRingsName(args)) => { + args.client_args + .new_client() + .await? + .publish_rings_name( + args.name.as_str(), + args.service.as_str(), + args.transport, + args.ttl_ms, + args.seq, + ) + .await? + .display(); + Ok(()) + } + Command::Service(ServiceCommand::ResolveRingsName(args)) => { + args.client_args + .new_client() + .await? + .resolve_rings_name(args.name.as_str(), args.include_expired) + .await? + .display(); + Ok(()) + } Command::Init(args) => { let session_sk_path = args.session_args.new_session_then_write_to_fs()?; let config = config::Config::new(session_sk_path); diff --git a/crates/node/src/error.rs b/crates/node/src/error.rs index 32b212abc..e1de6df40 100644 --- a/crates/node/src/error.rs +++ b/crates/node/src/error.rs @@ -152,6 +152,8 @@ pub enum Error { OnionRouteError(OnionRouteError) = 1601, #[error("Onion proxy IO error: {0}")] OnionProxyIoError(String) = 1602, + #[error("Invalid .rings name: {0}")] + InvalidRingsName(String) = 1701, } impl Error { diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 7a647f4a9..51a060a7f 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -14,6 +14,7 @@ pub mod prelude; pub mod processor; pub mod provider; pub mod registration; +pub mod rings_name; mod rpc_dto; mod rpc_impl; pub mod seed; diff --git a/crates/node/src/native/cli.rs b/crates/node/src/native/cli.rs index 35c1d1f3e..01def788e 100644 --- a/crates/node/src/native/cli.rs +++ b/crates/node/src/native/cli.rs @@ -174,6 +174,59 @@ impl Client { ClientOutput::ok(dids.join("\n"), ()) } + /// Publishes this node's self-authenticating `.rings` name record. + pub async fn publish_rings_name( + &self, + name: &str, + service: &str, + transport: OnionExitTransportInfo, + ttl_ms: u64, + seq: u64, + ) -> Output { + let record = self + .client + .publish_rings_name(&PublishRingsNameRequest { + name: name.to_string(), + service: service.to_string(), + transport, + ttl_ms, + seq, + }) + .await + .map_err(|e| anyhow::anyhow!("{}", e))? + .record + .ok_or_else(|| anyhow::anyhow!("publishRingsName response did not include record"))?; + + let display = + serde_json::to_string_pretty(&record).map_err(|e| anyhow::anyhow!("{}", e))?; + ClientOutput::ok(display, record) + } + + /// Resolves a self-authenticating `.rings` name record. + pub async fn resolve_rings_name( + &self, + name: &str, + include_expired: bool, + ) -> Output> { + let record = self + .client + .resolve_rings_name(&ResolveRingsNameRequest { + name: name.to_string(), + include_expired, + }) + .await + .map_err(|e| anyhow::anyhow!("{}", e))? + .record; + + let display = match record.as_ref() { + Some(record) => { + serde_json::to_string_pretty(record).map_err(|e| anyhow::anyhow!("{}", e))? + } + None => "null".to_string(), + }; + ClientOutput::ok(display, record) + } + /// Publishes a message to the specified topic. pub async fn publish_message_to_topic(&self, topic: &str, data: &str) -> Output<()> { self.client diff --git a/crates/node/src/prelude.rs b/crates/node/src/prelude.rs index b0d0e9f54..fb640a474 100644 --- a/crates/node/src/prelude.rs +++ b/crates/node/src/prelude.rs @@ -50,3 +50,8 @@ pub use crate::online::ONLINE_NODE_CAPABILITY_STORAGE; pub use crate::registration::DhtRegistrationPublisher; pub use crate::registration::RegistrationContext; pub use crate::registration::RegistrationTask; +pub use crate::rings_name::RingsName; +pub use crate::rings_name::RingsNameRecord; +pub use crate::rings_name::RingsNameRecordBody; +pub use crate::rings_name::RINGS_NAME_DHT_PREFIX; +pub use crate::rings_name::RINGS_NAME_SUFFIX; diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index a122bf6b4..8c55b3a09 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -78,6 +78,9 @@ use crate::registration::validate_online_node_registration_timing; use crate::registration::OnlineNodeRegistration; use crate::registration::RegistrationContext; use crate::registration::RegistrationTask; +use crate::rings_name::RingsName; +use crate::rings_name::RingsNameRecord; +use crate::rings_name::RingsNameRecordBody; mod builder; mod config; @@ -88,6 +91,9 @@ pub(crate) use config::parse_webrtc_udp_port_range; pub use config::ProcessorConfig; pub use config::ProcessorConfigSerialized; +/// Default `.rings` name record TTL in milliseconds. +pub const DEFAULT_RINGS_NAME_TTL_MS: u64 = 90_000; + /// Processor for rings-node rpc server. /// /// Cloning shares the same node handle; publishes from any clone are serialized @@ -138,6 +144,14 @@ impl Processor { OnionExitRegistration::descriptors_from_entry(entry) } + fn rings_name_records_from_entry(entry: &entry::Entry) -> Vec { + entry + .data + .iter() + .filter_map(|value| value.decode::().ok()) + .collect() + } + #[cfg(all(test, feature = "node"))] fn online_node_registry_entry(descriptors: Vec) -> Result { let data = descriptors @@ -242,6 +256,81 @@ impl Processor { directory::build_onion_proxy_route(self, proxy, target).await } + /// Publish this node's self-authenticating `.rings` service record. + pub async fn publish_rings_name( + &self, + requested_name: Option<&str>, + service: &str, + transport: crate::onion::OnionExitTransport, + ttl_ms: u64, + seq: u64, + ) -> Result { + let owner_public_key = self + .session_sk + .session() + .account_verification_pubkey() + .map_err(Error::CoreError)?; + let name = RingsName::for_owner(&owner_public_key); + if let Some(requested_name) = requested_name.filter(|name| !name.trim().is_empty()) { + let requested_name = RingsName::parse(requested_name)?; + if requested_name != name { + return Err(Error::InvalidRingsName( + "requested .rings name does not match this node's owner key".to_string(), + )); + } + } + + let now_ms = get_epoch_ms(); + let ttl_ms = if ttl_ms == 0 { + DEFAULT_RINGS_NAME_TTL_MS + } else { + ttl_ms + }; + let record = RingsNameRecord::new_signed( + RingsNameRecordBody { + name, + owner_public_key, + target_did: self.did(), + session_public_key: self.session_sk.session_public_key(), + service: service.to_string(), + transport, + network_id: self.swarm.network_id(), + seq, + expires_at_ms: now_ms.saturating_add(ttl_ms.into()), + }, + &self.session_sk, + ) + .map_err(Error::CoreError)?; + + let topic = record.name.dht_topic(record.network_id); + self.storage_touch_data(&topic, record.encode().map_err(Error::CoreError)?) + .await?; + Ok(record) + } + + /// Resolve a self-authenticating `.rings` name from DHT storage. + pub async fn resolve_rings_name( + &self, + name: &str, + include_expired: bool, + ) -> Result> { + let name = RingsName::parse(name)?; + let entry_key = name.dht_key(self.swarm.network_id())?; + self.storage_fetch(entry_key).await?; + let Some(entry) = self.storage_check_cache(entry_key).await else { + return Ok(None); + }; + + Ok(RingsNameRecord::latest_valid_by_name( + Self::rings_name_records_from_entry(&entry), + self.swarm.network_id(), + get_epoch_ms(), + include_expired, + ) + .into_iter() + .find(|record| record.name == name)) + } + async fn registration_task_daemon(&self, task: &dyn RegistrationTask) { loop { if let Err(error) = task.register_once(&self.registration_context()).await { diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index 2d1e5e3ac..080e9be06 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -275,3 +275,112 @@ async fn online_node_registry_lists_multiple_nodes() -> Result<()> { assert!(nodes.iter().all(OnlineNodeDescriptor::verify_signature)); Ok(()) } + +fn rings_name_body( + processor: &Processor, + network_id: u32, + seq: u64, + expires_at_ms: u128, +) -> Result { + let owner_public_key = processor + .session_sk + .session() + .account_verification_pubkey() + .map_err(Error::CoreError)?; + Ok(RingsNameRecordBody { + name: RingsName::for_owner(&owner_public_key), + owner_public_key, + target_did: processor.did(), + session_public_key: processor.session_sk.session_public_key(), + service: "web".to_string(), + transport: OnionExitTransport::Tcp, + network_id, + seq, + expires_at_ms, + }) +} + +#[tokio::test] +async fn rings_name_publish_resolves_signed_self_record() -> Result<()> { + let processor = prepare_processor().await; + let published = processor + .publish_rings_name(None, "web", OnionExitTransport::Tcp, 60_000, 1) + .await?; + + assert!(published.name.as_str().ends_with(RINGS_NAME_SUFFIX)); + assert!(published.verify_signature()); + assert_eq!(published.target_did, processor.did()); + + let resolved = processor + .resolve_rings_name(published.name.as_str(), false) + .await? + .expect("published .rings record should resolve"); + + assert_eq!(resolved, published); + Ok(()) +} + +#[tokio::test] +async fn rings_name_publish_rejects_human_alias_in_v1() { + let processor = prepare_processor().await; + + assert!(matches!( + processor + .publish_rings_name( + Some("alice.rings"), + "web", + OnionExitTransport::Tcp, + 60_000, + 1 + ) + .await, + Err(Error::InvalidRingsName(_)) + )); +} + +#[tokio::test] +async fn rings_name_resolve_filters_wrong_network_expired_and_stale_records() -> Result<()> { + let processor = prepare_processor_with_network(0).await; + let now_ms = get_epoch_ms(); + let old = RingsNameRecord::new_signed( + rings_name_body(&processor, 0, 1, now_ms + 60_000)?, + &processor.session_sk, + ) + .map_err(Error::CoreError)?; + let new = RingsNameRecord::new_signed( + rings_name_body(&processor, 0, 2, now_ms + 60_000)?, + &processor.session_sk, + ) + .map_err(Error::CoreError)?; + let foreign = RingsNameRecord::new_signed( + rings_name_body(&processor, 1, 3, now_ms + 60_000)?, + &processor.session_sk, + ) + .map_err(Error::CoreError)?; + let expired = RingsNameRecord::new_signed( + rings_name_body(&processor, 0, 4, now_ms.saturating_sub(1))?, + &processor.session_sk, + ) + .map_err(Error::CoreError)?; + let name = new.name.clone(); + let data = vec![old, foreign, expired, new.clone()] + .into_iter() + .map(|record| record.encode().map_err(Error::CoreError)) + .collect::>>()?; + + processor + .storage_store(entry::Entry::new( + name.dht_key(processor.swarm.network_id())?, + data, + entry::EntryKind::Data, + )) + .await?; + + let resolved = processor + .resolve_rings_name(name.as_str(), false) + .await? + .expect("latest live local-network .rings record should resolve"); + + assert_eq!(resolved, new); + Ok(()) +} diff --git a/crates/node/src/provider/browser/provider.rs b/crates/node/src/provider/browser/provider.rs index 19361eb25..296ffac75 100644 --- a/crates/node/src/provider/browser/provider.rs +++ b/crates/node/src/provider/browser/provider.rs @@ -42,6 +42,7 @@ use crate::onion::https::OnionHttpsPayload; use crate::onion::https::OnionHttpsRuntime; use crate::onion::proxy::OnionProxyConfig; use crate::onion::OnionExitPolicy; +use crate::onion::OnionExitTransport; use crate::processor::Processor; use crate::processor::ProcessorConfig; use crate::provider::AsyncSigner; @@ -623,6 +624,60 @@ impl Provider { } }) } + + /// Publish this node's self-authenticating `.rings` record. + pub fn publish_rings_name( + &self, + name: String, + service: String, + transport: String, + ttl_ms: u64, + seq: u64, + ) -> js_sys::Promise { + let p = self.processor.clone(); + future_to_promise(async move { + let transport = parse_onion_exit_transport(&transport)?; + let requested_name = (!name.trim().is_empty()).then_some(name.as_str()); + let record = p + .publish_rings_name(requested_name, service.as_str(), transport, ttl_ms, seq) + .await + .map_err(JsError::from)?; + let info = crate::rpc_dto::rings_name_record_info(record).map_err(JsError::from)?; + Ok(js_value::serialize(&info).map_err(JsError::from)?) + }) + } + + /// Resolve a self-authenticating `.rings` record. + pub fn resolve_rings_name(&self, name: String, include_expired: bool) -> js_sys::Promise { + let p = self.processor.clone(); + future_to_promise(async move { + match p + .resolve_rings_name(name.as_str(), include_expired) + .await + .map_err(JsError::from)? + { + Some(record) => { + let info = + crate::rpc_dto::rings_name_record_info(record).map_err(JsError::from)?; + Ok(js_value::serialize(&info).map_err(JsError::from)?) + } + None => Ok(JsValue::null()), + } + }) + } +} + +fn parse_onion_exit_transport(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "tcp" => Ok(OnionExitTransport::Tcp), + "udp" => Ok(OnionExitTransport::Udp), + "webtransport" | "web-transport" => Ok(OnionExitTransport::WebTransport), + "requestresponse" | "request-response" => Ok(OnionExitTransport::RequestResponse), + "https" => Ok(OnionExitTransport::Https), + other => Err(JsError::new(&format!( + "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" + ))), + } } impl Provider { diff --git a/crates/node/src/rings_name.rs b/crates/node/src/rings_name.rs new file mode 100644 index 000000000..19a363677 --- /dev/null +++ b/crates/node/src/rings_name.rs @@ -0,0 +1,518 @@ +#![warn(missing_docs)] +//! Authenticated `.rings` names backed by Chord storage. +//! +//! The first `.rings` namespace is deliberately self-authenticating: a name is +//! valid only when its left-most label is derived from the owner verification +//! public key. Human-readable aliases such as `alice.rings` need a separate +//! allocation and recovery protocol, so they are not accepted by this module. + +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; + +use rings_core::dht::Did; +use rings_core::ecc::keccak256; +use rings_core::ecc::PublicKey; +use rings_core::ecc::VerificationPublicKey; +use rings_core::error::Error as CoreError; +use rings_core::error::Result as CoreResult; +use rings_core::message::Decoder; +use rings_core::message::Encoded; +use rings_core::message::Encoder; +use rings_core::message::MessageVerification; +use rings_core::session::SessionSk; +use serde::Deserialize; +use serde::Serialize; + +use crate::descriptor::decode_descriptor; +use crate::descriptor::encode_descriptor; +use crate::error::Error; +use crate::error::Result; +use crate::onion::OnionExitTransport; + +/// Pseudo-TLD served by the Rings overlay resolver. +pub const RINGS_NAME_SUFFIX: &str = ".rings"; + +/// Domain-separated DHT topic prefix for `.rings` records. +pub const RINGS_NAME_DHT_PREFIX: &str = "rings-name:v1"; + +const RINGS_NAME_SCHEMA_VERSION: u16 = 1; +const SELF_AUTH_LABEL_BYTES: usize = 20; + +/// Canonical `.rings` name. +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct RingsName(String); + +impl RingsName { + /// Parse and canonicalize a `.rings` name. + pub fn parse(name: impl AsRef) -> Result { + let name = name.as_ref(); + let trimmed = name.trim().trim_end_matches('.'); + if trimmed.is_empty() { + return Err(Error::InvalidRingsName( + ".rings name must not be empty".to_string(), + )); + } + + let canonical = trimmed.to_ascii_lowercase(); + if !canonical.ends_with(RINGS_NAME_SUFFIX) { + return Err(Error::InvalidRingsName(format!( + "name {name:?} must end with {RINGS_NAME_SUFFIX}" + ))); + } + + let label = canonical + .strip_suffix(RINGS_NAME_SUFFIX) + .expect("suffix already checked"); + validate_self_auth_label(label)?; + Ok(Self(canonical)) + } + + /// Derive the self-authenticating `.rings` name for `owner_public_key`. + pub fn for_owner(owner_public_key: &VerificationPublicKey) -> Self { + Self(format!( + "{}{RINGS_NAME_SUFFIX}", + self_auth_label(owner_public_key) + )) + } + + /// Return the canonical name. + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Return the DHT topic used to store records for this name on `network_id`. + pub fn dht_topic(&self, network_id: u32) -> String { + format!("{RINGS_NAME_DHT_PREFIX}:{network_id}:{}", self.as_str()) + } + + /// Return the Chord key used to fetch records for this name on `network_id`. + pub fn dht_key(&self, network_id: u32) -> CoreResult { + rings_core::dht::entry::Entry::gen_did(&self.dht_topic(network_id)) + } + + /// Return whether this name is self-authenticated by `owner_public_key`. + pub fn matches_owner(&self, owner_public_key: &VerificationPublicKey) -> bool { + *self == Self::for_owner(owner_public_key) + } +} + +impl TryFrom for RingsName { + type Error = String; + + fn try_from(value: String) -> std::result::Result { + Self::parse(&value).map_err(|error| error.to_string()) + } +} + +impl From for String { + fn from(name: RingsName) -> Self { + name.0 + } +} + +impl std::fmt::Display for RingsName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +fn validate_self_auth_label(label: &str) -> Result<()> { + if label.is_empty() || label.len() > 63 { + return Err(Error::InvalidRingsName( + ".rings self-auth label must be 1..=63 bytes".to_string(), + )); + } + if label.contains('.') { + return Err(Error::InvalidRingsName( + "human-readable .rings aliases are not part of v1".to_string(), + )); + } + let Some(rest) = label.strip_prefix('r') else { + return Err(Error::InvalidRingsName( + ".rings self-auth label must start with 'r'".to_string(), + )); + }; + if rest.len() != SELF_AUTH_LABEL_BYTES * 2 || !rest.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(Error::InvalidRingsName( + ".rings self-auth label must be r + 40 lowercase hex chars".to_string(), + )); + } + if !rest + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + return Err(Error::InvalidRingsName( + ".rings self-auth label must be lowercase".to_string(), + )); + } + Ok(()) +} + +fn self_auth_label(owner_public_key: &VerificationPublicKey) -> String { + let mut transcript = b"rings-name:v1\0".to_vec(); + transcript.extend_from_slice(&owner_public_key.transcript_bytes()); + let digest = keccak256(&transcript); + format!("r{}", lowercase_hex(&digest[..SELF_AUTH_LABEL_BYTES])) +} + +fn lowercase_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +/// Descriptor fields covered by the `.rings` name signature. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct RingsNameRecordBody { + /// Canonical self-authenticating `.rings` name. + pub name: RingsName, + /// Account public key that owns `name`. + pub owner_public_key: VerificationPublicKey, + /// DID reached after resolving this name. + pub target_did: Did, + /// Session public key used by the target for encrypted overlay/onion setup. + pub session_public_key: PublicKey<33>, + /// Application service name exposed by the target. + pub service: String, + /// Transport class for the resolved service. + pub transport: OnionExitTransport, + /// Rings network identifier. + pub network_id: u32, + /// Monotonic version for deterministic conflict resolution. + pub seq: u64, + /// Record expiry timestamp in milliseconds since Unix epoch. + pub expires_at_ms: u128, +} + +#[derive(Serialize)] +struct RingsNameRecordBodyRef<'a> { + schema_version: u16, + name: &'a RingsName, + owner_public_key: &'a VerificationPublicKey, + target_did: Did, + session_public_key: &'a PublicKey<33>, + service: &'a str, + transport: OnionExitTransport, + network_id: u32, + seq: u64, + expires_at_ms: u128, +} + +impl RingsNameRecordBody { + fn body_ref(&self) -> RingsNameRecordBodyRef<'_> { + RingsNameRecordBodyRef { + schema_version: RINGS_NAME_SCHEMA_VERSION, + name: &self.name, + owner_public_key: &self.owner_public_key, + target_did: self.target_did, + session_public_key: &self.session_public_key, + service: self.service.as_str(), + transport: self.transport, + network_id: self.network_id, + seq: self.seq, + expires_at_ms: self.expires_at_ms, + } + } + + fn signing_data(&self) -> CoreResult> { + bincode::serialize(&self.body_ref()).map_err(CoreError::BincodeSerialize) + } + + fn validate_unsigned(&self) -> Result<()> { + if !self.name.matches_owner(&self.owner_public_key) { + return Err(Error::InvalidRingsName( + ".rings name does not match owner public key".to_string(), + )); + } + if self.service.trim().is_empty() || self.service.trim() != self.service { + return Err(Error::InvalidRingsName( + ".rings service must be non-empty and trimmed".to_string(), + )); + } + Ok(()) + } +} + +/// Signed `.rings` name record. +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct RingsNameRecord { + /// Wire schema version covered by the signature. + pub schema_version: u16, + /// Canonical self-authenticating `.rings` name. + pub name: RingsName, + /// Account public key that owns `name`. + pub owner_public_key: VerificationPublicKey, + /// DID reached after resolving this name. + pub target_did: Did, + /// Session public key used by the target for encrypted overlay/onion setup. + pub session_public_key: PublicKey<33>, + /// Application service name exposed by the target. + pub service: String, + /// Transport class for the resolved service. + pub transport: OnionExitTransport, + /// Rings network identifier. + pub network_id: u32, + /// Monotonic version for deterministic conflict resolution. + pub seq: u64, + /// Record expiry timestamp in milliseconds since Unix epoch. + pub expires_at_ms: u128, + /// Signature over the canonical record body. + pub signature: MessageVerification, +} + +impl RingsNameRecord { + /// Create and sign a `.rings` name record. + pub fn new_signed(body: RingsNameRecordBody, session_sk: &SessionSk) -> CoreResult { + if body.owner_public_key.did() != session_sk.account_did() { + return Err(CoreError::InvalidMessage( + ".rings record owner/session mismatch".to_string(), + )); + } + body.validate_unsigned() + .map_err(|error| CoreError::InvalidMessage(error.to_string()))?; + let signature = MessageVerification::new(&body.signing_data()?, session_sk)?; + Ok(Self { + schema_version: RINGS_NAME_SCHEMA_VERSION, + name: body.name, + owner_public_key: body.owner_public_key, + target_did: body.target_did, + session_public_key: body.session_public_key, + service: body.service, + transport: body.transport, + network_id: body.network_id, + seq: body.seq, + expires_at_ms: body.expires_at_ms, + signature, + }) + } + + fn body_ref(&self) -> RingsNameRecordBodyRef<'_> { + RingsNameRecordBodyRef { + schema_version: self.schema_version, + name: &self.name, + owner_public_key: &self.owner_public_key, + target_did: self.target_did, + session_public_key: &self.session_public_key, + service: self.service.as_str(), + transport: self.transport, + network_id: self.network_id, + seq: self.seq, + expires_at_ms: self.expires_at_ms, + } + } + + fn signing_data(&self) -> CoreResult> { + bincode::serialize(&self.body_ref()).map_err(CoreError::BincodeSerialize) + } + + /// Return whether this record uses the supported v1 schema. + pub const fn has_supported_schema(&self) -> bool { + self.schema_version == RINGS_NAME_SCHEMA_VERSION + } + + /// Return whether this record belongs to `network_id`. + pub const fn matches_network(&self, network_id: u32) -> bool { + self.network_id == network_id + } + + /// Return whether this record is expired at `now_ms`. + pub const fn is_expired_at(&self, now_ms: u128) -> bool { + self.expires_at_ms <= now_ms + } + + /// Verify schema, self-auth name binding, owner signature, and session binding. + pub fn verify_signature(&self) -> bool { + if !self.has_supported_schema() || !self.name.matches_owner(&self.owner_public_key) { + return false; + } + if self.signature.session.account_did() != self.owner_public_key.did() { + return false; + } + let Ok(session_public_key) = self.signature.session.account_verification_pubkey() else { + return false; + }; + if session_public_key != self.owner_public_key { + return false; + } + let Ok(data) = self.signing_data() else { + return false; + }; + self.signature.verify(&data) + } + + /// Return whether this record is valid and not expired at `now_ms`. + pub fn is_live_at(&self, now_ms: u128) -> bool { + self.verify_signature() && !self.is_expired_at(now_ms) + } + + /// Select the newest valid record per `.rings` name. + pub fn latest_valid_by_name( + records: impl IntoIterator, + network_id: u32, + now_ms: u128, + include_expired: bool, + ) -> Vec { + let mut latest = BTreeMap::::new(); + for record in records { + if !record.matches_network(network_id) { + continue; + } + if include_expired { + if !record.verify_signature() { + continue; + } + } else if !record.is_live_at(now_ms) { + continue; + } + match latest.entry(record.name.clone()) { + Entry::Occupied(mut entry) => { + let current = entry.get(); + if record.seq > current.seq + || (record.seq == current.seq + && record.expires_at_ms > current.expires_at_ms) + { + entry.insert(record); + } + } + Entry::Vacant(entry) => { + entry.insert(record); + } + } + } + latest.into_values().collect() + } +} + +impl Encoder for RingsNameRecord { + fn encode(&self) -> CoreResult { + encode_descriptor(self) + } +} + +impl Decoder for RingsNameRecord { + fn from_encoded(encoded: &Encoded) -> CoreResult { + let record: Self = decode_descriptor(encoded)?; + if record.has_supported_schema() { + Ok(record) + } else { + Err(CoreError::Decode) + } + } +} + +#[cfg(test)] +mod tests { + use rings_core::ecc::SecretKey; + use rings_core::session::SessionSk; + use rings_core::utils::get_epoch_ms; + + use super::*; + + fn session() -> SessionSk { + SessionSk::new_with_seckey(&SecretKey::random()).unwrap() + } + + fn body_at(session_sk: &SessionSk, now_ms: u128) -> RingsNameRecordBody { + let owner_public_key = session_sk + .session() + .account_verification_pubkey() + .expect("test session should expose account key"); + RingsNameRecordBody { + name: RingsName::for_owner(&owner_public_key), + owner_public_key, + target_did: session_sk.account_did(), + session_public_key: session_sk.session_public_key(), + service: "web".to_string(), + transport: OnionExitTransport::Tcp, + network_id: 7, + seq: 1, + expires_at_ms: now_ms + 60_000, + } + } + + #[test] + fn self_auth_name_round_trips_as_canonical_rings_name() -> Result<()> { + let session_sk = session(); + let owner_public_key = session_sk.session().account_verification_pubkey()?; + let name = RingsName::for_owner(&owner_public_key); + let parsed = RingsName::parse(format!("{}.", name.as_str().to_ascii_uppercase()))?; + + assert_eq!(parsed, name); + assert!(name.matches_owner(&owner_public_key)); + assert_eq!( + name.dht_topic(42), + format!("{RINGS_NAME_DHT_PREFIX}:42:{name}") + ); + Ok(()) + } + + #[test] + fn parser_rejects_human_aliases_in_v1() { + assert!(matches!( + RingsName::parse("alice.rings"), + Err(Error::InvalidRingsName(_)) + )); + assert!(matches!( + RingsName::parse("alice.example.rings"), + Err(Error::InvalidRingsName(_)) + )); + } + + #[test] + fn signed_record_verifies_owner_name_binding() -> CoreResult<()> { + let session_sk = session(); + let now_ms = get_epoch_ms(); + let record = RingsNameRecord::new_signed(body_at(&session_sk, now_ms), &session_sk)?; + + assert!(record.verify_signature()); + assert!(record.is_live_at(now_ms)); + assert!(!record.is_expired_at(now_ms)); + Ok(()) + } + + #[test] + fn record_rejects_wrong_self_auth_name() { + let session_sk = session(); + let other = session(); + let mut body = body_at(&session_sk, get_epoch_ms()); + let other_key = other.session().account_verification_pubkey().unwrap(); + body.name = RingsName::for_owner(&other_key); + + assert!(RingsNameRecord::new_signed(body, &session_sk).is_err()); + } + + #[test] + fn latest_valid_record_filters_network_expiry_and_stale_seq() -> CoreResult<()> { + let session_sk = session(); + let now_ms = get_epoch_ms(); + let mut old = RingsNameRecord::new_signed(body_at(&session_sk, now_ms), &session_sk)?; + old.seq = 1; + let mut new_body = body_at(&session_sk, now_ms); + new_body.seq = 2; + let new = RingsNameRecord::new_signed(new_body, &session_sk)?; + let mut foreign_body = body_at(&session_sk, now_ms); + foreign_body.network_id = 8; + foreign_body.seq = 3; + let foreign = RingsNameRecord::new_signed(foreign_body, &session_sk)?; + let mut expired_body = body_at(&session_sk, now_ms); + expired_body.seq = 4; + expired_body.expires_at_ms = now_ms; + let expired = RingsNameRecord::new_signed(expired_body, &session_sk)?; + + let selected = RingsNameRecord::latest_valid_by_name( + vec![old, foreign, expired, new.clone()], + 7, + now_ms, + false, + ); + + assert_eq!(selected, vec![new]); + Ok(()) + } +} diff --git a/crates/node/src/rpc_dto.rs b/crates/node/src/rpc_dto.rs index 00710c171..25e31dc7e 100644 --- a/crates/node/src/rpc_dto.rs +++ b/crates/node/src/rpc_dto.rs @@ -11,6 +11,7 @@ use rings_rpc::protos::rings_node::OnlineNodeDescriptorInfo; use rings_rpc::protos::rings_node::OnlineNodeTypeInfo; use rings_rpc::protos::rings_node::PeerMeasurementCountersInfo; use rings_rpc::protos::rings_node::PeerMeasurementInfo; +use rings_rpc::protos::rings_node::RingsNameRecordInfo; use serde::Serialize; use serde_json::Value; @@ -23,6 +24,7 @@ use crate::onion::OnionExitTransport; use crate::onion::OnionRoute; use crate::online::OnlineNodeDescriptor; use crate::online::OnlineNodeType; +use crate::rings_name::RingsNameRecord; fn json_value(value: impl Serialize) -> Result { serde_json::to_value(value).map_err(Error::SerdeJsonError) @@ -70,7 +72,7 @@ pub(crate) fn online_node_descriptor_infos( .collect() } -fn onion_exit_transport_info(transport: OnionExitTransport) -> OnionExitTransportInfo { +pub(crate) fn onion_exit_transport_info(transport: OnionExitTransport) -> OnionExitTransportInfo { match transport { OnionExitTransport::Tcp => OnionExitTransportInfo::Tcp, OnionExitTransport::Udp => OnionExitTransportInfo::Udp, @@ -80,6 +82,18 @@ fn onion_exit_transport_info(transport: OnionExitTransport) -> OnionExitTranspor } } +pub(crate) fn onion_exit_transport_from_info( + transport: OnionExitTransportInfo, +) -> OnionExitTransport { + match transport { + OnionExitTransportInfo::Tcp => OnionExitTransport::Tcp, + OnionExitTransportInfo::Udp => OnionExitTransport::Udp, + OnionExitTransportInfo::WebTransport => OnionExitTransport::WebTransport, + OnionExitTransportInfo::RequestResponse => OnionExitTransport::RequestResponse, + OnionExitTransportInfo::Https => OnionExitTransport::Https, + } +} + fn onion_exit_service_info(service: OnionExitService) -> OnionExitServiceInfo { OnionExitServiceInfo { name: service.name.into(), @@ -141,6 +155,22 @@ pub(crate) fn onion_route_response(route: OnionRoute) -> Result Result { + Ok(RingsNameRecordInfo { + schema_version: record.schema_version, + name: record.name.into(), + owner_public_key: json_value(record.owner_public_key)?, + target_did: record.target_did.to_string(), + session_public_key: json_value(record.session_public_key)?, + service: record.service, + transport: onion_exit_transport_info(record.transport), + network_id: record.network_id, + seq: record.seq, + expires_at_ms: descriptor_timestamp_ms(record.expires_at_ms)?, + signature: json_value(record.signature)?, + }) +} + fn peer_measurement_counters_info(evidence: PeerQualityEvidence) -> PeerMeasurementCountersInfo { PeerMeasurementCountersInfo { connected: evidence.connected, diff --git a/crates/node/src/rpc_impl.rs b/crates/node/src/rpc_impl.rs index 4a34aa228..ca33cb468 100644 --- a/crates/node/src/rpc_impl.rs +++ b/crates/node/src/rpc_impl.rs @@ -331,6 +331,37 @@ impl HandleRpc for Processor { } } +#[cfg_attr(feature = "browser", async_trait(?Send))] +#[cfg_attr(not(feature = "browser"), async_trait)] +impl HandleRpc for Processor { + async fn handle_rpc(&self, req: PublishRingsNameRequest) -> Result { + let transport = crate::rpc_dto::onion_exit_transport_from_info(req.transport); + let seq = if req.seq == 0 { 1 } else { req.seq }; + let requested_name = (!req.name.trim().is_empty()).then_some(req.name.as_str()); + let record = self + .publish_rings_name(requested_name, &req.service, transport, req.ttl_ms, seq) + .await + .map_err(Error::from)?; + Ok(PublishRingsNameResponse { + record: Some(crate::rpc_dto::rings_name_record_info(record)?), + }) + } +} + +#[cfg_attr(feature = "browser", async_trait(?Send))] +#[cfg_attr(not(feature = "browser"), async_trait)] +impl HandleRpc for Processor { + async fn handle_rpc(&self, req: ResolveRingsNameRequest) -> Result { + let record = self + .resolve_rings_name(&req.name, req.include_expired) + .await + .map_err(Error::from)? + .map(crate::rpc_dto::rings_name_record_info) + .transpose()?; + Ok(ResolveRingsNameResponse { record }) + } +} + #[cfg_attr(feature = "browser", async_trait(?Send))] #[cfg_attr(not(feature = "browser"), async_trait)] impl HandleRpc for Processor { diff --git a/crates/rpc/src/jsonrpc.rs b/crates/rpc/src/jsonrpc.rs index 997ffb003..31cb77b34 100644 --- a/crates/rpc/src/jsonrpc.rs +++ b/crates/rpc/src/jsonrpc.rs @@ -209,6 +209,22 @@ impl Client { self.call_method(Method::LookupService, req).await } + /// Publishes this node's self-authenticating `.rings` name record. + pub async fn publish_rings_name( + &self, + req: &PublishRingsNameRequest, + ) -> Result { + self.call_method(Method::PublishRingsName, req).await + } + + /// Resolves a self-authenticating `.rings` name record. + pub async fn resolve_rings_name( + &self, + req: &ResolveRingsNameRequest, + ) -> Result { + self.call_method(Method::ResolveRingsName, req).await + } + /// Looks up signed online-node descriptors. pub async fn lookup_online_nodes( &self, diff --git a/crates/rpc/src/method.rs b/crates/rpc/src/method.rs index 81b22745b..05a6bd505 100644 --- a/crates/rpc/src/method.rs +++ b/crates/rpc/src/method.rs @@ -38,6 +38,10 @@ pub enum Method { RegisterService, /// Lookup service LookupService, + /// Publish a self-authenticating `.rings` name record + PublishRingsName, + /// Resolve a self-authenticating `.rings` name record + ResolveRingsName, /// Lookup online-node registry descriptors LookupOnlineNodes, /// Lookup application-layer onion exit descriptors @@ -73,6 +77,8 @@ impl Method { Method::FetchTopicMessages => "fetchTopicMessages", Method::RegisterService => "registerService", Method::LookupService => "lookupService", + Method::PublishRingsName => "publishRingsName", + Method::ResolveRingsName => "resolveRingsName", Method::LookupOnlineNodes => "lookupOnlineNodes", Method::LookupOnionExits => "lookupOnionExits", Method::BuildOnionRoute => "buildOnionRoute", @@ -111,6 +117,8 @@ impl TryFrom<&str> for Method { "fetchTopicMessages" => Method::FetchTopicMessages, "registerService" => Method::RegisterService, "lookupService" => Method::LookupService, + "publishRingsName" => Method::PublishRingsName, + "resolveRingsName" => Method::ResolveRingsName, "lookupOnlineNodes" => Method::LookupOnlineNodes, "lookupOnionExits" => Method::LookupOnionExits, "buildOnionRoute" => Method::BuildOnionRoute, diff --git a/crates/rpc/src/protos/rings_node.rs b/crates/rpc/src/protos/rings_node.rs index 586b10d6b..cbfd7fff2 100644 --- a/crates/rpc/src/protos/rings_node.rs +++ b/crates/rpc/src/protos/rings_node.rs @@ -171,6 +171,41 @@ pub struct LookupServiceResponse { pub dids: Vec, } +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct PublishRingsNameRequest { + /// Optional name to validate. Empty means derive the self-authenticating name from this node. + #[serde(default)] + pub name: String, + /// Application service exposed by the resolved target. + pub service: String, + /// Transport class for the service. + #[serde(default)] + pub transport: OnionExitTransportInfo, + /// Record TTL in milliseconds. `0` means node default. + #[serde(default)] + pub ttl_ms: u64, + /// Monotonic record version. + #[serde(default)] + pub seq: u64, +} + +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct PublishRingsNameResponse { + pub record: Option, +} + +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct ResolveRingsNameRequest { + pub name: String, + #[serde(default)] + pub include_expired: bool, +} + +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct ResolveRingsNameResponse { + pub record: Option, +} + #[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] pub struct LookupOnlineNodesRequest { #[serde(default)] @@ -227,6 +262,24 @@ pub struct OnionExitServiceInfo { pub transport: OnionExitTransportInfo, } +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct RingsNameRecordInfo { + pub schema_version: u16, + pub name: String, + /// Owner verification public key encoded with the core serde shape. + pub owner_public_key: Value, + pub target_did: String, + /// Session encryption public key encoded with the core serde shape. + pub session_public_key: Value, + pub service: String, + pub transport: OnionExitTransportInfo, + pub network_id: u32, + pub seq: u64, + pub expires_at_ms: u64, + /// Record signature encoded with the core serde shape. + pub signature: Value, +} + #[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] pub struct OnionExitPolicyInfo { pub allowed_targets: Vec, diff --git a/crates/rpc/src/protos/rings_node_handler.rs b/crates/rpc/src/protos/rings_node_handler.rs index 86bd049f9..1a4339d34 100644 --- a/crates/rpc/src/protos/rings_node_handler.rs +++ b/crates/rpc/src/protos/rings_node_handler.rs @@ -48,6 +48,8 @@ impl InternalRpcHandler { + HandleRpc + HandleRpc + HandleRpc + + HandleRpc + + HandleRpc + HandleRpc + HandleRpc + HandleRpc @@ -153,6 +155,18 @@ impl InternalRpcHandler { let resp = processor.handle_rpc(req).await?; serde_json::to_value(resp).map_err(|_| Error::new(ErrorCode::ParseError)) } + Method::PublishRingsName => { + let req = serde_json::from_value::(params) + .map_err(|e| Error::invalid_params(e.to_string()))?; + let resp = processor.handle_rpc(req).await?; + serde_json::to_value(resp).map_err(|_| Error::new(ErrorCode::ParseError)) + } + Method::ResolveRingsName => { + let req = serde_json::from_value::(params) + .map_err(|e| Error::invalid_params(e.to_string()))?; + let resp = processor.handle_rpc(req).await?; + serde_json::to_value(resp).map_err(|_| Error::new(ErrorCode::ParseError)) + } Method::LookupOnlineNodes => { let req = serde_json::from_value::(params) .map_err(|e| Error::invalid_params(e.to_string()))?; From 54490bed825491174b18c9d35b601c23a92c7575 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Wed, 8 Jul 2026 23:50:18 +0800 Subject: [PATCH 2/7] Harden .rings record validation --- crates/node/src/processor/mod.rs | 4 +- .../node/src/processor/tests/test_registry.rs | 14 ++++- crates/node/src/rings_name.rs | 52 +++++++++++-------- crates/node/src/rpc_dto.rs | 2 +- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index 8c55b3a09..ce93f1e73 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -59,6 +59,7 @@ use crate::onion::OnionExitPolicy; use crate::onion::OnionExitRegistration; use crate::onion::OnionExitService; use crate::onion::OnionRoute; +use crate::onion::OnionServiceName; use crate::onion::ONION_EXITS_TOPIC; use crate::onion::ONION_RELAY_CAPABILITY; use crate::online::OnlineNodeDescriptor; @@ -286,13 +287,14 @@ impl Processor { } else { ttl_ms }; + let service = OnionServiceName::parse(service)?; let record = RingsNameRecord::new_signed( RingsNameRecordBody { name, owner_public_key, target_did: self.did(), session_public_key: self.session_sk.session_public_key(), - service: service.to_string(), + service, transport, network_id: self.swarm.network_id(), seq, diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index 080e9be06..d03442418 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -292,7 +292,7 @@ fn rings_name_body( owner_public_key, target_did: processor.did(), session_public_key: processor.session_sk.session_public_key(), - service: "web".to_string(), + service: OnionServiceName::parse("web")?, transport: OnionExitTransport::Tcp, network_id, seq, @@ -338,6 +338,18 @@ async fn rings_name_publish_rejects_human_alias_in_v1() { )); } +#[tokio::test] +async fn rings_name_publish_rejects_invalid_service_name() { + let processor = prepare_processor().await; + + assert!(matches!( + processor + .publish_rings_name(None, "bad service", OnionExitTransport::Tcp, 60_000, 1) + .await, + Err(Error::InvalidConfig(_)) + )); +} + #[tokio::test] async fn rings_name_resolve_filters_wrong_network_expired_and_stale_records() -> Result<()> { let processor = prepare_processor_with_network(0).await; diff --git a/crates/node/src/rings_name.rs b/crates/node/src/rings_name.rs index 19a363677..422de5ef3 100644 --- a/crates/node/src/rings_name.rs +++ b/crates/node/src/rings_name.rs @@ -28,6 +28,7 @@ use crate::descriptor::encode_descriptor; use crate::error::Error; use crate::error::Result; use crate::onion::OnionExitTransport; +use crate::onion::OnionServiceName; /// Pseudo-TLD served by the Rings overlay resolver. pub const RINGS_NAME_SUFFIX: &str = ".rings"; @@ -61,9 +62,11 @@ impl RingsName { ))); } - let label = canonical - .strip_suffix(RINGS_NAME_SUFFIX) - .expect("suffix already checked"); + let Some(label) = canonical.strip_suffix(RINGS_NAME_SUFFIX) else { + return Err(Error::InvalidRingsName(format!( + "name {name:?} must end with {RINGS_NAME_SUFFIX}" + ))); + }; validate_self_auth_label(label)?; Ok(Self(canonical)) } @@ -154,19 +157,29 @@ fn self_auth_label(owner_public_key: &VerificationPublicKey) -> String { let mut transcript = b"rings-name:v1\0".to_vec(); transcript.extend_from_slice(&owner_public_key.transcript_bytes()); let digest = keccak256(&transcript); - format!("r{}", lowercase_hex(&digest[..SELF_AUTH_LABEL_BYTES])) + format!( + "r{}", + lowercase_hex(digest.iter().take(SELF_AUTH_LABEL_BYTES).copied()) + ) } -fn lowercase_hex(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut out = String::with_capacity(bytes.len() * 2); +fn lowercase_hex(bytes: impl IntoIterator) -> String { + let mut out = String::new(); for byte in bytes { - out.push(HEX[(byte >> 4) as usize] as char); - out.push(HEX[(byte & 0x0f) as usize] as char); + out.push(hex_char(byte >> 4)); + out.push(hex_char(byte & 0x0f)); } out } +fn hex_char(nibble: u8) -> char { + match nibble { + 0..=9 => char::from(b'0' + nibble), + 10..=15 => char::from(b'a' + nibble - 10), + _ => '?', + } +} + /// Descriptor fields covered by the `.rings` name signature. #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] pub struct RingsNameRecordBody { @@ -178,8 +191,8 @@ pub struct RingsNameRecordBody { pub target_did: Did, /// Session public key used by the target for encrypted overlay/onion setup. pub session_public_key: PublicKey<33>, - /// Application service name exposed by the target. - pub service: String, + /// Canonical application service name exposed by the target. + pub service: OnionServiceName, /// Transport class for the resolved service. pub transport: OnionExitTransport, /// Rings network identifier. @@ -197,7 +210,7 @@ struct RingsNameRecordBodyRef<'a> { owner_public_key: &'a VerificationPublicKey, target_did: Did, session_public_key: &'a PublicKey<33>, - service: &'a str, + service: &'a OnionServiceName, transport: OnionExitTransport, network_id: u32, seq: u64, @@ -212,7 +225,7 @@ impl RingsNameRecordBody { owner_public_key: &self.owner_public_key, target_did: self.target_did, session_public_key: &self.session_public_key, - service: self.service.as_str(), + service: &self.service, transport: self.transport, network_id: self.network_id, seq: self.seq, @@ -230,11 +243,6 @@ impl RingsNameRecordBody { ".rings name does not match owner public key".to_string(), )); } - if self.service.trim().is_empty() || self.service.trim() != self.service { - return Err(Error::InvalidRingsName( - ".rings service must be non-empty and trimmed".to_string(), - )); - } Ok(()) } } @@ -252,8 +260,8 @@ pub struct RingsNameRecord { pub target_did: Did, /// Session public key used by the target for encrypted overlay/onion setup. pub session_public_key: PublicKey<33>, - /// Application service name exposed by the target. - pub service: String, + /// Canonical application service name exposed by the target. + pub service: OnionServiceName, /// Transport class for the resolved service. pub transport: OnionExitTransport, /// Rings network identifier. @@ -299,7 +307,7 @@ impl RingsNameRecord { owner_public_key: &self.owner_public_key, target_did: self.target_did, session_public_key: &self.session_public_key, - service: self.service.as_str(), + service: &self.service, transport: self.transport, network_id: self.network_id, seq: self.seq, @@ -428,7 +436,7 @@ mod tests { owner_public_key, target_did: session_sk.account_did(), session_public_key: session_sk.session_public_key(), - service: "web".to_string(), + service: OnionServiceName::tcp(), transport: OnionExitTransport::Tcp, network_id: 7, seq: 1, diff --git a/crates/node/src/rpc_dto.rs b/crates/node/src/rpc_dto.rs index 25e31dc7e..329287409 100644 --- a/crates/node/src/rpc_dto.rs +++ b/crates/node/src/rpc_dto.rs @@ -162,7 +162,7 @@ pub(crate) fn rings_name_record_info(record: RingsNameRecord) -> Result Date: Thu, 9 Jul 2026 00:06:03 +0800 Subject: [PATCH 3/7] Reject oversized .rings expiries --- crates/node/src/processor/mod.rs | 6 ++++- .../node/src/processor/tests/test_registry.rs | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index ce93f1e73..a43290d45 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -287,6 +287,10 @@ impl Processor { } else { ttl_ms }; + let expires_at_ms = now_ms.saturating_add(ttl_ms.into()); + if u64::try_from(expires_at_ms).is_err() { + return Err(Error::InvalidData); + } let service = OnionServiceName::parse(service)?; let record = RingsNameRecord::new_signed( RingsNameRecordBody { @@ -298,7 +302,7 @@ impl Processor { transport, network_id: self.swarm.network_id(), seq, - expires_at_ms: now_ms.saturating_add(ttl_ms.into()), + expires_at_ms, }, &self.session_sk, ) diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index d03442418..f090b2768 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -350,6 +350,29 @@ async fn rings_name_publish_rejects_invalid_service_name() { )); } +#[tokio::test] +async fn rings_name_publish_rejects_unrepresentable_expiry_before_storage() -> Result<()> { + let processor = prepare_processor().await; + let owner_public_key = processor + .session_sk + .session() + .account_verification_pubkey() + .map_err(Error::CoreError)?; + let name = RingsName::for_owner(&owner_public_key); + + assert!(matches!( + processor + .publish_rings_name(None, "web", OnionExitTransport::Tcp, u64::MAX, 1) + .await, + Err(Error::InvalidData) + )); + assert!(processor + .storage_check_cache(name.dht_key(processor.swarm.network_id())?) + .await + .is_none()); + Ok(()) +} + #[tokio::test] async fn rings_name_resolve_filters_wrong_network_expired_and_stale_records() -> Result<()> { let processor = prepare_processor_with_network(0).await; From 234193b41415637318560c83074b686ea72b0bc9 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:20:07 +0800 Subject: [PATCH 4/7] Filter unsupported .rings record expiries --- crates/node/src/processor/mod.rs | 3 +- crates/node/src/rings_name.rs | 74 +++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index a43290d45..2888e56d5 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -79,6 +79,7 @@ use crate::registration::validate_online_node_registration_timing; use crate::registration::OnlineNodeRegistration; use crate::registration::RegistrationContext; use crate::registration::RegistrationTask; +use crate::rings_name::rings_name_expiry_is_supported; use crate::rings_name::RingsName; use crate::rings_name::RingsNameRecord; use crate::rings_name::RingsNameRecordBody; @@ -288,7 +289,7 @@ impl Processor { ttl_ms }; let expires_at_ms = now_ms.saturating_add(ttl_ms.into()); - if u64::try_from(expires_at_ms).is_err() { + if !rings_name_expiry_is_supported(expires_at_ms) { return Err(Error::InvalidData); } let service = OnionServiceName::parse(service)?; diff --git a/crates/node/src/rings_name.rs b/crates/node/src/rings_name.rs index 422de5ef3..7bb2afc6c 100644 --- a/crates/node/src/rings_name.rs +++ b/crates/node/src/rings_name.rs @@ -38,6 +38,11 @@ pub const RINGS_NAME_DHT_PREFIX: &str = "rings-name:v1"; const RINGS_NAME_SCHEMA_VERSION: u16 = 1; const SELF_AUTH_LABEL_BYTES: usize = 20; +const RINGS_NAME_MAX_EXPIRES_AT_MS: u128 = u64::MAX as u128; + +pub(crate) const fn rings_name_expiry_is_supported(expires_at_ms: u128) -> bool { + expires_at_ms <= RINGS_NAME_MAX_EXPIRES_AT_MS +} /// Canonical `.rings` name. #[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] @@ -243,6 +248,9 @@ impl RingsNameRecordBody { ".rings name does not match owner public key".to_string(), )); } + if !rings_name_expiry_is_supported(self.expires_at_ms) { + return Err(Error::InvalidData); + } Ok(()) } } @@ -329,14 +337,22 @@ impl RingsNameRecord { self.network_id == network_id } + /// Return whether this record's expiry fits the supported v1 wire timestamp range. + pub const fn has_supported_expiry(&self) -> bool { + rings_name_expiry_is_supported(self.expires_at_ms) + } + /// Return whether this record is expired at `now_ms`. pub const fn is_expired_at(&self, now_ms: u128) -> bool { self.expires_at_ms <= now_ms } - /// Verify schema, self-auth name binding, owner signature, and session binding. + /// Verify schema, expiry range, self-auth name binding, owner signature, and session binding. pub fn verify_signature(&self) -> bool { - if !self.has_supported_schema() || !self.name.matches_owner(&self.owner_public_key) { + if !self.has_supported_schema() + || !self.has_supported_expiry() + || !self.name.matches_owner(&self.owner_public_key) + { return false; } if self.signature.session.account_did() != self.owner_public_key.did() { @@ -444,6 +460,26 @@ mod tests { } } + fn signed_record_unchecked( + body: RingsNameRecordBody, + session_sk: &SessionSk, + ) -> CoreResult { + let signature = MessageVerification::new(&body.signing_data()?, session_sk)?; + Ok(RingsNameRecord { + schema_version: RINGS_NAME_SCHEMA_VERSION, + name: body.name, + owner_public_key: body.owner_public_key, + target_did: body.target_did, + session_public_key: body.session_public_key, + service: body.service, + transport: body.transport, + network_id: body.network_id, + seq: body.seq, + expires_at_ms: body.expires_at_ms, + signature, + }) + } + #[test] fn self_auth_name_round_trips_as_canonical_rings_name() -> Result<()> { let session_sk = session(); @@ -495,6 +531,23 @@ mod tests { assert!(RingsNameRecord::new_signed(body, &session_sk).is_err()); } + #[test] + fn record_rejects_unrepresentable_expiry() -> CoreResult<()> { + let session_sk = session(); + let now_ms = get_epoch_ms(); + let mut body = body_at(&session_sk, now_ms); + body.expires_at_ms = RINGS_NAME_MAX_EXPIRES_AT_MS + 1; + + assert!(RingsNameRecord::new_signed(body.clone(), &session_sk).is_err()); + let record = signed_record_unchecked(body, &session_sk)?; + + assert!(record.has_supported_schema()); + assert!(!record.has_supported_expiry()); + assert!(!record.verify_signature()); + assert!(!record.is_live_at(now_ms)); + Ok(()) + } + #[test] fn latest_valid_record_filters_network_expiry_and_stale_seq() -> CoreResult<()> { let session_sk = session(); @@ -523,4 +576,21 @@ mod tests { assert_eq!(selected, vec![new]); Ok(()) } + + #[test] + fn latest_valid_record_filters_unrepresentable_expiry() -> CoreResult<()> { + let session_sk = session(); + let now_ms = get_epoch_ms(); + let live = RingsNameRecord::new_signed(body_at(&session_sk, now_ms), &session_sk)?; + let mut oversized_body = body_at(&session_sk, now_ms); + oversized_body.seq = live.seq + 1; + oversized_body.expires_at_ms = RINGS_NAME_MAX_EXPIRES_AT_MS + 1; + let oversized = signed_record_unchecked(oversized_body, &session_sk)?; + + let selected = + RingsNameRecord::latest_valid_by_name(vec![live.clone(), oversized], 7, now_ms, false); + + assert_eq!(selected, vec![live]); + Ok(()) + } } From 3b485278ec0149c034eeade75c8600e3d2a5aef4 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:44:34 +0800 Subject: [PATCH 5/7] Enforce .rings route target semantics --- crates/node/bin/rings.rs | 28 +++++ crates/node/src/error.rs | 2 + crates/node/src/native/cli.rs | 20 ++++ crates/node/src/onion/directory.rs | 31 +++++ crates/node/src/onion/failure.rs | 20 ++++ crates/node/src/processor/mod.rs | 14 +++ crates/node/src/processor/tests/test_onion.rs | 109 ++++++++++++++++++ .../node/src/processor/tests/test_registry.rs | 2 +- crates/node/src/provider/browser/provider.rs | 18 +++ crates/node/src/rings_name.rs | 43 +++++-- crates/node/src/rpc_impl.rs | 14 +++ crates/rpc/src/jsonrpc.rs | 8 ++ crates/rpc/src/method.rs | 4 + crates/rpc/src/protos/rings_node.rs | 11 ++ crates/rpc/src/protos/rings_node_handler.rs | 7 ++ 15 files changed, 322 insertions(+), 9 deletions(-) diff --git a/crates/node/bin/rings.rs b/crates/node/bin/rings.rs index 4acf93855..ec9c214f2 100644 --- a/crates/node/bin/rings.rs +++ b/crates/node/bin/rings.rs @@ -589,6 +589,7 @@ enum ServiceCommand { Lookup(ServiceLookupCommand), PublishRingsName(PublishRingsNameCommand), ResolveRingsName(ResolveRingsNameCommand), + BuildRingsNameRoute(BuildRingsNameRouteCommand), } #[derive(Args, Debug)] @@ -643,6 +644,24 @@ struct ResolveRingsNameCommand { include_expired: bool, } +#[derive(Args, Debug)] +struct BuildRingsNameRouteCommand { + #[command(flatten)] + client_args: ClientArgs, + + name: String, + + #[arg( + long, + default_value_t = 0, + help = "Desired hop count including the .rings target; 0 uses node default" + )] + hop_count: u32, + + #[arg(long, default_value_t = false)] + allow_short_paths: bool, +} + #[derive(Args, Debug)] struct InspectCommand { #[command(flatten)] @@ -972,6 +991,15 @@ async fn run(cli: Cli) -> anyhow::Result<()> { .display(); Ok(()) } + Command::Service(ServiceCommand::BuildRingsNameRoute(args)) => { + args.client_args + .new_client() + .await? + .build_rings_name_route(args.name.as_str(), args.hop_count, args.allow_short_paths) + .await? + .display(); + Ok(()) + } Command::Init(args) => { let session_sk_path = args.session_args.new_session_then_write_to_fs()?; let config = config::Config::new(session_sk_path); diff --git a/crates/node/src/error.rs b/crates/node/src/error.rs index e1de6df40..fab900c44 100644 --- a/crates/node/src/error.rs +++ b/crates/node/src/error.rs @@ -154,6 +154,8 @@ pub enum Error { OnionProxyIoError(String) = 1602, #[error("Invalid .rings name: {0}")] InvalidRingsName(String) = 1701, + #[error(".rings name not found: {0}")] + RingsNameNotFound(String) = 1702, } impl Error { diff --git a/crates/node/src/native/cli.rs b/crates/node/src/native/cli.rs index 01def788e..64b3e7ba5 100644 --- a/crates/node/src/native/cli.rs +++ b/crates/node/src/native/cli.rs @@ -227,6 +227,26 @@ impl Client { ClientOutput::ok(display, record) } + /// Builds an onion route to a resolved `.rings` target. + pub async fn build_rings_name_route( + &self, + name: &str, + hop_count: u32, + allow_short_paths: bool, + ) -> Output { + let route = self + .client + .build_rings_name_route(&BuildRingsNameRouteRequest { + name: name.to_string(), + hop_count, + allow_short_paths, + }) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + let display = serde_json::to_string_pretty(&route).map_err(|e| anyhow::anyhow!("{}", e))?; + ClientOutput::ok(display, route) + } + /// Publishes a message to the specified topic. pub async fn publish_message_to_topic(&self, topic: &str, data: &str) -> Output<()> { self.client diff --git a/crates/node/src/onion/directory.rs b/crates/node/src/onion/directory.rs index 1a5347581..f63e67de1 100644 --- a/crates/node/src/onion/directory.rs +++ b/crates/node/src/onion/directory.rs @@ -17,6 +17,7 @@ use crate::onion::proxy::OnionProxyConfig; use crate::onion::proxy::OnionProxyRoute; use crate::onion::proxy::OnionProxyTarget; use crate::online::OnlineNodeDescriptor; +use crate::rings_name::RingsNameRecord; /// Read-only directory effects required by onion route construction. #[cfg_attr(feature = "browser", async_trait::async_trait(?Send))] @@ -102,6 +103,36 @@ pub(crate) async fn build_onion_proxy_route( }) } +/// Build an onion route to the live target descriptor authenticated by a `.rings` record. +pub(crate) async fn build_rings_name_route( + reader: &impl OnionDirectoryReader, + record: RingsNameRecord, + hop_count: usize, + allow_short_paths: bool, +) -> Result { + let request = + OnionRouteRequest::from_service_name(record.service.clone(), hop_count, allow_short_paths); + let exits = reader + .live_onion_exits(record.service.as_str()) + .await? + .into_iter() + .filter(|exit| exit.did == record.target_did) + .filter(|exit| exit.session_public_key == record.session_public_key) + .filter(|exit| exit.matches_network(record.network_id)) + .filter(|exit| exit.offers_service_transport(record.service.as_str(), record.transport)) + .collect::>(); + if exits.is_empty() { + return Err(Error::OnionRouteError(OnionRouteError::NoRingsNameTarget { + name: record.name.to_string(), + target: record.target_did, + service: record.service.into(), + transport: record.transport, + })); + } + + build_onion_route_from_exits(reader, request, exits).await +} + async fn build_filtered_onion_route( reader: &impl OnionDirectoryReader, request: OnionRouteRequest, diff --git a/crates/node/src/onion/failure.rs b/crates/node/src/onion/failure.rs index a59239750..79a3f4213 100644 --- a/crates/node/src/onion/failure.rs +++ b/crates/node/src/onion/failure.rs @@ -47,6 +47,17 @@ pub enum OnionRouteError { /// Requested target authority. target: String, }, + /// A `.rings` name resolved but no live exit descriptor matched its signed target. + NoRingsNameTarget { + /// Resolved `.rings` name. + name: String, + /// Target DID signed by the name owner. + target: Did, + /// Requested service name. + service: String, + /// Required transport class. + transport: OnionExitTransport, + }, /// Route construction found duplicate DIDs. DuplicateRouteHops, /// The selected exit descriptor does not match the final encrypted hop. @@ -154,6 +165,15 @@ impl fmt::Display for OnionRouteError { f, "no live onion exit for service {service:?} allows target {target:?}" ), + Self::NoRingsNameTarget { + name, + target, + service, + transport, + } => write!( + f, + "resolved .rings name {name:?} points to {target}, but no live onion exit offers service {service:?} over {transport:?}" + ), Self::DuplicateRouteHops => f.write_str("onion route contains duplicate hops"), Self::ExitHopMismatch => { f.write_str("onion route exit hop does not match exit descriptor") diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index 2888e56d5..dd66f03ba 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -258,6 +258,20 @@ impl Processor { directory::build_onion_proxy_route(self, proxy, target).await } + /// Resolve a `.rings` name and build an onion route to its authenticated live target. + pub async fn build_rings_name_route( + &self, + name: &str, + hop_count: usize, + allow_short_paths: bool, + ) -> Result { + let record = self + .resolve_rings_name(name, false) + .await? + .ok_or_else(|| Error::RingsNameNotFound(name.to_string()))?; + directory::build_rings_name_route(self, record, hop_count, allow_short_paths).await + } + /// Publish this node's self-authenticating `.rings` service record. pub async fn publish_rings_name( &self, diff --git a/crates/node/src/processor/tests/test_onion.rs b/crates/node/src/processor/tests/test_onion.rs index c4c5a8c01..15ce23d9e 100644 --- a/crates/node/src/processor/tests/test_onion.rs +++ b/crates/node/src/processor/tests/test_onion.rs @@ -1,5 +1,33 @@ use super::common::*; use super::*; +use crate::onion::OnionServiceName; + +fn rings_name_record_for_exit( + exit: &Processor, + service: &str, + transport: OnionExitTransport, + network_id: u32, + seq: u64, + expires_at_ms: u128, +) -> Result { + let owner_public_key = exit + .session_sk + .session() + .account_verification_pubkey() + .map_err(Error::CoreError)?; + let body = RingsNameRecordBody { + name: RingsName::for_owner(&owner_public_key), + owner_public_key, + target_did: exit.did(), + session_public_key: exit.session_sk.session_public_key(), + service: OnionServiceName::parse(service)?, + transport, + network_id, + seq, + expires_at_ms, + }; + RingsNameRecord::new_signed(body, &exit.session_sk).map_err(Error::CoreError) +} #[tokio::test] async fn onion_exit_lookup_uses_dedicated_exit_registry() -> Result<()> { @@ -50,6 +78,87 @@ async fn onion_exit_lookup_preserves_distinct_services_for_same_did() -> Result< Ok(()) } +#[tokio::test] +async fn rings_name_route_targets_resolved_live_exit_descriptor() -> Result<()> { + let processor = prepare_processor().await; + let exit = prepare_processor().await; + let now_ms = get_epoch_ms(); + let exit_descriptor = onion_exit_descriptor_for_processor(&exit, "web", now_ms)?; + let record = rings_name_record_for_exit( + &exit, + "web", + OnionExitTransport::Tcp, + processor.swarm.network_id(), + 1, + now_ms + 60_000, + )?; + let name = record.name.clone(); + + processor + .storage_store(entry::Entry::new( + name.dht_key(processor.swarm.network_id())?, + vec![record.encode().map_err(Error::CoreError)?], + entry::EntryKind::Data, + )) + .await?; + processor + .storage_store(Processor::onion_exit_registry_entry(vec![ + exit_descriptor.clone() + ])?) + .await?; + + let route = processor + .build_rings_name_route(name.as_str(), 1, false) + .await?; + + assert_eq!(route.hops().len(), 1); + assert_eq!(route.exit_did(), exit.did()); + assert_eq!(route.hops().last().copied(), Some(exit.did())); + assert_eq!(route.exit(), &exit_descriptor); + assert_eq!(route.service(), "web"); + Ok(()) +} + +#[tokio::test] +async fn rings_name_route_rejects_missing_target_exit_descriptor() -> Result<()> { + let processor = prepare_processor().await; + let exit = prepare_processor().await; + let now_ms = get_epoch_ms(); + let record = rings_name_record_for_exit( + &exit, + "web", + OnionExitTransport::Tcp, + processor.swarm.network_id(), + 1, + now_ms + 60_000, + )?; + let name = record.name.clone(); + + processor + .storage_store(entry::Entry::new( + name.dht_key(processor.swarm.network_id())?, + vec![record.encode().map_err(Error::CoreError)?], + entry::EntryKind::Data, + )) + .await?; + + let error = processor + .build_rings_name_route(name.as_str(), 1, false) + .await + .err() + .ok_or_else(|| Error::InvalidConfig("expected route failure".to_string()))?; + + assert!(matches!( + error, + Error::OnionRouteError(OnionRouteError::NoRingsNameTarget { name: failed_name, target, service, transport }) + if failed_name == name.as_str() + && target == exit.did() + && service == "web" + && transport == OnionExitTransport::Tcp + )); + Ok(()) +} + #[tokio::test] async fn onion_route_builder_uses_presence_relays_without_exit_descriptor() -> Result<()> { let processor = prepare_processor().await; diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index f090b2768..5f1678f77 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -393,7 +393,7 @@ async fn rings_name_resolve_filters_wrong_network_expired_and_stale_records() -> ) .map_err(Error::CoreError)?; let expired = RingsNameRecord::new_signed( - rings_name_body(&processor, 0, 4, now_ms.saturating_sub(1))?, + rings_name_body(&processor, 0, 0, now_ms.saturating_sub(1))?, &processor.session_sk, ) .map_err(Error::CoreError)?; diff --git a/crates/node/src/provider/browser/provider.rs b/crates/node/src/provider/browser/provider.rs index 296ffac75..59c08c2d9 100644 --- a/crates/node/src/provider/browser/provider.rs +++ b/crates/node/src/provider/browser/provider.rs @@ -665,6 +665,24 @@ impl Provider { } }) } + + /// Build an onion route to a resolved `.rings` target. + pub fn build_rings_name_route( + &self, + name: String, + hop_count: usize, + allow_short_paths: bool, + ) -> js_sys::Promise { + let p = self.processor.clone(); + future_to_promise(async move { + let route = p + .build_rings_name_route(name.as_str(), hop_count, allow_short_paths) + .await + .map_err(JsError::from)?; + let info = crate::rpc_dto::onion_route_response(route).map_err(JsError::from)?; + Ok(js_value::serialize(&info).map_err(JsError::from)?) + }) + } } fn parse_onion_exit_transport(raw: &str) -> Result { diff --git a/crates/node/src/rings_name.rs b/crates/node/src/rings_name.rs index 7bb2afc6c..f22380c41 100644 --- a/crates/node/src/rings_name.rs +++ b/crates/node/src/rings_name.rs @@ -387,11 +387,7 @@ impl RingsNameRecord { if !record.matches_network(network_id) { continue; } - if include_expired { - if !record.verify_signature() { - continue; - } - } else if !record.is_live_at(now_ms) { + if !record.verify_signature() { continue; } match latest.entry(record.name.clone()) { @@ -409,7 +405,10 @@ impl RingsNameRecord { } } } - latest.into_values().collect() + latest + .into_values() + .filter(|record| include_expired || !record.is_expired_at(now_ms)) + .collect() } } @@ -549,7 +548,7 @@ mod tests { } #[test] - fn latest_valid_record_filters_network_expiry_and_stale_seq() -> CoreResult<()> { + fn latest_valid_record_filters_network_and_stale_seq() -> CoreResult<()> { let session_sk = session(); let now_ms = get_epoch_ms(); let mut old = RingsNameRecord::new_signed(body_at(&session_sk, now_ms), &session_sk)?; @@ -562,7 +561,7 @@ mod tests { foreign_body.seq = 3; let foreign = RingsNameRecord::new_signed(foreign_body, &session_sk)?; let mut expired_body = body_at(&session_sk, now_ms); - expired_body.seq = 4; + expired_body.seq = 0; expired_body.expires_at_ms = now_ms; let expired = RingsNameRecord::new_signed(expired_body, &session_sk)?; @@ -577,6 +576,34 @@ mod tests { Ok(()) } + #[test] + fn expired_highest_seq_suppresses_lower_live_record() -> CoreResult<()> { + let session_sk = session(); + let now_ms = get_epoch_ms(); + let live_lower = RingsNameRecord::new_signed(body_at(&session_sk, now_ms), &session_sk)?; + let mut expired_higher_body = body_at(&session_sk, now_ms); + expired_higher_body.seq = live_lower.seq + 1; + expired_higher_body.expires_at_ms = now_ms; + let expired_higher = RingsNameRecord::new_signed(expired_higher_body, &session_sk)?; + + let live_selected = RingsNameRecord::latest_valid_by_name( + vec![live_lower.clone(), expired_higher.clone()], + 7, + now_ms, + false, + ); + let expired_selected = RingsNameRecord::latest_valid_by_name( + vec![live_lower, expired_higher.clone()], + 7, + now_ms, + true, + ); + + assert!(live_selected.is_empty()); + assert_eq!(expired_selected, vec![expired_higher]); + Ok(()) + } + #[test] fn latest_valid_record_filters_unrepresentable_expiry() -> CoreResult<()> { let session_sk = session(); diff --git a/crates/node/src/rpc_impl.rs b/crates/node/src/rpc_impl.rs index ca33cb468..9798be2dd 100644 --- a/crates/node/src/rpc_impl.rs +++ b/crates/node/src/rpc_impl.rs @@ -362,6 +362,20 @@ impl HandleRpc for Processor } } +#[cfg_attr(feature = "browser", async_trait(?Send))] +#[cfg_attr(not(feature = "browser"), async_trait)] +impl HandleRpc for Processor { + async fn handle_rpc(&self, req: BuildRingsNameRouteRequest) -> Result { + let hop_count = usize::try_from(req.hop_count) + .map_err(|_| Error::invalid_params("hop_count does not fit usize"))?; + let route = self + .build_rings_name_route(&req.name, hop_count, req.allow_short_paths) + .await + .map_err(Error::from)?; + crate::rpc_dto::onion_route_response(route).map_err(Error::from) + } +} + #[cfg_attr(feature = "browser", async_trait(?Send))] #[cfg_attr(not(feature = "browser"), async_trait)] impl HandleRpc for Processor { diff --git a/crates/rpc/src/jsonrpc.rs b/crates/rpc/src/jsonrpc.rs index 31cb77b34..0b0f15da8 100644 --- a/crates/rpc/src/jsonrpc.rs +++ b/crates/rpc/src/jsonrpc.rs @@ -225,6 +225,14 @@ impl Client { self.call_method(Method::ResolveRingsName, req).await } + /// Builds an onion route to a resolved `.rings` target. + pub async fn build_rings_name_route( + &self, + req: &BuildRingsNameRouteRequest, + ) -> Result { + self.call_method(Method::BuildRingsNameRoute, req).await + } + /// Looks up signed online-node descriptors. pub async fn lookup_online_nodes( &self, diff --git a/crates/rpc/src/method.rs b/crates/rpc/src/method.rs index 05a6bd505..49aa3d46b 100644 --- a/crates/rpc/src/method.rs +++ b/crates/rpc/src/method.rs @@ -42,6 +42,8 @@ pub enum Method { PublishRingsName, /// Resolve a self-authenticating `.rings` name record ResolveRingsName, + /// Build an onion route to a resolved self-authenticating `.rings` name + BuildRingsNameRoute, /// Lookup online-node registry descriptors LookupOnlineNodes, /// Lookup application-layer onion exit descriptors @@ -79,6 +81,7 @@ impl Method { Method::LookupService => "lookupService", Method::PublishRingsName => "publishRingsName", Method::ResolveRingsName => "resolveRingsName", + Method::BuildRingsNameRoute => "buildRingsNameRoute", Method::LookupOnlineNodes => "lookupOnlineNodes", Method::LookupOnionExits => "lookupOnionExits", Method::BuildOnionRoute => "buildOnionRoute", @@ -119,6 +122,7 @@ impl TryFrom<&str> for Method { "lookupService" => Method::LookupService, "publishRingsName" => Method::PublishRingsName, "resolveRingsName" => Method::ResolveRingsName, + "buildRingsNameRoute" => Method::BuildRingsNameRoute, "lookupOnlineNodes" => Method::LookupOnlineNodes, "lookupOnionExits" => Method::LookupOnionExits, "buildOnionRoute" => Method::BuildOnionRoute, diff --git a/crates/rpc/src/protos/rings_node.rs b/crates/rpc/src/protos/rings_node.rs index cbfd7fff2..845a9c04c 100644 --- a/crates/rpc/src/protos/rings_node.rs +++ b/crates/rpc/src/protos/rings_node.rs @@ -206,6 +206,17 @@ pub struct ResolveRingsNameResponse { pub record: Option, } +#[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] +pub struct BuildRingsNameRouteRequest { + pub name: String, + /// Desired hop count including the `.rings` target. `0` means node default. + #[serde(default)] + pub hop_count: u32, + /// Allow route selection to return fewer hops when too few relays are live. + #[serde(default)] + pub allow_short_paths: bool, +} + #[derive(Clone, PartialEq, Debug, Default, Serialize, Deserialize)] pub struct LookupOnlineNodesRequest { #[serde(default)] diff --git a/crates/rpc/src/protos/rings_node_handler.rs b/crates/rpc/src/protos/rings_node_handler.rs index 1a4339d34..674da8174 100644 --- a/crates/rpc/src/protos/rings_node_handler.rs +++ b/crates/rpc/src/protos/rings_node_handler.rs @@ -50,6 +50,7 @@ impl InternalRpcHandler { + HandleRpc + HandleRpc + HandleRpc + + HandleRpc + HandleRpc + HandleRpc + HandleRpc @@ -167,6 +168,12 @@ impl InternalRpcHandler { let resp = processor.handle_rpc(req).await?; serde_json::to_value(resp).map_err(|_| Error::new(ErrorCode::ParseError)) } + Method::BuildRingsNameRoute => { + let req = serde_json::from_value::(params) + .map_err(|e| Error::invalid_params(e.to_string()))?; + let resp = processor.handle_rpc(req).await?; + serde_json::to_value(resp).map_err(|_| Error::new(ErrorCode::ParseError)) + } Method::LookupOnlineNodes => { let req = serde_json::from_value::(params) .map_err(|e| Error::invalid_params(e.to_string()))?; From 717233e2ee3f80e39aae956e68de402587ac8cc3 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:27:14 +0800 Subject: [PATCH 6/7] Stabilize .rings record conflict ordering --- crates/node/src/rings_name.rs | 54 ++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/crates/node/src/rings_name.rs b/crates/node/src/rings_name.rs index f22380c41..495a08184 100644 --- a/crates/node/src/rings_name.rs +++ b/crates/node/src/rings_name.rs @@ -375,6 +375,26 @@ impl RingsNameRecord { self.verify_signature() && !self.is_expired_at(now_ms) } + fn name_conflict_rank(&self) -> CoreResult<[u8; 32]> { + let encoded = encode_descriptor(self)?; + Ok(keccak256(encoded.as_bytes())) + } + + fn supersedes_name_conflict(&self, current: &Self) -> bool { + if self.seq != current.seq { + return self.seq > current.seq; + } + if self.expires_at_ms != current.expires_at_ms { + return self.expires_at_ms > current.expires_at_ms; + } + + match (self.name_conflict_rank(), current.name_conflict_rank()) { + (Ok(candidate), Ok(current)) => candidate > current, + (Ok(_), Err(_)) => true, + (Err(_), Ok(_)) | (Err(_), Err(_)) => false, + } + } + /// Select the newest valid record per `.rings` name. pub fn latest_valid_by_name( records: impl IntoIterator, @@ -393,10 +413,7 @@ impl RingsNameRecord { match latest.entry(record.name.clone()) { Entry::Occupied(mut entry) => { let current = entry.get(); - if record.seq > current.seq - || (record.seq == current.seq - && record.expires_at_ms > current.expires_at_ms) - { + if record.supersedes_name_conflict(current) { entry.insert(record); } } @@ -604,6 +621,35 @@ mod tests { Ok(()) } + #[test] + fn equal_seq_and_expiry_conflicts_have_stable_winner() -> CoreResult<()> { + let owner = session(); + let target = session(); + let now_ms = get_epoch_ms(); + let first = RingsNameRecord::new_signed(body_at(&owner, now_ms), &owner)?; + let mut second_body = body_at(&owner, now_ms); + second_body.target_did = target.account_did(); + second_body.session_public_key = target.session_public_key(); + let second = RingsNameRecord::new_signed(second_body, &owner)?; + let expected = if second.supersedes_name_conflict(&first) { + second.clone() + } else { + first.clone() + }; + + let forward = RingsNameRecord::latest_valid_by_name( + vec![first.clone(), second.clone()], + 7, + now_ms, + false, + ); + let reverse = RingsNameRecord::latest_valid_by_name(vec![second, first], 7, now_ms, false); + + assert_eq!(forward, vec![expected.clone()]); + assert_eq!(reverse, vec![expected]); + Ok(()) + } + #[test] fn latest_valid_record_filters_unrepresentable_expiry() -> CoreResult<()> { let session_sk = session(); From 34bcd4476b2c7724bd893ea9e6cd66f44848e971 Mon Sep 17 00:00:00 2001 From: "Ryan.K" <662346+RyanKung@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:20:15 +0800 Subject: [PATCH 7/7] Unify .rings browser and RPC adapters --- crates/node/bin/rings.rs | 32 ++---- crates/node/src/onion/mod.rs | 16 +++ .../src/onion/tests/test_exit_registry.rs | 21 ++++ crates/node/src/processor/mod.rs | 1 + .../node/src/processor/tests/test_registry.rs | 11 ++ crates/node/src/provider/browser/provider.rs | 103 +++++++++++------- crates/node/src/rpc_impl.rs | 3 +- crates/node/src/tests/wasm/test_browser.rs | 44 ++++++++ 8 files changed, 169 insertions(+), 62 deletions(-) diff --git a/crates/node/bin/rings.rs b/crates/node/bin/rings.rs index ec9c214f2..d5d74f64d 100644 --- a/crates/node/bin/rings.rs +++ b/crates/node/bin/rings.rs @@ -102,18 +102,8 @@ fn parse_onion_exit_service(raw: &str) -> Result { if name.is_empty() { return Err("onion exit service name must not be empty".to_string()); } - let transport = match transport.trim().to_ascii_lowercase().as_str() { - "tcp" => OnionExitTransport::Tcp, - "udp" => OnionExitTransport::Udp, - "webtransport" | "web-transport" => OnionExitTransport::WebTransport, - "requestresponse" | "request-response" => OnionExitTransport::RequestResponse, - "https" => OnionExitTransport::Https, - other => { - return Err(format!( - "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" - )); - } - }; + let transport = + OnionExitTransport::parse_user_input(transport).map_err(|error| error.to_string())?; OnionExitService::new(name, transport).map_err(|error| error.to_string()) } @@ -122,16 +112,14 @@ fn parse_onion_service_name(raw: &str) -> Result { } fn parse_onion_exit_transport_info(raw: &str) -> Result { - match raw.trim().to_ascii_lowercase().as_str() { - "tcp" => Ok(OnionExitTransportInfo::Tcp), - "udp" => Ok(OnionExitTransportInfo::Udp), - "webtransport" | "web-transport" => Ok(OnionExitTransportInfo::WebTransport), - "requestresponse" | "request-response" => Ok(OnionExitTransportInfo::RequestResponse), - "https" => Ok(OnionExitTransportInfo::Https), - other => Err(format!( - "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" - )), - } + let transport = OnionExitTransport::parse_user_input(raw).map_err(|error| error.to_string())?; + Ok(match transport { + OnionExitTransport::Tcp => OnionExitTransportInfo::Tcp, + OnionExitTransport::Udp => OnionExitTransportInfo::Udp, + OnionExitTransport::WebTransport => OnionExitTransportInfo::WebTransport, + OnionExitTransport::RequestResponse => OnionExitTransportInfo::RequestResponse, + OnionExitTransport::Https => OnionExitTransportInfo::Https, + }) } fn validate_native_onion_exit_services(services: &[OnionExitService]) -> anyhow::Result<()> { diff --git a/crates/node/src/onion/mod.rs b/crates/node/src/onion/mod.rs index 8b3ec9418..257a8eaf8 100644 --- a/crates/node/src/onion/mod.rs +++ b/crates/node/src/onion/mod.rs @@ -139,6 +139,22 @@ pub enum OnionExitTransport { Https, } +impl OnionExitTransport { + /// Parse user-facing transport names used by CLI, RPC adapters, and browser bindings. + pub fn parse_user_input(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "tcp" => Ok(Self::Tcp), + "udp" => Ok(Self::Udp), + "webtransport" | "web-transport" => Ok(Self::WebTransport), + "requestresponse" | "request-response" => Ok(Self::RequestResponse), + "https" => Ok(Self::Https), + other => Err(Error::InvalidConfig(format!( + "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" + ))), + } + } +} + /// One named service offered by an onion exit. #[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct OnionExitService { diff --git a/crates/node/src/onion/tests/test_exit_registry.rs b/crates/node/src/onion/tests/test_exit_registry.rs index 16426bbac..31e78d950 100644 --- a/crates/node/src/onion/tests/test_exit_registry.rs +++ b/crates/node/src/onion/tests/test_exit_registry.rs @@ -63,6 +63,27 @@ fn default_exit_services_include_native_tcp_only() { assert_eq!(https_onion_exit_services(), vec![OnionExitService::https()]); } +#[test] +fn onion_exit_transport_user_input_is_shared_and_canonical() -> Result<()> { + assert_eq!( + OnionExitTransport::parse_user_input(" tcp ")?, + OnionExitTransport::Tcp + ); + assert_eq!( + OnionExitTransport::parse_user_input("web-transport")?, + OnionExitTransport::WebTransport + ); + assert_eq!( + OnionExitTransport::parse_user_input("requestresponse")?, + OnionExitTransport::RequestResponse + ); + assert!(matches!( + OnionExitTransport::parse_user_input("smtp"), + Err(Error::InvalidConfig(_)) + )); + Ok(()) +} + #[test] fn reserved_service_name_requires_reserved_transport_for_routes() { assert!(OnionExitService::https().matches_route_service("https")); diff --git a/crates/node/src/processor/mod.rs b/crates/node/src/processor/mod.rs index dd66f03ba..4e4a2e6f9 100644 --- a/crates/node/src/processor/mod.rs +++ b/crates/node/src/processor/mod.rs @@ -307,6 +307,7 @@ impl Processor { return Err(Error::InvalidData); } let service = OnionServiceName::parse(service)?; + let seq = if seq == 0 { 1 } else { seq }; let record = RingsNameRecord::new_signed( RingsNameRecordBody { name, diff --git a/crates/node/src/processor/tests/test_registry.rs b/crates/node/src/processor/tests/test_registry.rs index 5f1678f77..85875a82f 100644 --- a/crates/node/src/processor/tests/test_registry.rs +++ b/crates/node/src/processor/tests/test_registry.rs @@ -320,6 +320,17 @@ async fn rings_name_publish_resolves_signed_self_record() -> Result<()> { Ok(()) } +#[tokio::test] +async fn rings_name_publish_defaults_zero_sequence_in_core() -> Result<()> { + let processor = prepare_processor().await; + let published = processor + .publish_rings_name(None, "web", OnionExitTransport::Tcp, 60_000, 0) + .await?; + + assert_eq!(published.seq, 1); + Ok(()) +} + #[tokio::test] async fn rings_name_publish_rejects_human_alias_in_v1() { let processor = prepare_processor().await; diff --git a/crates/node/src/provider/browser/provider.rs b/crates/node/src/provider/browser/provider.rs index 59c08c2d9..4f60be668 100644 --- a/crates/node/src/provider/browser/provider.rs +++ b/crates/node/src/provider/browser/provider.rs @@ -20,6 +20,7 @@ use rings_core::storage::idb::IdbStorage; use rings_core::utils::js_utils; use rings_core::utils::js_value; use rings_derive::wasm_export; +use rings_rpc::method::Method; use rings_rpc::protos::rings_node::*; use wasm_bindgen; use wasm_bindgen::prelude::*; @@ -634,33 +635,43 @@ impl Provider { ttl_ms: u64, seq: u64, ) -> js_sys::Promise { - let p = self.processor.clone(); + let provider = self.clone(); future_to_promise(async move { - let transport = parse_onion_exit_transport(&transport)?; - let requested_name = (!name.trim().is_empty()).then_some(name.as_str()); - let record = p - .publish_rings_name(requested_name, service.as_str(), transport, ttl_ms, seq) - .await - .map_err(JsError::from)?; - let info = crate::rpc_dto::rings_name_record_info(record).map_err(JsError::from)?; - Ok(js_value::serialize(&info).map_err(JsError::from)?) + let transport = parse_onion_exit_transport_info(&transport)?; + let response: PublishRingsNameResponse = request_internal_typed( + provider, + Method::PublishRingsName, + PublishRingsNameRequest { + name, + service, + transport, + ttl_ms, + seq, + }, + ) + .await?; + let record = response + .record + .ok_or_else(|| JsError::new("publishRingsName response did not include record"))?; + Ok(js_value::serialize(&record).map_err(JsError::from)?) }) } /// Resolve a self-authenticating `.rings` record. pub fn resolve_rings_name(&self, name: String, include_expired: bool) -> js_sys::Promise { - let p = self.processor.clone(); + let provider = self.clone(); future_to_promise(async move { - match p - .resolve_rings_name(name.as_str(), include_expired) - .await - .map_err(JsError::from)? - { - Some(record) => { - let info = - crate::rpc_dto::rings_name_record_info(record).map_err(JsError::from)?; - Ok(js_value::serialize(&info).map_err(JsError::from)?) - } + let response: ResolveRingsNameResponse = request_internal_typed( + provider, + Method::ResolveRingsName, + ResolveRingsNameRequest { + name, + include_expired, + }, + ) + .await?; + match response.record { + Some(record) => Ok(js_value::serialize(&record).map_err(JsError::from)?), None => Ok(JsValue::null()), } }) @@ -673,29 +684,45 @@ impl Provider { hop_count: usize, allow_short_paths: bool, ) -> js_sys::Promise { - let p = self.processor.clone(); + let provider = self.clone(); future_to_promise(async move { - let route = p - .build_rings_name_route(name.as_str(), hop_count, allow_short_paths) - .await - .map_err(JsError::from)?; - let info = crate::rpc_dto::onion_route_response(route).map_err(JsError::from)?; - Ok(js_value::serialize(&info).map_err(JsError::from)?) + let hop_count = + u32::try_from(hop_count).map_err(|_| JsError::new("hop_count does not fit u32"))?; + let response: BuildOnionRouteResponse = request_internal_typed( + provider, + Method::BuildRingsNameRoute, + BuildRingsNameRouteRequest { + name, + hop_count, + allow_short_paths, + }, + ) + .await?; + Ok(js_value::serialize(&response).map_err(JsError::from)?) }) } } -fn parse_onion_exit_transport(raw: &str) -> Result { - match raw.trim().to_ascii_lowercase().as_str() { - "tcp" => Ok(OnionExitTransport::Tcp), - "udp" => Ok(OnionExitTransport::Udp), - "webtransport" | "web-transport" => Ok(OnionExitTransport::WebTransport), - "requestresponse" | "request-response" => Ok(OnionExitTransport::RequestResponse), - "https" => Ok(OnionExitTransport::Https), - other => Err(JsError::new(&format!( - "unsupported onion exit transport {other:?}; expected tcp, udp, webtransport, request-response, or https" - ))), - } +async fn request_internal_typed( + provider: Provider, + method: Method, + request: Req, +) -> Result +where + Req: serde::Serialize, + Resp: serde::de::DeserializeOwned, +{ + let params = serde_json::to_value(request).map_err(|error| JsError::new(&error.to_string()))?; + let response = provider + .request_internal(method.to_string(), params) + .await + .map_err(JsError::from)?; + serde_json::from_value(response).map_err(|error| JsError::new(&error.to_string())) +} + +fn parse_onion_exit_transport_info(raw: &str) -> Result { + let transport = OnionExitTransport::parse_user_input(raw).map_err(JsError::from)?; + Ok(crate::rpc_dto::onion_exit_transport_info(transport)) } impl Provider { diff --git a/crates/node/src/rpc_impl.rs b/crates/node/src/rpc_impl.rs index 9798be2dd..f4b5845b0 100644 --- a/crates/node/src/rpc_impl.rs +++ b/crates/node/src/rpc_impl.rs @@ -336,10 +336,9 @@ impl HandleRpc for Processor { impl HandleRpc for Processor { async fn handle_rpc(&self, req: PublishRingsNameRequest) -> Result { let transport = crate::rpc_dto::onion_exit_transport_from_info(req.transport); - let seq = if req.seq == 0 { 1 } else { req.seq }; let requested_name = (!req.name.trim().is_empty()).then_some(req.name.as_str()); let record = self - .publish_rings_name(requested_name, &req.service, transport, req.ttl_ms, seq) + .publish_rings_name(requested_name, &req.service, transport, req.ttl_ms, req.seq) .await .map_err(Error::from)?; Ok(PublishRingsNameResponse { diff --git a/crates/node/src/tests/wasm/test_browser.rs b/crates/node/src/tests/wasm/test_browser.rs index d915570bd..2b29a2aa1 100644 --- a/crates/node/src/tests/wasm/test_browser.rs +++ b/crates/node/src/tests/wasm/test_browser.rs @@ -1,3 +1,7 @@ +use rings_rpc::protos::rings_node::OnionExitTransportInfo; +use rings_rpc::protos::rings_node::PublishRingsNameRequest; +use rings_rpc::protos::rings_node::PublishRingsNameResponse; +use rings_rpc::protos::rings_node::RingsNameRecordInfo; use rings_rpc::protos::rings_node::SendBackendMessageRequest; use wasm_bindgen_futures::JsFuture; use wasm_bindgen_test::*; @@ -12,6 +16,46 @@ use crate::provider::browser; #[cfg(feature = "browser_chrome_test")] wasm_bindgen_test_configure!(run_in_browser); +#[wasm_bindgen_test] +async fn publish_rings_name_convenience_matches_rpc_request_defaults() { + let provider = new_provider().await; + let direct = JsFuture::from(provider.publish_rings_name( + "".to_string(), + "web".to_string(), + "tcp".to_string(), + 60_000, + 0, + )) + .await + .unwrap(); + let direct = js_value::deserialize::(direct).unwrap(); + let request = PublishRingsNameRequest { + name: "".to_string(), + service: "web".to_string(), + transport: OnionExitTransportInfo::Tcp, + ttl_ms: 60_000, + seq: 0, + }; + let rpc = JsFuture::from(provider.request( + "publishRingsName".to_string(), + js_value::serialize(&request).unwrap(), + )) + .await + .unwrap(); + let rpc = js_value::deserialize::(rpc) + .unwrap() + .record + .unwrap(); + + assert_eq!(direct.name, rpc.name); + assert_eq!(direct.target_did, rpc.target_did); + assert_eq!(direct.service, rpc.service); + assert_eq!(direct.transport, rpc.transport); + assert_eq!(direct.network_id, rpc.network_id); + assert_eq!(direct.seq, 1); + assert_eq!(rpc.seq, 1); +} + #[wasm_bindgen_test] async fn test_two_provider_connect_and_list() { // super::setup_log();