From 0f54c073a7a3610fe77249127d2acd9de20b7adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jon=20H=C3=A4ggblad?= Date: Tue, 17 May 2022 07:18:50 +0200 Subject: [PATCH] wallet: simple cli tool for decrypting the wallet (#1272) * wallet: simple cli tool for decrypting the wallet * changelog: add note about wallet file recovery tool --- CHANGELOG.md | 1 + nym-wallet/Cargo.lock | 100 +++++++++- nym-wallet/Cargo.toml | 7 +- .../test-data/saved-wallet.json | 2 +- nym-wallet/wallet-recovery-cli/Cargo.toml | 17 ++ nym-wallet/wallet-recovery-cli/src/main.rs | 186 ++++++++++++++++++ 6 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 nym-wallet/wallet-recovery-cli/Cargo.toml create mode 100644 nym-wallet/wallet-recovery-cli/src/main.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e4b58981..8c3a40c7f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- wallet: add simple CLI tool for decrypting and recovering the wallet file. - wallet: added support for multiple accounts ([#1265]) - wallet: the wallet backend learned how to keep track of validator name, either hardcoded or by querying the status endpoint. - mixnet-contract: Replace all naked `-` with `saturating_sub`. diff --git a/nym-wallet/Cargo.lock b/nym-wallet/Cargo.lock index 420f49d27f..8f29f2236b 100644 --- a/nym-wallet/Cargo.lock +++ b/nym-wallet/Cargo.lock @@ -75,9 +75,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.55" +version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "159bb86af3a200e19a068f4224eae4c8bb2d0fa054c7e5d1cacd5cef95e684cd" +checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc" [[package]] name = "argon2" @@ -87,7 +87,18 @@ checksum = "25df3c03f1040d0069fcd3907e24e36d59f9b6fa07ba49be0eb25a794f036ba7" dependencies = [ "base64ct", "blake2", - "password-hash", + "password-hash 0.3.2", +] + +[[package]] +name = "argon2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a27e27b63e4a34caee411ade944981136fdfa535522dc9944d6700196cbd899f" +dependencies = [ + "base64ct", + "blake2", + "password-hash 0.4.1", ] [[package]] @@ -619,6 +630,45 @@ dependencies = [ "generic-array 0.14.5", ] +[[package]] +name = "clap" +version = "3.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" +dependencies = [ + "atty", + "bitflags", + "clap_derive", + "clap_lex", + "indexmap", + "lazy_static", + "strsim 0.10.0", + "termcolor", + "textwrap", +] + +[[package]] +name = "clap_derive" +version = "3.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c" +dependencies = [ + "heck 0.4.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "cloudabi" version = "0.0.3" @@ -2950,7 +3000,7 @@ name = "nym_wallet" version = "1.0.4" dependencies = [ "aes-gcm", - "argon2", + "argon2 0.3.4", "base64", "bip39", "cfg-if", @@ -3108,6 +3158,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_str_bytes" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" + [[package]] name = "pairing" version = "0.20.0" @@ -3184,6 +3240,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e029e94abc8fb0065241c308f1ac6bc8d20f450e8f7c5f0b25cd9b8d526ba294" +dependencies = [ + "base64ct", + "rand_core 0.6.3", + "subtle", +] + [[package]] name = "paste" version = "1.0.6" @@ -4262,9 +4329,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.79" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" +checksum = "9b7ce2b32a1aed03c558dc61a5cd328f15aff2dbc17daad8fb8af04d2100e15c" dependencies = [ "itoa 1.0.1", "ryu", @@ -5051,6 +5118,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "textwrap" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" + [[package]] name = "thin-slice" version = "0.1.1" @@ -5500,6 +5573,21 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wallet-recovery-cli" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "argon2 0.4.0", + "base64", + "bip39", + "clap", + "log", + "pretty_env_logger", + "serde_json", +] + [[package]] name = "want" version = "0.3.0" diff --git a/nym-wallet/Cargo.toml b/nym-wallet/Cargo.toml index b9fb14e41b..03978d332c 100644 --- a/nym-wallet/Cargo.toml +++ b/nym-wallet/Cargo.toml @@ -1,2 +1,7 @@ [workspace] -members = ["src-tauri"] \ No newline at end of file + +resolver = "2" +members = [ + "src-tauri", + "wallet-recovery-cli", +] diff --git a/nym-wallet/src-tauri/src/wallet_storage/test-data/saved-wallet.json b/nym-wallet/src-tauri/src/wallet_storage/test-data/saved-wallet.json index 2cf0288fbc..a9b4c62da2 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/test-data/saved-wallet.json +++ b/nym-wallet/src-tauri/src/wallet_storage/test-data/saved-wallet.json @@ -18,4 +18,4 @@ } } ] -} \ No newline at end of file +} diff --git a/nym-wallet/wallet-recovery-cli/Cargo.toml b/nym-wallet/wallet-recovery-cli/Cargo.toml new file mode 100644 index 0000000000..c56f9a6068 --- /dev/null +++ b/nym-wallet/wallet-recovery-cli/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "wallet-recovery-cli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +aes-gcm = "0.9" +anyhow = "1.0" +argon2 = "0.4" +base64 = "0.13" +bip39 = "1.0" +clap = { version = "3.0", features = ["derive"] } +log = "0.4" +pretty_env_logger = "0.4" +serde_json = "1.0.0" diff --git a/nym-wallet/wallet-recovery-cli/src/main.rs b/nym-wallet/wallet-recovery-cli/src/main.rs new file mode 100644 index 0000000000..c1ff08d857 --- /dev/null +++ b/nym-wallet/wallet-recovery-cli/src/main.rs @@ -0,0 +1,186 @@ +//! Wallet CLI recovery tool +//! +//! Can decrypted wallet files saved by nym-wallet. + +use std::fs::File; + +use aes_gcm::{aead::Aead, Aes256Gcm, Key, NewAead, Nonce}; +use anyhow::{anyhow, Result}; +use argon2::{Algorithm, Argon2, Params, Version}; +use clap::Parser; +use serde_json::Value; + +// Mostly defaults +const MEMORY_COST: u32 = 16 * 1024; // 4096 is default +const ITERATIONS: u32 = 3; // This appears to be the default +const PARALLELISM: u32 = 1; // 1 thread. Default +const OUTPUT_LENGTH: usize = 32; // Default + +/// Simple utility to decrypt wallet file used by `nym-wallet` to store encrypted mnemonics. +#[derive(Parser, Debug)] +#[clap(author, about)] +struct Args { + /// Password used to attempt to decrypt the logins found in the file. The option can be + /// provided multiple times for files that require multiple passwords. + #[clap(short, long, min_values(1), required = true)] + password: Vec, + + /// Path to the wallet file that will be decrypted. + #[clap(short, long)] + file: String, +} + +fn main() -> Result<()> { + setup_logging(); + let args = Args::parse(); + let file = File::open(args.file)?; + decrypt_file(file, &args.password) +} + +fn setup_logging() { + let mut log_builder = pretty_env_logger::formatted_timed_builder(); + if let Ok(s) = ::std::env::var("RUST_LOG") { + log_builder.parse_filters(&s); + } else { + // default to 'Info' + log_builder.filter(None, log::LevelFilter::Info); + } + + log_builder.init(); +} + +fn decrypt_file(file: File, passwords: &[String]) -> Result<()> { + let json_file: Value = serde_json::from_reader(file)?; + + // The logins are stored under the more generic name "accounts" + let logins = json_file["accounts"] + .as_array() + .ok_or_else(|| anyhow!("No accounts found in file!"))?; + + println!("The file contains the logins:"); + for login in logins { + let id = &login["id"]; + println!(" - id: {id}"); + } + + println!("We have {} password(s) to try", passwords.len()); + let mut successes = 0; + for login in logins { + match decrypt_login(login, passwords) { + Ok(is_success) if is_success => successes += 1, + Ok(_) => println!("None of the provided passwords succeeded"), + Err(err) => println!("Failed: {}", err), + } + } + + println!( + "\nManaged to decrypt {} out of {} found logins, using the {} provided password(s)", + successes, + logins.len(), + passwords.len(), + ); + if successes != logins.len() { + return Err(anyhow!("Failed to decrypt all logins")); + } + Ok(()) +} + +fn decrypt_login(login: &Value, passwords: &[String]) -> Result { + let id = &login["id"]; + println!("\nAttempting to parse login entry: {id}"); + + let (ciphertext, iv, salt) = get_login_entry(login)?; + let (ciphertext, iv, salt) = base64_decode(ciphertext, iv, salt)?; + + for (i, password) in passwords.iter().enumerate() { + print!("Trying to decrypt with password {i}:"); + if let Ok((mnemonic, hd_path)) = decrypt_password(password, &ciphertext, &iv, &salt) { + println!(" success!"); + println!(" mnemonic: {mnemonic}"); + println!(" hd_path: {hd_path}"); + return Ok(true); + } + println!(" failed") + } + + Ok(false) +} + +fn get_login_entry(login: &Value) -> Result<(&str, &str, &str)> { + let account = &login["account"] + .as_object() + .ok_or_else(|| anyhow!("No account entry in json"))?; + let ciphertext = account["ciphertext"] + .as_str() + .ok_or_else(|| anyhow!("No ciphertext entry"))?; + let iv = account["iv"] + .as_str() + .ok_or_else(|| anyhow!("No IV entry"))?; + let salt = account["salt"] + .as_str() + .ok_or_else(|| anyhow!("No salt entry"))?; + Ok((ciphertext, iv, salt)) +} + +fn base64_decode(ciphertext: &str, iv: &str, salt: &str) -> Result<(Vec, Vec, Vec)> { + let ciphertext = base64::decode(&ciphertext) + .map_err(|err| anyhow!("Unable to base64 decode ciphertext: {}", err))?; + let iv = base64::decode(iv).map_err(|err| anyhow!("Unable to base64 decode iv: {}", err))?; + let salt = + base64::decode(salt).map_err(|err| anyhow!("Unable to base64 decode salt: {}", err))?; + Ok((ciphertext, iv, salt)) +} + +fn decrypt_password( + password: &str, + ciphertext: &[u8], + iv: &[u8], + salt: &[u8], +) -> Result<(String, String)> { + let params = Params::new(MEMORY_COST, ITERATIONS, PARALLELISM, Some(OUTPUT_LENGTH)).unwrap(); + + // Argon2id is default, V0x13 is default + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Key::default(); + argon2 + .hash_password_into(password.as_bytes(), salt, &mut key) + .map_err(|err| anyhow!("Unable to hash password: {}", err))?; + + // Create the Cipher + let cipher = Aes256Gcm::new(&key); + + // Decrypt using the nonce, which we get from the IV + let nonce = Nonce::from_slice(iv); + + let plaintext = cipher + .decrypt(nonce, ciphertext.as_ref()) + .map_err(|_| anyhow!("Unable to decrypt"))?; + + let plaintext = String::from_utf8(plaintext)?; + + let json_data: Value = serde_json::from_str(&plaintext)?; + + let mnemonic = json_data["mnemonic"] + .as_str() + .ok_or_else(|| anyhow!("No mnemonic entry after decrypting"))?; + let hd_path = json_data["hd_path"] + .as_str() + .ok_or_else(|| anyhow!("No hd_path entry after decrypting"))?; + Ok((mnemonic.to_string(), hd_path.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + #[test] + fn decrypt_saved_file() { + const SAVED_WALLET: &str = "../src-tauri/src/wallet_storage/test-data/saved-wallet.json"; + let wallet_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SAVED_WALLET); + let file = File::open(wallet_file).unwrap(); + let passwords = vec!["password".to_string()]; + assert!(decrypt_file(file, &passwords).is_ok()); + } +}