Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
I2P Support
Merged lorenz opened 2 months ago
24 files changed +231 -92 91b2fd89 ac3eba09
modified CHANGELOG.md
@@ -24,6 +24,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
  value are asked to keep the substring `/radicle:{YOUR_VERSION}/` which allows
  for better telemetry regarding version distribution on the network.
  To opt-out of sending any meaningful user agent, set `node.userAgent = null`.
+
- In addition to connections via SOCKS proxy and Tor for `*.onion` names, now
+
  connections via SOCKS proxy and I2P for `*.i2p{,.alt}` names is now supported.
+
  To enable making connections via I2P, configure `node.i2p`.

## 1.8.0

modified Cargo.lock
@@ -714,9 +714,9 @@ dependencies = [

[[package]]
name = "cypheraddr"
-
version = "0.4.0"
+
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ba5c54d2ad4ab9941383519471b75d12abc1a7b4779265e233168f2703a730d9"
+
checksum = "4204e8808fcdd40bed39e49371f13f56d6984c32bc03dcb577d2a40b989b9d68"
dependencies = [
 "amplify",
 "base32",
@@ -738,9 +738,9 @@ dependencies = [

[[package]]
name = "cyphernet"
-
version = "0.5.2"
+
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ac949369884a7a1d802cc669821269c707be8cec4d65043382e253733d2e62e1"
+
checksum = "2de2031ff4b9fc77e4dad022047341b55d285398164cc698e4082f4754b2e684"
dependencies = [
 "cypheraddr",
 "cyphergraphy",
@@ -3990,9 +3990,9 @@ dependencies = [

[[package]]
name = "socks5-client"
-
version = "0.4.1"
+
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "ffc7dcf6fab1d65d82d633006a4cc658d76ce436e01cf1a7c71873c0eeba324c"
+
checksum = "a87421b87207f5d39606da1ac9edd9a5333f4809d26a1fa9bbac58ca42913535"
dependencies = [
 "amplify",
 "cypheraddr",
modified Cargo.toml
@@ -25,8 +25,8 @@ bytes = "1.11.1"
chrono = { version = "0.4.26", default-features = false }
colored = "2.1.0"
crossbeam-channel = "0.5.6"
-
cypheraddr = "0.4.0"
-
cyphernet = "0.5.2"
+
cypheraddr = "0.4.1"
+
cyphernet = "0.5.3"
dunce = "1.0.5"
fastrand = { version = "2.0.0", default-features = false }
git2 = { version = "0.20.4", default-features = false, features = ["vendored-libgit2"] }
modified crates/radicle-cli/Cargo.toml
@@ -14,7 +14,8 @@ name = "rad"
path = "src/main.rs"

[features]
-
default = ["tor"]
+
default = ["i2p", "tor"]
+
i2p = ["radicle/i2p"]
tor = ["radicle/tor"]

[dependencies]
modified crates/radicle-cli/examples/rad-config.md
@@ -111,12 +111,13 @@ $ rad config schema
      "type": "string"
    },
    "ConnectAddress": {
-
      "description": "A node address to connect to. Format: An Ed25519 public key in multibase encoding, followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.",
+
      "description": "A node address to connect to. Format: An Ed25519 public key in multibase encoding, followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion name, or an I2P address, followed by the symbol ':', followed by a TCP port number.",
      "type": "string",
      "pattern": "^.+@.+:((6553[0-5])|(655[0-2][0-9])|(65[0-4][0-9]{2})|(6[0-4][0-9]{3})|([1-5][0-9]{4})|([0-5]{0,5})|([0-9]{1,4}))$",
      "examples": [
        "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@rosa.radicle.xyz:8776",
        "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
        "z6Mkvky2mnSYCTUMKRdAUoZXBXLLKtnWEkWeYQcGjjnmobAU@f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776",
        "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776",
        "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337"
      ]
@@ -260,6 +261,10 @@ $ rad config schema
          "description": "Onion address config.",
          "$ref": "#/$defs/AddressConfig"
        },
+
        "i2p": {
+
          "description": "I2P address config.",
+
          "$ref": "#/$defs/AddressConfig"
+
        },
        "network": {
          "description": "Peer-to-peer network.",
          "$ref": "#/$defs/Network",
@@ -383,10 +388,11 @@ $ rad config schema
      ]
    },
    "Address": {
-
      "description": "An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', followed by a TCP port number.",
+
      "description": "An IP address, or a DNS name, or a Tor onion name, or an I2P address,followed by the symbol ':', followed by a TCP port number.",
      "type": "string",
      "examples": [
        "xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
        "f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776",
        "seed.example.com:8776",
        "192.0.2.0:31337"
      ],
modified crates/radicle-crypto/src/lib.rs
@@ -138,7 +138,7 @@ impl TryFrom<String> for Signature {
}

/// The public/verification key.
-
#[derive(Hash, Serialize, Deserialize, PartialEq, Eq, Copy, Clone)]
+
#[derive(Hash, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
#[serde(into = "String", try_from = "String")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(
@@ -154,12 +154,28 @@ impl TryFrom<String> for Signature {
        ]),
    ),
)]
-
pub struct PublicKey(pub ed25519::PublicKey);
+
pub struct PublicKey(amplify::Bytes32);
+

+
impl PublicKey {
+
    /// Verify the signature for a given payload.
+
    pub fn verify(
+
        &self,
+
        payload: impl AsRef<[u8]>,
+
        signature: &ed25519::Signature,
+
    ) -> Result<(), ed25519::Error> {
+
        ed25519::PublicKey::new(self.0.to_byte_array()).verify(payload, signature)
+
    }
+

+
    /// Returns a byte array representation of the public key.
+
    #[inline]
+
    pub fn to_byte_array(&self) -> [u8; 32] {
+
        self.0.to_byte_array()
+
    }
+
}

impl signature::Verifier<Signature> for PublicKey {
    fn verify(&self, msg: &[u8], signature: &Signature) -> Result<(), signature::Error> {
-
        self.0
-
            .verify(msg, signature)
+
        self.verify(msg, signature)
            .map_err(signature::Error::from_source)
    }
}
@@ -176,7 +192,7 @@ impl cyphernet::display::MultiDisplay<cyphernet::display::Encoding> for PublicKe
#[cfg(feature = "ssh")]
impl From<PublicKey> for ssh_key::PublicKey {
    fn from(key: PublicKey) -> Self {
-
        ssh_key::PublicKey::from(ssh_key::public::Ed25519PublicKey(**key))
+
        ssh_key::PublicKey::from(ssh_key::public::Ed25519PublicKey(key.to_byte_array()))
    }
}

@@ -185,24 +201,24 @@ impl cyphernet::EcPk for PublicKey {
    const COMPRESSED_LEN: usize = 32;
    const CURVE_NAME: &'static str = "Edwards25519";

-
    type Compressed = [u8; 32];
+
    type Compressed = amplify::Bytes32;

    fn base_point() -> Self {
        unimplemented!()
    }

    fn to_pk_compressed(&self) -> Self::Compressed {
-
        *self.0.deref()
+
        amplify::Bytes32::from_byte_array(self.to_byte_array())
    }

    fn from_pk_compressed(pk: Self::Compressed) -> Result<Self, cyphernet::EcPkInvalid> {
-
        Ok(PublicKey::from(pk))
+
        Ok(PublicKey::from(pk.to_byte_array()))
    }

    fn from_pk_compressed_slice(slice: &[u8]) -> Result<Self, cyphernet::EcPkInvalid> {
        ed25519::PublicKey::from_slice(slice)
            .map_err(|_| cyphernet::EcPkInvalid::default())
-
            .map(Self)
+
            .map(Self::from)
    }
}

@@ -214,7 +230,8 @@ impl SecretKey {
    /// Elliptic-curve Diffie-Hellman.
    pub fn ecdh(&self, pk: &PublicKey) -> Result<[u8; 32], ed25519::Error> {
        let scalar = self.seed().scalar();
-
        let ge = edwards25519::GeP3::from_bytes_vartime(pk).ok_or(Error::InvalidPublicKey)?;
+
        let ge = edwards25519::GeP3::from_bytes_vartime(&pk.to_byte_array())
+
            .ok_or(Error::InvalidPublicKey)?;

        Ok(edwards25519::ge_scalarmult(&scalar, &ge).to_bytes())
    }
@@ -291,18 +308,6 @@ pub enum PublicKeyError {
    InvalidKey(#[from] ed25519::Error),
}

-
impl PartialOrd for PublicKey {
-
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
-
        Some(self.cmp(other))
-
    }
-
}
-

-
impl Ord for PublicKey {
-
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
-
        self.0.as_ref().cmp(other.as_ref())
-
    }
-
}
-

impl fmt::Display for PublicKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.to_human())
@@ -323,13 +328,19 @@ impl fmt::Debug for PublicKey {

impl From<ed25519::PublicKey> for PublicKey {
    fn from(other: ed25519::PublicKey) -> Self {
-
        Self(other)
+
        Self(amplify::Bytes32::from_byte_array(*other.deref()))
+
    }
+
}
+

+
impl From<PublicKey> for ed25519::PublicKey {
+
    fn from(val: PublicKey) -> Self {
+
        ed25519::PublicKey::new(val.to_byte_array())
    }
}

impl From<[u8; 32]> for PublicKey {
    fn from(other: [u8; 32]) -> Self {
-
        Self(ed25519::PublicKey::new(other))
+
        Self(amplify::Bytes32::from_byte_array(other))
    }
}

@@ -337,7 +348,7 @@ impl TryFrom<&[u8]> for PublicKey {
    type Error = ed25519::Error;

    fn try_from(other: &[u8]) -> Result<Self, Self::Error> {
-
        ed25519::PublicKey::from_slice(other).map(Self)
+
        ed25519::PublicKey::from_slice(other).map(Self::from)
    }
}

@@ -352,7 +363,7 @@ impl PublicKey {
    pub fn to_human(&self) -> String {
        let mut buf = [0; 2 + ed25519::PublicKey::BYTES];
        buf[..2].copy_from_slice(&Self::MULTICODEC_TYPE);
-
        buf[2..].copy_from_slice(self.0.deref());
+
        buf[2..].copy_from_slice(self.to_byte_array().as_slice());

        multibase::encode(multibase::Base::Base58Btc, buf)
    }
@@ -387,7 +398,7 @@ impl FromStr for PublicKey {
        if let Some(bytes) = bytes.strip_prefix(&Self::MULTICODEC_TYPE) {
            let key = ed25519::PublicKey::from_slice(bytes)?;

-
            Ok(Self(key))
+
            Ok(key.into())
        } else {
            Err(PublicKeyError::Multicodec(Self::MULTICODEC_TYPE))
        }
@@ -402,14 +413,6 @@ impl TryFrom<String> for PublicKey {
    }
}

-
impl Deref for PublicKey {
-
    type Target = ed25519::PublicKey;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.0
-
    }
-
}
-

#[cfg(feature = "git-ref-format-core")]
impl From<&PublicKey> for git_ref_format_core::Component<'_> {
    fn from(id: &PublicKey) -> Self {
modified crates/radicle-crypto/src/ssh.rs
@@ -43,7 +43,9 @@ impl ExtendedSignature {
    /// Convert to OpenSSH standard PEM format.
    pub fn to_pem(&self) -> Result<String, ExtendedSignatureError> {
        ssh_key::SshSig::new(
-
            ssh_key::public::KeyData::from(ssh_key::public::Ed25519PublicKey(**self.key)),
+
            ssh_key::public::KeyData::from(ssh_key::public::Ed25519PublicKey(
+
                self.key.to_byte_array(),
+
            )),
            String::from("radicle"),
            ssh_key::HashAlg::Sha256,
            ssh_key::Signature::new(ssh_key::Algorithm::Ed25519, **self.sig)?,
modified crates/radicle-crypto/src/ssh/agent.rs
@@ -125,7 +125,7 @@ impl Agent {
    }

    fn key_data(key: &PublicKey) -> KeyData {
-
        KeyData::Ed25519(Ed25519PublicKey(***key))
+
        KeyData::Ed25519(Ed25519PublicKey(key.to_byte_array()))
    }
}

@@ -191,7 +191,6 @@ mod test {
    use ssh_agent_lib::blocking::Client;
    use ssh_agent_lib::proto::SignRequest;
    use ssh_agent_lib::ssh_key::public::{Ed25519PublicKey, KeyData};
-
    use std::ops::Deref;

    #[test]
    fn test_agent_encoding_remove() {
@@ -216,7 +215,7 @@ mod test {
        // since we are not actually connected to SSH agent.
        assert!(
            matches!(client.remove_identity(ssh_agent_lib::proto::RemoveIdentity {
-
                pubkey: KeyData::Ed25519(Ed25519PublicKey(**pk.deref())),
+
                pubkey: KeyData::Ed25519(Ed25519PublicKey(pk.to_byte_array())),
            }),
                Err(
                    super::AgentError::Proto(ssh_agent_lib::proto::ProtoError::IO(err)),
@@ -251,7 +250,7 @@ mod test {

        client
            .sign(SignRequest {
-
                pubkey: KeyData::Ed25519(Ed25519PublicKey(**pk.deref())),
+
                pubkey: KeyData::Ed25519(Ed25519PublicKey(pk.to_byte_array())),
                data,
                flags: 0,
            })
modified crates/radicle-crypto/src/ssh/keystore.rs
@@ -332,7 +332,7 @@ impl MemorySigner {
            .ok_or_else(|| MemorySignerError::NotFound(public_path.to_path_buf()))?;

        secret
-
            .validate_public_key(&public)
+
            .validate_public_key(&public.into())
            .map_err(|_| MemorySignerError::KeyMismatch {
                secret: keystore.secret_key_path().to_path_buf(),
                public: public_path.to_path_buf(),
@@ -345,7 +345,7 @@ impl MemorySigner {
    /// the public key from the secret key.
    pub fn from_secret(secret: Zeroizing<SecretKey>) -> Self {
        Self {
-
            public: PublicKey(secret.public_key()),
+
            public: secret.public_key().into(),
            secret,
        }
    }
modified crates/radicle-crypto/src/test/arbitrary.rs
@@ -18,6 +18,6 @@ impl Arbitrary for PublicKey {
        let seed = Seed::new(bytes);
        let keypair = KeyPair::from_seed(seed);

-
        PublicKey(keypair.pk)
+
        keypair.pk.into()
    }
}
modified crates/radicle-crypto/src/test/signer.rs
@@ -27,7 +27,9 @@ impl signature::Signer<Signature> for MockSigner {

impl signature::Verifier<Signature> for MockSigner {
    fn verify(&self, msg: &[u8], signature: &Signature) -> Result<(), signature::Error> {
-
        self.pk.verify(msg, signature)
+
        self.pk
+
            .verify(msg, signature)
+
            .map_err(signature::Error::from_source)
    }
}

modified crates/radicle-node/Cargo.toml
@@ -10,7 +10,8 @@ build = "build.rs"
rust-version.workspace = true

[features]
-
default = ["backtrace", "systemd", "structured-logger", "socket2", "tor"]
+
default = ["backtrace", "i2p", "systemd", "structured-logger", "socket2", "tor"]
+
i2p = ["cyphernet/i2p", "radicle/i2p", "radicle-protocol/i2p"]
systemd = ["dep:radicle-systemd"]
test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "radicle-protocol/test", "qcheck", "snapbox"]
tor = ["cyphernet/tor", "radicle/tor", "radicle-protocol/tor"]
modified crates/radicle-node/src/fingerprint.rs
@@ -68,7 +68,7 @@ impl Fingerprint {
        home: &Home,
        secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
    ) -> Result<(), Error> {
-
        let public_key = crypto::PublicKey(secret_key.deref().public_key());
+
        let public_key = secret_key.deref().public_key().into();
        let mut file = std::fs::OpenOptions::new()
            .create_new(true)
            .write(true)
@@ -86,7 +86,7 @@ impl Fingerprint {
        &self,
        secret_key: &impl std::ops::Deref<Target = crypto::SecretKey>,
    ) -> FingerprintVerification {
-
        let public_key = crypto::PublicKey(secret_key.deref().public_key());
+
        let public_key = secret_key.deref().public_key().into();
        if crypto::ssh::fmt::fingerprint(&public_key) == self.0 {
            FingerprintVerification::Match
        } else {
modified crates/radicle-node/src/wire.rs
@@ -21,7 +21,7 @@ use radicle::collections::{RandomMap, RandomSet};
use radicle::crypto;
use radicle::node::Link;
use radicle::node::NodeId;
-
#[cfg(feature = "tor")]
+
#[cfg(any(feature = "i2p", feature = "tor"))]
use radicle::node::config::AddressConfig;
use radicle::storage::WriteStorage;
use radicle_protocol::deserializer::Deserializer;
@@ -1083,6 +1083,30 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
    signer: G,
    config: &radicle::node::Config,
) -> io::Result<WireSession<G>> {
+
    #[cfg(any(feature = "i2p", feature = "tor"))]
+
    fn proxy_or_forward<H: std::fmt::Display>(
+
        config: &AddressConfig,
+
        global_proxy: Option<net::SocketAddr>,
+
        host: H,
+
        port: u16,
+
    ) -> io::Result<NetAddr<InetHost>> {
+
        match config {
+
            // In proxy mode, simply use the configured proxy address.
+
            // This takes precedence over any global proxy.
+
            AddressConfig::Proxy { address } => Ok((*address).into()),
+
            // In "forward" mode, if a global proxy is set, we use that, otherwise
+
            // we treat the address as a regular DNS name.
+
            AddressConfig::Forward => Ok(global_proxy
+
                .map(Into::into)
+
                .unwrap_or_else(|| NetAddr::new(InetHost::Dns(host.to_string()), port))),
+
            // If address type support isn't configured, refuse to connect.
+
            AddressConfig::Drop => Err(io::Error::new(
+
                io::ErrorKind::Unsupported,
+
                "no configuration found for address type",
+
            )),
+
        }
+
    }
+

    // Determine what address to establish a TCP connection with, given the remote peer
    // address and our node configuration.
    let inet_addr: NetAddr<InetHost> = match (&remote_addr.host, config.proxy) {
@@ -1093,27 +1117,11 @@ pub fn dial<G: Ecdh<Pk = NodeId>>(
        (HostName::Dns(dns), None) => NetAddr::new(InetHost::Dns(dns.clone()), remote_addr.port),
        // For onion addresses, handle with care.
        #[cfg(feature = "tor")]
-
        (HostName::Tor(onion), proxy) => match config.onion {
-
            // In onion proxy mode, simply use the configured proxy address.
-
            // This takes precedence over any global proxy.
-
            AddressConfig::Proxy { address } => address.into(),
-
            // In "forward" mode, if a global proxy is set, we use that, otherwise
-
            // we treat `.onion` addresses as regular DNS names.
-
            AddressConfig::Forward => {
-
                if let Some(proxy) = proxy {
-
                    proxy.into()
-
                } else {
-
                    NetAddr::new(InetHost::Dns(onion.to_string()), remote_addr.port)
-
                }
-
            }
-
            // If onion address support isn't configured, refuse to connect.
-
            AddressConfig::Drop => {
-
                return Err(io::Error::new(
-
                    io::ErrorKind::Unsupported,
-
                    "no configuration found for .onion addresses",
-
                ));
-
            }
-
        },
+
        (HostName::Tor(onion), proxy) => {
+
            proxy_or_forward(&config.onion, proxy, onion, remote_addr.port)?
+
        }
+
        #[cfg(feature = "i2p")]
+
        (HostName::I2p(i2p), proxy) => proxy_or_forward(&config.i2p, proxy, i2p, remote_addr.port)?,
        _ => {
            return Err(io::Error::new(
                io::ErrorKind::Unsupported,
modified crates/radicle-protocol/Cargo.toml
@@ -9,6 +9,7 @@ edition.workspace = true
rust-version.workspace = true

[features]
+
i2p = ["cypheraddr/i2p", "radicle/i2p"]
test = ["radicle/test", "radicle-crypto/test", "radicle-crypto/cyphernet", "qcheck"]
tor = ["cypheraddr/tor", "radicle/tor"]

@@ -36,4 +37,4 @@ pastey = "0.2"
qcheck = { workspace = true }
qcheck-macros = { workspace = true }
radicle = { workspace = true, features = ["test"] }
-
radicle-crypto = { workspace = true, features = ["test", "cyphernet"] }
+
radicle-crypto = { workspace = true, features = ["test", "cyphernet"] }

\ No newline at end of file
modified crates/radicle-protocol/src/service.rs
@@ -2654,11 +2654,18 @@ where
    ///
    /// If the [`Address`] is an `.onion` address and the service supports onion
    /// routing then this will return `true`.
+
    ///
+
    /// # I2P
+
    ///
+
    /// If the [`Address`] is an I2P address and the service supports I2P
+
    /// connections then this will return `true`.
    fn is_supported_address(&self, address: &Address) -> bool {
        match AddressType::from(address) {
            // Only consider onion addresses if configured.
            #[cfg(feature = "tor")]
            AddressType::Onion => self.config.onion != AddressConfig::Drop,
+
            #[cfg(feature = "i2p")]
+
            AddressType::I2p => self.config.i2p != AddressConfig::Drop,
            AddressType::Dns | AddressType::Ipv4 | AddressType::Ipv6 => true,
        }
    }
modified crates/radicle-protocol/src/wire.rs
@@ -14,6 +14,8 @@ use std::string::FromUtf8Error;

use bytes::{Buf, BufMut};

+
#[cfg(feature = "i2p")]
+
use cypheraddr::i2p;
#[cfg(feature = "tor")]
use cypheraddr::tor;

@@ -60,6 +62,9 @@ pub enum Invalid {
    #[cfg(feature = "tor")]
    #[error("invalid onion address: {0}")]
    OnionAddr(#[from] tor::OnionAddrDecodeError),
+
    #[cfg(feature = "i2p")]
+
    #[error("invalid i2p address: {0}")]
+
    I2pAddr(#[from] i2p::I2pAddrParseError),
    #[error("invalid timestamp: {actual_millis} millis")]
    Timestamp { actual_millis: u64 },

@@ -175,7 +180,7 @@ impl Encode for u64 {

impl Encode for PublicKey {
    fn encode(&self, buf: &mut impl BufMut) {
-
        self.deref().encode(buf)
+
        self.to_byte_array().encode(buf)
    }
}

@@ -266,6 +271,13 @@ impl Encode for cypheraddr::tor::OnionAddrV3 {
    }
}

+
#[cfg(feature = "i2p")]
+
impl Encode for i2p::I2pAddr {
+
    fn encode(&self, buf: &mut impl BufMut) {
+
        self.to_string().encode(buf)
+
    }
+
}
+

impl Encode for UserAgent {
    fn encode(&self, buf: &mut impl BufMut) {
        self.as_ref().encode(buf)
@@ -531,6 +543,16 @@ impl Decode for tor::OnionAddrV3 {
    }
}

+
#[cfg(feature = "i2p")]
+
impl Decode for i2p::I2pAddr {
+
    fn decode(buf: &mut impl Buf) -> Result<Self, Error> {
+
        let s = String::decode(buf)?;
+
        let addr = i2p::I2pAddr::from_str(&s).map_err(Invalid::from)?;
+

+
        Ok(addr)
+
    }
+
}
+

impl Encode for Timestamp {
    fn encode(&self, buf: &mut impl BufMut) {
        self.deref().encode(buf)
modified crates/radicle-protocol/src/wire/message.rs
@@ -2,6 +2,8 @@ use std::{mem, net};

use bytes::Buf;
use bytes::BufMut;
+
#[cfg(feature = "i2p")]
+
use cypheraddr::i2p;
#[cfg(feature = "tor")]
use cypheraddr::tor;
use cypheraddr::{HostName, NetAddr};
@@ -83,6 +85,8 @@ pub enum AddressType {
    Dns = 3,
    #[cfg(feature = "tor")]
    Onion = 4,
+
    #[cfg(feature = "i2p")]
+
    I2p = 5,
}

impl From<AddressType> for u8 {
@@ -99,6 +103,8 @@ impl From<&Address> for AddressType {
            HostName::Dns(_) => AddressType::Dns,
            #[cfg(feature = "tor")]
            HostName::Tor(_) => AddressType::Onion,
+
            #[cfg(feature = "i2p")]
+
            HostName::I2p(_) => AddressType::I2p,
            _ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive`
        }
    }
@@ -114,6 +120,8 @@ impl TryFrom<u8> for AddressType {
            3 => Ok(AddressType::Dns),
            #[cfg(feature = "tor")]
            4 => Ok(AddressType::Onion),
+
            #[cfg(feature = "i2p")]
+
            5 => Ok(AddressType::I2p),
            _ => Err(other),
        }
    }
@@ -366,6 +374,11 @@ impl wire::Encode for Address {
                u8::from(AddressType::Onion).encode(buf);
                addr.encode(buf);
            }
+
            #[cfg(feature = "i2p")]
+
            HostName::I2p(ref addr) => {
+
                u8::from(AddressType::I2p).encode(buf);
+
                addr.encode(buf);
+
            }
            _ => {
                unimplemented!(
                    "Encoding not defined for addresses of the same type as the following: {:?}",
@@ -405,6 +418,12 @@ impl wire::Decode for Address {

                HostName::Tor(onion)
            }
+
            #[cfg(feature = "i2p")]
+
            Ok(AddressType::I2p) => {
+
                let i2p: i2p::I2pAddr = wire::Decode::decode(buf)?;
+

+
                HostName::I2p(i2p)
+
            }
            Err(other) => return Err(wire::Invalid::AddressType { actual: other }.into()),
        };
        let port = u16::decode(buf)?;
modified crates/radicle/Cargo.toml
@@ -11,6 +11,7 @@ rust-version.workspace = true

[features]
default = []
+
i2p = ["cyphernet/i2p"]
test = ["tempfile", "qcheck", "radicle-crypto/test", "radicle-cob/test"]
logger = ["colored", "chrono"]
qcheck = [
modified crates/radicle/src/node.rs
@@ -470,11 +470,11 @@ impl TryFrom<&sqlite::Value> for Alias {
    feature = "schemars",
    derive(schemars::JsonSchema),
    schemars(description = "\
-
    An IP address, or a DNS name, or a Tor onion name, followed by the symbol ':', \
-
    followed by a TCP port number.",
-
    extend(
-
        "examples" = [
+
    An IP address, or a DNS name, or a Tor onion name, or an I2P address,\
+
    followed by the symbol ':', followed by a TCP port number.",
+
    extend("examples" = [
            "xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
            "f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776",
            "seed.example.com:8776",
            "192.0.2.0:31337",
        ],
@@ -522,6 +522,15 @@ impl Address {
        }
    }

+
    /// Returns `true` if the [`HostName`] is an I2P address.
+
    #[cfg(feature = "i2p")]
+
    pub fn is_i2p(&self) -> bool {
+
        match self.0.host {
+
            HostName::I2p(_) => true,
+
            _ => false,
+
        }
+
    }
+

    /// Return the port number of the [`Address`].
    pub fn port(&self) -> u16 {
        self.0.port
@@ -542,6 +551,8 @@ impl Address {
                    .collect::<String>();
                format!("{start}…{end}")
            }
+
            #[cfg(feature = "i2p")]
+
            HostName::I2p(i2p) => i2p.to_string(),
            _ => unreachable!(),
        };

modified crates/radicle/src/node/address.rs
@@ -203,6 +203,8 @@ pub enum AddressType {
    Dns = 3,
    #[cfg(feature = "tor")]
    Onion = 4,
+
    #[cfg(feature = "i2p")]
+
    I2p = 5,
}

impl From<AddressType> for u8 {
@@ -219,6 +221,8 @@ impl From<&Address> for AddressType {
            HostName::Dns(_) => AddressType::Dns,
            #[cfg(feature = "tor")]
            HostName::Tor(_) => AddressType::Onion,
+
            #[cfg(feature = "i2p")]
+
            HostName::I2p(_) => AddressType::I2p,
            _ => todo!(), // FIXME(cloudhead): Maxim will remove `non-exhaustive`
        }
    }
@@ -234,6 +238,8 @@ impl TryFrom<u8> for AddressType {
            3 => Ok(AddressType::Dns),
            #[cfg(feature = "tor")]
            4 => Ok(AddressType::Onion),
+
            #[cfg(feature = "i2p")]
+
            5 => Ok(AddressType::I2p),
            _ => Err(other),
        }
    }
modified crates/radicle/src/node/address/store.rs
@@ -537,6 +537,8 @@ impl TryFrom<&sql::Value> for AddressType {
                "dns" => Ok(AddressType::Dns),
                #[cfg(feature = "tor")]
                "onion" => Ok(AddressType::Onion),
+
                #[cfg(feature = "i2p")]
+
                "i2p" => Ok(AddressType::I2p),
                _ => Err(err),
            },
            _ => Err(err),
@@ -552,6 +554,8 @@ impl sql::BindableWithIndex for AddressType {
            Self::Dns => "dns".bind(stmt, i),
            #[cfg(feature = "tor")]
            Self::Onion => "onion".bind(stmt, i),
+
            #[cfg(feature = "i2p")]
+
            Self::I2p => "i2p".bind(stmt, i),
        }
    }
}
modified crates/radicle/src/node/config.rs
@@ -278,7 +278,7 @@ pub struct RateLimits {
    schemars(description = "\
    A node address to connect to. Format: An Ed25519 public key in multibase encoding, \
    followed by the symbol '@', followed by an IP address, or a DNS name, or a Tor onion \
-
    name, followed by the symbol ':', followed by a TCP port number.\
+
    name, or an I2P address, followed by the symbol ':', followed by a TCP port number.\
")
)]
pub struct ConnectAddress(
@@ -289,6 +289,7 @@ pub struct ConnectAddress(
        extend("examples" = [
            "z6MkrLMMsiPWUcNPHcRajuMi9mDfYckSoJyPwwnknocNYPm7@rosa.radicle.xyz:8776",
            "z6MkvUJtYD9dHDJfpevWRT98mzDDpdAtmUjwyDSkyqksUr7C@xmrhfasfg5suueegrnc4gsgyi2tyclcy5oz7f5drnrodmdtob6t2ioyd.onion:8776",
+
            "z6Mkvky2mnSYCTUMKRdAUoZXBXLLKtnWEkWeYQcGjjnmobAU@f2atcc7udeub5kh4nkljtjwyk7ikjviorufzgwnfwhkphljl3vhq.b32.i2p:8776",
            "z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi@seed.example.com:8776",
            "z6MkkfM3tPXNPrPevKr3uSiQtHPuwnNhu2yUVjgd2jXVsVz5@192.0.2.0:31337",
        ]),
@@ -358,7 +359,7 @@ pub enum Relay {
#[derive(Debug, Copy, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "mode")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
-
#[cfg(feature = "tor")]
+
#[cfg(any(feature = "i2p", feature = "tor"))]
pub enum AddressConfig {
    /// Proxy connections to this address type.
    Proxy {
@@ -561,6 +562,10 @@ pub struct Config {
        deserialize_with = "crate::serde_ext::null_to_default"
    )]
    pub onion: AddressConfig,
+
    /// I2P address config.
+
    #[cfg(feature = "i2p")]
+
    #[serde(default, skip_serializing_if = "crate::serde_ext::is_default")]
+
    pub i2p: AddressConfig,
    /// Peer-to-peer network.
    #[serde(default)]
    pub network: Network,
@@ -618,6 +623,8 @@ impl Config {
            proxy: None,
            #[cfg(feature = "tor")]
            onion: AddressConfig::Drop,
+
            #[cfg(feature = "i2p")]
+
            i2p: AddressConfig::Drop,
            relay: Relay::default(),
            limits: Limits::default(),
            workers: Workers::default(),
modified crates/radicle/src/test/arbitrary.rs
@@ -5,6 +5,8 @@ use std::str::FromStr;
use std::{iter, net};

use crypto::PublicKey;
+
#[cfg(feature = "i2p")]
+
use cyphernet::addr::i2p::I2pAddr;
#[cfg(feature = "tor")]
use cyphernet::{EcPk, addr::tor::OnionAddrV3};
use qcheck::Arbitrary;
@@ -208,10 +210,14 @@ impl Arbitrary for MockRepository {

impl Arbitrary for AddressType {
    fn arbitrary(g: &mut qcheck::Gen) -> Self {
-
        #[cfg(not(feature = "tor"))]
-
        let types = [1, 2, 3];
+
        #[allow(unused_mut)]
+
        let mut types = vec![1, 2, 3];
+

        #[cfg(feature = "tor")]
-
        let types = [1, 2, 3, 4];
+
        types.push(4);
+

+
        #[cfg(feature = "i2p")]
+
        types.push(5);

        let t = *g.choose(&types).unwrap() as u8;

@@ -238,10 +244,40 @@ impl Arbitrary for Address {
            AddressType::Onion => {
                let pk = PublicKey::arbitrary(g);
                let addr = OnionAddrV3::from(
-
                    cyphernet::ed25519::PublicKey::from_pk_compressed(**pk).unwrap(),
+
                    cyphernet::ed25519::PublicKey::from_pk_compressed(pk.to_byte_array()).unwrap(),
                );
                cyphernet::addr::HostName::Tor(addr)
            }
+
            #[cfg(feature = "i2p")]
+
            AddressType::I2p => {
+
                let address = if bool::arbitrary(g) {
+
                    let name: String = iter::repeat_with(|| {
+
                        char::from(
+
                            // Base32 alphabet from RFC 4648.
+
                            *g.choose(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567")
+
                                .expect("alphabet is non-empty"),
+
                        )
+
                    })
+
                    .take(56)
+
                    .collect();
+

+
                    name + ".b32"
+
                } else {
+
                    g.choose(&["iris.radicle.example", "rosa.radicle.example"])
+
                        .unwrap()
+
                        .to_string()
+
                };
+

+
                let suffix = if bool::arbitrary(g) {
+
                    ".i2p"
+
                } else {
+
                    ".i2p.alt"
+
                };
+

+
                let address = address + suffix;
+

+
                cyphernet::addr::HostName::I2p(I2pAddr::from_str(&address).unwrap())
+
            }
        };

        Address::from(cyphernet::addr::NetAddr {