From a236939bc0f29bf4ae93dfad0d6f625e0b7a4835 Mon Sep 17 00:00:00 2001 From: /alex/ Date: Tue, 23 Jan 2024 10:18:47 +0100 Subject: [PATCH] CLI wallet: Ledger Nano support (#1606) * finish impl * changelog * Add Ledger Nano simulator choice * panic instead of unreachable * panic message Co-authored-by: DaughterOfMars * full ledger nano support * fix usability issues * fix backup/restore * clean up failed restore * PR suggestion * small cleanup * Update year (how time flies :see_no_evil:) Co-authored-by: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> * revert breaking change * fix fs cleanup after failed restore * getter * derive ValueEnum * optional secret manager choice * remove unnecessary password input * create initial account for init * ensure set stronghold password (lazily) * fix * changelog * Edit changelog * Bump version and changelog --------- Co-authored-by: DaughterOfMars Co-authored-by: Thibault Martinez Co-authored-by: Thoralf-M <46689931+Thoralf-M@users.noreply.github.com> --- Cargo.lock | 6 +- cli/CHANGELOG.md | 6 + cli/Cargo.toml | 3 +- cli/src/account.rs | 73 +++- cli/src/command/wallet.rs | 195 ++++------ cli/src/helper.rs | 45 ++- cli/src/wallet.rs | 347 ++++++++++++++---- sdk/CHANGELOG.md | 7 + sdk/src/client/secret/mod.rs | 29 +- sdk/src/client/stronghold/mod.rs | 7 +- sdk/src/wallet/core/builder.rs | 2 +- .../core/operations/stronghold_backup/mod.rs | 8 +- 12 files changed, 515 insertions(+), 213 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a103185c44..2bd26c7981 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,9 +431,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +checksum = "41daef31d7a747c5c847246f36de49ced6f7403b4cdabc807a97b5cc184cda7a" dependencies = [ "num-traits", ] @@ -502,7 +502,7 @@ checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" [[package]] name = "cli-wallet" -version = "1.2.0" +version = "1.3.0" dependencies = [ "chrono", "clap", diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index cbfff54e27..e750a8d9db 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## 1.3.0 - 2024-01-23 + +### Added + +- Ledger Nano support; + ## 1.2.0 - 2023-10-26 ### Added diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 9d6a8ee451..f3ace0900c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli-wallet" -version = "1.2.0" +version = "1.3.0" authors = ["IOTA Stiftung"] edition = "2021" homepage = "https://iota.org" @@ -22,6 +22,7 @@ iota-sdk = { path = "../sdk", default-features = false, features = [ "rocksdb", "stronghold", "participation", + "ledger_nano", ] } chrono = { version = "0.4.31", default-features = false, features = ["std"] } diff --git a/cli/src/account.rs b/cli/src/account.rs index 474774fa2a..cf5afef8e2 100644 --- a/cli/src/account.rs +++ b/cli/src/account.rs @@ -3,7 +3,10 @@ use clap::Parser; use colored::Colorize; -use iota_sdk::wallet::{Account, Wallet}; +use iota_sdk::{ + client::secret::SecretManager, + wallet::{Account, Wallet}, +}; use rustyline::{error::ReadlineError, history::MemHistory, Config, Editor}; use crate::{ @@ -22,7 +25,7 @@ use crate::{ account_completion::AccountPromptHelper, }, error::Error, - helper::bytes_from_hex_or_file, + helper::{bytes_from_hex_or_file, get_password}, println_log_error, }; @@ -62,6 +65,17 @@ pub enum AccountPromptResponse { Switch(Account), } +async fn ensure_password(wallet: &Wallet) -> Result<(), Error> { + if matches!(*wallet.get_secret_manager().read().await, SecretManager::Stronghold(_)) + && !wallet.is_stronghold_password_available().await? + { + let password = get_password("Stronghold password", false)?; + wallet.set_stronghold_password(password).await?; + } + + Ok(()) +} + // loop on the account prompt pub async fn account_prompt_internal( wallet: &Wallet, @@ -110,19 +124,33 @@ pub async fn account_prompt_internal( AccountCommand::Addresses => addresses_command(account).await, AccountCommand::Balance { addresses } => balance_command(account, addresses).await, AccountCommand::BurnNativeToken { token_id, amount } => { + ensure_password(wallet).await?; burn_native_token_command(account, token_id, amount).await } - AccountCommand::BurnNft { nft_id } => burn_nft_command(account, nft_id).await, - AccountCommand::Claim { output_id } => claim_command(account, output_id).await, + AccountCommand::BurnNft { nft_id } => { + ensure_password(wallet).await?; + burn_nft_command(account, nft_id).await + } + AccountCommand::Claim { output_id } => { + ensure_password(wallet).await?; + claim_command(account, output_id).await + } AccountCommand::ClaimableOutputs => claimable_outputs_command(account).await, - AccountCommand::Consolidate => consolidate_command(account).await, - AccountCommand::CreateAliasOutput => create_alias_outputs_command(account).await, + AccountCommand::Consolidate => { + ensure_password(wallet).await?; + consolidate_command(account).await + } + AccountCommand::CreateAliasOutput => { + ensure_password(wallet).await?; + create_alias_outputs_command(account).await + } AccountCommand::CreateNativeToken { circulating_supply, maximum_supply, foundry_metadata_hex, foundry_metadata_file, } => { + ensure_password(wallet).await?; create_native_token_command( account, circulating_supply, @@ -131,8 +159,12 @@ pub async fn account_prompt_internal( ) .await } - AccountCommand::DestroyAlias { alias_id } => destroy_alias_command(account, alias_id).await, + AccountCommand::DestroyAlias { alias_id } => { + ensure_password(wallet).await?; + destroy_alias_command(account, alias_id).await + } AccountCommand::DestroyFoundry { foundry_id } => { + ensure_password(wallet).await?; destroy_foundry_command(account, foundry_id).await } AccountCommand::Exit => { @@ -140,9 +172,11 @@ pub async fn account_prompt_internal( } AccountCommand::Faucet { address, url } => faucet_command(account, address, url).await, AccountCommand::MeltNativeToken { token_id, amount } => { + ensure_password(wallet).await?; melt_native_token_command(account, token_id, amount).await } AccountCommand::MintNativeToken { token_id, amount } => { + ensure_password(wallet).await?; mint_native_token(account, token_id, amount).await } AccountCommand::MintNft { @@ -155,6 +189,7 @@ pub async fn account_prompt_internal( sender, issuer, } => { + ensure_password(wallet).await?; mint_nft_command( account, address, @@ -166,7 +201,10 @@ pub async fn account_prompt_internal( ) .await } - AccountCommand::NewAddress => new_address_command(account).await, + AccountCommand::NewAddress => { + ensure_password(wallet).await?; + new_address_command(account).await + } AccountCommand::NodeInfo => node_info_command(account).await, AccountCommand::Output { selector } => output_command(account, selector).await, AccountCommand::Outputs => outputs_command(account).await, @@ -177,6 +215,7 @@ pub async fn account_prompt_internal( expiration, allow_micro_amount, } => { + ensure_password(wallet).await?; let allow_micro_amount = if return_address.is_some() || expiration.is_some() { true } else { @@ -197,8 +236,14 @@ pub async fn account_prompt_internal( token_id, amount, gift_storage_deposit, - } => send_native_token_command(account, address, token_id, amount, gift_storage_deposit).await, - AccountCommand::SendNft { address, nft_id } => send_nft_command(account, address, nft_id).await, + } => { + ensure_password(wallet).await?; + send_native_token_command(account, address, token_id, amount, gift_storage_deposit).await + } + AccountCommand::SendNft { address, nft_id } => { + ensure_password(wallet).await?; + send_nft_command(account, address, nft_id).await + } AccountCommand::Switch { account_id } => { return Ok(AccountPromptResponse::Switch(wallet.get_account(account_id).await?)); } @@ -208,8 +253,12 @@ pub async fn account_prompt_internal( transactions_command(account, show_details).await } AccountCommand::UnspentOutputs => unspent_outputs_command(account).await, - AccountCommand::Vote { event_id, answers } => vote_command(account, event_id, answers).await, + AccountCommand::Vote { event_id, answers } => { + ensure_password(wallet).await?; + vote_command(account, event_id, answers).await + } AccountCommand::StopParticipating { event_id } => { + ensure_password(wallet).await?; stop_participating_command(account, event_id).await } AccountCommand::ParticipationOverview { event_ids } => { @@ -218,9 +267,11 @@ pub async fn account_prompt_internal( } AccountCommand::VotingPower => voting_power_command(account).await, AccountCommand::IncreaseVotingPower { amount } => { + ensure_password(wallet).await?; increase_voting_power_command(account, amount).await } AccountCommand::DecreaseVotingPower { amount } => { + ensure_password(wallet).await?; decrease_voting_power_command(account, amount).await } AccountCommand::VotingOutput => voting_output_command(account).await, diff --git a/cli/src/command/wallet.rs b/cli/src/command/wallet.rs index b9781880b5..bbf3a52ccf 100644 --- a/cli/src/command/wallet.rs +++ b/cli/src/command/wallet.rs @@ -17,8 +17,8 @@ use log::LevelFilter; use crate::{ error::Error, - helper::{check_file_exists, enter_or_generate_mnemonic, generate_mnemonic, get_password, import_mnemonic}, - println_log_error, println_log_info, + helper::{check_file_exists, generate_mnemonic, get_password, SecretManagerChoice}, + println_log_info, }; const DEFAULT_LOG_LEVEL: &str = "debug"; @@ -32,9 +32,6 @@ pub struct WalletCli { /// Set the path to the wallet database. #[arg(long, value_name = "PATH", env = "WALLET_DATABASE_PATH", default_value = DEFAULT_WALLET_DATABASE_PATH)] pub wallet_db_path: String, - /// Set the path to the stronghold snapshot file. - #[arg(long, value_name = "PATH", env = "STRONGHOLD_SNAPSHOT_PATH", default_value = DEFAULT_STRONGHOLD_SNAPSHOT_PATH)] - pub stronghold_snapshot_path: String, /// Set the account to enter. pub account: Option, /// Set the log level. @@ -110,8 +107,15 @@ pub enum WalletCommand { #[derive(Debug, Clone, Args)] pub struct InitParameters { + /// Set the secret manager to use. + #[arg(short, long, value_name = "SECRET_MANAGER")] + pub secret_manager: Option, + /// Set the path to the stronghold snapshot file. Ignored if the is not a Stronghold secret + /// manager. + #[arg(short = 't', long, value_name = "PATH", env = "STRONGHOLD_SNAPSHOT_PATH", default_value = DEFAULT_STRONGHOLD_SNAPSHOT_PATH)] + pub stronghold_snapshot_path: String, /// Set the path to a file containing mnemonics. If empty, a mnemonic has to be entered or will be randomly - /// generated. + /// generated. Only used by some secret managers. #[arg(short, long, value_name = "PATH")] pub mnemonic_file_path: Option, /// Set the node to connect to with this wallet. @@ -125,6 +129,8 @@ pub struct InitParameters { impl Default for InitParameters { fn default() -> Self { Self { + secret_manager: Some(SecretManagerChoice::Stronghold), + stronghold_snapshot_path: DEFAULT_STRONGHOLD_SNAPSHOT_PATH.to_string(), mnemonic_file_path: None, node_url: DEFAULT_NODE_URL.to_string(), coin_type: SHIMMER_COIN_TYPE, @@ -132,9 +138,7 @@ impl Default for InitParameters { } } -pub async fn accounts_command(storage_path: &Path, snapshot_path: &Path) -> Result<(), Error> { - let password = get_password("Stronghold password", false)?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; +pub async fn accounts_command(wallet: &Wallet) -> Result<(), Error> { let accounts = wallet.get_accounts().await?; println!("INDEX\tALIAS"); @@ -146,61 +150,35 @@ pub async fn accounts_command(storage_path: &Path, snapshot_path: &Path) -> Resu Ok(()) } -pub async fn backup_command(storage_path: &Path, snapshot_path: &Path, backup_path: &Path) -> Result<(), Error> { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password.clone()).await?; - wallet.backup(backup_path.into(), password).await?; +pub async fn backup_command_stronghold(wallet: &Wallet, password: &Password, backup_path: &Path) -> Result<(), Error> { + wallet.backup(backup_path.into(), password.clone()).await?; println_log_info!("Wallet has been backed up to \"{}\".", backup_path.display()); Ok(()) } -pub async fn change_password_command(storage_path: &Path, snapshot_path: &Path) -> Result { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password.clone()).await?; - let new_password = get_password("Stronghold new password", true)?; - wallet.change_stronghold_password(password, new_password).await?; +pub async fn change_password_command(wallet: &Wallet, current_password: Password) -> Result<(), Error> { + let new_password = get_password("New Stronghold password", true)?; + wallet + .change_stronghold_password(current_password, new_password) + .await?; println_log_info!("The password has been changed"); - Ok(wallet) + Ok(()) } pub async fn init_command( storage_path: &Path, - snapshot_path: &Path, - parameters: InitParameters, + secret_manager: SecretManager, + init_params: InitParameters, ) -> Result { - if storage_path.exists() { - return Err(Error::Miscellaneous(format!( - "cannot initialize: {} already exists", - storage_path.display() - ))); - } - if snapshot_path.exists() { - return Err(Error::Miscellaneous(format!( - "cannot initialize: {} already exists", - snapshot_path.display() - ))); - } - let password = get_password("Stronghold password", true)?; - let mnemonic = match parameters.mnemonic_file_path { - Some(path) => import_mnemonic(&path).await?, - None => enter_or_generate_mnemonic().await?, - }; - - let secret_manager = StrongholdSecretManager::builder() - .password(password) - .build(snapshot_path)?; - secret_manager.store_mnemonic(mnemonic).await?; - let secret_manager = SecretManager::Stronghold(secret_manager); - Ok(Wallet::builder() .with_secret_manager(secret_manager) - .with_client_options(ClientOptions::new().with_node(parameters.node_url.as_str())?) - .with_storage_path(storage_path.to_str().expect("invalid unicode")) - .with_coin_type(parameters.coin_type) + .with_client_options(ClientOptions::new().with_node(init_params.node_url.as_str())?) + .with_storage_path(storage_path.to_str().expect("invalid wallet db path")) + .with_coin_type(init_params.coin_type) .finish() .await?) } @@ -222,46 +200,52 @@ pub async fn mnemonic_command(output_file_name: Option, output_stdout: O Ok(()) } -pub async fn new_account_command( - storage_path: &Path, - snapshot_path: &Path, - alias: Option, -) -> Result<(Wallet, AccountIdentifier), Error> { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; - - let alias = add_account(&wallet, alias).await?; +pub async fn new_account_command(wallet: &Wallet, alias: Option) -> Result { + let alias = add_account(wallet, alias).await?; - Ok((wallet, alias)) + Ok(alias) } -pub async fn node_info_command(storage_path: &Path) -> Result { - let wallet = unlock_wallet(storage_path, None, None).await?; +pub async fn node_info_command(wallet: &Wallet) -> Result<(), Error> { let node_info = wallet.client().get_info().await?; println_log_info!("Current node info: {}", serde_json::to_string_pretty(&node_info)?); - Ok(wallet) + Ok(()) } -pub async fn restore_command(storage_path: &Path, snapshot_path: &Path, backup_path: &Path) -> Result { +pub async fn restore_command_stronghold( + storage_path: &Path, + snapshot_path: &Path, + backup_path: &Path, +) -> Result { check_file_exists(backup_path).await?; let mut builder = Wallet::builder(); - if check_file_exists(snapshot_path).await.is_ok() { - println!( - "Detected a stronghold file at {}. Enter password to unlock:", - snapshot_path.to_str().unwrap() - ); - let password = get_password("Stronghold password", false)?; + + let password = if snapshot_path.exists() { + Some(get_password("Stronghold password", false)?) + } else { + None + }; + + if let Some(password) = password { + println!("Detected a stronghold file at {}.", snapshot_path.to_str().unwrap()); let secret_manager = SecretManager::Stronghold( StrongholdSecretManager::builder() .password(password) .build(snapshot_path)?, ); builder = builder.with_secret_manager(secret_manager); + } else { + // If there is no db, set the placeholder so the wallet builder doesn't fail. + if check_file_exists(storage_path).await.is_err() { + builder = builder.with_secret_manager(SecretManager::Placeholder); + } } + // If the restore fails we do not want to remove an already existing wallet + let restore_into_existing_wallet = storage_path.is_dir(); let wallet = builder // Will be overwritten by the backup's value. .with_client_options(ClientOptions::new().with_node(DEFAULT_NODE_URL)?) @@ -272,32 +256,30 @@ pub async fn restore_command(storage_path: &Path, snapshot_path: &Path, backup_p .await?; let password = get_password("Stronghold backup password", false)?; - wallet.restore_backup(backup_path.into(), password, None, None).await?; - - println_log_info!( - "Wallet has been restored from the backup file \"{}\".", - backup_path.display() - ); - - Ok(wallet) + if let Err(e) = wallet.restore_backup(backup_path.into(), password, None, None).await { + // Clean up the file system after a failed restore (typically produces a wallet without a secret manager). + // TODO: a better way would be to not create any files/dirs in the first place when it's not clear yet whether + // the restore will be successful. + if storage_path.is_dir() && !restore_into_existing_wallet { + std::fs::remove_dir_all(storage_path)?; + } + Err(e.into()) + } else { + println_log_info!( + "Wallet has been restored from the backup file \"{}\".", + backup_path.display() + ); + Ok(wallet) + } } -pub async fn set_node_url_command(storage_path: &Path, snapshot_path: &Path, url: String) -> Result { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; +pub async fn set_node_url_command(wallet: &Wallet, url: String) -> Result<(), Error> { wallet.set_client_options(ClientOptions::new().with_node(&url)?).await?; - Ok(wallet) + Ok(()) } -pub async fn set_pow_command( - storage_path: &Path, - snapshot_path: &Path, - local_pow: bool, - worker_count: Option, -) -> Result { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; +pub async fn set_pow_command(wallet: &Wallet, local_pow: bool, worker_count: Option) -> Result<(), Error> { // Need to get the current node, so it's not removed let node = wallet.client().get_node().await?; let client_options = ClientOptions::new() @@ -306,46 +288,15 @@ pub async fn set_pow_command( .with_pow_worker_count(worker_count); wallet.set_client_options(client_options).await?; - Ok(wallet) + Ok(()) } -pub async fn sync_command(storage_path: &Path, snapshot_path: &Path) -> Result { - let password = get_password("Stronghold password", !snapshot_path.exists())?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; +pub async fn sync_command(wallet: &Wallet) -> Result<(), Error> { let total_balance = wallet.sync(None).await?; println_log_info!("Synchronized all accounts: {:?}", total_balance); - Ok(wallet) -} - -pub async fn unlock_wallet( - storage_path: &Path, - snapshot_path: impl Into> + Send, - password: impl Into> + Send, -) -> Result { - let secret_manager = if let Some(password) = password.into() { - let snapshot_path = snapshot_path.into(); - Some(SecretManager::Stronghold( - StrongholdSecretManager::builder() - .password(password) - .build(snapshot_path.ok_or(Error::Miscellaneous("Snapshot file path is not given".to_string()))?)?, - )) - } else { - None - }; - - let maybe_wallet = Wallet::builder() - .with_secret_manager(secret_manager) - .with_storage_path(storage_path.to_str().expect("invalid unicode")) - .finish() - .await; - - if let Err(iota_sdk::wallet::Error::MissingParameter(_)) = maybe_wallet { - println_log_error!("Please make sure the wallet is initialized."); - } - - Ok(maybe_wallet?) + Ok(()) } pub async fn add_account(wallet: &Wallet, alias: Option) -> Result { diff --git a/cli/src/helper.rs b/cli/src/helper.rs index 0a0fe7b936..1fdea278a3 100644 --- a/cli/src/helper.rs +++ b/cli/src/helper.rs @@ -1,6 +1,7 @@ // Copyright 2020-2022 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use core::str::FromStr; use std::path::Path; use chrono::{DateTime, NaiveDateTime, Utc}; @@ -105,7 +106,7 @@ pub async fn enter_or_generate_mnemonic() -> Result { let mnemonic = match selected_choice { 0 => generate_mnemonic(None, None).await?, 1 => enter_mnemonic()?, - _ => unreachable!(), + _ => panic!("invalid choice index"), }; Ok(mnemonic) @@ -343,3 +344,45 @@ pub async fn check_file_exists(path: &Path) -> Result<(), Error> { } Ok(()) } + +#[derive(Copy, Clone, Debug, clap::ValueEnum)] +pub enum SecretManagerChoice { + Stronghold, + LedgerNano, + LedgerNanoSimulator, +} + +impl From for SecretManagerChoice { + fn from(value: usize) -> Self { + match value { + 0 => Self::Stronghold, + 1 => Self::LedgerNano, + 2 => Self::LedgerNanoSimulator, + _ => panic!("invalid secret manager choice index"), + } + } +} + +impl FromStr for SecretManagerChoice { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "stronghold" => Ok(Self::Stronghold), + "ledger-nano" => Ok(Self::LedgerNano), + "ledger-nano-sim" => Ok(Self::LedgerNanoSimulator), + _ => Err("invalid secret manager specifier [stronghold|ledger-nano|ledger-nano-sim]"), + } + } +} + +pub async fn select_secret_manager() -> Result { + let choices = ["Stronghold", "Ledger Nano", "Ledger Nano Simulator"]; + + Ok(Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Select secret manager") + .items(&choices) + .default(0) + .interact_on(&Term::stderr())? + .into()) +} diff --git a/cli/src/wallet.rs b/cli/src/wallet.rs index b098c9d675..7efad360cb 100644 --- a/cli/src/wallet.rs +++ b/cli/src/wallet.rs @@ -3,68 +3,238 @@ use std::path::Path; -use iota_sdk::wallet::{account::types::AccountIdentifier, Wallet}; +use iota_sdk::{ + client::secret::{ledger_nano::LedgerSecretManager, stronghold::StrongholdSecretManager, SecretManager}, + wallet::{account::types::AccountIdentifier, Wallet}, +}; use crate::{ command::wallet::{ - accounts_command, add_account, backup_command, change_password_command, init_command, + accounts_command, add_account, backup_command_stronghold, change_password_command, init_command, migrate_stronghold_snapshot_v2_to_v3_command, mnemonic_command, new_account_command, node_info_command, - restore_command, set_node_url_command, set_pow_command, sync_command, unlock_wallet, InitParameters, WalletCli, + restore_command_stronghold, set_node_url_command, set_pow_command, sync_command, InitParameters, WalletCli, WalletCommand, }, error::Error, - helper::{get_account_alias, get_decision, get_password, pick_account}, + helper::{ + enter_or_generate_mnemonic, get_account_alias, get_decision, get_password, import_mnemonic, pick_account, + select_secret_manager, SecretManagerChoice, + }, println_log_error, println_log_info, }; pub async fn new_wallet(cli: WalletCli) -> Result<(Option, Option), Error> { let storage_path = Path::new(&cli.wallet_db_path); - let snapshot_path = Path::new(&cli.stronghold_snapshot_path); + + enum LinkedSecretManager { + Stronghold { + snapshot_path: std::path::PathBuf, + snapshot_exists: bool, + }, + LedgerNano, + } + + let wallet_and_secret_manager = { + if storage_path.is_dir() { + match Wallet::builder().with_storage_path(storage_path).finish().await { + Ok(wallet) => { + let linked_secret_manager = match &mut *wallet.get_secret_manager().write().await { + SecretManager::Stronghold(stronghold) => { + let snapshot_path = stronghold.snapshot_path().to_path_buf(); + let snapshot_exists = snapshot_path.exists(); + LinkedSecretManager::Stronghold { + snapshot_path, + snapshot_exists, + } + } + SecretManager::LedgerNano(_) => LinkedSecretManager::LedgerNano, + _ => panic!("only Stronghold and LedgerNano supported at the moment."), + }; + Some((wallet, linked_secret_manager)) + } + Err(e) => { + println_log_error!("failed to load wallet db from storage: {e}"); + return Ok((None, None)); + } + } + } else { + None + } + }; let (wallet, account_id) = if let Some(command) = cli.command { match command { - WalletCommand::Accounts => { - accounts_command(storage_path, snapshot_path).await?; - return Ok((None, None)); - } - WalletCommand::Init(init_parameters) => { - let wallet = init_command(storage_path, snapshot_path, init_parameters).await?; - (Some(wallet), None) + WalletCommand::Init(init_params) => { + if wallet_and_secret_manager.is_some() { + return Err(Error::Miscellaneous(format!( + "cannot initialize: wallet db at '{}' already exists", + storage_path.display() + ))); + } + let secret_manager = create_secret_manager(&init_params).await?; + let secret_manager_variant = secret_manager.to_string(); + let wallet = init_command(storage_path, secret_manager, init_params).await?; + println_log_info!("Created new wallet with '{}' secret manager.", secret_manager_variant); + let initial_account = create_initial_account(&wallet).await?; + (Some(wallet), initial_account) } - WalletCommand::Restore { backup_path } => { - let wallet = restore_command(storage_path, snapshot_path, std::path::Path::new(&backup_path)).await?; - (Some(wallet), None) + WalletCommand::Accounts => { + if let Some((wallet, _)) = wallet_and_secret_manager { + accounts_command(&wallet).await?; + return Ok((None, None)); + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::Backup { backup_path } => { - backup_command(storage_path, snapshot_path, std::path::Path::new(&backup_path)).await?; - return Ok((None, None)); + if let Some((wallet, secret_manager)) = wallet_and_secret_manager { + match secret_manager { + LinkedSecretManager::Stronghold { + snapshot_exists: true, .. + } => { + let password = get_password("Stronghold password", false)?; + backup_command_stronghold(&wallet, &password, Path::new(&backup_path)).await?; + return Ok((None, None)); + } + LinkedSecretManager::Stronghold { snapshot_path, .. } => { + return Err(Error::Miscellaneous(format!( + "Stronghold snapshot does not exist at '{}'", + snapshot_path.display() + ))); + } + _ => { + println_log_info!("only Stronghold backup supported"); + return Ok((None, None)); + } + } + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::ChangePassword => { - let wallet = change_password_command(storage_path, snapshot_path).await?; - (Some(wallet), None) - } - WalletCommand::MigrateStrongholdSnapshotV2ToV3 { path } => { - migrate_stronghold_snapshot_v2_to_v3_command(path).await?; - return Ok((None, None)); + if let Some((wallet, secret_manager)) = wallet_and_secret_manager { + match secret_manager { + LinkedSecretManager::Stronghold { + snapshot_exists: true, .. + } => { + let current_password = get_password("Stronghold password", false)?; + change_password_command(&wallet, current_password).await?; + (Some(wallet), None) + } + LinkedSecretManager::Stronghold { snapshot_path, .. } => { + return Err(Error::Miscellaneous(format!( + "Stronghold snapshot does not exist at '{}'", + snapshot_path.display() + ))); + } + _ => { + println_log_info!("only Stronghold password change supported"); + return Ok((None, None)); + } + } + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::NewAccount { alias } => { - let (wallet, account) = new_account_command(storage_path, snapshot_path, alias).await?; - (Some(wallet), Some(account)) + if let Some((wallet, _)) = wallet_and_secret_manager { + let account = new_account_command(&wallet, alias).await?; + (Some(wallet), Some(account)) + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::SetNodeUrl { url } => { - let wallet = set_node_url_command(storage_path, snapshot_path, url).await?; - (Some(wallet), None) + if let Some((wallet, _)) = wallet_and_secret_manager { + set_node_url_command(&wallet, url).await?; + (Some(wallet), None) + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::SetPow { local_pow, worker_count, } => { - let wallet = set_pow_command(storage_path, snapshot_path, local_pow, worker_count).await?; - (Some(wallet), None) + if let Some((wallet, _)) = wallet_and_secret_manager { + set_pow_command(&wallet, local_pow, worker_count).await?; + (Some(wallet), None) + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } } WalletCommand::Sync => { - let wallet = sync_command(storage_path, snapshot_path).await?; - (Some(wallet), None) + if let Some((wallet, _)) = wallet_and_secret_manager { + sync_command(&wallet).await?; + (Some(wallet), None) + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } + } + WalletCommand::NodeInfo => { + if let Some((wallet, _)) = wallet_and_secret_manager { + node_info_command(&wallet).await?; + return Ok((None, None)); + } else { + return Err(Error::Miscellaneous(format!( + "wallet db does not exist at '{}'", + storage_path.display() + ))); + } + } + WalletCommand::Restore { backup_path } => { + if let Some((wallet, linked_secret_manager)) = wallet_and_secret_manager { + match linked_secret_manager { + LinkedSecretManager::Stronghold { snapshot_path, .. } => { + // we need to explicitly drop the current wallet here to prevent: + // "error accessing storage: IO error: lock hold by current process" + drop(wallet); + let wallet = restore_command_stronghold( + storage_path, + snapshot_path.as_path(), + Path::new(&backup_path), + ) + .await?; + (Some(wallet), None) + } + _ => { + println_log_info!("only Stronghold restore supported at the moment"); + return Ok((None, None)); + } + } + } else { + // the wallet db does not exist + let init_params = InitParameters::default(); + let snapshot_path = Path::new(&init_params.stronghold_snapshot_path); + let wallet = + restore_command_stronghold(storage_path, snapshot_path, Path::new(&backup_path)).await?; + (Some(wallet), None) + } + } + WalletCommand::MigrateStrongholdSnapshotV2ToV3 { path } => { + migrate_stronghold_snapshot_v2_to_v3_command(path).await?; + return Ok((None, None)); } WalletCommand::Mnemonic { output_file_name, @@ -73,43 +243,54 @@ pub async fn new_wallet(cli: WalletCli) -> Result<(Option, Option { - node_info_command(storage_path).await?; - return Ok((None, None)); - } } } else { - // no command provided, i.e. `> ./wallet` - match (storage_path.exists(), snapshot_path.exists()) { - (true, true) => { - let password = get_password("Stronghold password", false)?; - let wallet = unlock_wallet(storage_path, snapshot_path, password).await?; - if wallet.get_accounts().await?.is_empty() { - create_initial_account(wallet).await? - } else if let Some(alias) = cli.account { - (Some(wallet), Some(alias)) - } else if let Some(account) = pick_account(&wallet).await? { - (Some(wallet), Some(account.alias().await.into())) - } else { - (Some(wallet), None) - } + // no wallet command provided + if let Some((wallet, linked_secret_manager)) = wallet_and_secret_manager { + if let LinkedSecretManager::Stronghold { + snapshot_exists: false, + snapshot_path, + } = linked_secret_manager + { + println_log_error!( + "Snapshot file for Stronghold secret manager linked with the wallet not found at '{}'", + snapshot_path.display() + ); + return Ok((None, None)); + } + + if wallet.get_accounts().await?.is_empty() { + let initial_account = create_initial_account(&wallet).await?; + (Some(wallet), initial_account) + } else if let Some(alias) = cli.account { + (Some(wallet), Some(alias)) + } else if let Some(account) = pick_account(&wallet).await? { + (Some(wallet), Some(account.alias().await.into())) + } else { + (Some(wallet), None) } - (false, false) => { + } else { + // init new wallet with default init parameters + let init_params = InitParameters::default(); + let snapshot_path = Path::new(&init_params.stronghold_snapshot_path); + if !snapshot_path.exists() { if get_decision("Create a new wallet with default parameters?")? { - let wallet = init_command(storage_path, snapshot_path, InitParameters::default()).await?; - println_log_info!("Created new wallet."); - create_initial_account(wallet).await? + let secret_manager = create_secret_manager(&init_params).await?; + let secret_manager_variant = secret_manager.to_string(); + let wallet = init_command(storage_path, secret_manager, init_params).await?; + println_log_info!("Created new wallet with '{}' secret manager.", secret_manager_variant); + let initial_account = create_initial_account(&wallet).await?; + (Some(wallet), initial_account) } else { WalletCli::print_help()?; (None, None) } - } - (true, false) => { - println_log_error!("Stronghold snapshot not found at '{}'.", snapshot_path.display()); - (None, None) - } - (false, true) => { - println_log_error!("Wallet database not found at '{}'.", storage_path.display()); + } else { + println_log_error!( + "Inconsistent wallet: Stronghold snapshot found at '{}', but no Wallet database at '{}'.", + snapshot_path.display(), + storage_path.display() + ); (None, None) } } @@ -117,14 +298,50 @@ pub async fn new_wallet(cli: WalletCli) -> Result<(Option, Option Result<(Option, Option), Error> { +async fn create_initial_account(wallet: &Wallet) -> Result, Error> { // Ask the user whether an initial account should be created. if get_decision("Create initial account?")? { - let alias = get_account_alias("New account alias", &wallet).await?; - let account_id = add_account(&wallet, Some(alias)).await?; + let alias = get_account_alias("New account alias", wallet).await?; + let account_id = add_account(wallet, Some(alias)).await?; println_log_info!("Created initial account.\nType `help` to see all available account commands."); - Ok((Some(wallet), Some(account_id))) + Ok(Some(account_id)) } else { - Ok((Some(wallet), None)) + Ok(None) } } + +async fn create_secret_manager(init_params: &InitParameters) -> Result { + let choice = if let Some(choice) = &init_params.secret_manager { + *choice + } else { + select_secret_manager().await? + }; + + Ok(match choice { + SecretManagerChoice::Stronghold => { + let snapshot_path = Path::new(&init_params.stronghold_snapshot_path); + + if snapshot_path.exists() { + return Err(Error::Miscellaneous(format!( + "cannot initialize: {} already exists", + snapshot_path.display() + ))); + } + + let password = get_password("Stronghold password", true)?; + let mnemonic = match &init_params.mnemonic_file_path { + Some(path) => import_mnemonic(path).await?, + None => enter_or_generate_mnemonic().await?, + }; + + let secret_manager = StrongholdSecretManager::builder() + .password(password) + .build(snapshot_path)?; + secret_manager.store_mnemonic(mnemonic).await?; + + SecretManager::Stronghold(secret_manager) + } + SecretManagerChoice::LedgerNano => SecretManager::LedgerNano(LedgerSecretManager::new(false)), + SecretManagerChoice::LedgerNanoSimulator => SecretManager::LedgerNano(LedgerSecretManager::new(true)), + }) +} diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index af016b5a17..b17e488518 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -19,6 +19,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## 1.1.5 - 2024-MM-DD + +### Added + +- `Display` for `SecretManager`; +- `StrongholdAdapter::snapshot_path` getter method; + ## 1.1.4 - 2024-01-22 ### Added diff --git a/sdk/src/client/secret/mod.rs b/sdk/src/client/secret/mod.rs index 8881f93c71..06cd1be0df 100644 --- a/sdk/src/client/secret/mod.rs +++ b/sdk/src/client/secret/mod.rs @@ -22,7 +22,7 @@ pub mod types; #[cfg(feature = "stronghold")] use std::time::Duration; -use std::{collections::HashMap, fmt::Debug, ops::Range, str::FromStr}; +use std::{collections::HashMap, fmt, ops::Range, str::FromStr}; use async_trait::async_trait; use crypto::{ @@ -117,7 +117,7 @@ pub trait SecretManage: Send + Sync { } pub trait SecretManagerConfig: SecretManage { - type Config: Serialize + DeserializeOwned + Debug + Send + Sync; + type Config: Serialize + DeserializeOwned + fmt::Debug + Send + Sync; fn to_config(&self) -> Option; @@ -180,8 +180,8 @@ impl From for SecretManager { } } -impl Debug for SecretManager { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Debug for SecretManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { #[cfg(feature = "stronghold")] Self::Stronghold(_) => f.debug_tuple("Stronghold").field(&"...").finish(), @@ -195,6 +195,27 @@ impl Debug for SecretManager { } } +impl fmt::Display for SecretManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + #[cfg(feature = "stronghold")] + Self::Stronghold(_) => write!(f, "Stronghold"), + #[cfg(feature = "ledger_nano")] + Self::LedgerNano(l) => { + if l.is_simulator { + write!(f, "LedgerNano Simulator") + } else { + write!(f, "LedgerNano") + } + } + Self::Mnemonic(_) => write!(f, "Mnemonic"), + #[cfg(feature = "private_key_secret_manager")] + Self::PrivateKey(_) => write!(f, "PrivateKey"), + Self::Placeholder => write!(f, "Placeholder"), + } + } +} + impl FromStr for SecretManager { type Err = Error; diff --git a/sdk/src/client/stronghold/mod.rs b/sdk/src/client/stronghold/mod.rs index 845a5a0c49..f9f684e0bb 100644 --- a/sdk/src/client/stronghold/mod.rs +++ b/sdk/src/client/stronghold/mod.rs @@ -99,7 +99,7 @@ pub struct StrongholdAdapter { /// A handle to the timeout task. timeout_task: Arc>>, - /// The path to a Stronghold snapshot file. + /// The path to the corresponding Stronghold snapshot file. pub(crate) snapshot_path: PathBuf, } @@ -230,6 +230,11 @@ impl StrongholdAdapterBuilder { } impl StrongholdAdapter { + /// Get the path to the corresponding Stronghold snapshot file. + pub fn snapshot_path(&self) -> &Path { + self.snapshot_path.as_path() + } + /// Create a builder to construct a [StrongholdAdapter]. pub fn builder() -> StrongholdAdapterBuilder { StrongholdAdapterBuilder::default() diff --git a/sdk/src/wallet/core/builder.rs b/sdk/src/wallet/core/builder.rs index 31e2bba876..a76ed99967 100644 --- a/sdk/src/wallet/core/builder.rs +++ b/sdk/src/wallet/core/builder.rs @@ -99,7 +99,7 @@ where /// Set the storage path to be used. #[cfg(feature = "storage")] #[cfg_attr(docsrs, doc(cfg(feature = "storage")))] - pub fn with_storage_path(mut self, path: &str) -> Self { + pub fn with_storage_path(mut self, path: impl Into) -> Self { self.storage_options = Some(StorageOptions { path: path.into(), ..Default::default() diff --git a/sdk/src/wallet/core/operations/stronghold_backup/mod.rs b/sdk/src/wallet/core/operations/stronghold_backup/mod.rs index edde6c8770..7deac4abef 100644 --- a/sdk/src/wallet/core/operations/stronghold_backup/mod.rs +++ b/sdk/src/wallet/core/operations/stronghold_backup/mod.rs @@ -23,8 +23,8 @@ use crate::{ }; impl Wallet { - /// Backup the wallet data in a Stronghold file - /// stronghold_password must be the current one when Stronghold is used as SecretManager. + /// Backup the wallet data in a Stronghold file. + /// `stronghold_password` must be the current one when Stronghold is used as SecretManager. pub async fn backup( &self, backup_path: PathBuf, @@ -60,7 +60,7 @@ impl Wallet { Ok(()) } - /// Restore a backup from a Stronghold file + /// Restore the wallet from a Stronghold backup file. /// Replaces client_options, coin_type, secret_manager and accounts. Returns an error if accounts were already /// created If Stronghold is used as secret_manager, the existing Stronghold file will be overwritten. If a /// mnemonic was stored, it will be gone. @@ -131,7 +131,7 @@ impl Wallet { if let Some(mut read_secret_manager) = read_secret_manager { // We have to replace the snapshot path with the current one, when building stronghold if let SecretManagerDto::Stronghold(stronghold_dto) = &mut read_secret_manager { - stronghold_dto.snapshot_path = new_snapshot_path.to_string_lossy().into_owned(); + stronghold_dto.snapshot_path = new_snapshot_path.display().to_string(); } let restored_secret_manager = SecretManager::from_config(&read_secret_manager)