From 0a1fe886656642ab978bd25cdc21872fc8173e23 Mon Sep 17 00:00:00 2001 From: parazyd Date: Tue, 9 May 2023 13:06:59 +0200 Subject: [PATCH] faucetd: Implement airdrop spam protection using a VDF. --- bin/drk/src/rpc_airdrop.rs | 33 ++++- bin/faucetd/src/error.rs | 6 + bin/faucetd/src/main.rs | 132 ++++++++++++++++-- .../localnet/darkfid-single-node/faucetd.toml | 2 +- contrib/localnet/darkfid/faucetd.toml | 2 +- 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/bin/drk/src/rpc_airdrop.rs b/bin/drk/src/rpc_airdrop.rs index e9939543f..33316d339 100644 --- a/bin/drk/src/rpc_airdrop.rs +++ b/bin/drk/src/rpc_airdrop.rs @@ -16,9 +16,13 @@ * along with this program. If not, see . */ -use anyhow::Result; +use anyhow::{anyhow, Result}; use darkfi::rpc::{client::RpcClient, jsonrpc::JsonRequest}; -use darkfi_sdk::crypto::PublicKey; +use darkfi_sdk::{ + crypto::{mimc_vdf, PublicKey}, + num_bigint::BigUint, + num_traits::Num, +}; use serde_json::json; use url::Url; @@ -34,7 +38,30 @@ impl Drk { address: PublicKey, ) -> Result { let rpc_client = RpcClient::new(faucet_endpoint).await?; - let params = json!([format!("{}", address), amount]); + + // First we request a VDF challenge from the faucet + let params = json!([format!("{}", address)]); + let req = JsonRequest::new("challenge", params); + let rep = rpc_client.request(req).await?; + + let Some(rep) = rep.as_array() else { + return Err(anyhow!("Invalid challenge response from faucet: {:?}", rep)) + }; + if rep.len() != 2 || !rep[0].is_string() || !rep[1].is_u64() { + return Err(anyhow!("Invalid challenge response from faucet: {:?}", rep)) + } + + // Retrieve VDF challenge + let challenge = BigUint::from_str_radix(rep[0].as_str().unwrap(), 16)?; + let n_steps = rep[1].as_u64().unwrap(); + + // Then evaluate the VDF + eprintln!("Evaluating VDF with n_steps={} ... (this could take about a minute)", n_steps); + let witness = mimc_vdf::eval(&challenge, n_steps); + eprintln!("Done! Sending airdrop request..."); + + // And finally request airdrop with the VDF evaluation witness + let params = json!([format!("{}", address), amount, witness.to_str_radix(16)]); let req = JsonRequest::new("airdrop", params); let rep = rpc_client.oneshot_request(req).await?; diff --git a/bin/faucetd/src/error.rs b/bin/faucetd/src/error.rs index 2ab720b72..be5c034b8 100644 --- a/bin/faucetd/src/error.rs +++ b/bin/faucetd/src/error.rs @@ -25,6 +25,9 @@ pub enum RpcError { TimeLimitReached = -32108, ParseError = -32109, InternalError = -32110, + RateLimitReached = -32111, + NoVdfChallenge = -32112, + VdfVerifyFailed = -32113, } fn to_tuple(e: RpcError) -> (i64, String) { @@ -33,6 +36,9 @@ fn to_tuple(e: RpcError) -> (i64, String) { RpcError::TimeLimitReached => "Timeout not expired, try again later", RpcError::ParseError => "Parse error", RpcError::InternalError => "Internal error", + RpcError::RateLimitReached => "Rate limit reached, try again later", + RpcError::NoVdfChallenge => "No VDF challenge found for pubkey, request it first", + RpcError::VdfVerifyFailed => "VDF verification failed", }; (e as i64, msg.to_string()) diff --git a/bin/faucetd/src/main.rs b/bin/faucetd/src/main.rs index 51f2876b7..7eb3257e8 100644 --- a/bin/faucetd/src/main.rs +++ b/bin/faucetd/src/main.rs @@ -45,10 +45,12 @@ use darkfi_money_contract::{ }; use darkfi_sdk::{ crypto::{ - constants::MERKLE_DEPTH, contract_id::MONEY_CONTRACT_ID, Keypair, MerkleNode, PublicKey, - DARK_TOKEN_ID, + constants::MERKLE_DEPTH, contract_id::MONEY_CONTRACT_ID, mimc_vdf, Keypair, MerkleNode, + PublicKey, DARK_TOKEN_ID, }, incrementalmerkletree::bridgetree::BridgeTree, + num_bigint::BigUint, + num_traits::Num, pasta::{group::ff::PrimeField, pallas}, tx::ContractCall, }; @@ -191,6 +193,7 @@ pub struct Faucetd { airdrop_timeout: i64, airdrop_limit: u64, airdrop_map: Arc>>, + challenge_map: Arc>>, proving_keys: ProvingKeyMap, } @@ -204,6 +207,7 @@ impl RequestHandler for Faucetd { let params = req.params.as_array().unwrap(); match req.method.as_str() { + Some("challenge") => return self.challenge(req.id, params).await, Some("airdrop") => return self.airdrop(req.id, params).await, Some(_) | None => return JsonError::new(MethodNotFound, None, req.id).into(), } @@ -283,6 +287,7 @@ impl Faucetd { airdrop_timeout: timeout, airdrop_limit: limit, airdrop_map: Arc::new(Mutex::new(HashMap::new())), + challenge_map: Arc::new(Mutex::new(HashMap::new())), proving_keys, }; @@ -362,17 +367,75 @@ impl Faucetd { Ok(keypair) } + // RPCAPI: + // Request a VDF challenge in order to become eligible for an airdrop. It is then + // necessary to execute the VDF with the challenge as input and pass it to the + // `airdrop` call, which the faucet will then verify. + // Params: + // 0: base58 encoded address of the recipient + // Returns: + // 0: hex-encoded challenge string + // 1: n steps needed for VDF evaluation + // + // --> {"jsonrpc": "2.0", "method": "challenge", "params": ["1DarkFi..."], "id": 1} + // <-- {"jsonrpc": "2.0", "result": "["0x123...", 10000]", "id": 1} + async fn challenge(&self, id: Value, params: &[Value]) -> JsonResult { + const N_STEPS: u64 = 2_000_000; + + if params.len() != 1 || !params[0].is_string() { + return JsonError::new(InvalidParams, None, id).into() + } + + if !(*self.synced.lock().await) { + error!("challenge(): Blockchain is not yet synced"); + return JsonError::new(InternalError, None, id).into() + } + + let pubkey = match PublicKey::from_str(params[0].as_str().unwrap()) { + Ok(v) => v, + Err(e) => { + error!("challenge(): Failed parsing PublicKey from String: {}", e); + return server_error(RpcError::ParseError, id) + } + }; + + let map = self.challenge_map.lock().await; + if map.contains_key(&pubkey.to_bytes()) { + return server_error(RpcError::RateLimitReached, id) + } + drop(map); + + // Create a random challenge + let mut hasher = blake3::Hasher::new(); + hasher.update(&pubkey.to_bytes()); + hasher.update(&pallas::Base::random(&mut OsRng).to_repr()); + let h = hasher.finalize(); + let c = BigUint::from_str_radix(&h.to_hex(), 16).unwrap(); + + // Add/Update this airdrop into the hashmap + let mut map = self.challenge_map.lock().await; + map.insert(pubkey.to_bytes(), (c.clone(), N_STEPS)); + drop(map); + + JsonResponse::new(json!([c.to_str_radix(16), N_STEPS]), id).into() + } + // RPCAPI: // Processes a native token airdrop request and airdrops requested amount to address. // Returns the transaction ID upon success. // Params: // 0: base58 encoded address of the recipient // 1: Amount to airdrop in form of f64 + // 2: VDF evaluation witness as hex-encoded BigUint string // - // --> {"jsonrpc": "2.0", "method": "airdrop", "params": ["1DarkFi...", 1.42], "id": 1} + // --> {"jsonrpc": "2.0", "method": "airdrop", "params": ["1DarkFi...", 1.42, "0x123..."], "id": 1} // <-- {"jsonrpc": "2.0", "result": "txID", "id": 1} async fn airdrop(&self, id: Value, params: &[Value]) -> JsonResult { - if params.len() != 2 || !params[0].is_string() || !params[1].is_f64() { + if params.len() != 3 || + !params[0].is_string() || + !params[1].is_f64() || + !params[2].is_string() + { return JsonError::new(InvalidParams, None, id).into() } @@ -381,6 +444,7 @@ impl Faucetd { return JsonError::new(InternalError, None, id).into() } + // Decode public key let pubkey = match PublicKey::from_str(params[0].as_str().unwrap()) { Ok(v) => v, Err(e) => { @@ -389,6 +453,7 @@ impl Faucetd { } }; + // Decode requested airdrop amount let amount = params[1].as_f64().unwrap().to_string(); let amount = match decode_base10(&amount, 8, true) { Ok(v) => v, @@ -402,16 +467,45 @@ impl Faucetd { return server_error(RpcError::AmountExceedsLimit, id) } + // Decode VDF witness + let witness = params[2].as_str().unwrap(); + let Ok(witness) = BigUint::from_str_radix(witness, 16) else { + error!("airdrop(): Failed parsing VDF witness from string"); + return server_error(RpcError::ParseError, id) + }; + // Check if there as a previous airdrop and the timeout has passed. let now = Utc::now().timestamp(); let map = self.airdrop_map.lock().await; if let Some(last_airdrop) = map.get(&pubkey.to_bytes()) { if now - last_airdrop <= self.airdrop_timeout { + error!("airdrop(): Time limit reached for {}", pubkey); return server_error(RpcError::TimeLimitReached, id) } }; drop(map); + // Check if a VDF challenge exists + let map = self.challenge_map.lock().await; + let Some((challenge, n_steps)) = map.get(&pubkey.to_bytes()).cloned() else { + error!("airdrop(): No VDF challenge found for {}", pubkey); + return server_error(RpcError::NoVdfChallenge, id) + }; + drop(map); + + // Verify the VDF + info!("airdrop(): Verifying VDF for {}...", pubkey); + if !mimc_vdf::verify(&challenge, n_steps, &witness) { + error!("airdrop(): VDF verification failed for {}", pubkey); + return server_error(RpcError::VdfVerifyFailed, id) + } + + // Remove the challenge from the map at this point. Latter stuff might + // fail, but we want clients to be able to request things again. + let mut mut_map = self.challenge_map.lock().await; + mut_map.remove(&pubkey.to_bytes()); + drop(mut_map); + let cid = *MONEY_CONTRACT_ID; let (mint_zkbin, mint_pk, burn_zkbin, burn_pk) = { @@ -496,16 +590,20 @@ impl Faucetd { } } -async fn prune_airdrop_map(map: Arc>>, timeout: i64) { +async fn prune_airdrop_maps( + rate_map: Arc>>, + challenge_map: Arc>>, + timeout: i64, +) { loop { sleep(timeout as u64).await; - debug!("Pruning airdrop map"); + debug!("Pruning airdrop maps"); let now = Utc::now().timestamp(); let mut prune = vec![]; - let im_map = map.lock().await; + let im_map = rate_map.lock().await; for (k, v) in im_map.iter() { if now - *v > timeout { prune.push(*k); @@ -513,11 +611,16 @@ async fn prune_airdrop_map(map: Arc>>, timeout: i64 } drop(im_map); - let mut mut_map = map.lock().await; + let mut mut_rate_map = rate_map.lock().await; + let mut mut_challenge_map = challenge_map.lock().await; + for i in prune { - mut_map.remove(&i); + mut_rate_map.remove(&i); + mut_challenge_map.remove(&i); } - drop(mut_map); + + drop(mut_rate_map); + drop(mut_challenge_map); } } @@ -655,8 +758,13 @@ async fn realmain(args: Args, ex: Arc>) -> Result<()> { .await?; let faucetd = Arc::new(faucetd); - // Task to periodically clean up the hashmap of airdrops. - ex.spawn(prune_airdrop_map(faucetd.airdrop_map.clone(), airdrop_timeout)).detach(); + // Task to periodically clean up the airdrop rate/challenge hashmaps + ex.spawn(prune_airdrop_maps( + faucetd.airdrop_map.clone(), + faucetd.challenge_map.clone(), + airdrop_timeout, + )) + .detach(); // JSON-RPC server info!("Starting JSON-RPC server"); diff --git a/contrib/localnet/darkfid-single-node/faucetd.toml b/contrib/localnet/darkfid-single-node/faucetd.toml index 2ccaa82a7..2a422ec22 100644 --- a/contrib/localnet/darkfid-single-node/faucetd.toml +++ b/contrib/localnet/darkfid-single-node/faucetd.toml @@ -46,7 +46,7 @@ localnet = true channel_log = true # Airdrop timeout limit in seconds -airdrop_timeout = 10 +airdrop_timeout = 120 # Airdrop amount limit airdrop_limit = "1000000" diff --git a/contrib/localnet/darkfid/faucetd.toml b/contrib/localnet/darkfid/faucetd.toml index ede5ea643..9e09e8b7a 100644 --- a/contrib/localnet/darkfid/faucetd.toml +++ b/contrib/localnet/darkfid/faucetd.toml @@ -46,7 +46,7 @@ localnet = true channel_log = true # Airdrop timeout limit in seconds -airdrop_timeout = 10 +airdrop_timeout = 120 # Airdrop amount limit airdrop_limit = "1000000"