Repository: radicle-dev/heartwood (rust)

Issue:
Node announcement DoS protection
To prevent DoS by flooding the networking with bogus `NodeAnnouncement` messages, we include a small proof-of-work within the announcement message that is verified on receipt, before the message is relayed and processed.

We can use the same principle as bitcoin mining: include a nonce in the message and check that the message hash is below a certain number.

Pull request:
node: implement node announcement sybil resistance
We implement a small proof-of-work on the node announcement message to prevent the routing table from being filled by fake entries.

We choose `scrypt` for its memory-hard properties.

Closes #28 

Signed-off-by: Alexis Sellier <alexis@radicle.xyz>

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 7ab33f88..ba65e93b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -255,6 +255,16 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "cipher"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1873270f8f7942c191139cb8a40fd228da6c3fd2fc376d7e92d47aa14aeb59e"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
 [[package]]
 name = "colored"
 version = "1.9.3"
@@ -374,6 +384,7 @@ checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c"
 dependencies = [
  "block-buffer 0.10.3",
  "crypto-common",
+ "subtle",
 ]
 
 [[package]]
@@ -572,6 +583,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.5",
+]
+
 [[package]]
 name = "home"
 version = "0.5.3"
@@ -678,6 +698,15 @@ dependencies = [
  "hashbrown",
 ]
 
+[[package]]
+name = "inout"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "instant"
 version = "0.1.12"
@@ -952,6 +981,15 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
 
+[[package]]
+name = "pbkdf2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917"
+dependencies = [
+ "digest 0.10.5",
+]
+
 [[package]]
 name = "percent-encoding"
 version = "2.2.0"
@@ -1178,6 +1216,7 @@ dependencies = [
  "quickcheck",
  "quickcheck_macros",
  "radicle",
+ "scrypt",
  "serde",
  "serde_json",
  "sqlite",
@@ -1286,6 +1325,27 @@ version = "1.0.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
 
+[[package]]
+name = "salsa20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "scrypt"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d"
+dependencies = [
+ "hmac",
+ "pbkdf2",
+ "salsa20",
+ "sha2 0.10.6",
+]
+
 [[package]]
 name = "serde"
 version = "1.0.145"
@@ -1437,6 +1497,12 @@ dependencies = [
  "sqlite3-src",
 ]
 
+[[package]]
+name = "subtle"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
+
 [[package]]
 name = "syn"
 version = "1.0.101"
diff --git a/radicle-node/Cargo.toml b/radicle-node/Cargo.toml
index d3f58219..1958a283 100644
--- a/radicle-node/Cargo.toml
+++ b/radicle-node/Cargo.toml
@@ -21,6 +21,7 @@ nakamoto-net-poll = { version = "0.3.0" }
 nonempty = { version = "0.8.0", features = ["serialize"] }
 sqlite = { version = "0.27.0" }
 sqlite3-src = { version = "0.4.0", features = ["bundled"] } # Ensures static linking
+scrypt = { version = "0.10.0", default-features = false }
 serde = { version = "1", features = ["derive"] }
 serde_json = { version = "1", features = ["preserve_order"] }
 tempfile = { version = "3.3.0" }
diff --git a/radicle-node/src/service.rs b/radicle-node/src/service.rs
index 358ee6c5..3df70402 100644
--- a/radicle-node/src/service.rs
+++ b/radicle-node/src/service.rs
@@ -687,23 +687,24 @@ where
                     }
                 }
             }
