Skip to main content

cuprate_wire/network_address/
onion_addr.rs

1//! Onion address
2//!
3//! This module define v3 Tor onion addresses
4//!
5
6use std::{
7    fmt::Display,
8    str::{self, FromStr},
9};
10
11use borsh::{BorshDeserialize, BorshSerialize};
12use thiserror::Error;
13
14use super::{NetworkAddress, NetworkAddressIncorrectZone};
15
16/// A v3, `Copy`able onion address.
17#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)]
18pub struct OnionAddr {
19    /// 56 characters encoded onion v3 domain without the .onion suffix
20    /// <https://spec.torproject.org/rend-spec/encoding-onion-addresses.html>
21    domain: [u8; 56],
22    /// Virtual port of the peer
23    pub port: u16,
24}
25
26/// Error enum at parsing onion addresses
27#[derive(Debug, Error)]
28pub enum OnionAddrParsingError {
29    #[error("Address is either too long or short, length: {0}")]
30    InvalidLength(usize),
31    #[error("Address contain non-utf8 code point at tld byte location: {0:x}")]
32    NonUtf8Char(u8),
33    #[error("This is not an onion address, Tld: {0}")]
34    InvalidTld(String),
35    #[error("Domain contains non base32 characters")]
36    NonBase32Char,
37    #[error("Invalid version. Found: {0}")]
38    InvalidVersion(u8),
39    #[error("The checksum is invalid.")]
40    InvalidChecksum,
41    #[error("Invalid port specified")]
42    InvalidPort,
43}
44
45impl OnionAddr {
46    /// Attempt to create an [`OnionAddr`] from a complete .onion address string and a port.
47    ///
48    /// Return an [`OnionAddrParsingError`] if the supplied `addr` is invalid.
49    pub fn new(addr: &str, port: u16) -> Result<Self, OnionAddrParsingError> {
50        Self::check_addr(addr).map(|d| Self { domain: d, port })
51    }
52
53    /// Establish if the .onion address is valid.
54    ///
55    /// Return the 56 character domain bytes if valid, `OnionAddrParsingError` otherwise.
56    pub fn check_addr(addr: &str) -> Result<[u8; 56], OnionAddrParsingError> {
57        // v3 onion addresses are 62 characters long
58        if addr.len() != 62 {
59            return Err(OnionAddrParsingError::InvalidLength(addr.len()));
60        }
61
62        let Some((domain, tld)) = addr.split_at_checked(56) else {
63            return Err(OnionAddrParsingError::NonUtf8Char(addr.as_bytes()[56]));
64        };
65
66        // The ".onion" suffix must be located at the 57th byte.
67        if tld != ".onion" {
68            return Err(OnionAddrParsingError::InvalidTld(String::from(tld)));
69        }
70
71        // The domain part must only contain base32 characters. monerod also
72        // accepts uppercase characters.
73        if !domain
74            .as_bytes()
75            .iter()
76            .copied()
77            .all(|c| c.is_ascii_alphabetic() || (b'2'..=b'7').contains(&c))
78        {
79            return Err(OnionAddrParsingError::NonBase32Char);
80        }
81
82        let mut domain: [u8; 56] = addr.as_bytes()[..56]
83            .try_into()
84            .unwrap_or_else(|e| panic!("We just validated address: {addr} : {e}"));
85        domain.make_ascii_lowercase();
86
87        Ok(domain)
88    }
89
90    /// Generate an onion address string.
91    ///
92    /// Returns a `String` containing the onion domain name and ".onion" TLD only, in form of `zbjkbs...ofptid.onion`.
93    pub fn addr_string(&self) -> String {
94        let mut domain = str::from_utf8(&self.domain)
95            .expect("Onion addresses are always containing UTF-8 characters.")
96            .to_string();
97
98        domain.push_str(".onion");
99        domain
100    }
101
102    #[inline]
103    pub const fn port(&self) -> u16 {
104        self.port
105    }
106
107    #[inline]
108    pub const fn domain(&self) -> [u8; 56] {
109        self.domain
110    }
111}
112
113/// Display for [`OnionAddr`]. It prints the onion address and port, in the form of `<domain>.onion:<port>`
114impl Display for OnionAddr {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        let domain = str::from_utf8(&self.domain)
117            .expect("Onion addresses are always containing UTF-8 characters.");
118
119        f.write_str(domain)?;
120        f.write_str(".onion:")?;
121        self.port.fmt(f)
122    }
123}
124
125/// [`OnionAddr`] parses an onion address **and a port**.
126impl FromStr for OnionAddr {
127    type Err = OnionAddrParsingError;
128
129    fn from_str(addr: &str) -> Result<Self, Self::Err> {
130        let (addr, port) = addr
131            .split_at_checked(62)
132            .ok_or(OnionAddrParsingError::InvalidLength(addr.len()))?;
133
134        // Port
135        let port: u16 = port
136            .starts_with(':')
137            .then(|| port[1..].parse().ok())
138            .flatten()
139            .ok_or(OnionAddrParsingError::InvalidPort)?;
140
141        // Address
142        let domain = Self::check_addr(addr)?;
143
144        Ok(Self { domain, port })
145    }
146}
147
148impl TryFrom<NetworkAddress> for OnionAddr {
149    type Error = NetworkAddressIncorrectZone;
150    fn try_from(value: NetworkAddress) -> Result<Self, Self::Error> {
151        match value {
152            NetworkAddress::Tor(addr) => Ok(addr),
153            NetworkAddress::Clear(_) => Err(NetworkAddressIncorrectZone),
154        }
155    }
156}
157
158impl From<OnionAddr> for NetworkAddress {
159    fn from(value: OnionAddr) -> Self {
160        Self::Tor(value)
161    }
162}
163
164#[cfg(feature = "serde")]
165impl serde::Serialize for OnionAddr {
166    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
167    where
168        S: serde::Serializer,
169    {
170        serializer.collect_str(self)
171    }
172}
173
174#[cfg(feature = "serde")]
175impl<'de> serde::Deserialize<'de> for OnionAddr {
176    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
177    where
178        D: serde::Deserializer<'de>,
179    {
180        let s = <String as serde::Deserialize>::deserialize(deserializer)?;
181        s.parse().map_err(serde::de::Error::custom)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use proptest::{collection::vec, prelude::*};
188
189    use super::OnionAddr;
190
191    const VALID_ONION_ADDRESSES: &[&str] = &[
192        "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion", // Tor Website
193        "pzhdfe7jraknpj2qgu5cz2u3i4deuyfwmonvzu5i3nyw4t4bmg7o5pad.onion", // Tor Blog
194        "monerotoruzizulg5ttgat2emf4d6fbmiea25detrmmy7erypseyteyd.onion", // Monero Website
195        "sfprivg7qec6tdle7u6hdepzjibin6fn3ivm6qlwytr235rh5vc6bfqd.onion", // SethForPrivacy
196        "yucmgsbw7nknw7oi3bkuwudvc657g2xcqahhbjyewazusyytapqo4xid.onion", // P2Pool
197        "p2pool2giz2r5cpqicajwoazjcxkfujxswtk3jolfk2ubilhrkqam2id.onion", // P2Pool Observer
198        "d6ac5qatnyodxisdehb3i4m7edfvtukxzhhtyadbgaxghcxee2xadpid.onion", // Rucknium ♥
199        "duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion", // DuckDuckGo
200        "featherdvtpi7ckdbkb2yxjfwx3oyvr3xjz3oo4rszylfzjdg6pbm3id.onion", // Feather wallet
201        "revuo75joezkbeitqmas4ab6spbrkr4vzbhjmeuv75ovrfqfp47mtjid.onion", // Revuo
202        "xoe4vn5uwdztif6goazfbmogh6wh5jc4up35bqdflu6bkdc5cas5vjqd.onion", // PrivacyGuides.org
203        "allyouhavetodecideiswhattodowiththetimethatisgiventoyouu.onion", // Gandalf the Grey
204        // Tor mainnet seed nodes as of 2025-05-15 with random ports
205        "zbjkbsxc5munw3qusl7j2hpcmikhqocdf4pqhnhtpzw5nt5jrmofptid.onion",
206        "qz43zul2x56jexzoqgkx2trzwcfnr6l3hbtfcfx54g4r3eahy3bssjyd.onion",
207        "plowsof3t5hogddwabaeiyrno25efmzfxyro2vligremt7sxpsclfaid.onion",
208        "plowsoffjexmxalw73tkjmf422gq6575fc7vicuu4javzn2ynnte6tyd.onion",
209        "plowsofe6cleftfmk2raiw5h2x66atrik3nja4bfd3zrfa2hdlgworad.onion",
210        "aclc4e2jhhtr44guufbnwk5bzwhaecinax4yip4wr4tjn27sjsfg6zqd.onion",
211    ];
212
213    #[test]
214    fn valid_onion_address() {
215        for addr in VALID_ONION_ADDRESSES {
216            assert!(
217                OnionAddr::check_addr(addr).is_ok(),
218                "Address {addr} has been reported as invalid."
219            );
220        }
221    }
222
223    proptest! {
224        #[test]
225        fn parse_random_valid_onion_address(addresses in vec("[[a-z][2-7]]{56}\\.onion", 250)) {
226            for addr in addresses {
227                assert!(
228                    OnionAddr::check_addr(&addr).is_ok(),
229                    "Address {addr} has been reported as invalid."
230                );
231            }
232        }
233
234        #[test]
235        fn parse_random_valid_onion_address_w_port(
236            mut addresses in vec("[[a-z][2-7]]{56}\\.onion", 250),
237            ports in vec(any::<u16>(), 18)
238        ) {
239            for (addr,port) in addresses.iter_mut().zip(ports) {
240                addr.push(':');
241                addr.push_str(&port.to_string());
242
243                assert!(
244                    addr.parse::<OnionAddr>().is_ok(),
245                    "Address {addr} has been reported as invalid."
246                );
247            }
248        }
249
250        #[test]
251        fn parse_random_invalid_onion_address(addresses in vec(".{0,62}", 250)) {
252            for addr in addresses {
253                assert!(
254                    addr.parse::<OnionAddr>().is_err(),
255                    "Input {addr} has been parsed correctly."
256                );
257            }
258        }
259
260        #[test]
261        fn parse_random_invalid_onion_address_w_port(addresses in vec(".{0,62}:.{1,5}", 250)) {
262            for addr in addresses {
263                assert!(
264                    addr.parse::<OnionAddr>().is_err(),
265                    "Input {addr} has been parsed correctly."
266                );
267            }
268        }
269    }
270}