diff --git a/Cargo.lock b/Cargo.lock index 4cacece..d3020d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,7 @@ dependencies = [ "hex", "rand", "rand_core 0.6.4", + "zeroize", ] [[package]] @@ -428,3 +429,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/Cargo.toml b/Cargo.toml index c7ed3f6..85f6d32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ argon2 = "0.5.3" clap = { version = "4.6.1", features = ["derive"] } rand_core = { version = "0.6", features = ["getrandom"] } hex = "0.4.3" +zeroize = "1" [dev-dependencies] rand = { version = "0.9.4", features = ["std", "std_rng"] } diff --git a/src/main.rs b/src/main.rs index 48f860e..f9541ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use argon2::password_hash::SaltString; use clap::{ArgGroup, Parser}; -use std::io::{self, BufRead, IsTerminal, Write}; +use std::io::{self, IsTerminal, Read, Write}; +use zeroize::Zeroizing; // Usage: argon2 [-h] salt [-i|-d|-id] [-t iterations] [-m log2(memory in KiB) | -k memory in KiB] [-p parallelism] [-l hash length] [-e|-r] [-v (10|13)] #[derive(Parser, Debug)] @@ -61,19 +62,25 @@ struct Args { v: u32, } -fn get_input() -> io::Result { +fn get_input() -> io::Result>> { let stdin = io::stdin(); if stdin.is_terminal() { print!("Enter password: "); io::stdout().flush()?; - let mut input = String::new(); + let mut input = Zeroizing::new(String::new()); stdin.read_line(&mut input)?; - Ok(input.trim().to_string()) + // Strip only the newline from pressing Enter, keep other whitespace + while input.ends_with('\n') || input.ends_with('\r') { + input.pop(); + } + Ok(Zeroizing::new(input.as_bytes().to_vec())) } else { - let lines: Vec = stdin.lock().lines().collect::>()?; - Ok(lines.join("\n")) + // Read stdin verbatim (including any trailing newline) to match the reference implementation + let mut input = Zeroizing::new(Vec::new()); + stdin.lock().read_to_end(&mut input)?; + Ok(input) } } @@ -108,10 +115,7 @@ fn main() -> Result<(), Box> { None => SaltString::generate(&mut rand_core::OsRng), }; - let password = get_input().unwrap_or_else(|e| { - eprintln!("Error reading input: {}", e); - std::process::exit(1); - }); + let password = get_input().map_err(|e| format!("Error reading input: {}", e))?; // Select algorithm variant let algorithm = if args.d { @@ -138,11 +142,9 @@ fn main() -> Result<(), Box> { let start = std::time::Instant::now(); - let password_bytes = password.as_bytes(); - - use argon2::PasswordHasher; + use argon2::{PasswordHasher, PasswordVerifier}; let password_hash = argon2 - .hash_password(password_bytes, salt_string.as_salt()) + .hash_password(&password, salt_string.as_salt()) .map_err(|e| format!("Hashing failed: {}", e))?; let duration = start.elapsed(); @@ -152,7 +154,7 @@ fn main() -> Result<(), Box> { println!("{}", password_hash); } else if args.r { if let Some(hash) = password_hash.hash { - io::stdout().write_all(hash.as_bytes())?; + println!("{}", hex::encode(hash.as_bytes())); } } else { println!("Type: {:?}", algorithm); @@ -166,6 +168,10 @@ fn main() -> Result<(), Box> { println!("Encoded: {}", password_hash); println!("{:.3} seconds", duration.as_secs_f64()); + + argon2 + .verify_password(&password, &password_hash) + .map_err(|e| format!("Verification failed: {}", e))?; println!("Verification ok"); } diff --git a/tests/verify.rs b/tests/verify.rs index 6a3acea..e35d9c7 100644 --- a/tests/verify.rs +++ b/tests/verify.rs @@ -132,7 +132,7 @@ fn verify(i: u32, memory_exp: u32, parallelism: u32, variant: &str) -> bool { fn test_argon2_compatibility() { // Build release binary first let status = Command::new("cargo") - .args(&["build", "--release", "--bin", "argon2-cli"]) + .args(["build", "--release", "--bin", "argon2-cli"]) .status() .expect("Failed to build binary"); assert!(status.success(), "Construction of binary failed"); @@ -160,9 +160,54 @@ fn test_argon2_compatibility() { } } +// Debug binary is always built by `cargo test` for integration tests +const RUST_DEBUG_BINARY: &str = "./target/debug/argon2-cli"; + +#[test] +fn test_encoded_output_matches_reference() { + let salt = generate_random_string(8); + let password = generate_random_string(12); + let args = vec!["-e".to_string()]; + + let ref_out = run_argon2(REF_BINARY, &salt, &password, &args).expect("reference failed"); + let rust_out = run_argon2(RUST_DEBUG_BINARY, &salt, &password, &args).expect("rust failed"); + + assert_eq!(ref_out, rust_out, "-e output differs from reference"); +} + +#[test] +fn test_raw_output_matches_reference() { + let salt = generate_random_string(8); + let password = generate_random_string(12); + let args = vec!["-r".to_string()]; + + let ref_out = run_argon2(REF_BINARY, &salt, &password, &args).expect("reference failed"); + let rust_out = run_argon2(RUST_DEBUG_BINARY, &salt, &password, &args).expect("rust failed"); + + assert_eq!(ref_out, rust_out, "-r output differs from reference"); +} + +#[test] +fn test_stdin_read_verbatim() { + let salt = generate_random_string(8); + let args = vec!["-e".to_string()]; + + // Trailing newlines and inner newlines are part of the password, as in the reference + for password in ["password\n", "pass\nword", " password "] { + let ref_out = run_argon2(REF_BINARY, &salt, password, &args).expect("reference failed"); + let rust_out = run_argon2(RUST_DEBUG_BINARY, &salt, password, &args).expect("rust failed"); + + assert_eq!( + ref_out, rust_out, + "stdin handling differs from reference for {:?}", + password + ); + } +} + #[test] fn test_short_salt_fails_before_hashing() { - let output = Command::new("./target/debug/argon2-cli") + let output = Command::new(RUST_DEBUG_BINARY) .arg("1234567") .output() .expect("Failed to run debug binary");