// Copyright (C) 2019-2023 Aleo Systems Inc. // This file is part of the Aleo SDK library. // The Aleo SDK library is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // The Aleo SDK library is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with the Aleo SDK library. If not, see . use crate::{helpers::AccountModel, CurrentNetwork}; use aleo_rust::account::Encryptor; use snarkvm::prelude::{Address, Ciphertext, PrivateKey, ViewKey}; use anyhow::{bail, Result}; use clap::Parser; use colored::*; use rand::SeedableRng; use rand_chacha::ChaChaRng; use std::{convert::TryFrom, fs::File}; /// Commands to manage Aleo account creation, import, and encryption/decryption #[derive(Debug, Parser)] pub enum Account { /// Generates a new aleo account New { /// Seed the RNG with a numeric value #[clap(short = 's', long)] seed: Option, /// Flag to encrypt the private key (will prompt for an encryption password) #[clap(short = 'e', long)] encrypt: bool, /// Write key data to disk #[clap(short = 'w', long)] write: bool, /// password to encrypt the private key #[clap(short = 'p', long)] password: Option, }, /// Derive view key and address from a private key plaintext Import { /// Private key plaintext private_key: PrivateKey, /// Write key data to disk #[clap(short = 'w', long)] write: bool, }, /// Encrypt a private key plaintext Encrypt { /// Provide private key plaintext to command line #[clap(short = 'k', long)] private_key: Option>, /// Get private key plaintext from file #[clap(short = 'f', long)] file: Option, /// Write private key ciphertext and address to disk #[clap(short = 'w', long)] write: bool, /// password to encrypt the private key #[clap(short = 'p', long)] password: Option, }, /// Decrypt a private key ciphertext Decrypt { /// Provide ciphertext directly to command line #[clap(short = 'k', long)] ciphertext: Option>, /// Get ciphertext from file #[clap(short = 'f', long)] file: Option, /// Write account plaintext data to disk #[clap(short = 'w', long)] write: bool, /// password to encrypt the private key #[clap(short = 'p', long)] password: Option, }, } impl Account { pub fn parse(self) -> Result { match self { Self::New { seed, encrypt, write, password } => { // Sample a new Aleo account. let private_key = match seed { Some(seed) => PrivateKey::::new(&mut ChaChaRng::seed_from_u64(seed))?, None => PrivateKey::new(&mut rand::thread_rng())?, }; // Create success message let mut key_output = format!("\n{:>12}", "✅ Account keys successfully generated:\n".green().bold(),); // If encryption flag is specified encrypt private key and add it to the output let private_key_ciphertext = if encrypt { let ciphertext = Self::encrypt_with_password(&private_key, password)?; key_output += format!("\n {:>1} {ciphertext}", "Private Key Ciphertext".cyan().bold()).as_str(); Some(ciphertext) } else { None }; let view_key = ViewKey::try_from(&private_key)?; let address = Address::try_from(&view_key)?; // Add remaining keys to the output key_output += format!( "\n {:>1} {private_key}\n {:>1} {view_key}\n {:>1} {address}", "Private Key".cyan().bold(), "View Key".cyan().bold(), "Address".cyan().bold(), ) .as_str(); // Save output to file if specified let save_output = Self::write_account_to_file( write, private_key_ciphertext, private_key.into(), view_key.into(), address.into(), )?; Ok(format!("{key_output}{save_output}")) } Self::Import { private_key, write } => { let view_key = ViewKey::try_from(&private_key)?; let address = Address::try_from(&view_key)?; // Print the Aleo account corresponding to the provided private key let key_output = format!( "\n{:>12}\n\n {:>1} {private_key}\n {:>1} {view_key}\n {:>1} {address}", "✅ Account keys successfully imported:".green().bold(), "Private Key".cyan().bold(), "View Key".cyan().bold(), "Address".cyan().bold(), ); // Save output to file if specified let save_output = Self::write_account_to_file(write, None, private_key.into(), view_key.into(), address.into())?; Ok(format!("{key_output}{save_output}")) } Self::Encrypt { private_key, file, write, password } => { // Check for ambiguous input if private_key.is_some() && file.is_some() { bail!("❌ Please provide either a private key or a filepath, not both"); } // Get private key from file or command line let private_key = match file { Some(file) => { let mut file = File::open(file)?; let account_keys: AccountModel = serde_json::from_reader(&mut file)?; account_keys.private_key.ok_or_else(|| anyhow::anyhow!("❌ No private key found in file"))? } None => match private_key { Some(private_key) => private_key, None => bail!("❌ Please provide either a private key or a filepath"), }, }; // Use the provided password or prompt for a password and encrypt private key let private_key_ciphertext = Self::encrypt_with_password(&private_key, password)?; let address = Address::try_from(&private_key)?; // Display private key ciphertext and public address let key_output = format!( "\n{:>12}\n\n {:>1} {private_key_ciphertext}\n {:>1} {address}", "✅ Account private key successfully encrypted:".green().bold(), "Private Key Ciphertext".cyan().bold(), "Address".cyan().bold(), ); // Save output to file if specified let save_output = Self::write_account_to_file(write, private_key_ciphertext.into(), None, None, address.into())?; Ok(format!("{key_output}{save_output}")) } Self::Decrypt { ciphertext, file, write, password } => { // Check for ambiguous input if ciphertext.is_some() && file.is_some() { bail!("❌ Please provide either a private key or a filepath, not both"); } // Get the ciphertext from file or command line let private_key_ciphertext = match file { Some(file) => { let mut file = File::open(file)?; let account_keys: AccountModel = serde_json::from_reader(&mut file)?; account_keys .private_key_ciphertext .ok_or_else(|| anyhow::anyhow!("❌ No private key ciphertext found in file"))? } None => match ciphertext { Some(ciphertext) => ciphertext, None => bail!("❌ Please provide either a ciphertext or a filepath"), }, }; // Use supplied password or prompt for the user for a password and attempt to decrypt private key let secret = if let Some(password) = password { password } else { rpassword::prompt_password("Enter decryption password: ")? }; let private_key = Encryptor::decrypt_private_key_with_secret(&private_key_ciphertext, &secret) .map_err(|_| anyhow::anyhow!("❌ Incorrect password"))?; let view_key = ViewKey::try_from(&private_key)?; let address = Address::try_from(&view_key)?; // Print the new Aleo account. let key_output = format!( "\n{:>1}\n\n {:>1} {private_key}\n {:>1} {view_key}\n {:>1} {address}", "✅ Account keys successfully decrypted:".green().bold(), "Private Key".cyan().bold(), "View Key".cyan().bold(), "Address".cyan().bold(), ); // Save output to file if specified let save_output = Self::write_account_to_file(write, None, private_key.into(), view_key.into(), address.into())?; Ok(format!("{key_output}{save_output}")) } } } // Encrypt the private key with a password specified at the command line fn encrypt_with_password( private_key: &PrivateKey, password: Option, ) -> Result> { if let Some(password) = password { Ok(Encryptor::encrypt_private_key_with_secret(private_key, &password)?) } else { let password = rpassword::prompt_password("Enter encryption password: ")?; let password_confirm = rpassword::prompt_password("Confirm encryption password: ")?; if password != password_confirm { bail!("❌ Passwords do not match"); } Ok(Encryptor::encrypt_private_key_with_secret(private_key, &password)?) } } // Write the account keys to a file or return if write flag is not specified fn write_account_to_file( write: bool, private_key_ciphertext: Option>, private_key: Option>, view_key: Option>, address: Option>, ) -> Result { if !write { return Ok("".to_string()); } // Get file name and account serialization let (preamble, filename, account_keys) = if private_key_ciphertext.is_some() { ("✅ Account key ciphertext written to", "account-ciphertext.json", AccountModel { private_key_ciphertext, private_key: None, view_key: None, address, }) } else { ("✅ Account key plaintexts written to", "account-plaintext.json", AccountModel { private_key_ciphertext, private_key, view_key, address, }) }; // Check if file already exists if so error, else write to file let path = std::env::current_dir()?.join(filename); if path.exists() { Ok(format!( "\n\n{} {} {} {}", "❌".red().bold(), "Keys not written to disk, an".red().bold(), filename.red().bold(), "file already exists in this directory".red().bold() )) } else { let mut file = File::create(path)?; serde_json::to_writer_pretty(&mut file, &account_keys)?; Ok(format!("\n\n{} {}", preamble.green().bold(), filename.green().bold())) } } } #[cfg(test)] mod tests { use super::*; use std::{fs, str::FromStr}; use snarkvm::prelude::TestRng; #[test] fn test_account_new() { for _ in 0..3 { let account = Account::New { seed: None, encrypt: false, write: false, password: None }; assert!(account.parse().is_ok()); } } #[test] fn test_account_create_import_encrypt_and_decrypt_from_file() { // Create a new account in a file let temp_dir = std::env::temp_dir(); let _ = fs::remove_file(temp_dir.join("account-plaintext.json")); let _ = fs::remove_file(temp_dir.join("account-ciphertext.json")); std::env::set_current_dir(&temp_dir).unwrap(); let account = Account::New { seed: None, encrypt: false, write: true, password: None }; // Ensure it was created correctly let new_account_parse_attempt = account.parse().unwrap(); // Assert a write message is emitted assert!(new_account_parse_attempt.contains("written to")); let account: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert!(account.private_key.is_some()); assert!(account.view_key.is_some()); assert!(account.address.is_some()); assert!(account.private_key_ciphertext.is_none()); // Encrypt the account let encrypted_account = Account::Encrypt { private_key: None, file: Some("account-plaintext.json".to_string()), write: true, password: Some("mypassword".to_string()), }; assert!(encrypted_account.parse().is_ok()); let account_ciphertext: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-ciphertext.json")).unwrap()).unwrap(); // Ensure the ciphertext exists assert!(account_ciphertext.private_key.is_none()); assert!(account_ciphertext.view_key.is_none()); assert_eq!(account_ciphertext.address, account.address); assert!(account_ciphertext.private_key_ciphertext.is_some()); // Ensure creating, encrypting and decrypting to file fails to write file if file already exists let encrypt_parse_attempt = Account::Encrypt { private_key: None, file: Some("account-plaintext.json".to_string()), write: true, password: Some("mypassword".to_string()), }; let encrypt_parse_attempt_result = encrypt_parse_attempt.parse().unwrap(); assert!(encrypt_parse_attempt_result.contains("✅ Account private key successfully encrypted")); assert!(encrypt_parse_attempt_result.contains("not written to disk")); let decrypt_parse_attempt = Account::Decrypt { ciphertext: None, file: Some("account-ciphertext.json".to_string()), write: true, password: Some("mypassword".to_string()), }; let decrypt_parse_attempt_result = decrypt_parse_attempt.parse().unwrap(); assert!(decrypt_parse_attempt_result.contains("✅ Account keys successfully decrypted:")); assert!(decrypt_parse_attempt_result.contains("not written to disk")); // Remove plaintext files and ensure account decrypt from file works fs::remove_file(temp_dir.join("account-plaintext.json")).unwrap(); let decrypt_parse_attempt_2 = Account::Decrypt { ciphertext: None, file: Some("account-ciphertext.json".to_string()), write: true, password: Some("mypassword".to_string()), }; let decrypt_parse_attempt_2 = decrypt_parse_attempt_2.parse().unwrap(); // Assert a write message is emitted assert!(decrypt_parse_attempt_2.contains("written to")); // Assert the decrypted account is correct let recovered_account: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert_eq!(recovered_account.private_key, account.private_key); assert_eq!(recovered_account.view_key, account.view_key); assert_eq!(recovered_account.address, account.address); // Assert no new plaintext accounts can be written to file let new_account_plaintext = Account::New { seed: None, encrypt: false, write: true, password: None }; let new_plaintext_account_parse_attempt_result = new_account_plaintext.parse().unwrap(); // Ensure a not written to disk message is emitted assert!(new_plaintext_account_parse_attempt_result.contains("not written to disk")); // Ensure the account was not written to disk let plaintext_recovered_account_check: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert_eq!(plaintext_recovered_account_check.private_key, account.private_key); assert_eq!(plaintext_recovered_account_check.view_key, account.view_key); assert_eq!(plaintext_recovered_account_check.address, account.address); // Assert no new encrypted accounts can be written to file let new_account_ciphertext = Account::New { seed: None, encrypt: false, write: true, password: Some("mypassword".to_string()) }; let new_account_ciphertext_parse_attempt_result = new_account_ciphertext.parse().unwrap(); // Ensure a not written to disk message is emitted assert!(new_account_ciphertext_parse_attempt_result.contains("not written to disk")); // Ensure the account was not written to disk let ciphertext_recovered_account_check: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert_eq!(ciphertext_recovered_account_check.private_key, account.private_key); assert_eq!(ciphertext_recovered_account_check.view_key, account.view_key); assert_eq!(ciphertext_recovered_account_check.address, account.address); // Ensure new encrypted accounts can be written to file if the file does not exist fs::remove_file(temp_dir.join("account-ciphertext.json")).unwrap(); let new_account_ciphertext = Account::New { seed: None, encrypt: true, write: true, password: Some("mypassword".to_string()) }; let new_account_ciphertext_parse_attempt_result_2 = new_account_ciphertext.parse().unwrap(); let ciphertext_recovered_account_check_2: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-ciphertext.json")).unwrap()).unwrap(); // Assert a write confirmation is printed assert!(new_account_ciphertext_parse_attempt_result_2.contains("written to")); // Assert we've created a different account and that data was written properly assert_ne!(ciphertext_recovered_account_check_2.private_key_ciphertext, account.private_key_ciphertext); assert_ne!(ciphertext_recovered_account_check_2.address, account.address); assert!(ciphertext_recovered_account_check_2.private_key.is_none()); assert!(ciphertext_recovered_account_check_2.view_key.is_none()); fs::remove_file(temp_dir.join("account-ciphertext.json")).unwrap(); fs::remove_file(temp_dir.join("account-plaintext.json")).unwrap(); // Ensure we can import an account and write it let import_private_key = PrivateKey::::from_str("APrivateKey1zkp76ubxnPqcYFSiWpRAQQ2yJ9vRtEZB9t2ok2cFa8wTLKq") .unwrap(); let import_view_key = ViewKey::::try_from(import_private_key).unwrap(); let import_address = Address::::try_from(import_private_key).unwrap(); let import_parse_attempt = Account::Import { private_key: import_private_key, write: true }; let import_parse_attempt_result = import_parse_attempt.parse().unwrap(); assert!(import_parse_attempt_result.contains("written to")); let imported_account: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert_eq!(imported_account.private_key.unwrap(), import_private_key); assert_eq!(imported_account.view_key.unwrap(), import_view_key); assert_eq!(imported_account.address.unwrap(), import_address); fs::remove_file(temp_dir.join("account-plaintext.json")).unwrap(); // Ensure we can write an account ciphertext from console input let encrypted_from_console_parse_attempt = Account::Encrypt { private_key: Some(import_private_key), file: None, write: true, password: Some("mypassword".to_string()), }; let encrypted_from_console_parse_attempt_result = encrypted_from_console_parse_attempt.parse().unwrap(); // Ensure a write message is emitted assert!(encrypted_from_console_parse_attempt_result.contains("written to")); let encrypted_from_console_account: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-ciphertext.json")).unwrap()).unwrap(); // Ensure the result is well formed assert!(encrypted_from_console_account.private_key.is_none()); assert!(encrypted_from_console_account.view_key.is_none()); assert_eq!(encrypted_from_console_account.address, Some(import_address)); assert!(encrypted_from_console_account.private_key_ciphertext.is_some()); // Ensure we can decrypt the ciphertext to file from the command line let encrypted_from_console_parse_attempt = Account::Decrypt { ciphertext: encrypted_from_console_account.private_key_ciphertext, file: None, write: true, password: Some("mypassword".to_string()), }; let encrypted_from_console_parse_attempt_result = encrypted_from_console_parse_attempt.parse().unwrap(); // Ensure a write message is emitted assert!(encrypted_from_console_parse_attempt_result.contains("written to")); let decrypted_from_console_account: AccountModel = serde_json::from_reader(&mut File::open(temp_dir.join("account-plaintext.json")).unwrap()).unwrap(); assert!(decrypted_from_console_account.private_key_ciphertext.is_none()); assert_eq!(decrypted_from_console_account.private_key, Some(import_private_key)); assert_eq!(decrypted_from_console_account.view_key, Some(import_view_key)); assert_eq!(decrypted_from_console_account.address, Some(import_address)); fs::remove_file(temp_dir.join("account-plaintext.json")).unwrap(); fs::remove_file(temp_dir.join("account-ciphertext.json")).unwrap(); } #[test] fn test_account_encrypt_fails_with_invalid_inputs() { let account_no_inputs = Account::Encrypt { private_key: None, file: None, write: false, password: None }; assert!(account_no_inputs.parse().is_err()); let private_key = Some(PrivateKey::::new(&mut TestRng::default()).unwrap()); let account_ambiguous_inputs = Account::Encrypt { private_key, file: Some("test.json".to_string()), write: false, password: None }; assert!(account_ambiguous_inputs.parse().is_err()); } #[test] fn test_account_decrypt_fails_with_invalid_inputs() { let account_no_inputs = Account::Decrypt { ciphertext: None, file: None, write: false, password: None }; assert!(account_no_inputs.parse().is_err()); let private_key = PrivateKey::::new(&mut ChaChaRng::seed_from_u64(5)).unwrap(); let ciphertext = Some(Encryptor::encrypt_private_key_with_secret(&private_key, "password").unwrap()); let account_ambiguous_inputs = Account::Decrypt { ciphertext, file: Some("test.json".to_string()), write: false, password: None }; assert!(account_ambiguous_inputs.parse().is_err()); } }