Skip to main content

irontide_session/i2p/
destination.rs

1//! I2P destination address.
2//!
3//! An I2P destination is a cryptographic identifier (~516 bytes) that serves
4//! as the I2P equivalent of an IP address. It contains a 256-byte public key,
5//! a 128-byte signing key, and a certificate. Conventionally encoded as Base64
6//! with I2P's custom alphabet (uses `-` and `~` instead of `+` and `/`).
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12/// An I2P destination address (~516 bytes, Base64-encoded for display/storage).
13///
14/// This is the I2P equivalent of a `SocketAddr`. Peers are identified by their
15/// destination rather than by IP:port.
16#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct I2pDestination {
18    /// Raw binary destination (typically ~516 bytes).
19    #[serde(with = "serde_bytes")]
20    bytes: Vec<u8>,
21}
22
23/// I2P uses a modified Base64 alphabet: standard except `+` -> `-`, `/` -> `~`.
24const I2P_BASE64_CHARS: &[u8; 64] =
25    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~";
26
27impl I2pDestination {
28    /// Create from raw bytes.
29    #[must_use]
30    pub fn from_bytes(bytes: Vec<u8>) -> Self {
31        Self { bytes }
32    }
33
34    /// The raw binary representation.
35    #[must_use]
36    pub fn as_bytes(&self) -> &[u8] {
37        &self.bytes
38    }
39
40    /// Encode to I2P-style Base64 string.
41    #[must_use]
42    pub fn to_base64(&self) -> String {
43        i2p_base64_encode(&self.bytes)
44    }
45
46    /// Decode from I2P-style Base64 string.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if the input is malformed.
51    pub fn from_base64(s: &str) -> Result<Self, I2pDestinationError> {
52        let bytes = i2p_base64_decode(s)?;
53        if bytes.is_empty() {
54            return Err(I2pDestinationError::Empty);
55        }
56        Ok(Self { bytes })
57    }
58
59    /// Compute the 52-character Base32 hash used in .b32.i2p addresses.
60    ///
61    /// This is SHA-256 of the destination bytes, encoded as Base32 (lowercase,
62    /// no padding). The result is 52 characters.
63    #[must_use]
64    pub fn to_b32_address(&self) -> String {
65        let hash = irontide_core::sha256(&self.bytes);
66        let mut out = String::with_capacity(52);
67        base32_encode_lower(hash.as_bytes(), &mut out);
68        format!("{out}.b32.i2p")
69    }
70
71    /// Length of the raw binary destination.
72    #[must_use]
73    pub fn len(&self) -> usize {
74        self.bytes.len()
75    }
76
77    /// Whether the destination is empty (invalid).
78    #[must_use]
79    pub fn is_empty(&self) -> bool {
80        self.bytes.is_empty()
81    }
82}
83
84impl fmt::Debug for I2pDestination {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        let b64 = self.to_base64();
87        if b64.len() > 16 {
88            write!(
89                f,
90                "I2pDestination({}...{} bytes)",
91                &b64[..16],
92                self.bytes.len()
93            )
94        } else {
95            write!(f, "I2pDestination({b64})")
96        }
97    }
98}
99
100impl fmt::Display for I2pDestination {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "{}", self.to_base64())
103    }
104}
105
106/// Error type for I2P destination parsing.
107#[derive(Debug, Clone, thiserror::Error)]
108pub enum I2pDestinationError {
109    /// The input string contains an invalid Base64 character.
110    #[error("invalid Base64 character at position {0}")]
111    InvalidBase64(
112        /// Byte offset of the invalid character.
113        usize,
114    ),
115    /// The destination was empty after decoding.
116    #[error("empty destination")]
117    Empty,
118}
119
120// ── I2P Base64 encode/decode ─────────────────────────────────────────
121
122/// Encode bytes to I2P Base64 (uses `-` and `~` instead of `+` and `/`).
123pub(crate) fn i2p_base64_encode(data: &[u8]) -> String {
124    let mut result = String::with_capacity(data.len().div_ceil(3) * 4);
125
126    for chunk in data.chunks(3) {
127        let b0 = u32::from(chunk[0]);
128        let b1 = if chunk.len() > 1 {
129            u32::from(chunk[1])
130        } else {
131            0
132        };
133        let b2 = if chunk.len() > 2 {
134            u32::from(chunk[2])
135        } else {
136            0
137        };
138        let triple = (b0 << 16) | (b1 << 8) | b2;
139
140        result.push(I2P_BASE64_CHARS[((triple >> 18) & 0x3F) as usize] as char);
141        result.push(I2P_BASE64_CHARS[((triple >> 12) & 0x3F) as usize] as char);
142
143        if chunk.len() > 1 {
144            result.push(I2P_BASE64_CHARS[((triple >> 6) & 0x3F) as usize] as char);
145        } else {
146            result.push('=');
147        }
148
149        if chunk.len() > 2 {
150            result.push(I2P_BASE64_CHARS[(triple & 0x3F) as usize] as char);
151        } else {
152            result.push('=');
153        }
154    }
155
156    result
157}
158
159/// Decode I2P Base64 string to bytes.
160#[allow(
161    clippy::many_single_char_names,
162    reason = "base64 decoding variables a/b/c/d follow the standard naming convention"
163)]
164pub(crate) fn i2p_base64_decode(s: &str) -> Result<Vec<u8>, I2pDestinationError> {
165    fn char_to_val(c: u8, pos: usize) -> Result<u32, I2pDestinationError> {
166        match c {
167            b'A'..=b'Z' => Ok(u32::from(c - b'A')),
168            b'a'..=b'z' => Ok(u32::from(c - b'a' + 26)),
169            b'0'..=b'9' => Ok(u32::from(c - b'0' + 52)),
170            b'-' => Ok(62),
171            b'~' => Ok(63),
172            b'=' => Ok(0), // padding
173            _ => Err(I2pDestinationError::InvalidBase64(pos)),
174        }
175    }
176
177    let bytes = s.as_bytes();
178    let mut result = Vec::with_capacity(bytes.len() * 3 / 4);
179
180    for (chunk_idx, chunk) in bytes.chunks(4).enumerate() {
181        if chunk.len() < 4 {
182            // Incomplete final group -- reject
183            if !chunk.is_empty() {
184                return Err(I2pDestinationError::InvalidBase64(chunk_idx * 4));
185            }
186            break;
187        }
188
189        let base = chunk_idx * 4;
190        let a = char_to_val(chunk[0], base)?;
191        let b = char_to_val(chunk[1], base + 1)?;
192        let c = char_to_val(chunk[2], base + 2)?;
193        let d = char_to_val(chunk[3], base + 3)?;
194
195        let triple = (a << 18) | (b << 12) | (c << 6) | d;
196
197        result.push(((triple >> 16) & 0xFF) as u8);
198        if chunk[2] != b'=' {
199            result.push(((triple >> 8) & 0xFF) as u8);
200        }
201        if chunk[3] != b'=' {
202            result.push((triple & 0xFF) as u8);
203        }
204    }
205
206    Ok(result)
207}
208
209/// Encode bytes as lowercase Base32 (RFC 4648, no padding).
210fn base32_encode_lower(data: &[u8], out: &mut String) {
211    const ALPHABET: &[u8; 32] = b"abcdefghijklmnopqrstuvwxyz234567";
212    let mut bits: u64 = 0;
213    let mut num_bits: u32 = 0;
214
215    for &byte in data {
216        bits = (bits << 8) | u64::from(byte);
217        num_bits += 8;
218        while num_bits >= 5 {
219            num_bits -= 5;
220            out.push(ALPHABET[((bits >> num_bits) & 0x1F) as usize] as char);
221        }
222    }
223
224    if num_bits > 0 {
225        out.push(ALPHABET[((bits << (5 - num_bits)) & 0x1F) as usize] as char);
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn i2p_base64_roundtrip() {
235        let data = vec![0u8; 516]; // typical destination size
236        let encoded = i2p_base64_encode(&data);
237        let decoded = i2p_base64_decode(&encoded).unwrap();
238        assert_eq!(decoded, data);
239    }
240
241    #[test]
242    fn i2p_base64_alphabet_differs_from_standard() {
243        // I2P uses `-` (62) and `~` (63) instead of `+` and `/`
244        let data = vec![0xFF, 0xFE, 0xFD]; // produces high-value sextets
245        let encoded = i2p_base64_encode(&data);
246        assert!(!encoded.contains('+'));
247        assert!(!encoded.contains('/'));
248    }
249
250    #[test]
251    fn i2p_base64_decode_invalid_char() {
252        let err = i2p_base64_decode("AAAA+AAA").unwrap_err();
253        assert!(matches!(err, I2pDestinationError::InvalidBase64(_)));
254    }
255
256    #[test]
257    fn i2p_base64_known_vector() {
258        // "hello" -> aGVsbG8= in standard Base64
259        // In I2P Base64, same encoding since no +/~ characters needed
260        let data = b"hello";
261        let encoded = i2p_base64_encode(data);
262        assert_eq!(encoded, "aGVsbG8=");
263        let decoded = i2p_base64_decode(&encoded).unwrap();
264        assert_eq!(decoded, data);
265    }
266
267    #[test]
268    fn destination_from_base64_roundtrip() {
269        let raw = vec![42u8; 516];
270        let dest = I2pDestination::from_bytes(raw.clone());
271        let b64 = dest.to_base64();
272        let parsed = I2pDestination::from_base64(&b64).unwrap();
273        assert_eq!(parsed.as_bytes(), raw.as_slice());
274        assert_eq!(parsed, dest);
275    }
276
277    #[test]
278    fn destination_from_base64_empty_rejected() {
279        let err = I2pDestination::from_base64("").unwrap_err();
280        assert!(matches!(err, I2pDestinationError::Empty));
281    }
282
283    #[test]
284    fn destination_debug_truncated() {
285        let dest = I2pDestination::from_bytes(vec![0; 516]);
286        let dbg = format!("{dest:?}");
287        assert!(dbg.contains("I2pDestination("));
288        assert!(dbg.contains("..."));
289        assert!(dbg.contains("516 bytes"));
290    }
291
292    #[test]
293    fn destination_display_is_base64() {
294        let dest = I2pDestination::from_bytes(vec![1, 2, 3]);
295        let display = format!("{dest}");
296        let base64 = dest.to_base64();
297        assert_eq!(display, base64);
298    }
299
300    #[test]
301    fn destination_b32_address() {
302        let dest = I2pDestination::from_bytes(vec![0u8; 516]);
303        let b32 = dest.to_b32_address();
304        assert!(b32.ends_with(".b32.i2p"));
305        // SHA-256 -> 32 bytes -> 52 Base32 chars
306        let host = b32.strip_suffix(".b32.i2p").unwrap();
307        assert_eq!(host.len(), 52);
308    }
309
310    #[test]
311    fn destination_hash_and_eq() {
312        use std::collections::HashSet;
313        let a = I2pDestination::from_bytes(vec![1, 2, 3]);
314        let b = I2pDestination::from_bytes(vec![1, 2, 3]);
315        let c = I2pDestination::from_bytes(vec![4, 5, 6]);
316        assert_eq!(a, b);
317        assert_ne!(a, c);
318
319        let mut set = HashSet::new();
320        set.insert(a);
321        set.insert(b); // duplicate
322        set.insert(c);
323        assert_eq!(set.len(), 2);
324    }
325
326    #[test]
327    fn destination_serde_roundtrip() {
328        let dest = I2pDestination::from_bytes(vec![7u8; 100]);
329        let json = serde_json::to_string(&dest).unwrap();
330        let parsed: I2pDestination = serde_json::from_str(&json).unwrap();
331        assert_eq!(parsed, dest);
332    }
333
334    #[test]
335    fn base32_encode_known_vector() {
336        // SHA-256 of 32 zero bytes produces a known hash
337        let hash = irontide_core::sha256(&[0u8; 32]);
338        let mut out = String::new();
339        base32_encode_lower(hash.as_bytes(), &mut out);
340        assert_eq!(out.len(), 52); // 32 bytes -> 52 Base32 chars
341        // All chars must be lowercase alphanumeric or 2-7
342        assert!(
343            out.chars()
344                .all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c))
345        );
346    }
347}