-            AnnouncementMessage::Node(NodeAnnouncement {
-                features,
-                alias,
-                addresses,
-                ..
-            }) => {
+            AnnouncementMessage::Node(
+                ann @ NodeAnnouncement {
+                    features,
+                    alias,
+                    addresses,
+                    ..
+                },
+            ) => {
                 // Discard node messages we've already seen, otherwise update
-                // out last seen time.
+                // our last seen time.
                 if !peer.node_announced(timestamp) {
                     debug!("Ignoring stale node announcement from {announcer}");
                     return Ok(false);
                 }
 
-                // If this node isn't a seed, we're not interested in adding it
-                // to our address book, but other nodes may be, so we relay the message anyway.
-                if !features.has(Features::SEED) {
-                    return Ok(relay);
+                if !ann.validate() {
+                    warn!("Dropping node announcement from {announcer}: invalid proof-of-work");
+                    return Ok(false);
                 }
 
                 let alias = match str::from_utf8(alias) {
@@ -714,6 +715,12 @@ where
                     }
                 };
 
+                // If this node isn't a seed, we're not interested in adding it
+                // to our address book, but other nodes may be, so we relay the message anyway.
+                if !features.has(Features::SEED) {
+                    return Ok(relay);
+                }
+
                 match self.addresses.insert(
                     announcer,
                     *features,
@@ -1223,7 +1230,9 @@ mod gossip {
             timestamp,
             alias,
             addresses,
+            nonce: 0,
         }
+        .solve()
     }
 
     pub fn inventory(timestamp: Timestamp, inventory: Vec<Id>) -> InventoryAnnouncement {
diff --git a/radicle-node/src/service/message.rs b/radicle-node/src/service/message.rs
index f7ca7056..af7e888c 100644
--- a/radicle-node/src/service/message.rs
+++ b/radicle-node/src/service/message.rs
@@ -133,6 +133,52 @@ pub struct NodeAnnouncement {
     pub alias: [u8; 32],
     /// Announced addresses.
     pub addresses: Vec<Address>,
+    /// Nonce used for announcement proof-of-work.
+    pub nonce: u64,
+}
+
+impl NodeAnnouncement {
+    /// Validate a node announcement message.
+    ///
+    /// Checks that the proof-of-work is valid, by generating a single byte that
+    /// must be zero.
+    ///
+    /// `scrypt(encode(announcement)) == 0`
+    ///
+    pub fn validate(&self) -> bool {
+        let (n, r, p) = Announcement::POW_PARAMS;
+        let params = scrypt::Params::new(n, r, p).expect("proof-of-work parameters are valid");
+        let mut output = [0; 1];
+
+        scrypt::scrypt(
+            wire::serialize(self).as_ref(),
+            Announcement::POW_SALT,
+            &params,
+            &mut output,
+        )
+        .expect("proof-of-work output vector is a valid length");
+
+        output == [0]
+    }
+
+    /// Solve the proof-of-work of a node announcement by iterating through different nonces.
+    pub fn solve(mut self) -> Self {
+        loop {
+            if let Some(nonce) = self.nonce.checked_add(1) {
+                self.nonce = nonce;
+
+                if self.validate() {
+                    break;
+                }
+            } else {
+                // If a very high difficulty is chosen, it's possible to iterate through all
+                // possible values of the nonce without solving the puzzle. However, with "normal"
+                // values, this is virtually impossible.
+                panic!("could not solve proof-of-work!");
+            }
+        }
+        self
+    }
 }
 
 impl wire::Encode for NodeAnnouncement {
@@ -143,6 +189,7 @@ impl wire::Encode for NodeAnnouncement {
         n += self.timestamp.encode(writer)?;
         n += self.alias.encode(writer)?;
         n += self.addresses.as_slice().encode(writer)?;
+        n += self.nonce.encode(writer)?;
 
         Ok(n)
     }
@@ -154,12 +201,14 @@ impl wire::Decode for NodeAnnouncement {
         let timestamp = Timestamp::decode(reader)?;
         let alias = wire::Decode::decode(reader)?;
         let addresses = Vec::<Address>::decode(reader)?;
+        let nonce = u64::decode(reader)?;
 
         Ok(Self {
             features,
             timestamp,
             alias,
             addresses,
+            nonce,
         })
     }
 }
@@ -271,6 +320,24 @@ pub struct Announcement {
 }
 
 impl Announcement {
+    /// Proof-of-work parameters for announcements.
+    ///
+    /// These parameters are fed into `scrypt`.
+    /// They represent the `log2(N)`, `r`, `p` parameters, respectively.
+    ///
+    /// * log2(N) – iterations count (affects memory and CPU usage), e.g. 15
+    /// * r – block size (affects memory and CPU usage), e.g. 8
+    /// * p – parallelism factor (threads to run in parallel - affects the memory, CPU usage), usually 1
+    ///
+    /// `15, 8, 1` are usually the recommended parameters.
+    ///
+    #[cfg(test)]
+    pub const POW_PARAMS: (u8, u32, u32) = (1, 1, 1);
+    #[cfg(not(test))]
+    pub const POW_PARAMS: (u8, u32, u32) = (15, 8, 1);
+    /// Salt used for generating PoW.
+    pub const POW_SALT: &[u8] = &[b'r', b'a', b'd'];
+
     /// Verify this announcement's signature.
     pub fn verify(&self) -> bool {
         let msg = wire::serialize(&self.message);
@@ -447,4 +514,18 @@ mod tests {
 
         assert!(ann.verify());
     }
+
+    #[test]
+    fn test_node_announcement_validate() {
+        let ann = NodeAnnouncement {
+            features: node::Features::SEED,
+            timestamp: 42491841,
+            alias: [0; 32],
+            addresses: vec![],
+            nonce: 0,
+        };
+
+        assert!(!ann.validate());
+        assert!(ann.solve().validate());
+    }
 }


Test patch:
diff --git a/radicle-node/src/test/arbitrary.rs b/radicle-node/src/test/arbitrary.rs
index dfc37081..c140522b 100644
--- a/radicle-node/src/test/arbitrary.rs
+++ b/radicle-node/src/test/arbitrary.rs
@@ -69,6 +69,7 @@ impl Arbitrary for Message {
                     timestamp: Timestamp::arbitrary(g),
                     alias: ByteArray::<32>::arbitrary(g).into_inner(),
                     addresses: Arbitrary::arbitrary(g),
+                    nonce: u64::arbitrary(g),
                 }
                 .into();
                 let bytes: ByteArray<64> = Arbitrary::arbitrary(g);
diff --git a/radicle-node/src/test/peer.rs b/radicle-node/src/test/peer.rs
index c5689194..cfbe55f7 100644
--- a/radicle-node/src/test/peer.rs
+++ b/radicle-node/src/test/peer.rs
@@ -164,7 +164,9 @@ where
                 timestamp: self.timestamp(),
                 alias,
                 addresses: vec![net::SocketAddr::from((self.ip, service::DEFAULT_PORT)).into()],
-            },
+                nonce: 0,
+            }
+            .solve(),
             self.signer(),
         )
     }