Skip to main content

rings_node/onion/route/
mod.rs

1//! Onion route selection.
2
3use std::collections::BTreeMap;
4use std::collections::BTreeSet;
5
6use rings_core::dht::Did;
7use rings_core::ecc::PublicKey;
8use rings_core::measure::PeerQuality;
9use rings_core::message::DhtProtocolMode;
10
11use super::circuit::MAX_ONION_CIRCUIT_HOPS;
12use super::OnionExitDescriptor;
13use super::OnionRouteError;
14use super::OnionServiceName;
15use super::ONION_RELAY_CAPABILITY;
16use crate::error::Error;
17use crate::error::Result;
18use crate::online::OnlineNodeDescriptor;
19
20/// Default number of DID hops in a production onion route, including the exit.
21pub const DEFAULT_ONION_ROUTE_HOPS: usize = 3;
22
23/// Route-building request for an onion circuit.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct OnionRouteRequest {
26    /// Exit service required by the route.
27    pub service: OnionServiceName,
28    /// Desired hop count including the exit. `0` uses [`DEFAULT_ONION_ROUTE_HOPS`].
29    pub hop_count: usize,
30    /// Whether a route may be shorter than `hop_count` when the network is too small.
31    pub allow_short_paths: bool,
32}
33
34impl OnionRouteRequest {
35    /// Build a route request from an untrusted service string.
36    pub fn new(
37        service: impl AsRef<str>,
38        hop_count: usize,
39        allow_short_paths: bool,
40    ) -> Result<Self> {
41        Ok(Self::from_service_name(
42            parse_route_service(service)?,
43            hop_count,
44            allow_short_paths,
45        ))
46    }
47
48    /// Build a route request from an already canonical service name.
49    pub fn from_service_name(
50        service: OnionServiceName,
51        hop_count: usize,
52        allow_short_paths: bool,
53    ) -> Self {
54        Self {
55            service,
56            hop_count,
57            allow_short_paths,
58        }
59    }
60
61    /// Return the canonical service selected by this request.
62    pub fn service(&self) -> &str {
63        self.service.as_str()
64    }
65
66    pub(crate) fn service_name(&self) -> &OnionServiceName {
67        &self.service
68    }
69
70    fn target_hop_count(&self) -> usize {
71        if self.hop_count == 0 {
72            DEFAULT_ONION_ROUTE_HOPS
73        } else {
74            self.hop_count
75        }
76    }
77}
78
79/// One hop selected for encrypted onion routing.
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub struct OnionRouteHop {
82    /// Hop DID.
83    pub did: Did,
84    /// Hop session public key used for ElGamal-AEAD layers.
85    pub session_public_key: PublicKey<33>,
86}
87
88impl OnionRouteHop {
89    /// Build a route hop from its DID and session public key.
90    pub const fn new(did: Did, session_public_key: PublicKey<33>) -> Self {
91        Self {
92            did,
93            session_public_key,
94        }
95    }
96}
97
98/// Selected onion route.
99#[derive(Clone, Debug, Eq, PartialEq)]
100pub struct OnionRoute {
101    /// Exit service requested by the route.
102    service: OnionServiceName,
103    /// Ordered DIDs, ending with the exit DID.
104    hops: Vec<Did>,
105    /// Ordered encrypted route hops, ending with the exit hop.
106    encryption_hops: Vec<OnionRouteHop>,
107    /// Signed descriptor for the selected exit.
108    exit: OnionExitDescriptor,
109}
110
111impl OnionRoute {
112    /// Build a route after proving the hop and exit fields agree.
113    ///
114    /// Invariant: `hops == encryption_hops.map(|hop| hop.did)`, no DID repeats, and the last hop is
115    /// the selected exit descriptor.
116    ///
117    /// Invariant: `service` is canonical, so route/payload service equality is ordinary value
118    /// equality over [`OnionServiceName`], not caller-dependent string normalization.
119    pub(crate) fn new(
120        service: OnionServiceName,
121        encryption_hops: Vec<OnionRouteHop>,
122        exit: OnionExitDescriptor,
123    ) -> Result<Self> {
124        validate_route_hops(&service, &encryption_hops, &exit)?;
125        let hops = encryption_hops
126            .iter()
127            .map(|hop| hop.did)
128            .collect::<Vec<_>>();
129        Ok(Self {
130            service,
131            hops,
132            encryption_hops,
133            exit,
134        })
135    }
136
137    /// Return the service used to select this route.
138    pub fn service(&self) -> &str {
139        self.service.as_str()
140    }
141
142    /// Return the canonical service name used to select this route.
143    pub fn service_name(&self) -> &OnionServiceName {
144        &self.service
145    }
146
147    /// Return the ordered route DIDs, ending with the exit DID.
148    pub fn hops(&self) -> &[Did] {
149        self.hops.as_slice()
150    }
151
152    /// Return the ordered encrypted hops, ending with the exit hop.
153    pub(crate) fn encryption_hops(&self) -> &[OnionRouteHop] {
154        self.encryption_hops.as_slice()
155    }
156
157    /// Return the selected exit descriptor.
158    pub fn exit(&self) -> &OnionExitDescriptor {
159        &self.exit
160    }
161
162    /// Return the selected exit DID.
163    pub fn exit_did(&self) -> Did {
164        self.exit.did
165    }
166}
167
168pub(crate) trait RouteEntropy {
169    fn next_u64(&mut self) -> u64;
170}
171
172pub(crate) struct SystemRouteEntropy;
173
174impl SystemRouteEntropy {
175    pub(crate) const fn new() -> Self {
176        Self
177    }
178}
179
180impl RouteEntropy for SystemRouteEntropy {
181    fn next_u64(&mut self) -> u64 {
182        rand::random()
183    }
184}
185
186#[derive(Clone, Debug)]
187pub(crate) struct OnionRouteCandidates {
188    pub(in crate::onion) relays: Vec<OnionRouteHop>,
189    pub(in crate::onion) exits: Vec<OnionExitDescriptor>,
190}
191
192impl OnionRouteCandidates {
193    pub(crate) fn from_validated_descriptors(
194        local: Did,
195        dht_protocol: DhtProtocolMode,
196        now_ms: u128,
197        service: &OnionServiceName,
198        online_nodes: impl IntoIterator<Item = OnlineNodeDescriptor>,
199        exits: impl IntoIterator<Item = OnionExitDescriptor>,
200    ) -> Self {
201        let relays = eligible_relay_dids(dht_protocol, now_ms, local, online_nodes);
202        let exits = eligible_exits(dht_protocol.network_id, now_ms, service, exits)
203            .into_iter()
204            .filter(|descriptor| descriptor.did != local)
205            .collect();
206
207        Self { relays, exits }
208    }
209}
210
211/// Select an onion route from live presence and exit descriptors.
212///
213/// Invariant: the returned hop list contains no duplicate DID and always ends
214/// in a descriptor from the exit registry.
215pub fn select_onion_route(
216    local: Did,
217    dht_protocol: DhtProtocolMode,
218    now_ms: u128,
219    request: &OnionRouteRequest,
220    online_nodes: impl IntoIterator<Item = OnlineNodeDescriptor>,
221    exits: impl IntoIterator<Item = OnionExitDescriptor>,
222    qualities: impl IntoIterator<Item = (Did, PeerQuality)>,
223) -> Result<OnionRoute> {
224    let candidates = OnionRouteCandidates {
225        relays: eligible_relay_dids(dht_protocol, now_ms, local, online_nodes)
226            .into_iter()
227            .collect(),
228        exits: eligible_exits(
229            dht_protocol.network_id,
230            now_ms,
231            request.service_name(),
232            exits,
233        )
234        .into_iter()
235        .filter(|descriptor| descriptor.did != local)
236        .collect(),
237    };
238    select_onion_route_from_candidates(
239        request,
240        candidates,
241        qualities,
242        &mut SystemRouteEntropy::new(),
243    )
244}
245
246pub(crate) fn select_onion_route_from_candidates(
247    request: &OnionRouteRequest,
248    candidates: OnionRouteCandidates,
249    qualities: impl IntoIterator<Item = (Did, PeerQuality)>,
250    entropy: &mut impl RouteEntropy,
251) -> Result<OnionRoute> {
252    select_onion_route_from_candidates_with_first_hop(
253        request,
254        candidates,
255        qualities,
256        entropy,
257        |_| true,
258    )
259}
260
261pub(crate) fn select_onion_route_from_candidates_with_first_hop(
262    request: &OnionRouteRequest,
263    candidates: OnionRouteCandidates,
264    qualities: impl IntoIterator<Item = (Did, PeerQuality)>,
265    entropy: &mut impl RouteEntropy,
266    first_hop_permitted: impl Fn(Did) -> bool,
267) -> Result<OnionRoute> {
268    let target_hop_count = request.target_hop_count();
269    if target_hop_count == 0 || target_hop_count > usize::from(MAX_ONION_CIRCUIT_HOPS) {
270        return Err(Error::OnionRouteError(
271            OnionRouteError::HopCountOutOfBounds {
272                hop_count: target_hop_count,
273                max_hops: MAX_ONION_CIRCUIT_HOPS,
274            },
275        ));
276    }
277
278    let quality_by_did = qualities.into_iter().collect::<BTreeMap<_, _>>();
279    let mut exit_candidates = candidates.exits;
280    let first_hop_permitted = &first_hop_permitted;
281    let first_hop_exit_only = target_hop_count == 1;
282    if exit_candidates.is_empty() {
283        return Err(Error::OnionRouteError(OnionRouteError::NoLiveExit {
284            service: request.service().to_string(),
285        }));
286    }
287    if first_hop_exit_only {
288        return select_direct_exit_route(
289            request,
290            exit_candidates,
291            &quality_by_did,
292            entropy,
293            first_hop_permitted,
294        );
295    }
296
297    let mut relay_candidates = candidates.relays.into_iter().collect::<Vec<_>>();
298    let relay_hops_needed = target_hop_count.saturating_sub(1);
299    let mut selected_relays = Vec::with_capacity(relay_hops_needed);
300    if relay_hops_needed > 0 {
301        let has_relay_candidates = !relay_candidates.is_empty();
302        let Some(first_index) =
303            pick_weighted_hop_index_where(&relay_candidates, &quality_by_did, entropy, |did| {
304                first_hop_permitted(did)
305                    && route_can_still_select_exit(&selected_relays, did, &exit_candidates)
306            })
307        else {
308            if request.allow_short_paths {
309                return select_direct_exit_route(
310                    request,
311                    exit_candidates,
312                    &quality_by_did,
313                    entropy,
314                    first_hop_permitted,
315                );
316            }
317            let error = if has_relay_candidates {
318                OnionRouteError::NoPermittedFirstHop
319            } else {
320                OnionRouteError::NotEnoughRelays {
321                    hop_count: target_hop_count,
322                }
323            };
324            return Err(Error::OnionRouteError(error));
325        };
326        selected_relays.push(relay_candidates.remove(first_index));
327        while selected_relays.len() < relay_hops_needed {
328            let Some(next_index) =
329                pick_weighted_hop_index_where(&relay_candidates, &quality_by_did, entropy, |did| {
330                    route_can_still_select_exit(&selected_relays, did, &exit_candidates)
331                })
332            else {
333                break;
334            };
335            selected_relays.push(relay_candidates.remove(next_index));
336        }
337    }
338
339    if selected_relays.len() < relay_hops_needed && !request.allow_short_paths {
340        return Err(Error::OnionRouteError(OnionRouteError::NotEnoughRelays {
341            hop_count: target_hop_count,
342        }));
343    }
344
345    let exit_index =
346        pick_weighted_exit_index_where(&exit_candidates, &quality_by_did, entropy, |did| {
347            !route_already_contains_did(&selected_relays, did)
348        })
349        .ok_or_else(|| {
350            Error::OnionRouteError(OnionRouteError::NoLiveExit {
351                service: request.service().to_string(),
352            })
353        })?;
354    let exit = exit_candidates.remove(exit_index);
355    let exit_did = exit.did;
356    let mut encryption_hops = selected_relays;
357    encryption_hops.push(OnionRouteHop::new(exit_did, exit.session_public_key));
358    OnionRoute::new(request.service.clone(), encryption_hops, exit)
359}
360
361fn select_direct_exit_route(
362    request: &OnionRouteRequest,
363    mut exits: Vec<OnionExitDescriptor>,
364    quality_by_did: &BTreeMap<Did, PeerQuality>,
365    entropy: &mut impl RouteEntropy,
366    first_hop_permitted: &impl Fn(Did) -> bool,
367) -> Result<OnionRoute> {
368    let exit_index =
369        pick_weighted_exit_index_where(&exits, quality_by_did, entropy, first_hop_permitted)
370            .ok_or(Error::OnionRouteError(OnionRouteError::NoPermittedFirstHop))?;
371    let exit = exits.remove(exit_index);
372    let encryption_hops = vec![OnionRouteHop::new(exit.did, exit.session_public_key)];
373    OnionRoute::new(request.service.clone(), encryption_hops, exit)
374}
375
376fn route_can_still_select_exit(
377    selected_relays: &[OnionRouteHop],
378    candidate_relay: Did,
379    exits: &[OnionExitDescriptor],
380) -> bool {
381    exits.iter().any(|exit| {
382        exit.did != candidate_relay && !route_already_contains_did(selected_relays, exit.did)
383    })
384}
385
386fn route_already_contains_did(selected_relays: &[OnionRouteHop], did: Did) -> bool {
387    selected_relays.iter().any(|hop| hop.did == did)
388}
389
390fn pick_weighted_hop_index_where(
391    hops: &[OnionRouteHop],
392    quality_by_did: &BTreeMap<Did, PeerQuality>,
393    entropy: &mut impl RouteEntropy,
394    permitted: impl Fn(Did) -> bool,
395) -> Option<usize> {
396    let eligible = hops
397        .iter()
398        .enumerate()
399        .filter_map(|(index, hop)| permitted(hop.did).then_some((index, hop.did)))
400        .collect::<Vec<_>>();
401    pick_weighted_candidate_index(eligible, quality_by_did, entropy)
402}
403
404fn pick_weighted_exit_index_where(
405    exits: &[OnionExitDescriptor],
406    quality_by_did: &BTreeMap<Did, PeerQuality>,
407    entropy: &mut impl RouteEntropy,
408    permitted: impl Fn(Did) -> bool,
409) -> Option<usize> {
410    let eligible = exits
411        .iter()
412        .enumerate()
413        .filter_map(|(index, descriptor)| {
414            permitted(descriptor.did).then_some((index, descriptor.did))
415        })
416        .collect::<Vec<_>>();
417    pick_weighted_candidate_index(eligible, quality_by_did, entropy)
418}
419
420fn pick_weighted_candidate_index(
421    eligible: Vec<(usize, Did)>,
422    quality_by_did: &BTreeMap<Did, PeerQuality>,
423    entropy: &mut impl RouteEntropy,
424) -> Option<usize> {
425    let dids = eligible.iter().map(|(_, did)| *did).collect::<Vec<_>>();
426    let selected = pick_weighted_index(&dids, quality_by_did, entropy)?;
427    eligible.into_iter().nth(selected).map(|(index, _)| index)
428}
429
430fn pick_weighted_index(
431    dids: &[Did],
432    quality_by_did: &BTreeMap<Did, PeerQuality>,
433    entropy: &mut impl RouteEntropy,
434) -> Option<usize> {
435    let total_weight = dids
436        .iter()
437        .map(|did| quality_weight(quality_by_did.get(did).copied()))
438        .sum::<u64>();
439    if total_weight == 0 {
440        return None;
441    }
442
443    let mut roll = entropy.next_u64() % total_weight;
444    for (index, did) in dids.iter().enumerate() {
445        let weight = quality_weight(quality_by_did.get(did).copied());
446        if roll < weight {
447            return Some(index);
448        }
449        roll -= weight;
450    }
451    None
452}
453
454fn quality_weight(quality: Option<PeerQuality>) -> u64 {
455    match quality {
456        Some(PeerQuality::Healthy) => 8,
457        Some(PeerQuality::Unknown) | None => 4,
458        Some(PeerQuality::Degraded) => 1,
459    }
460}
461
462fn eligible_exits(
463    network_id: u32,
464    now_ms: u128,
465    service: &OnionServiceName,
466    exits: impl IntoIterator<Item = OnionExitDescriptor>,
467) -> Vec<OnionExitDescriptor> {
468    OnionExitDescriptor::latest_valid_by_service_did(exits, now_ms, false)
469        .into_iter()
470        .filter(|descriptor| descriptor.matches_network(network_id))
471        .filter(|descriptor| descriptor.offers_service(service.as_str()))
472        .collect()
473}
474
475fn eligible_relay_dids(
476    dht_protocol: DhtProtocolMode,
477    now_ms: u128,
478    local: Did,
479    online_nodes: impl IntoIterator<Item = OnlineNodeDescriptor>,
480) -> Vec<OnionRouteHop> {
481    OnlineNodeDescriptor::latest_valid_by_did(online_nodes, now_ms, false)
482        .into_iter()
483        .filter(|descriptor| descriptor.matches_dht_protocol(dht_protocol))
484        .filter(has_onion_relay_capability)
485        .map(|descriptor| OnionRouteHop::new(descriptor.did, descriptor.session_public_key))
486        .filter(|hop| hop.did != local)
487        .map(|hop| (hop.did, hop))
488        .collect::<BTreeMap<_, _>>()
489        .into_values()
490        .collect()
491}
492
493fn has_onion_relay_capability(descriptor: &OnlineNodeDescriptor) -> bool {
494    descriptor
495        .capabilities
496        .iter()
497        .any(|capability| capability == ONION_RELAY_CAPABILITY)
498}
499
500fn has_duplicate_dids(hops: &[Did]) -> bool {
501    let mut seen = BTreeSet::new();
502    hops.iter().any(|did| !seen.insert(*did))
503}
504
505fn validate_route_hops(
506    service: &OnionServiceName,
507    encryption_hops: &[OnionRouteHop],
508    exit: &OnionExitDescriptor,
509) -> Result<()> {
510    if encryption_hops.is_empty() || encryption_hops.len() > usize::from(MAX_ONION_CIRCUIT_HOPS) {
511        return Err(Error::OnionRouteError(
512            OnionRouteError::HopCountOutOfBounds {
513                hop_count: encryption_hops.len(),
514                max_hops: MAX_ONION_CIRCUIT_HOPS,
515            },
516        ));
517    }
518    let Some(last) = encryption_hops.last() else {
519        return Err(Error::OnionRouteError(OnionRouteError::RouteHasNoHops));
520    };
521    if last.did != exit.did || last.session_public_key != exit.session_public_key {
522        return Err(Error::OnionRouteError(OnionRouteError::ExitHopMismatch));
523    }
524    let hops = encryption_hops
525        .iter()
526        .map(|hop| hop.did)
527        .collect::<Vec<_>>();
528    if has_duplicate_dids(&hops) {
529        return Err(Error::OnionRouteError(OnionRouteError::DuplicateRouteHops));
530    }
531    if !exit.offers_service(service.as_str()) {
532        return Err(Error::OnionRouteError(OnionRouteError::ExitServiceMismatch));
533    }
534    Ok(())
535}
536
537fn parse_route_service(service: impl AsRef<str>) -> Result<OnionServiceName> {
538    let service = service.as_ref();
539    if service.trim().is_empty() {
540        return Err(Error::OnionRouteError(OnionRouteError::EmptyRouteService));
541    }
542    OnionServiceName::parse(service)
543}
544
545#[cfg(test)]
546mod tests;