diff --git a/packages/ciphernode/Cargo.lock b/packages/ciphernode/Cargo.lock index 580d8beb..fde6c003 100644 --- a/packages/ciphernode/Cargo.lock +++ b/packages/ciphernode/Cargo.lock @@ -1694,6 +1694,7 @@ dependencies = [ "dirs", "figment", "serde", + "url", ] [[package]] @@ -2164,12 +2165,15 @@ dependencies = [ "config", "data", "dialoguer", + "dirs", "enclave-core", "enclave_node", "hex", "once_cell", + "phf", "router", - "rpassword", + "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", @@ -4587,6 +4591,48 @@ dependencies = [ "rustc_version 0.4.0", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.72", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -5235,17 +5281,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "rpassword" -version = "7.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80472be3c897911d0137b2d2b9055faf6eeac5b14e324073d83bc17b191d7e3f" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.48.0", -] - [[package]] name = "rtnetlink" version = "0.10.1" @@ -5262,16 +5297,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "rtoolbox" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c247d24e63230cdb56463ae328478bd5eac8b8faa8c69461a77e8e323afac90e" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - [[package]] name = "ruint" version = "1.12.3" @@ -5575,9 +5600,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.127" +version = "1.0.133" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad" +checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" dependencies = [ "itoa", "memchr", @@ -5686,6 +5711,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + [[package]] name = "slab" version = "0.4.9" diff --git a/packages/ciphernode/Cargo.toml b/packages/ciphernode/Cargo.toml index c7df1e8c..382ac6b7 100644 --- a/packages/ciphernode/Cargo.toml +++ b/packages/ciphernode/Cargo.toml @@ -51,6 +51,7 @@ num = "0.4.3" rand_chacha = "0.3.1" rand = "0.8.5" serde = { version = "1.0.208", features = ["derive"] } +serde_json = { version = "1.0.133" } sled = "0.34.7" sha2 = "0.10.8" tokio = { version = "1.38", features = ["full"] } diff --git a/packages/ciphernode/cipher/src/password_manager.rs b/packages/ciphernode/cipher/src/password_manager.rs index 550eadf2..6cfe02d8 100644 --- a/packages/ciphernode/cipher/src/password_manager.rs +++ b/packages/ciphernode/cipher/src/password_manager.rs @@ -14,6 +14,7 @@ pub trait PasswordManager { async fn get_key(&self) -> Result>>; async fn delete_key(&mut self) -> Result<()>; async fn set_key(&mut self, contents: Zeroizing>) -> Result<()>; + fn is_set(&self) -> bool; } pub struct InMemPasswordManager(pub Option>>); @@ -54,6 +55,10 @@ impl PasswordManager for EnvPasswordManager { self.0 = None; Ok(()) } + + fn is_set(&self) -> bool { + self.0.is_some() + } } #[async_trait] @@ -73,6 +78,10 @@ impl PasswordManager for InMemPasswordManager { self.0 = None; Ok(()) } + + fn is_set(&self) -> bool { + self.0.is_some() + } } pub struct FilePasswordManager { @@ -149,6 +158,11 @@ impl PasswordManager for FilePasswordManager { Ok(()) } + + fn is_set(&self) -> bool { + let path = &self.path; + path.exists() + } } fn ensure_file_permissions(path: &PathBuf, perms: u32) -> Result<()> { diff --git a/packages/ciphernode/config/Cargo.toml b/packages/ciphernode/config/Cargo.toml index a694ceb5..a336f7fe 100644 --- a/packages/ciphernode/config/Cargo.toml +++ b/packages/ciphernode/config/Cargo.toml @@ -9,3 +9,5 @@ anyhow = { workspace = true } serde = { workspace = true } figment = { workspace = true } alloy = { workspace = true } +url = { workspace = true } + diff --git a/packages/ciphernode/config/src/app_config.rs b/packages/ciphernode/config/src/app_config.rs index 903af725..133c23be 100644 --- a/packages/ciphernode/config/src/app_config.rs +++ b/packages/ciphernode/config/src/app_config.rs @@ -1,4 +1,7 @@ use alloy::primitives::Address; +use anyhow::anyhow; +use anyhow::bail; +use anyhow::Context; use anyhow::Result; use figment::{ providers::{Format, Serialized, Yaml}, @@ -9,6 +12,7 @@ use std::{ env, path::{Path, PathBuf}, }; +use url::Url; #[derive(Debug, Deserialize, Serialize, PartialEq)] #[serde(untagged)] @@ -20,6 +24,63 @@ pub enum Contract { AddressOnly(String), } +#[derive(Clone)] +pub enum RPC { + Http(String), + Https(String), + Ws(String), + Wss(String), +} + +impl RPC { + pub fn from_url(url: &str) -> Result { + let parsed = Url::parse(url).context("Invalid URL format")?; + match parsed.scheme() { + "http" => Ok(RPC::Http(url.to_string())), + "https" => Ok(RPC::Https(url.to_string())), + "ws" => Ok(RPC::Ws(url.to_string())), + "wss" => Ok(RPC::Wss(url.to_string())), + _ => bail!("Invalid protocol. Expected: http://, https://, ws://, wss://"), + } + } + + pub fn as_http_url(&self) -> Result { + match self { + RPC::Http(url) | RPC::Https(url) => Ok(url.clone()), + RPC::Ws(url) | RPC::Wss(url) => { + let mut parsed = + Url::parse(url).context(format!("Failed to parse URL: {}", url))?; + parsed + .set_scheme(if self.is_secure() { "https" } else { "http" }) + .map_err(|_| anyhow!("http(s) are valid schemes"))?; + Ok(parsed.to_string()) + } + } + } + + pub fn as_ws_url(&self) -> Result { + match self { + RPC::Ws(url) | RPC::Wss(url) => Ok(url.clone()), + RPC::Http(url) | RPC::Https(url) => { + let mut parsed = + Url::parse(url).context(format!("Failed to parse URL: {}", url))?; + parsed + .set_scheme(if self.is_secure() { "wss" } else { "ws" }) + .map_err(|_| anyhow!("ws(s) are valid schemes"))?; + Ok(parsed.to_string()) + } + } + } + + pub fn is_websocket(&self) -> bool { + matches!(self, RPC::Ws(_) | RPC::Wss(_)) + } + + pub fn is_secure(&self) -> bool { + matches!(self, RPC::Https(_) | RPC::Wss(_)) + } +} + impl Contract { pub fn address(&self) -> &String { use Contract::*; @@ -63,12 +124,19 @@ impl Default for RpcAuth { pub struct ChainConfig { pub enabled: Option, pub name: String, - pub rpc_url: String, // We may need multiple per chain for redundancy at a later point + rpc_url: String, // We may need multiple per chain for redundancy at a later point #[serde(default)] pub rpc_auth: RpcAuth, pub contracts: ContractAddresses, } +impl ChainConfig { + pub fn rpc_url(&self) -> Result { + Ok(RPC::from_url(&self.rpc_url) + .map_err(|e| anyhow!("Failed to parse RPC URL for chain {}: {}", self.name, e))?) + } +} + #[derive(Debug, Deserialize, Serialize)] pub struct AppConfig { /// The chains config @@ -254,10 +322,12 @@ fn expand_tilde(path: &Path) -> PathBuf { struct OsDirs; impl OsDirs { pub fn config_dir() -> PathBuf { + // TODO: handle unwrap error case dirs::config_dir().unwrap().join("enclave") } pub fn data_dir() -> PathBuf { + // TODO: handle unwrap error case dirs::data_local_dir().unwrap().join("enclave") } } diff --git a/packages/ciphernode/docs/user_guide.md b/packages/ciphernode/docs/user_guide.md new file mode 100644 index 00000000..c7c1bb69 --- /dev/null +++ b/packages/ciphernode/docs/user_guide.md @@ -0,0 +1,149 @@ +# Running a Ciphernode + +_NOTE: passing an address to a node may not be required in future versions as we may be moving towards BLS keys_ + +You can use the cli to setup your node: + +``` +$ enclave init +Enter WebSocket devnet RPC URL [wss://ethereum-sepolia-rpc.publicnode.com]: wss://ethereum-sepolia-rpc.publicnode.com +✔ Enter your Ethereum address (press Enter to skip) · 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + + +Please enter a new password: +Please confirm your password: +Password sucessfully set. +Enclave configuration successfully created! +You can start your node using `enclave start` +``` + +This will setup an initial configuration: + +``` +$ cat ~/.config/enclave/config.yaml +--- +# Enclave Configuration File +# Ethereum Account Configuration +address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" +chains: + - name: "devnet" + rpc_url: "wss://ethereum-sepolia-rpc.publicnode.com" + contracts: + enclave: + address: "0xCe087F31e20E2F76b6544A2E4A74D4557C8fDf77" + deploy_block: 7073317 + ciphernode_registry: + address: "0x0952388f6028a9Eda93a5041a3B216Ea331d97Ab" + deploy_block: 7073318 + filter_registry: + address: "0xcBaCE7C360b606bb554345b20884A28e41436934" + deploy_block: 7073319 +``` + +It will also setup the nodes key_file in the following path: + +``` +~/.config/enclave/key +``` + +You can now setup your wallet if you have your node configured for writing to the blockchain: + +``` +# Example key DO NOT USE +$ enclave wallet set --private-key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +``` + +_*NOTE: do not use the above private key as this is obviously public and all funds will be lost_ + +## Configuration + +Enclave is configured using a configuration file. By default this file is located under `~/.config/enclave/config.yaml` + +Default values for this file might effectively look like: + +``` +# ~/.config/enclave/config.yaml +key_file: "{config_dir}/key" +db_file: "{data_dir}/db" +config_dir: "~/.config/enclave" +data_dir: "~/.local/share/enclave" +``` + +> Note if you set `config_dir` it will change the default location for both the config file and the `key_file` and if you specify `data_dir` it will change the default location for the `db_file` for example: +> If I run `enclave start --config ./some-config.yaml` where `./some-config.yaml` contains: +> +> ``` +> # some-config.yaml +> config_dir: "/my/config/dir" +> ``` +> +> The `enclave` binary will look for the key_file under: `/my/config/dir/key` + +### Setting a relative folder as a config dir + +You may set a relative folder for your config and data dirs. + +``` +# /path/to/config.yaml +config_dir: ./conf +data_dir: ./data +``` + +Within the above config the `key_file` location will be: `/path/to/conf/key` and the `db_file` will be `/path/to/data/db`. + +## Providing a registration address + +_NOTE: this will likely change soon as we move to using BLS signatures for Ciphernode identification_ + +Ciphernodes need a registration address to identify themselves within a committee you can specify this with the `address` field within the configuration: + +``` +# ~/.config/enclave/config.yaml +address: "0x2546BcD3c84621e976D8185a91A922aE77ECEc30" +``` + +## Setting your encryption password + +Your encryption password is required to encrypt sensitive data within the database such as keys and wallet private keys. You can set this key in two ways: + +1. Use the command line +2. Provide a key file + +## Provide your password using the commandline + +``` +> enclave password create + +Please enter a new password: +``` + +Enter your chosen password. + +``` +Please confirm your password: +``` + +Enter your password again to confirm it. + +``` +Password sucessfully set. +``` + +Assuming default settings you should now be able to find your keyfile under `~/.config/enclave/key` + +## Provide your password using a key file + +You can use a keyfile to provide your password by creating a file under `~/.config/enclave/key` and setting the file permissions to `400` + +``` +mkdir -p ~/.config/enclave && read -s password && echo -n "$password" > ~/.config/enclave/key && chmod 400 ~/.config/enclave/key +``` + +You can change the location of your keyfile by using the `key_file` option within your configuration file: + +``` +# ~/.config/enclave/config.yaml +key_file: "/path/to/enclave/key" +``` + + diff --git a/packages/ciphernode/enclave/Cargo.toml b/packages/ciphernode/enclave/Cargo.toml index f0a93ab7..d38cf579 100644 --- a/packages/ciphernode/enclave/Cargo.toml +++ b/packages/ciphernode/enclave/Cargo.toml @@ -4,8 +4,7 @@ version = "0.1.0" edition = "2021" description = ": coordinates the encryption and decryption of enclave computations" repository = "https://github.com/gnosisguild/enclave/packages/ciphernode" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +build = "build.rs" [dependencies] actix = { workspace = true } @@ -15,14 +14,20 @@ cipher = { path = "../cipher" } clap = { workspace = true } config = { path = "../config" } data = { path = "../data" } +dirs = { workspace = true } dialoguer = "0.11.0" enclave-core = { path = "../core" } enclave_node = { path = "../enclave_node" } hex = { workspace = true } once_cell = "1.20.2" router = { path = "../router" } -rpassword = "7.3.1" tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } zeroize = { workspace = true } +phf = { version = "0.11", features = ["macros"] } + +[build-dependencies] +serde_json = { workspace = true } diff --git a/packages/ciphernode/enclave/build.rs b/packages/ciphernode/enclave/build.rs new file mode 100644 index 00000000..01a47dd1 --- /dev/null +++ b/packages/ciphernode/enclave/build.rs @@ -0,0 +1,63 @@ +// Here we build some contract information from the EVM deployment artifacts that we can use within +// our binaries. Specifically we wbuild out a rust file that has a structure we can import and use +// within our configuration builder +use serde_json::{from_reader, Value}; +use std::env; +use std::fs::{self, File}; +use std::path::Path; + +fn main() -> std::io::Result<()> { + // Get the manifest directory (where Cargo.toml is located) + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + + // Path to deployment artifacts + let deployments_path = Path::new(&manifest_dir) + .join("..") // Adjust based on your actual path structure + .join("..") + .join("evm") + .join("deployments") + .join("sepolia"); + + // Create output string for contract info + let mut contract_info = String::from( + "pub struct ContractInfo {\n pub address: &'static str,\n pub deploy_block: u64,\n}\n\n" + ); + contract_info.push_str( + "pub static CONTRACT_DEPLOYMENTS: phf::Map<&'static str, ContractInfo> = phf::phf_map! {\n", + ); + + // Process each JSON file in the deployments directory + for entry in fs::read_dir(deployments_path)? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + let contract_name = path.file_stem().and_then(|s| s.to_str()).unwrap(); + + let file = File::open(&path)?; + let json: Value = from_reader(file)?; + + // Extract address and block number + if let (Some(address), Some(deploy_block)) = ( + json["address"].as_str(), + json["receipt"]["blockNumber"].as_u64(), + ) { + contract_info.push_str(&format!( + " \"{}\" => ContractInfo {{\n address: \"{}\",\n deploy_block: {},\n }},\n", + contract_name, address, deploy_block + )); + } + } + } + + contract_info.push_str("};\n"); + + // Write the generated code to a file + let out_dir = env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("contract_deployments.rs"); + fs::write(dest_path, contract_info)?; + + println!("cargo:rerun-if-changed=../packages/evm/deployments/sepolia"); + + Ok(()) +} diff --git a/packages/ciphernode/enclave/src/commands/init.rs b/packages/ciphernode/enclave/src/commands/init.rs new file mode 100644 index 00000000..65b16658 --- /dev/null +++ b/packages/ciphernode/enclave/src/commands/init.rs @@ -0,0 +1,166 @@ +use crate::commands::password::{self, PasswordCommands}; +use anyhow::anyhow; +use anyhow::bail; +use anyhow::Result; +use config::load_config; +use config::RPC; +use dialoguer::{theme::ColorfulTheme, Input}; +use enclave_core::get_tag; +use std::fs; +use tracing::instrument; + +// Import a built file: +// see /target/debug/enclave-xxxxxx/out/contract_deployments.rs +// also see build.rs +include!(concat!(env!("OUT_DIR"), "/contract_deployments.rs")); + +// Get the ContractInfo object +fn get_contract_info(name: &str) -> Result<&ContractInfo> { + Ok(CONTRACT_DEPLOYMENTS + .get(name) + .ok_or(anyhow!("Could not get contract info"))?) +} + +fn validate_rpc_url(url: &String) -> Result<()> { + RPC::from_url(url)?; + Ok(()) +} + +fn validate_eth_address(address: &String) -> Result<()> { + if address.is_empty() { + return Ok(()); + } + if !address.starts_with("0x") { + bail!("Address must start with '0x'") + } + if address.len() != 42 { + bail!("Address must be 42 characters long (including '0x')") + } + for c in address[2..].chars() { + if !c.is_ascii_hexdigit() { + bail!("Address must contain only hexadecimal characters") + } + } + Ok(()) +} + +#[instrument(name = "app", skip_all, fields(id = get_tag()))] +pub async fn execute( + rpc_url: Option, + eth_address: Option, + password: Option, + skip_eth: bool, +) -> Result<()> { + let rpc_url = match rpc_url { + Some(url) => { + validate_rpc_url(&url)?; + url + } + None => Input::::new() + .with_prompt("Enter WebSocket devnet RPC URL") + .default("wss://ethereum-sepolia-rpc.publicnode.com".to_string()) + .validate_with(validate_rpc_url) + .interact_text()?, + }; + + let eth_address: Option = match eth_address { + Some(address) => { + validate_eth_address(&address)?; + Some(address) + } + None => { + if skip_eth { + None + } else { + Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter your Ethereum address (press Enter to skip)") + .allow_empty(true) + .validate_with(validate_eth_address) + .interact() + .ok() + .map(|s| if s.is_empty() { None } else { Some(s) }) + .flatten() + } + } + }; + + let config_dir = dirs::home_dir() + .ok_or_else(|| anyhow!("Could not determine home directory"))? + .join(".config") + .join("enclave"); + fs::create_dir_all(&config_dir)?; + + let config_path = config_dir.join("config.yaml"); + + let config_content = format!( + r#"--- +# Enclave Configuration File +{} +chains: + - name: "devnet" + rpc_url: "{}" + contracts: + enclave: + address: "{}" + deploy_block: {} + ciphernode_registry: + address: "{}" + deploy_block: {} + filter_registry: + address: "{}" + deploy_block: {} +"#, + eth_address.map_or(String::new(), |addr| format!( + "# Ethereum Account Configuration\naddress: \"{}\"", + addr + )), + rpc_url, + get_contract_info("Enclave")?.address, + get_contract_info("Enclave")?.deploy_block, + get_contract_info("CiphernodeRegistryOwnable")?.address, + get_contract_info("CiphernodeRegistryOwnable")?.deploy_block, + get_contract_info("NaiveRegistryFilter")?.address, + get_contract_info("NaiveRegistryFilter")?.deploy_block, + ); + + fs::write(config_path.clone(), config_content)?; + + // Load with default location + let config = load_config(Some(&config_path.display().to_string()))?; + + password::execute( + PasswordCommands::Create { + password, + overwrite: true, + }, + config, + ) + .await?; + + println!("Enclave configuration successfully created!"); + println!("You can start your node using `enclave start`"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_eth_address; + use anyhow::Result; + + #[test] + fn eth_address_validation() -> Result<()> { + assert!( + validate_eth_address(&"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string()).is_ok() + ); + assert!( + validate_eth_address(&"d8dA6BF26964aF9D7eEd9e03E53415D37aA96045".to_string()).is_err() + ); + assert!(validate_eth_address(&"0x1234567890abcdef".to_string()).is_err()); + assert!( + validate_eth_address(&"0x0000000000000000000000000000000000000000".to_string()).is_ok() + ); + + Ok(()) + } +} diff --git a/packages/ciphernode/enclave/src/commands/mod.rs b/packages/ciphernode/enclave/src/commands/mod.rs index 169593af..ec26de49 100644 --- a/packages/ciphernode/enclave/src/commands/mod.rs +++ b/packages/ciphernode/enclave/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod aggregator; +pub mod init; pub mod net; pub mod password; pub mod start; @@ -38,4 +39,22 @@ pub enum Commands { #[command(subcommand)] command: NetCommands, }, + + Init { + /// An rpc url for enclave to connect to + #[arg(long = "rpc-url")] + rpc_url: Option, + + /// An Ethereum address that enclave should use to identify the node + #[arg(long = "eth-address")] + eth_address: Option, + + /// The password + #[arg(short, long)] + password: Option, + + /// Skip asking for eth + #[arg(long = "skip-eth")] + skip_eth: bool, + }, } diff --git a/packages/ciphernode/enclave/src/commands/password/create.rs b/packages/ciphernode/enclave/src/commands/password/create.rs index 5af3fa46..382d87e6 100644 --- a/packages/ciphernode/enclave/src/commands/password/create.rs +++ b/packages/ciphernode/enclave/src/commands/password/create.rs @@ -1,9 +1,10 @@ use anyhow::{bail, Result}; use cipher::{FilePasswordManager, PasswordManager}; use config::AppConfig; -use rpassword::prompt_password; use zeroize::{Zeroize, Zeroizing}; +use super::prompt_password; + fn get_zeroizing_pw_vec(input: Option) -> Result>> { if let Some(mut pw_str) = input { if pw_str.trim().is_empty() { @@ -15,13 +16,13 @@ fn get_zeroizing_pw_vec(input: Option) -> Result>> { } // First password entry - let mut pw_str = prompt_password("\n\nPlease enter a new password: ")?; + let mut pw_str = prompt_password("Please enter a new password")?; if pw_str.trim().is_empty() { bail!("Password must not be blank") } // Second password entry for confirmation - let mut confirm_pw_str = prompt_password("Please confirm your password: ")?; + let mut confirm_pw_str = prompt_password("Please confirm your password")?; // Check if passwords match if pw_str.trim() != confirm_pw_str.trim() { @@ -40,9 +41,18 @@ fn get_zeroizing_pw_vec(input: Option) -> Result>> { Ok(pw) } -pub async fn execute(config: &AppConfig, input: Option) -> Result<()> { +pub async fn execute(config: &AppConfig, input: Option, overwrite: bool) -> Result<()> { let key_file = config.key_file(); let mut pm = FilePasswordManager::new(key_file); + + if overwrite && pm.is_set() { + pm.delete_key().await?; + } + + if pm.is_set() { + bail!("Keyfile already exists. Refusing to overwrite. Try using `enclave password overwrite` or `enclave password delete` in order to change or delete your password.") + } + let pw = get_zeroizing_pw_vec(input)?; match pm.set_key(pw).await { diff --git a/packages/ciphernode/enclave/src/commands/password/delete.rs b/packages/ciphernode/enclave/src/commands/password/delete.rs index bffdf6a0..0bf07077 100644 --- a/packages/ciphernode/enclave/src/commands/password/delete.rs +++ b/packages/ciphernode/enclave/src/commands/password/delete.rs @@ -2,9 +2,10 @@ use anyhow::*; use cipher::{FilePasswordManager, PasswordManager}; use config::AppConfig; use dialoguer::{theme::ColorfulTheme, Confirm}; -use rpassword::prompt_password; use zeroize::Zeroize; +use super::prompt_password; + pub enum DeleteMode { Delete, Overwrite, @@ -29,9 +30,13 @@ pub async fn prompt_delete(config: &AppConfig, delete_mode: DeleteMode) -> Resul .interact()?; if proceed { - let mut pw_str = prompt_password("\n\nPlease enter the current password: ")?; let key_file = config.key_file(); let mut pm = FilePasswordManager::new(key_file); + if !pm.is_set() { + println!("Password is not set. Nothing to do."); + return Ok(false); + } + let mut pw_str = prompt_password("Please enter the current password")?; let mut cur_pw = pm.get_key().await?; if pw_str != String::from_utf8_lossy(&cur_pw) { diff --git a/packages/ciphernode/enclave/src/commands/password/helpers.rs b/packages/ciphernode/enclave/src/commands/password/helpers.rs new file mode 100644 index 00000000..d0a4f91d --- /dev/null +++ b/packages/ciphernode/enclave/src/commands/password/helpers.rs @@ -0,0 +1,10 @@ +use anyhow::Result; +use dialoguer::{theme::ColorfulTheme, Password}; + +pub fn prompt_password(prompt: impl Into) -> Result { + let password = Password::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .interact()?; + + Ok(password) +} diff --git a/packages/ciphernode/enclave/src/commands/password/mod.rs b/packages/ciphernode/enclave/src/commands/password/mod.rs index 8067bc1e..5ebaf984 100644 --- a/packages/ciphernode/enclave/src/commands/password/mod.rs +++ b/packages/ciphernode/enclave/src/commands/password/mod.rs @@ -1,9 +1,11 @@ mod create; mod delete; +mod helpers; mod overwrite; use anyhow::*; use clap::Subcommand; use config::AppConfig; +use helpers::*; #[derive(Subcommand, Debug)] pub enum PasswordCommands { @@ -12,6 +14,9 @@ pub enum PasswordCommands { /// The new password #[arg(short, long)] password: Option, + + #[arg(short, long)] + overwrite: bool, }, /// Delete the current password @@ -27,7 +32,10 @@ pub enum PasswordCommands { pub async fn execute(command: PasswordCommands, config: AppConfig) -> Result<()> { match command { - PasswordCommands::Create { password } => create::execute(&config, password).await?, + PasswordCommands::Create { + password, + overwrite, + } => create::execute(&config, password, overwrite).await?, PasswordCommands::Delete => delete::execute(&config).await?, PasswordCommands::Overwrite { password } => overwrite::execute(&config, password).await?, }; diff --git a/packages/ciphernode/enclave/src/commands/password/overwrite.rs b/packages/ciphernode/enclave/src/commands/password/overwrite.rs index 301539d5..1720bb36 100644 --- a/packages/ciphernode/enclave/src/commands/password/overwrite.rs +++ b/packages/ciphernode/enclave/src/commands/password/overwrite.rs @@ -5,7 +5,7 @@ use config::AppConfig; pub async fn execute(config: &AppConfig, input: Option) -> Result<()> { if prompt_delete(config, DeleteMode::Overwrite).await? { - set_password(config, input).await?; + set_password(config, input, true).await?; } Ok(()) } diff --git a/packages/ciphernode/enclave/src/commands/wallet/mod.rs b/packages/ciphernode/enclave/src/commands/wallet/mod.rs index 43130405..6c41c82b 100644 --- a/packages/ciphernode/enclave/src/commands/wallet/mod.rs +++ b/packages/ciphernode/enclave/src/commands/wallet/mod.rs @@ -10,7 +10,7 @@ pub enum WalletCommands { /// The new private key - note we are leaving as hex string as it is easier to manage with /// the allow Signer coercion #[arg(long = "private-key", value_parser = ensure_hex)] - private_key: String, + private_key: Option, }, } diff --git a/packages/ciphernode/enclave/src/commands/wallet/set.rs b/packages/ciphernode/enclave/src/commands/wallet/set.rs index ae6d5773..0e1faac5 100644 --- a/packages/ciphernode/enclave/src/commands/wallet/set.rs +++ b/packages/ciphernode/enclave/src/commands/wallet/set.rs @@ -1,11 +1,52 @@ use actix::Actor; -use anyhow::{bail, Result}; +use anyhow::{anyhow, bail, Result}; use cipher::Cipher; use config::AppConfig; +use dialoguer::{theme::ColorfulTheme, Password}; use enclave_core::{EventBus, GetErrors}; use enclave_node::get_repositories; -pub async fn execute(config: &AppConfig, input: String) -> Result<()> { +pub fn validate_private_key(input: &String) -> Result<()> { + // Require 0x prefix + if !input.starts_with("0x") { + return Err(anyhow!( + "Invalid private key format: must start with '0x' prefix" + )); + } + + // Remove 0x prefix + let key = &input[2..]; + + // Check length + if key.len() != 64 { + return Err(anyhow!( + "Invalid private key length: {}. Expected 64 characters after '0x' prefix", + key.len() + )); + } + + // Validate hex characters and convert to bytes + let _ = (0..key.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&key[i..i + 2], 16)) + .collect::, _>>() + .map_err(|e| anyhow!("Invalid hex character: {}", e))?; + + Ok(()) +} +pub async fn execute(config: &AppConfig, private_key: Option) -> Result<()> { + let input = if let Some(private_key) = private_key { + validate_private_key(&private_key)?; + private_key + } else { + Password::with_theme(&ColorfulTheme::default()) + .with_prompt("Enter your Ethereum private key") + .validate_with(validate_private_key) + .interact()? + .trim() + .to_string() + }; + let cipher = Cipher::from_config(config).await?; let encrypted = cipher.encrypt_data(&mut input.as_bytes().to_vec())?; let bus = EventBus::new(true).start(); diff --git a/packages/ciphernode/enclave/src/main.rs b/packages/ciphernode/enclave/src/main.rs index b49df740..808b7629 100644 --- a/packages/ciphernode/enclave/src/main.rs +++ b/packages/ciphernode/enclave/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use commands::{aggregator, net, password, start, wallet, Commands}; +use commands::{aggregator, init, net, password, start, wallet, Commands}; use config::load_config; use enclave_core::{get_tag, set_tag}; use tracing::instrument; @@ -50,6 +50,12 @@ impl Cli { match self.command { Commands::Start => start::execute(config).await?, + Commands::Init { + rpc_url, + eth_address, + password, + skip_eth, + } => init::execute(rpc_url, eth_address, password, skip_eth).await?, Commands::Password { command } => password::execute(command, config).await?, Commands::Aggregator { command } => aggregator::execute(command, config).await?, Commands::Wallet { command } => wallet::execute(command, config).await?, diff --git a/packages/ciphernode/enclave_node/src/aggregator.rs b/packages/ciphernode/enclave_node/src/aggregator.rs index 1cfbfbdf..b5dfba4e 100644 --- a/packages/ciphernode/enclave_node/src/aggregator.rs +++ b/packages/ciphernode/enclave_node/src/aggregator.rs @@ -4,7 +4,7 @@ use cipher::Cipher; use config::AppConfig; use enclave_core::EventBus; use evm::{ - helpers::{get_signer_from_repository, ProviderConfig, RPC}, + helpers::{get_signer_from_repository, ProviderConfig}, CiphernodeRegistrySol, EnclaveSol, RegistryFilterSol, }; use logger::SimpleLogger; @@ -28,9 +28,7 @@ pub async fn setup_aggregator( plaintext_write_path: Option<&str>, ) -> Result<(Addr, JoinHandle>, String)> { let bus = EventBus::new(true).start(); - let rng = Arc::new(Mutex::new( - ChaCha20Rng::from_rng(OsRng).expect("Failed to create RNG"), - )); + let rng = Arc::new(Mutex::new(ChaCha20Rng::from_rng(OsRng)?)); let store = setup_datastore(&config, &bus)?; let repositories = store.repositories(); let sortition = Sortition::attach(&bus, repositories.sortition()).await?; @@ -42,9 +40,7 @@ pub async fn setup_aggregator( .iter() .filter(|chain| chain.enabled.unwrap_or(true)) { - let rpc_url = RPC::from_url(&chain.rpc_url).map_err(|e| { - anyhow::anyhow!("Failed to parse RPC URL for chain {}: {}", chain.name, e) - })?; + let rpc_url = chain.rpc_url()?; let provider_config = ProviderConfig::new(rpc_url, chain.rpc_auth.clone()); let read_provider = provider_config.create_readonly_provider().await?; let write_provider = provider_config.create_ws_signer_provider(&signer).await?; diff --git a/packages/ciphernode/enclave_node/src/ciphernode.rs b/packages/ciphernode/enclave_node/src/ciphernode.rs index 4e760614..5b1e60d9 100644 --- a/packages/ciphernode/enclave_node/src/ciphernode.rs +++ b/packages/ciphernode/enclave_node/src/ciphernode.rs @@ -4,10 +4,7 @@ use anyhow::Result; use cipher::Cipher; use config::AppConfig; use enclave_core::{get_tag, EventBus}; -use evm::{ - helpers::{ProviderConfig, RPC}, - CiphernodeRegistrySol, EnclaveSolReader, -}; +use evm::{helpers::ProviderConfig, CiphernodeRegistrySol, EnclaveSolReader}; use logger::SimpleLogger; use net::NetworkManager; use rand::SeedableRng; @@ -27,9 +24,7 @@ pub async fn setup_ciphernode( config: AppConfig, address: Address, ) -> Result<(Addr, JoinHandle>, String)> { - let rng = Arc::new(Mutex::new( - rand_chacha::ChaCha20Rng::from_rng(OsRng).expect("Failed to create RNG"), - )); + let rng = Arc::new(Mutex::new(rand_chacha::ChaCha20Rng::from_rng(OsRng)?)); let bus = EventBus::new(true).start(); let cipher = Arc::new(Cipher::from_config(&config).await?); let store = setup_datastore(&config, &bus)?; @@ -44,9 +39,7 @@ pub async fn setup_ciphernode( .iter() .filter(|chain| chain.enabled.unwrap_or(true)) { - let rpc_url = RPC::from_url(&chain.rpc_url).map_err(|e| { - anyhow::anyhow!("Failed to parse RPC URL for chain {}: {}", chain.name, e) - })?; + let rpc_url = chain.rpc_url()?; let provider_config = ProviderConfig::new(rpc_url, chain.rpc_auth.clone()); let read_provider = provider_config.create_readonly_provider().await?; EnclaveSolReader::attach( diff --git a/packages/ciphernode/evm/src/helpers.rs b/packages/ciphernode/evm/src/helpers.rs index 9f6d7373..08a5921f 100644 --- a/packages/ciphernode/evm/src/helpers.rs +++ b/packages/ciphernode/evm/src/helpers.rs @@ -25,67 +25,10 @@ use alloy::{ use anyhow::{bail, Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine}; use cipher::Cipher; -use config::RpcAuth; +use config::{RpcAuth, RPC}; use data::Repository; use std::{env, marker::PhantomData, sync::Arc}; -use url::Url; use zeroize::Zeroizing; - -#[derive(Clone)] -pub enum RPC { - Http(String), - Https(String), - Ws(String), - Wss(String), -} - -impl RPC { - pub fn from_url(url: &str) -> Result { - let parsed = Url::parse(url).context("Invalid URL format")?; - match parsed.scheme() { - "http" => Ok(RPC::Http(url.to_string())), - "https" => Ok(RPC::Https(url.to_string())), - "ws" => Ok(RPC::Ws(url.to_string())), - "wss" => Ok(RPC::Wss(url.to_string())), - _ => bail!("Invalid protocol. Expected: http://, https://, ws://, wss://"), - } - } - - pub fn as_http_url(&self) -> String { - match self { - RPC::Http(url) | RPC::Https(url) => url.clone(), - RPC::Ws(url) | RPC::Wss(url) => { - let mut parsed = Url::parse(url).expect(&format!("Failed to parse URL: {}", url)); - parsed - .set_scheme(if self.is_secure() { "https" } else { "http" }) - .expect("http(s) are valid schemes"); - parsed.to_string() - } - } - } - - pub fn as_ws_url(&self) -> String { - match self { - RPC::Ws(url) | RPC::Wss(url) => url.clone(), - RPC::Http(url) | RPC::Https(url) => { - let mut parsed = Url::parse(url).expect(&format!("Failed to parse URL: {}", url)); - parsed - .set_scheme(if self.is_secure() { "wss" } else { "ws" }) - .expect("ws(s) are valid schemes"); - parsed.to_string() - } - } - } - - pub fn is_websocket(&self) -> bool { - matches!(self, RPC::Ws(_) | RPC::Wss(_)) - } - - pub fn is_secure(&self) -> bool { - matches!(self, RPC::Https(_) | RPC::Wss(_)) - } -} - pub trait AuthConversions { fn to_header_value(&self) -> Option; fn to_ws_auth(&self) -> Option; @@ -183,7 +126,7 @@ impl ProviderConfig { async fn create_ws_provider(&self) -> Result> { Ok(ProviderBuilder::new() - .on_ws(self.create_ws_connect()) + .on_ws(self.create_ws_connect()?) .await? .boxed()) } @@ -213,7 +156,7 @@ impl ProviderConfig { let provider = ProviderBuilder::new() .with_recommended_fillers() .wallet(wallet) - .on_ws(self.create_ws_connect()) + .on_ws(self.create_ws_connect()?) .await .context("Failed to create WS signer provider")?; @@ -232,12 +175,12 @@ impl ProviderConfig { WithChainId::new(provider).await } - fn create_ws_connect(&self) -> WsConnect { - if let Some(ws_auth) = self.auth.to_ws_auth() { - WsConnect::new(self.rpc.as_ws_url()).with_auth(ws_auth) + fn create_ws_connect(&self) -> Result { + Ok(if let Some(ws_auth) = self.auth.to_ws_auth() { + WsConnect::new(self.rpc.as_ws_url()?).with_auth(ws_auth) } else { - WsConnect::new(self.rpc.as_ws_url()) - } + WsConnect::new(self.rpc.as_ws_url()?) + }) } fn create_http_client(&self) -> Result>> { @@ -249,7 +192,7 @@ impl ProviderConfig { .default_headers(headers) .build() .context("Failed to create HTTP client")?; - let http = Http::with_client(client, self.rpc.as_http_url().parse()?); + let http = Http::with_client(client, self.rpc.as_http_url()?.parse()?); Ok(RpcClient::new(http, false)) } } @@ -282,30 +225,32 @@ mod test { use super::*; #[test] - fn test_rpc_type_conversion() { + fn test_rpc_type_conversion() -> Result<()> { // Test HTTP URLs let http = RPC::from_url("http://localhost:8545/").unwrap(); assert!(matches!(http, RPC::Http(_))); - assert_eq!(http.as_http_url(), "http://localhost:8545/"); - assert_eq!(http.as_ws_url(), "ws://localhost:8545/"); + assert_eq!(http.as_http_url()?, "http://localhost:8545/"); + assert_eq!(http.as_ws_url()?, "ws://localhost:8545/"); // Test HTTPS URLs let https = RPC::from_url("https://example.com/").unwrap(); assert!(matches!(https, RPC::Https(_))); - assert_eq!(https.as_http_url(), "https://example.com/"); - assert_eq!(https.as_ws_url(), "wss://example.com/"); + assert_eq!(https.as_http_url()?, "https://example.com/"); + assert_eq!(https.as_ws_url()?, "wss://example.com/"); // Test WS URLs let ws = RPC::from_url("ws://localhost:8545/").unwrap(); assert!(matches!(ws, RPC::Ws(_))); - assert_eq!(ws.as_http_url(), "http://localhost:8545/"); - assert_eq!(ws.as_ws_url(), "ws://localhost:8545/"); + assert_eq!(ws.as_http_url()?, "http://localhost:8545/"); + assert_eq!(ws.as_ws_url()?, "ws://localhost:8545/"); // Test WSS URLs let wss = RPC::from_url("wss://example.com/").unwrap(); assert!(matches!(wss, RPC::Wss(_))); - assert_eq!(wss.as_http_url(), "https://example.com/"); - assert_eq!(wss.as_ws_url(), "wss://example.com/"); + assert_eq!(wss.as_http_url()?, "https://example.com/"); + assert_eq!(wss.as_ws_url()?, "wss://example.com/"); + + Ok(()) } #[test]