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