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.
72        if !domain
73            .as_bytes()
74            .iter()
75            .copied()
76            .all(|c| c.is_ascii_lowercase() || (b'2'..=b'7').contains(&c))
77        {
78            return Err(OnionAddrParsingError::NonBase32Char);
79        }
80
81        Ok(addr.as_bytes()[..56]
82            .try_into()
83            .unwrap_or_else(|e| panic!("We just validated address: {addr} : {e}")))
84    }
85
86    /// Generate an onion address string.
87    ///
88    /// Returns a `String` containing the onion domain name and ".onion" TLD only, in form of `zbjkbs...ofptid.onion`.
89    pub fn addr_string(&self) -> String {
90        let mut domain = str::from_utf8(&self.domain)
91            .expect("Onion addresses are always containing UTF-8 characters.")
92            .to_string();
93
94        domain.push_str(".onion");
95        domain
96    }
97
98    #[inline]
99    pub const fn port(&self) -> u16 {
100        self.port
101    }
102
103    #[inline]
104    pub const fn domain(&self) -> [u8; 56] {
105        self.domain
106    }
107}
108
109/// Display for [`OnionAddr`]. It prints the onion address and port, in the form of `<domain>.onion:<port>`
110impl Display for OnionAddr {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        let domain = str::from_utf8(&self.domain)
113            .expect("Onion addresses are always containing UTF-8 characters.");
114
115        f.write_str(domain)?;
116        f.write_str(".onion:")?;
117        self.port.fmt(f)
118    }
119}
120
121/// [`OnionAddr`] parses an onion address **and a port**.
122impl FromStr for OnionAddr {
123    type Err = OnionAddrParsingError;
124
125    fn from_str(addr: &str) -> Result<Self, Self::Err> {
126        let (addr, port) = addr
127            .split_at_checked(62)
128            .ok_or(OnionAddrParsingError::InvalidLength(addr.len()))?;
129
130        // Port
131        let port: u16 = port
132            .starts_with(':')
133            .then(|| port[1..].parse().ok())
134            .flatten()
135            .ok_or(OnionAddrParsingError::InvalidPort)?;
136
137        // Address
138        let domain = Self::check_addr(addr)?;
139
140        Ok(Self { domain, port })
141    }
142}
143
144impl TryFrom<NetworkAddress> for OnionAddr {
145    type Error = NetworkAddressIncorrectZone;
146    fn try_from(value: NetworkAddress) -> Result<Self, Self::Error> {
147        match value {
148            NetworkAddress::Tor(addr) => Ok(addr),
149            NetworkAddress::Clear(_) => Err(NetworkAddressIncorrectZone),
150        }
151    }
152}
153
154impl From<OnionAddr> for NetworkAddress {
155    fn from(value: OnionAddr) -> Self {
156        Self::Tor(value)
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use proptest::{collection::vec, prelude::*};
163
164    use super::OnionAddr;
165
166    const VALID_ONION_ADDRESSES: &[&str] = &[
167        "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid.onion", // Tor Website
168        "pzhdfe7jraknpj2qgu5cz2u3i4deuyfwmonvzu5i3nyw4t4bmg7o5pad.onion", // Tor Blog
169        "monerotoruzizulg5ttgat2emf4d6fbmiea25detrmmy7erypseyteyd.onion", // Monero Website
170        "sfprivg7qec6tdle7u6hdepzjibin6fn3ivm6qlwytr235rh5vc6bfqd.onion", // SethForPrivacy
171        "yucmgsbw7nknw7oi3bkuwudvc657g2xcqahhbjyewazusyytapqo4xid.onion", // P2Pool
172        "p2pool2giz2r5cpqicajwoazjcxkfujxswtk3jolfk2ubilhrkqam2id.onion", // P2Pool Observer
173        "d6ac5qatnyodxisdehb3i4m7edfvtukxzhhtyadbgaxghcxee2xadpid.onion", // Rucknium ♥
174        "duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion", // DuckDuckGo
175        "featherdvtpi7ckdbkb2yxjfwx3oyvr3xjz3oo4rszylfzjdg6pbm3id.onion", // Feather wallet
176        "revuo75joezkbeitqmas4ab6spbrkr4vzbhjmeuv75ovrfqfp47mtjid.onion", // Revuo
177        "xoe4vn5uwdztif6goazfbmogh6wh5jc4up35bqdflu6bkdc5cas5vjqd.onion", // PrivacyGuides.org
178        "allyouhavetodecideiswhattodowiththetimethatisgiventoyouu.onion", // Gandalf the Grey
179        // Tor mainnet seed nodes as of 2025-05-15 with random ports
180        "zbjkbsxc5munw3qusl7j2hpcmikhqocdf4pqhnhtpzw5nt5jrmofptid.onion",
181        "qz43zul2x56jexzoqgkx2trzwcfnr6l3hbtfcfx54g4r3eahy3bssjyd.onion",
182        "plowsof3t5hogddwabaeiyrno25efmzfxyro2vligremt7sxpsclfaid.onion",
183        "plowsoffjexmxalw73tkjmf422gq6575fc7vicuu4javzn2ynnte6tyd.onion",
184        "plowsofe6cleftfmk2raiw5h2x66atrik3nja4bfd3zrfa2hdlgworad.onion",
185        "aclc4e2jhhtr44guufbnwk5bzwhaecinax4yip4wr4tjn27sjsfg6zqd.onion",
186    ];
187
188    #[test]
189    fn valid_onion_address() {
190        for addr in VALID_ONION_ADDRESSES {
191            assert!(
192                OnionAddr::check_addr(addr).is_ok(),
193                "Address {addr} has been reported as invalid."
194            );
195        }
196    }
197
198    proptest! {
199        #[test]
200        fn parse_valid_onion_address_w_port(ports in vec(any::<u16>(), 18)) {
201            for (addr,port) in VALID_ONION_ADDRESSES.iter().zip(ports) {
202
203                let mut s = (*addr).to_string();
204                s.push(':');
205                s.push_str(&port.to_string());
206
207                assert!(
208                    s.parse::<OnionAddr>().is_ok(),
209                    "Address {addr} has been reported as invalid."
210                );
211            }
212        }
213
214        #[test]
215        fn invalid_onion_address(addresses in vec("[a-z][2-7]{56}.onion", 250)) {
216            for addr in addresses {
217                assert!(
218                    OnionAddr::check_addr(&addr).is_err(),
219                    "Address {addr} has been reported as valid."
220                );
221            }
222        }
223
224        #[test]
225        fn parse_invalid_onion_address_w_port(addresses in vec("[a-z][2-7]{56}.onion:[0-9]{1,5}", 250)) {
226            for addr in addresses {
227                assert!(
228                    addr.parse::<OnionAddr>().is_err(),
229                    "Address {addr} has been reported as valid."
230                );
231            }
232        }
233    }
234}