diff --git a/soundness-cli/Cargo.lock b/soundness-cli/Cargo.lock index 7e54fdf..c663775 100644 --- a/soundness-cli/Cargo.lock +++ b/soundness-cli/Cargo.lock @@ -394,6 +394,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -919,6 +940,16 @@ version = "0.2.170" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags 2.9.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.9.2" @@ -1073,6 +1104,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.3" @@ -1339,6 +1376,17 @@ dependencies = [ "bitflags 2.9.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror", +] + [[package]] name = "reqwest" version = "0.11.27" @@ -1615,6 +1663,7 @@ dependencies = [ "base64", "bip39", "clap", + "dirs", "ed25519-dalek", "generic-array", "hex", @@ -1632,6 +1681,7 @@ dependencies = [ "tempfile", "tokio", "typenum", + "zeroize", ] [[package]] @@ -1725,6 +1775,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.7.6" diff --git a/soundness-cli/Cargo.toml b/soundness-cli/Cargo.toml index 48b845c..4e66173 100644 --- a/soundness-cli/Cargo.toml +++ b/soundness-cli/Cargo.toml @@ -27,6 +27,8 @@ sha2 = "0.10" generic-array = "0.14" typenum = "1.16" once_cell = "1.19" +dirs = "5.0" +zeroize = "1.7" [dev-dependencies] tempfile = "3.8" diff --git a/soundness-cli/src/main.rs b/soundness-cli/src/main.rs index 9b30e54..19787f2 100644 --- a/soundness-cli/src/main.rs +++ b/soundness-cli/src/main.rs @@ -5,20 +5,20 @@ use aes_gcm::{ use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use bip39; +use dirs; use clap::{Parser, Subcommand}; use ed25519_dalek::{Signer, SigningKey}; use indicatif::{ProgressBar, ProgressStyle}; -use once_cell::sync::Lazy; use pbkdf2::pbkdf2_hmac_array; use rand::{rngs::OsRng, RngCore}; use rpassword::prompt_password; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; +use sha2::Sha256; +use zeroize::Zeroize; use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::str::FromStr; -use std::sync::Mutex; use std::time::Duration; const SALT_LENGTH: usize = 32; @@ -26,8 +26,6 @@ const NONCE_LENGTH: usize = 12; const KEY_LENGTH: usize = 32; const ITERATIONS: u32 = 100_000; -static PASSWORD_CACHE: Lazy>> = Lazy::new(|| Mutex::new(None)); - #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -180,8 +178,15 @@ fn create_progress_bar(message: &str) -> ProgressBar { pb } +fn get_key_store_path() -> Result { + let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Failed to find home directory"))?; + let soundness_dir = home_dir.join(".soundness"); + fs::create_dir_all(&soundness_dir).context("Failed to create .soundness directory")?; + Ok(soundness_dir.join("key_store.json")) +} + fn load_key_store() -> Result { - let key_store_path = PathBuf::from("key_store.json"); + let key_store_path = get_key_store_path()?; if key_store_path.exists() { let contents = fs::read_to_string(&key_store_path)?; let key_store: KeyStore = serde_json::from_str(&contents)?; @@ -194,7 +199,7 @@ fn load_key_store() -> Result { } fn save_key_store(key_store: &KeyStore) -> Result<()> { - let key_store_path = PathBuf::from("key_store.json"); + let key_store_path = get_key_store_path()?; let contents = serde_json::to_string_pretty(key_store)?; fs::write(key_store_path, contents)?; Ok(()) @@ -319,15 +324,8 @@ fn list_keys() -> Result<()> { Ok(()) } -// Calculate hash of key store contents -fn calculate_key_store_hash(key_store: &KeyStore) -> String { - let serialized = serde_json::to_string(key_store).unwrap_or_default(); - format!("{:x}", Sha256::digest(serialized.as_bytes())) -} - fn sign_payload(payload: &[u8], key_name: &str) -> Result> { let key_store = load_key_store()?; - let key_store_hash = calculate_key_store_hash(&key_store); let key_pair = key_store .keys @@ -339,38 +337,21 @@ fn sign_payload(payload: &[u8], key_name: &str) -> Result> { .as_ref() .ok_or_else(|| anyhow::anyhow!("Secret key not found for '{}'", key_name))?; - // Create a new scope for the password guard to ensure it's dropped properly - let password = { - let mut password_guard = PASSWORD_CACHE.lock().unwrap(); - - if let Some((stored_password, stored_hash)) = password_guard.as_ref() { - // Check if key store has changed - if stored_hash != &key_store_hash { - *password_guard = None; - drop(password_guard); - return sign_payload(payload, key_name); - } - stored_password.clone() - } else { - // If no password is stored, prompt for it - let new_password = prompt_password("Enter password to decrypt the secret key: ") - .map_err(|e| anyhow::anyhow!("Failed to read password: {}", e))?; + let mut password = prompt_password("Enter password to decrypt the secret key: ") + .map_err(|e| anyhow::anyhow!("Failed to read password: {}", e))?; - // Try to decrypt with the password to verify it's correct - if let Err(e) = decrypt_secret_key(encrypted_secret, &new_password) { - anyhow::bail!("Invalid password: {}", e); - } + let pb = create_progress_bar("✍️ Signing payload..."); - // Store the password and key store hash - *password_guard = Some((new_password.clone(), key_store_hash)); - new_password + let secret_key_bytes = match decrypt_secret_key(encrypted_secret, &password) { + Ok(bytes) => bytes, + Err(_) => { + password.zeroize(); + anyhow::bail!("Invalid password or corrupted key"); } - }; // password_guard is dropped here + }; - // Only show the progress bar after we have the password - let pb = create_progress_bar("✍️ Signing payload..."); + password.zeroize(); - let secret_key_bytes = decrypt_secret_key(encrypted_secret, &password)?; let secret_key_array: [u8; 32] = secret_key_bytes .try_into() .map_err(|_| anyhow::anyhow!("Invalid secret key length"))?; @@ -394,17 +375,24 @@ fn get_public_key(key_name: &str) -> Result> { /// Check if a string looks like a Walrus Blob ID vs a file path /// Blob IDs are typically base64-like strings without path separators fn is_blob_id(input: &str) -> bool { - // Check if it contains path separators - if so, it's likely a file path + // If a file with this name exists and it's not a directory, treat it as a file path. + let path = PathBuf::from(input); + if path.exists() && path.is_file() { + return false; + } + + // Check for path separators, which are unlikely in a blob ID. if input.contains('/') || input.contains('\\') { return false; } - // Check if it looks like a base64 string (alphanumeric + / and +, possibly with = padding) - // and is reasonably long (blob IDs are typically 40+ characters) - input.len() > 20 - && input.chars().all(|c| { - c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '-' || c == '_' - }) + // A simple heuristic: blob IDs are long and look like base64. + // This is not foolproof, but it's better than before. + let is_base64_like = input.len() > 30 && input.chars().all(|c| { + c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c == '-' || c == '_' + }); + + is_base64_like } #[derive(Debug, Deserialize)] @@ -610,40 +598,31 @@ async fn main() -> Result<()> { } } - // Create canonical string for signing + // Create canonical JSON string for signing + let mut canonical_map = std::collections::BTreeMap::new(); + let proof_value = request_body .get("proof") .or_else(|| request_body.get("proof_blob_id")) - .unwrap_or(&serde_json::Value::Null) - .as_str() + .and_then(|v| v.as_str()) .unwrap_or(""); + canonical_map.insert("proof", proof_value); + canonical_map.insert("proof_filename", &proof_filename); - let elf_value = request_body - .get("elf") - .or_else(|| request_body.get("elf_blob_id")) - .unwrap_or(&serde_json::Value::Null) - .as_str() - .unwrap_or(""); + if !elf_filename.is_empty() { + let elf_value = request_body + .get("elf") + .or_else(|| request_body.get("elf_blob_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + canonical_map.insert("elf", elf_value); + canonical_map.insert("elf_filename", &elf_filename); + } - let canonical_string = if !elf_filename.is_empty() { - format!( - "proof:{}\nelf:{}\nproof_filename:{}\nelf_filename:{}\nproving_system:{}", - proof_value, - elf_value, - proof_filename, - elf_filename, - format!("{:?}", proving_system).to_lowercase() - ) - } else { - format!( - "proof:{}\nproof_filename:{}\nproving_system:{}", - proof_value, - proof_filename, - format!("{:?}", proving_system).to_lowercase() - ) - }; + let proving_system_str = format!("{:?}", proving_system).to_lowercase(); + canonical_map.insert("proving_system", &proving_system_str); - request_body["canonical_string"] = serde_json::Value::String(canonical_string.clone()); + let canonical_string = serde_json::to_string(&canonical_map)?; // Sign the canonical string let signature = sign_payload(canonical_string.as_bytes(), &key_name)?; diff --git a/soundness-cli/tests/e2e_test.rs b/soundness-cli/tests/e2e_test.rs index 964f0da..c4bbfc3 100644 --- a/soundness-cli/tests/e2e_test.rs +++ b/soundness-cli/tests/e2e_test.rs @@ -1,60 +1,93 @@ use anyhow::Result; use std::fs; -use std::process::Command; +use std::io::Write; +use std::process::{Command, Stdio}; use tempfile::tempdir; -fn run_cli_command(args: &[&str]) -> Result { - let output = Command::new("cargo") - .arg("run") - .arg("--") - .args(args) - .output()?; +fn run_cli_command(args: &[&str], stdin_data: Option<&str>) -> Result { + let mut command = Command::new("cargo"); + command.arg("run").arg("--").args(args); + + if stdin_data.is_some() { + command.stdin(Stdio::piped()); + } + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + + let mut child = command.spawn()?; + + if let (Some(data), Some(mut stdin)) = (stdin_data, child.stdin.take()) { + stdin.write_all(data.as_bytes())?; + } + + let output = child.wait_with_output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Command failed: {}", stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + anyhow::bail!( + "Command failed with status: {}\n---\nSTDOUT:\n{}\n---\nSTDERR:\n{}", + output.status, + stdout, + stderr + ); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) } #[test] -fn test_signature_verification() -> Result<()> { +fn test_e2e_flow() -> Result<()> { // Create a temporary directory for test files let temp_dir = tempdir()?; - let temp_path = temp_dir.path(); - // Generate a test key pair + // --- Generate a test key pair --- let key_name = "test_key"; - run_cli_command(&["generate-key", "--name", key_name])?; + let password = "test_password\n"; + let stdin_data = format!("{}{}", password, password); + let gen_output = run_cli_command(&["generate-key", "--name", key_name], Some(&stdin_data))?; + assert!(gen_output.contains("Generated new key pair")); - // Create test files + // --- Create test files --- let proof_content = "test proof content"; let elf_content = "test elf content"; - - let proof_path = temp_path.join("test.proof"); - let elf_path = temp_path.join("test.elf"); - + let proof_path = temp_dir.path().join("test.proof"); + let elf_path = temp_dir.path().join("test.elf"); fs::write(&proof_path, proof_content)?; fs::write(&elf_path, elf_content)?; - // Send the files to the server - let output = run_cli_command(&[ - "send", - "--proof-file", - proof_path.to_str().unwrap(), - "--elf-file", - elf_path.to_str().unwrap(), - "--key-name", - key_name, - ])?; - - // Verify the response contains success message - assert!( - output.contains("Successfully sent files"), - "Expected success message, got: {}", - output + // --- Send the files --- + // The `send` command will fail because there is no server running. + // We can only test that it attempts to send. + // We also need to provide the password for signing. + let send_result = run_cli_command( + &[ + "send", + "--proof-file", + proof_path.to_str().unwrap(), + "--elf-file", + elf_path.to_str().unwrap(), + "--key-name", + key_name, + "--proving-system", + "sp1", + ], + Some(password), ); + // Expect an error because the server is not running + assert!(send_result.is_err()); + if let Err(e) = send_result { + let error_msg = e.to_string(); + assert!( + error_msg.contains("Failed to send request") + || error_msg.contains("connection refused") + ); + } + + // --- List keys --- + let list_output = run_cli_command(&["list-keys"], None)?; + assert!(list_output.contains(key_name)); + Ok(()) } diff --git a/soundnessup/install b/soundnessup/install index d78b65f..188a739 100644 --- a/soundnessup/install +++ b/soundnessup/install @@ -10,10 +10,44 @@ SOUNDNESS_BIN_DIR="$SOUNDNESS_DIR/bin" BIN_URL="https://raw.githubusercontent.com/soundnesslabs/soundness-layer/main/soundnessup/soundnessup" BIN_PATH="$SOUNDNESS_BIN_DIR/soundnessup" -# Create the .soundness bin directory and soundnessup binary if it doesn't exist. -mkdir -p $SOUNDNESS_BIN_DIR -curl -# -L $BIN_URL -o $BIN_PATH -chmod +x $BIN_PATH +# Define the expected checksum +EXPECTED_CHECKSUM="42a2abf07ca082712a7fe9fa2173e80aba788877d6d11cc30e631a4c1f3f4439" + +# Create a temporary directory for the download +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT +DOWNLOAD_PATH="$TMP_DIR/soundnessup" + +# Download the binary +echo "Downloading soundnessup..." +curl -# -L "$BIN_URL" -o "$DOWNLOAD_PATH" + +# Verify the checksum +echo "Verifying checksum..." +# The syntax for sha256sum can differ between systems (e.g., macOS vs. Linux) +# We will try to create a compatible command. +if command -v sha256sum >/dev/null; then + CHECKSUM=$(sha256sum "$DOWNLOAD_PATH" | awk '{print $1}') +elif command -v shasum >/dev/null; then + CHECKSUM=$(shasum -a 256 "$DOWNLOAD_PATH" | awk '{print $1}') +else + echo "Error: Neither sha256sum nor shasum found. Cannot verify download." >&2 + exit 1 +fi + + +if [ "$CHECKSUM" != "$EXPECTED_CHECKSUM" ]; then + echo "Checksum verification failed!" >&2 + echo "Expected: $EXPECTED_CHECKSUM" >&2 + echo "Got: $CHECKSUM" >&2 + exit 1 +fi +echo "Checksum verified." + +# Move the binary to the final destination +mkdir -p "$SOUNDNESS_BIN_DIR" +mv "$DOWNLOAD_PATH" "$BIN_PATH" +chmod +x "$BIN_PATH" # Store the correct profile file (i.e. .profile for bash or .zshenv for ZSH). case $SHELL in diff --git a/soundnessup/soundnessup b/soundnessup/soundnessup index 13d6177..285c8e8 100644 --- a/soundnessup/soundnessup +++ b/soundnessup/soundnessup @@ -47,49 +47,70 @@ install_cli() { trap 'rm -rf "$TEMP_DIR"' EXIT # Clone the repository - git clone --depth 1 https://github.com/$REPO.git "$TEMP_DIR" + print_message "Cloning repository..." "$YELLOW" + git clone https://github.com/$REPO.git "$TEMP_DIR" > /dev/null 2>&1 + + cd "$TEMP_DIR" + + # Get the latest tag + LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) + print_message "Latest version is $LATEST_TAG. Checking out..." "$YELLOW" + + git checkout "$LATEST_TAG" > /dev/null 2>&1 # Build the CLI + print_message "Building the CLI (this may take a few minutes)..." "$YELLOW" cd "$TEMP_DIR/soundness-cli" - cargo build --release + cargo build --release > /dev/null 2>&1 # Copy the binary to the bin directory mkdir -p "$SOUNDNESS_BIN_DIR" cp target/release/$CLI_BINARY "$CLI_BINARY_PATH" - print_message "✅ Installation complete!" "$GREEN" + print_message "✅ Installation of $LATEST_TAG complete!" "$GREEN" } # Function to update the CLI update_cli() { print_message "🔄 Checking for updates..." "$YELLOW" - # Create temporary directory for building + # Create temporary directory for cloning TEMP_DIR=$(mktemp -d) trap 'rm -rf "$TEMP_DIR"' EXIT # Clone the repository - git clone --depth 1 https://github.com/$REPO.git "$TEMP_DIR" - - # Get the latest version - LATEST_VERSION=$(cd "$TEMP_DIR" && git describe --tags --abbrev=0 2>/dev/null || echo "v$VERSION") + git clone https://github.com/$REPO.git "$TEMP_DIR" > /dev/null 2>&1 - if [ "$LATEST_VERSION" = "v$VERSION" ]; then - print_message "✅ You're already using the latest version ($VERSION)" "$GREEN" + cd "$TEMP_DIR" + # Get the latest tag + LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) + + # Get the current version if it exists + if [ -f "$CLI_BINARY_PATH" ]; then + CURRENT_VERSION=$($CLI_BINARY_PATH --version | awk '{print $2}') + else + CURRENT_VERSION="0.0.0" # Not installed + fi + + if [ "v$CURRENT_VERSION" = "$LATEST_TAG" ]; then + print_message "✅ You're already using the latest version ($CURRENT_VERSION)" "$GREEN" return fi - print_message "📦 Updating to $LATEST_VERSION..." "$YELLOW" + print_message "📦 Updating from $CURRENT_VERSION to $LATEST_TAG..." "$YELLOW" + git checkout "$LATEST_TAG" > /dev/null 2>&1 + # Build the CLI + print_message "Building the CLI (this may take a few minutes)..." "$YELLOW" cd "$TEMP_DIR/soundness-cli" - cargo build --release + cargo build --release > /dev/null 2>&1 # Copy the binary to the bin directory mkdir -p "$SOUNDNESS_BIN_DIR" cp target/release/$CLI_BINARY "$CLI_BINARY_PATH" - print_message "✅ Update complete!" "$GREEN" + print_message "✅ Update to $LATEST_TAG complete!" "$GREEN" } # Function to show help