Skip to main content

rings_node/onion/
mod.rs

1//! Application-layer circuit directory and route selection.
2//!
3//! This module deliberately sits in `rings-node`, not `rings-core`: Chord
4//! remains the storage and discovery substrate, while exit policy is an
5//! application protocol decision.
6//!
7//! The current data plane selects route-aware circuits and exit policies, with layered
8//! ElGamal-AEAD frames described by [`circuit::ONION_CIRCUIT_SECURITY`].
9
10use std::collections::btree_map::Entry;
11use std::collections::BTreeMap;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use rings_core::dht::Did;
16use rings_core::ecc::PublicKey;
17use rings_core::ecc::VerificationPublicKey;
18use rings_core::error::Error as CoreError;
19use rings_core::error::Result as CoreResult;
20use rings_core::message::Decoder;
21use rings_core::message::Encoded;
22use rings_core::message::Encoder;
23use rings_core::message::MessageVerification;
24use rings_core::session::SessionSk;
25use rings_core::utils::get_epoch_ms;
26use serde::Deserialize;
27use serde::Serialize;
28
29use crate::descriptor::decode_descriptor;
30use crate::descriptor::encode_descriptor;
31use crate::descriptor::sign_descriptor_body;
32use crate::descriptor::SignedDescriptor;
33use crate::descriptor::SignedDescriptorBody;
34use crate::error::Error;
35use crate::error::Result;
36use crate::online::OnlineNodeType;
37use crate::registration::DhtRegistrationPublisher;
38use crate::registration::RegistrationContext;
39use crate::registration::RegistrationTask;
40
41pub mod circuit;
42pub(crate) mod directory;
43pub(crate) mod exit_accounting;
44mod failure;
45#[cfg(rings_native)]
46mod gateway;
47#[cfg(any(rings_native, rings_browser))]
48pub mod https;
49pub mod proxy;
50pub(crate) mod replay;
51pub mod route;
52pub mod target;
53#[cfg(rings_native)]
54pub mod tcp;
55
56pub use failure::OnionExitFailure;
57pub use failure::OnionRouteError;
58#[cfg(rings_native)]
59pub use gateway::NativeOnionGatewayConnector;
60pub use route::select_onion_route;
61pub(crate) use route::select_onion_route_from_candidates_with_first_hop;
62pub use route::OnionRoute;
63pub(crate) use route::OnionRouteCandidates;
64pub use route::OnionRouteHop;
65pub use route::OnionRouteRequest;
66pub(crate) use route::SystemRouteEntropy;
67pub use route::DEFAULT_ONION_ROUTE_HOPS;
68pub use target::OnionProxyTarget;
69pub use target::OnionProxyTargetError;
70
71/// DHT topic used for application-layer onion exit descriptors.
72pub const ONION_EXITS_TOPIC: &str = "onion_exits";
73
74pub(crate) const ONION_EXIT_DESCRIPTOR_SCHEMA_VERSION: u16 = 2;
75
76/// Capability label for nodes willing to relay onion cells.
77pub const ONION_RELAY_CAPABILITY: &str = "onion-relay";
78
79const DEFAULT_ONION_EXIT_HEARTBEAT_INTERVAL_SECS: u64 = 30;
80const DEFAULT_ONION_EXIT_TTL_SECS: u64 = 90;
81
82/// Default onion-exit registry heartbeat interval in seconds.
83pub(crate) const fn default_onion_exit_heartbeat_interval_secs() -> u64 {
84    DEFAULT_ONION_EXIT_HEARTBEAT_INTERVAL_SECS
85}
86
87/// Default onion-exit registry descriptor TTL in seconds.
88pub(crate) const fn default_onion_exit_ttl_secs() -> u64 {
89    DEFAULT_ONION_EXIT_TTL_SECS
90}
91
92/// Default onion relay advertisement enablement.
93pub(crate) const fn default_advertise_onion_relay() -> bool {
94    false
95}
96
97/// Default onion exit advertisement enablement.
98pub(crate) const fn default_advertise_onion_exit() -> bool {
99    false
100}
101
102/// Default native exit services. It is only published when onion-exit advertisement is enabled.
103/// HTTPS is advertised as a TCP-backed service because HTTPS proxying ultimately tunnels TLS bytes.
104pub fn default_onion_exit_services() -> Vec<OnionExitService> {
105    vec![OnionExitService::tcp(), OnionExitService::https()]
106}
107
108/// Standard HTTPS-over-TCP onion-exit service set.
109pub fn https_onion_exit_services() -> Vec<OnionExitService> {
110    vec![OnionExitService::https()]
111}
112
113/// Default exit policy. It is intentionally closed until the operator configures targets.
114pub fn default_onion_exit_policy() -> OnionExitPolicy {
115    OnionExitPolicy::default()
116}
117
118/// Validate onion-exit registration scheduling.
119pub(crate) fn validate_onion_exit_registration_timing(
120    advertise_exit: bool,
121    heartbeat_interval: Duration,
122    ttl: Duration,
123) -> Result<()> {
124    if advertise_exit && heartbeat_interval >= ttl {
125        return Err(Error::InvalidConfig(format!(
126            "onion_exit_heartbeat_interval ({heartbeat_interval:?}) must be less than onion_exit_ttl ({ttl:?}) when advertise_onion_exit is enabled"
127        )));
128    }
129    Ok(())
130}
131
132/// Application transport exposed by an onion exit service.
133#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
134pub enum OnionExitTransport {
135    /// Native TCP service.
136    Tcp,
137    /// Native UDP service.
138    Udp,
139    /// Browser/WebTransport-backed service.
140    WebTransport,
141    /// Protocol-specific request/response service.
142    RequestResponse,
143    /// Legacy browser/application-layer HTTPS proxy marker.
144    ///
145    /// The reserved `https` service is TCP-backed; this variant remains for compatibility with
146    /// older serialized descriptors and explicit custom transports.
147    Https,
148}
149
150/// One named service offered by an onion exit.
151#[derive(Clone, Debug, Deserialize, Serialize, Eq, Hash, Ord, PartialEq, PartialOrd)]
152pub struct OnionExitService {
153    /// Service name advertised to route builders.
154    pub name: OnionServiceName,
155    /// Transport used by this service.
156    pub transport: OnionExitTransport,
157}
158
159/// Canonical onion-exit service name.
160#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
161#[serde(try_from = "String", into = "String")]
162pub struct OnionServiceName(String);
163
164impl OnionServiceName {
165    /// Parse and canonicalize a service name.
166    pub fn parse(name: impl AsRef<str>) -> Result<Self> {
167        let name = name.as_ref();
168        let trimmed = name.trim();
169        if trimmed.is_empty() || trimmed != name {
170            return Err(Error::InvalidConfig(
171                "onion exit service name must be non-empty and trimmed".to_string(),
172            ));
173        }
174        if trimmed.len() > 64 || trimmed.chars().any(|ch| !is_service_name_char(ch)) {
175            return Err(Error::InvalidConfig(format!(
176                "invalid onion exit service name {name:?}; expected [A-Za-z0-9._-] up to 64 bytes"
177            )));
178        }
179        Ok(Self(trimmed.to_ascii_lowercase()))
180    }
181
182    /// Return the standard HTTPS-over-TCP exit service name.
183    pub fn https() -> Self {
184        Self::static_name("https")
185    }
186
187    /// Return the standard native TCP exit service name.
188    pub fn tcp() -> Self {
189        Self::static_name("tcp")
190    }
191
192    /// Build a trusted static service name.
193    fn static_name(name: &'static str) -> Self {
194        Self(name.to_string())
195    }
196
197    /// Return the service name as a string slice.
198    pub fn as_str(&self) -> &str {
199        self.0.as_str()
200    }
201
202    /// Return whether this name equals `service` after service-name canonicalization.
203    pub fn matches(&self, service: &str) -> bool {
204        Self::parse(service).is_ok_and(|candidate| candidate == *self)
205    }
206}
207
208impl TryFrom<String> for OnionServiceName {
209    type Error = String;
210
211    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
212        Self::parse(&value).map_err(|error| error.to_string())
213    }
214}
215
216impl From<OnionServiceName> for String {
217    fn from(name: OnionServiceName) -> Self {
218        name.0
219    }
220}
221
222impl OnionExitService {
223    /// Return a named exit service with an explicit transport.
224    pub fn new(name: impl AsRef<str>, transport: OnionExitTransport) -> Result<Self> {
225        Ok(Self::from_name(OnionServiceName::parse(name)?, transport))
226    }
227
228    /// Return a named exit service from an already validated name.
229    pub fn from_name(name: OnionServiceName, transport: OnionExitTransport) -> Self {
230        Self { name, transport }
231    }
232
233    /// Return the standard HTTPS-over-TCP exit service.
234    pub fn https() -> Self {
235        Self::from_name(OnionServiceName::https(), OnionExitTransport::Tcp)
236    }
237
238    /// Return the standard native TCP exit service.
239    pub fn tcp() -> Self {
240        Self::from_name(OnionServiceName::tcp(), OnionExitTransport::Tcp)
241    }
242
243    /// Return whether this service has the requested name.
244    pub fn has_name(&self, service: &str) -> bool {
245        self.name.matches(service)
246    }
247
248    /// Return whether this service has the requested name and transport.
249    pub fn matches(&self, service: &str, transport: OnionExitTransport) -> bool {
250        self.has_name(service) && self.transport == transport
251    }
252
253    /// Return whether this service satisfies a route request for `service`.
254    ///
255    /// Built-in service names reserve their transport class. Custom service names remain
256    /// application-defined and match by name. HTTPS is a reserved TCP-backed service, while the
257    /// legacy `https`/`Https` pair remains readable for older descriptors.
258    pub fn matches_route_service(&self, service: &str) -> bool {
259        match Self::reserved_transport(service) {
260            Some(transport) => {
261                self.matches(service, transport) || self.matches_legacy_reserved_transport(service)
262            }
263            None => self.has_name(service),
264        }
265    }
266
267    fn matches_legacy_reserved_transport(&self, service: &str) -> bool {
268        OnionServiceName::parse(service).is_ok_and(|name| {
269            name == OnionServiceName::https()
270                && self.name == name
271                && self.transport == OnionExitTransport::Https
272        })
273    }
274
275    /// Return the reserved transport for a built-in service name.
276    pub fn reserved_transport(service: &str) -> Option<OnionExitTransport> {
277        let service = OnionServiceName::parse(service).ok()?;
278        match service.as_str() {
279            "tcp" => Some(OnionExitTransport::Tcp),
280            "https" => Some(OnionExitTransport::Tcp),
281            _ => None,
282        }
283    }
284}
285
286fn is_service_name_char(ch: char) -> bool {
287    ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')
288}
289
290/// Signed policy fields for an onion exit.
291#[derive(Clone, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
292pub struct OnionExitPolicy {
293    /// Target allow-list entries understood by the exit implementation. Empty means closed.
294    pub allowed_targets: Vec<OnionExitTarget>,
295    /// Target deny-list entries understood by the exit implementation. Deny entries override allows.
296    pub denied_targets: Vec<OnionExitTarget>,
297    /// Maximum concurrent circuits this exit wants to serve. `0` means unspecified.
298    pub max_circuits: u32,
299    /// Maximum streams per circuit. `0` means unspecified.
300    pub max_streams_per_circuit: u32,
301    /// Maximum bytes per minute. `0` means unspecified.
302    pub max_bytes_per_minute: u64,
303}
304
305/// Canonical target authority admitted by an onion exit policy.
306#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
307#[serde(try_from = "String", into = "String")]
308pub struct OnionExitTarget(String);
309
310impl OnionExitTarget {
311    const WILDCARD_AUTHORITY: &'static str = "*:*";
312
313    /// Parse and canonicalize an exit target authority.
314    pub fn parse(target: impl AsRef<str>) -> Result<Self> {
315        let raw = target.as_ref().trim();
316        if raw == "*" || raw == Self::WILDCARD_AUTHORITY {
317            return Ok(Self(Self::WILDCARD_AUTHORITY.to_string()));
318        }
319        OnionProxyTarget::parse_authority(raw)
320            .map(|target| Self(target.authority()))
321            .map_err(|error| {
322                Error::InvalidConfig(format!(
323                    "invalid onion exit target {:?}; expected host:port or *:*: {error}",
324                    target.as_ref()
325                ))
326            })
327    }
328
329    /// Return the canonical host:port authority.
330    pub fn authority(&self) -> &str {
331        self.0.as_str()
332    }
333
334    /// Build a policy target from an already-validated proxy target.
335    pub fn from_proxy_target(target: &OnionProxyTarget) -> Self {
336        Self(target.authority())
337    }
338
339    fn matches_target(&self, target: &Self) -> bool {
340        self.0 == Self::WILDCARD_AUTHORITY || self == target
341    }
342}
343
344impl TryFrom<String> for OnionExitTarget {
345    type Error = String;
346
347    fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
348        Self::parse(&value).map_err(|error| error.to_string())
349    }
350}
351
352impl From<OnionExitTarget> for String {
353    fn from(target: OnionExitTarget) -> Self {
354        target.0
355    }
356}
357
358impl OnionExitPolicy {
359    /// Build a policy from raw target strings at configuration or API boundaries.
360    pub fn from_target_strings(
361        allowed_targets: Vec<String>,
362        denied_targets: Vec<String>,
363    ) -> Result<Self> {
364        Ok(Self {
365            allowed_targets: parse_exit_targets(allowed_targets)?,
366            denied_targets: parse_exit_targets(denied_targets)?,
367            ..Self::default()
368        })
369    }
370
371    /// Return whether this policy denies every exit target.
372    pub fn is_closed(&self) -> bool {
373        self.allowed_targets.is_empty()
374    }
375
376    /// Validate target lists for an advertised onion exit.
377    pub fn validate_targets(&self) -> Result<()> {
378        if self.is_closed() {
379            return Err(Error::InvalidConfig(
380                "advertise_onion_exit requires at least one valid onion_exit_policy allowed target"
381                    .to_string(),
382            ));
383        }
384        Ok(())
385    }
386
387    /// Return whether `target` is admitted by this policy's allow-list.
388    pub fn allows_target(&self, target: &OnionExitTarget) -> bool {
389        if self.is_closed() {
390            return false;
391        }
392        if self.denies(target) {
393            return false;
394        }
395        self.allows(target)
396    }
397
398    fn allows(&self, target: &OnionExitTarget) -> bool {
399        self.allowed_targets
400            .iter()
401            .any(|allowed| allowed.matches_target(target))
402    }
403
404    fn denies(&self, target: &OnionExitTarget) -> bool {
405        self.denied_targets
406            .iter()
407            .any(|denied| denied.matches_target(target))
408    }
409}
410
411fn parse_exit_targets(targets: Vec<String>) -> Result<Vec<OnionExitTarget>> {
412    targets
413        .into_iter()
414        .map(OnionExitTarget::parse)
415        .collect::<Result<Vec<_>>>()
416}
417
418/// Descriptor fields covered by the onion-exit signature.
419#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
420pub struct OnionExitDescriptorBody {
421    /// DID of the exit node/account.
422    pub did: Did,
423    /// Account public key corresponding to `did`.
424    pub public_key: VerificationPublicKey,
425    /// Session public key used for encrypted onion exit frames.
426    pub session_public_key: PublicKey<33>,
427    /// Runtime family of this exit node.
428    pub node_type: OnlineNodeType,
429    /// Network identifier.
430    pub network_id: u32,
431    /// Service this descriptor is willing to expose.
432    pub service: OnionExitService,
433    /// Signed exit policy.
434    pub policy: OnionExitPolicy,
435    /// Process start timestamp in milliseconds since Unix epoch.
436    pub started_at_ms: u128,
437    /// Heartbeat timestamp in milliseconds since Unix epoch.
438    pub heartbeat_at_ms: u128,
439    /// Expiry timestamp in milliseconds since Unix epoch.
440    pub expires_at_ms: u128,
441    /// Node software version.
442    pub version: String,
443}
444
445impl OnionExitDescriptorBody {
446    fn body_ref(&self) -> OnionExitDescriptorBodyRef<'_> {
447        OnionExitDescriptorBodyRef {
448            schema_version: ONION_EXIT_DESCRIPTOR_SCHEMA_VERSION,
449            did: self.did,
450            public_key: &self.public_key,
451            session_public_key: &self.session_public_key,
452            node_type: &self.node_type,
453            network_id: self.network_id,
454            service: &self.service,
455            policy: &self.policy,
456            started_at_ms: self.started_at_ms,
457            heartbeat_at_ms: self.heartbeat_at_ms,
458            expires_at_ms: self.expires_at_ms,
459            version: self.version.as_str(),
460        }
461    }
462
463    fn signing_data(&self) -> CoreResult<Vec<u8>> {
464        self.body_ref().signing_data()
465    }
466}
467
468impl SignedDescriptorBody for OnionExitDescriptorBody {
469    type Descriptor = OnionExitDescriptor;
470
471    fn body_did(&self) -> Did {
472        self.did
473    }
474
475    fn body_public_key(&self) -> &VerificationPublicKey {
476        &self.public_key
477    }
478
479    fn body_signing_data(&self) -> CoreResult<Vec<u8>> {
480        self.signing_data()
481    }
482
483    fn into_signed_descriptor(self, signature: MessageVerification) -> Self::Descriptor {
484        OnionExitDescriptor {
485            schema_version: ONION_EXIT_DESCRIPTOR_SCHEMA_VERSION,
486            did: self.did,
487            public_key: self.public_key,
488            session_public_key: self.session_public_key,
489            node_type: self.node_type,
490            network_id: self.network_id,
491            service: self.service,
492            policy: self.policy,
493            started_at_ms: self.started_at_ms,
494            heartbeat_at_ms: self.heartbeat_at_ms,
495            expires_at_ms: self.expires_at_ms,
496            version: self.version,
497            signature,
498        }
499    }
500}
501
502#[derive(Serialize)]
503struct OnionExitDescriptorBodyRef<'a> {
504    schema_version: u16,
505    did: Did,
506    public_key: &'a VerificationPublicKey,
507    session_public_key: &'a PublicKey<33>,
508    node_type: &'a OnlineNodeType,
509    network_id: u32,
510    service: &'a OnionExitService,
511    policy: &'a OnionExitPolicy,
512    started_at_ms: u128,
513    heartbeat_at_ms: u128,
514    expires_at_ms: u128,
515    version: &'a str,
516}
517
518impl OnionExitDescriptorBodyRef<'_> {
519    fn signing_data(&self) -> CoreResult<Vec<u8>> {
520        rings_codec::serialize(self).map_err(CoreError::CodecSerialize)
521    }
522}
523
524/// Signed descriptor published by onion exits.
525#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
526pub struct OnionExitDescriptor {
527    /// Onion-exit descriptor wire schema version covered by the descriptor signature.
528    pub schema_version: u16,
529    /// DID of the exit node/account.
530    pub did: Did,
531    /// Account public key corresponding to `did`.
532    pub public_key: VerificationPublicKey,
533    /// Session public key used for encrypted onion exit frames.
534    pub session_public_key: PublicKey<33>,
535    /// Runtime family of this exit node.
536    pub node_type: OnlineNodeType,
537    /// Network identifier.
538    pub network_id: u32,
539    /// Service this descriptor is willing to expose.
540    pub service: OnionExitService,
541    /// Signed exit policy.
542    pub policy: OnionExitPolicy,
543    /// Process start timestamp in milliseconds since Unix epoch.
544    pub started_at_ms: u128,
545    /// Heartbeat timestamp in milliseconds since Unix epoch.
546    pub heartbeat_at_ms: u128,
547    /// Expiry timestamp in milliseconds since Unix epoch.
548    pub expires_at_ms: u128,
549    /// Node software version.
550    pub version: String,
551    /// Signature covering every descriptor field above.
552    pub signature: MessageVerification,
553}
554
555impl OnionExitDescriptor {
556    /// Create and sign an onion-exit descriptor.
557    pub fn new_signed(body: OnionExitDescriptorBody, session_sk: &SessionSk) -> CoreResult<Self> {
558        sign_descriptor_body(
559            body,
560            session_sk,
561            "onion exit descriptor DID/public key/session mismatch",
562        )
563    }
564
565    fn body_ref(&self) -> OnionExitDescriptorBodyRef<'_> {
566        let Self {
567            schema_version,
568            did,
569            public_key,
570            session_public_key,
571            node_type,
572            network_id,
573            service,
574            policy,
575            started_at_ms,
576            heartbeat_at_ms,
577            expires_at_ms,
578            version,
579            signature: _,
580        } = self;
581
582        OnionExitDescriptorBodyRef {
583            schema_version: *schema_version,
584            did: *did,
585            public_key,
586            session_public_key,
587            node_type,
588            network_id: *network_id,
589            service,
590            policy,
591            started_at_ms: *started_at_ms,
592            heartbeat_at_ms: *heartbeat_at_ms,
593            expires_at_ms: *expires_at_ms,
594            version: version.as_str(),
595        }
596    }
597
598    fn signing_data(&self) -> CoreResult<Vec<u8>> {
599        self.body_ref().signing_data()
600    }
601
602    /// Return whether this descriptor uses the local registry wire schema.
603    pub const fn has_supported_schema(&self) -> bool {
604        self.schema_version == ONION_EXIT_DESCRIPTOR_SCHEMA_VERSION
605    }
606
607    /// Return whether this descriptor belongs to `network_id`.
608    pub const fn matches_network(&self, network_id: u32) -> bool {
609        self.network_id == network_id
610    }
611
612    /// Return whether this descriptor advertises the requested service name.
613    pub fn advertises_service_name(&self, service: &str) -> bool {
614        self.service.has_name(service)
615    }
616
617    /// Return whether this descriptor offers `service`.
618    pub fn offers_service(&self, service: &str) -> bool {
619        self.service.matches_route_service(service)
620    }
621
622    /// Return whether this descriptor offers `service` over `transport`.
623    pub fn offers_service_transport(&self, service: &str, transport: OnionExitTransport) -> bool {
624        self.service.matches(service, transport)
625            || (transport == OnionExitTransport::Tcp
626                && self.service.matches_legacy_reserved_transport(service))
627    }
628
629    /// Verify the descriptor signature and DID/public-key binding.
630    pub fn verify_signature(&self) -> bool {
631        self.has_supported_schema() && self.descriptor_verify_signature()
632    }
633
634    /// Returns whether this descriptor is expired at `now_ms`.
635    pub fn is_expired_at(&self, now_ms: u128) -> bool {
636        self.descriptor_is_expired_at(now_ms)
637    }
638
639    /// Returns whether this descriptor has a valid signature and is not expired.
640    pub fn is_live_at(&self, now_ms: u128) -> bool {
641        self.verify_signature() && !self.is_expired_at(now_ms)
642    }
643
644    /// Select the newest valid onion-exit descriptor per `(DID, service)`.
645    ///
646    /// Invariant: an exit may publish independent TCP and HTTPS registrations under the same DID.
647    /// Preservation: heartbeat ordering is compared only inside each `(DID, service name,
648    /// transport)` key.
649    pub fn latest_valid_by_service_did(
650        descriptors: impl IntoIterator<Item = Self>,
651        now_ms: u128,
652        include_expired: bool,
653    ) -> Vec<Self> {
654        let mut latest = BTreeMap::<(Did, OnionExitService), Self>::new();
655        for descriptor in descriptors {
656            if include_expired {
657                if !descriptor.verify_signature() {
658                    continue;
659                }
660            } else if !descriptor.is_live_at(now_ms) {
661                continue;
662            }
663            let key = (descriptor.did, descriptor.service.clone());
664            match latest.entry(key) {
665                Entry::Occupied(mut entry) => {
666                    if descriptor.heartbeat_at_ms > entry.get().heartbeat_at_ms {
667                        entry.insert(descriptor);
668                    }
669                }
670                Entry::Vacant(entry) => {
671                    entry.insert(descriptor);
672                }
673            }
674        }
675        latest.into_values().collect()
676    }
677}
678
679impl SignedDescriptor for OnionExitDescriptor {
680    fn descriptor_did(&self) -> Did {
681        self.did
682    }
683
684    fn descriptor_public_key(&self) -> &VerificationPublicKey {
685        &self.public_key
686    }
687
688    fn descriptor_signature(&self) -> &MessageVerification {
689        &self.signature
690    }
691
692    fn descriptor_heartbeat_at_ms(&self) -> u128 {
693        self.heartbeat_at_ms
694    }
695
696    fn descriptor_expires_at_ms(&self) -> u128 {
697        self.expires_at_ms
698    }
699
700    fn descriptor_signing_data(&self) -> CoreResult<Vec<u8>> {
701        self.signing_data()
702    }
703}
704
705impl Encoder for OnionExitDescriptor {
706    fn encode(&self) -> CoreResult<Encoded> {
707        encode_descriptor(self)
708    }
709}
710
711impl Decoder for OnionExitDescriptor {
712    fn from_encoded(encoded: &Encoded) -> CoreResult<Self> {
713        let descriptor: Self = decode_descriptor(encoded)?;
714        if descriptor.has_supported_schema() {
715            Ok(descriptor)
716        } else {
717            Err(CoreError::Decode)
718        }
719    }
720}
721
722/// Result of decoding one onion-exit registry entry.
723#[derive(Clone, Debug, Default, Eq, PartialEq)]
724pub struct OnionExitDescriptorDecodeReport {
725    /// Descriptors that matched the local wire schema and decoded successfully.
726    pub descriptors: Vec<OnionExitDescriptor>,
727    /// Number of registry values explicitly rejected at the decode boundary.
728    pub rejected_values: usize,
729}
730
731impl OnionExitDescriptorDecodeReport {
732    /// Return whether the entry contained values this node could not decode.
733    pub const fn has_rejections(&self) -> bool {
734        self.rejected_values > 0
735    }
736}
737
738/// Periodic node-layer registration for onion exit policy.
739#[derive(Clone, Debug)]
740pub struct OnionExitRegistration {
741    heartbeat_interval: Duration,
742    ttl: Duration,
743    node_type: OnlineNodeType,
744    started_at_ms: u128,
745    services: Vec<OnionExitService>,
746    policy: OnionExitPolicy,
747    publisher: DhtRegistrationPublisher,
748}
749
750impl OnionExitRegistration {
751    /// Create an onion-exit registration task.
752    pub fn new(
753        heartbeat_interval: Duration,
754        ttl: Duration,
755        node_type: OnlineNodeType,
756        services: Vec<OnionExitService>,
757        policy: OnionExitPolicy,
758    ) -> Self {
759        Self {
760            heartbeat_interval,
761            ttl,
762            node_type,
763            started_at_ms: get_epoch_ms(),
764            services,
765            policy,
766            publisher: DhtRegistrationPublisher::new(ONION_EXITS_TOPIC),
767        }
768    }
769
770    /// Validate this registration's periodic schedule when it is enabled.
771    pub fn validate_enabled_schedule(&self) -> Result<()> {
772        if self.heartbeat_interval >= self.ttl {
773            return Err(Error::InvalidConfig(format!(
774                "onion_exit_heartbeat_interval ({:?}) must be less than onion_exit_ttl ({:?})",
775                self.heartbeat_interval, self.ttl
776            )));
777        }
778        Ok(())
779    }
780
781    /// Build this node's signed onion-exit descriptors at `now_ms`.
782    pub fn descriptors_at(
783        &self,
784        context: &RegistrationContext<'_>,
785        now_ms: u128,
786    ) -> Result<Vec<OnionExitDescriptor>> {
787        self.services
788            .iter()
789            .cloned()
790            .map(|service| self.descriptor_for_service(context, now_ms, service))
791            .collect()
792    }
793
794    fn descriptor_for_service(
795        &self,
796        context: &RegistrationContext<'_>,
797        now_ms: u128,
798        service: OnionExitService,
799    ) -> Result<OnionExitDescriptor> {
800        OnionExitDescriptor::new_signed(
801            OnionExitDescriptorBody {
802                did: context.did(),
803                public_key: context.account_verification_pubkey()?,
804                session_public_key: context.session_sk().session_public_key(),
805                node_type: self.node_type.clone(),
806                network_id: context.network_id(),
807                service,
808                policy: self.policy.clone(),
809                started_at_ms: self.started_at_ms,
810                heartbeat_at_ms: now_ms,
811                expires_at_ms: now_ms + self.ttl.as_millis(),
812                version: crate::util::build_version(),
813            },
814            context.session_sk(),
815        )
816        .map_err(Error::CoreError)
817    }
818
819    /// Publish this node's signed onion-exit descriptors.
820    pub async fn publish_descriptors(
821        &self,
822        context: &RegistrationContext<'_>,
823    ) -> Result<Vec<OnionExitDescriptor>> {
824        let now_ms = get_epoch_ms();
825        let descriptors = self.descriptors_at(context, now_ms)?;
826        let encoded = descriptors
827            .iter()
828            .map(|descriptor| descriptor.encode().map_err(Error::CoreError))
829            .collect::<Result<Vec<_>>>()?;
830        self.publisher
831            .publish_many_replacing(context, encoded, |observed| {
832                observed
833                    .decode::<OnionExitDescriptor>()
834                    .is_ok_and(|descriptor| {
835                        descriptor.did == context.did()
836                            || (descriptor.verify_signature() && descriptor.is_expired_at(now_ms))
837                    })
838            })
839            .await?;
840        Ok(descriptors)
841    }
842
843    /// Decode onion-exit descriptors from a DHT entry.
844    pub fn decode_descriptors_from_entry(
845        entry: &rings_core::dht::entry::Entry,
846    ) -> OnionExitDescriptorDecodeReport {
847        let mut report = OnionExitDescriptorDecodeReport::default();
848        for value in &entry.data {
849            match value.decode::<OnionExitDescriptor>() {
850                Ok(descriptor) => report.descriptors.push(descriptor),
851                Err(error) => {
852                    report.rejected_values = report.rejected_values.saturating_add(1);
853                    tracing::debug!(
854                        "rejected onion-exit descriptor registry value at schema boundary: {error}"
855                    );
856                }
857            }
858        }
859        report
860    }
861
862    /// Decode onion-exit descriptors from a DHT entry, dropping values rejected at the schema boundary.
863    pub fn descriptors_from_entry(
864        entry: &rings_core::dht::entry::Entry,
865    ) -> Vec<OnionExitDescriptor> {
866        let report = Self::decode_descriptors_from_entry(entry);
867        if report.has_rejections() {
868            tracing::warn!(
869                rejected_values = report.rejected_values,
870                "ignored unsupported onion-exit descriptor registry values"
871            );
872        }
873        report.descriptors
874    }
875}
876
877#[cfg_attr(rings_browser, async_trait(?Send))]
878#[cfg_attr(rings_native, async_trait)]
879impl RegistrationTask for OnionExitRegistration {
880    fn name(&self) -> &'static str {
881        "onion-exit"
882    }
883
884    fn interval(&self) -> Duration {
885        self.heartbeat_interval
886    }
887
888    async fn register_once(&self, context: &RegistrationContext<'_>) -> Result<()> {
889        self.publish_descriptors(context).await.map(|_| ())
890    }
891}
892
893#[cfg(test)]
894mod tests;